diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 028bbca96f..ff096daf96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,12 @@ jobs: steps: - uses: actions/checkout@v5 + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version-file: package.json + package-manager-cache: false + - name: Reject CRLF in shell and deploy assets run: | bad=$(git ls-files -z \ @@ -53,6 +59,9 @@ jobs: done < <(git ls-files -z '*.sh' '*.bash') exit "$rc" + - name: Verify release and version-generation contracts + run: node --test scripts/tauri-release-manifest.test.mjs scripts/linux-binaries-manifest.test.mjs scripts/version-generation.test.mjs + - name: Verify minisign download fallback run: | set -euo pipefail @@ -188,16 +197,51 @@ jobs: - uses: swatinem/rust-cache@v2 with: - shared-key: "ci-check-v3-${{ runner.os }}-no-cargo-bin-v1" + shared-key: "ci-check-v5-${{ runner.os }}-no-cargo-bin-v1" cache-bin: false # PR caches are scoped to merge refs; trusted main pushes own shared # refreshes and retain completed dependency builds after late test failures. save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} cache-on-failure: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + # rust-cache prunes the workspace target directory before saving it, so + # native libraries stored under target need an independent cache lifecycle. + - name: Restore Sherpa native libraries + id: sherpa-native-cache + uses: actions/cache/restore@v5 + with: + path: target/sherpa-onnx-prebuilt + key: sherpa-onnx-v1-${{ runner.os }}-${{ runner.arch }}-1.13.4-static + + - name: Repair missing Sherpa native state + shell: bash + run: | + if ! find target/sherpa-onnx-prebuilt -type f \ + \( -name 'sherpa-onnx-c-api.lib' -o -name 'libsherpa-onnx-c-api.a' \) \ + -print -quit 2>/dev/null | grep -q .; then + rm -rf target/sherpa-onnx-prebuilt + cargo clean -p sherpa-onnx-sys + fi + - name: Check compilation run: cargo check --locked --workspace + - name: Save Sherpa native libraries + if: >- + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + steps.sherpa-native-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: target/sherpa-onnx-prebuilt + key: sherpa-onnx-v1-${{ runner.os }}-${{ runner.arch }}-1.13.4-static + + # The installer is intentionally excluded from the root Cargo workspace, + # so the workspace check above cannot catch drift in its shared Rust APIs. + - name: Check installer compilation + if: runner.os == 'Windows' + run: cargo check --manifest-path BitFun-Installer/src-tauri/Cargo.toml + - name: Run core and desktop library tests run: cargo test --locked -p bitfun-core -p bitfun-desktop --lib @@ -291,6 +335,9 @@ jobs: - name: Validate theme visual governance contract run: pnpm run theme:visual-contract + - name: Validate WebKit compatibility gate + run: pnpm run verify:webkit-compatibility:test + - name: Lint web UI run: pnpm run lint:web diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 03c2202abc..3add2b43a5 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -7,9 +7,21 @@ on: workflow_dispatch: inputs: tag_name: - description: "Tag name to build (e.g. v0.2.0). Leave empty to build from HEAD." + description: "Release tag (stable: v0.2.18, beta: v0.2.18-beta.1)." required: false type: string + checkout_ref: + description: "Commit, branch, or tag to build. Defaults to tag_name, then HEAD." + required: false + type: string + release_channel: + description: "Immutable update channel compiled into the Desktop artifact." + required: false + default: stable + type: choice + options: + - stable + - beta upload_to_release: description: "Upload built artifacts to the release specified by tag_name." required: false @@ -26,8 +38,8 @@ permissions: packages: write concurrency: - group: desktop-package-${{ github.event.release.tag_name || inputs.tag_name || github.sha }} - cancel-in-progress: true + group: desktop-package-${{ (github.event.release.prerelease || inputs.release_channel == 'beta') && 'beta' || github.event.release.tag_name || inputs.tag_name || github.sha }} + cancel-in-progress: false jobs: # ── Resolve version info ─────────────────────────────────────────── @@ -39,9 +51,12 @@ jobs: release_tag: ${{ steps.meta.outputs.release_tag }} upload_to_release: ${{ steps.meta.outputs.upload_to_release }} checkout_ref: ${{ steps.meta.outputs.checkout_ref }} + release_channel: ${{ steps.meta.outputs.release_channel }} relay_image_only: ${{ steps.meta.outputs.relay_image_only }} steps: - uses: actions/checkout@v5 + with: + fetch-depth: 0 - name: Resolve version metadata id: meta @@ -51,6 +66,8 @@ jobs: GITHUB_SHA: ${{ github.sha }} RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} INPUT_TAG_NAME: ${{ inputs.tag_name }} + INPUT_CHECKOUT_REF: ${{ inputs.checkout_ref }} + INPUT_RELEASE_CHANNEL: ${{ inputs.release_channel }} INPUT_UPLOAD_TO_RELEASE: ${{ inputs.upload_to_release }} INPUT_RELAY_IMAGE_ONLY: ${{ inputs.relay_image_only }} run: | @@ -61,13 +78,21 @@ jobs: VERSION="${TAG#v}" UPLOAD="true" CHECKOUT_REF="${TAG}" + if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then + CHANNEL="beta" + else + CHANNEL="stable" + fi elif [[ -n "${INPUT_TAG_NAME}" ]]; then TAG="${INPUT_TAG_NAME}" VERSION="${TAG#v}" + CHANNEL="${INPUT_RELEASE_CHANNEL:-stable}" # A one-off image backfill must use the workflow branch: an older # tag does not contain Dockerfile.release or this publishing job. if [[ "${INPUT_RELAY_IMAGE_ONLY}" == "true" ]]; then CHECKOUT_REF="${GITHUB_SHA}" + elif [[ -n "${INPUT_CHECKOUT_REF}" ]]; then + CHECKOUT_REF="${INPUT_CHECKOUT_REF}" else CHECKOUT_REF="${TAG}" fi @@ -81,12 +106,32 @@ jobs: TAG="v${VERSION}" UPLOAD="false" CHECKOUT_REF="${GITHUB_SHA}" + CHANNEL="${INPUT_RELEASE_CHANNEL:-stable}" + fi + + node --input-type=module -e \ + "import { validateReleaseVersion } from './scripts/release-channel.mjs'; validateReleaseVersion(process.argv[1], process.argv[2]);" \ + "${CHANNEL}" "${VERSION}" + + git fetch origin main --tags --force + CHECKOUT_SHA="$(git rev-parse "${CHECKOUT_REF}^{commit}")" + if [[ "${GITHUB_REPOSITORY}" == "GCWing/BitFun" ]] && \ + ! git merge-base --is-ancestor "${CHECKOUT_SHA}" origin/main; then + echo "Ref ${CHECKOUT_REF} (${CHECKOUT_SHA}) is not part of the protected main history." >&2 + exit 1 + fi + TAG_SHA="$(git rev-parse --verify --quiet "${TAG}^{commit}" || true)" + if [[ -n "${TAG_SHA}" && "${TAG_SHA}" != "${CHECKOUT_SHA}" ]]; then + echo "Existing tag ${TAG} points to ${TAG_SHA}, not requested commit ${CHECKOUT_SHA}." >&2 + exit 1 fi + CHECKOUT_REF="${CHECKOUT_SHA}" echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "release_tag=$TAG" >> "$GITHUB_OUTPUT" echo "upload_to_release=$UPLOAD" >> "$GITHUB_OUTPUT" echo "checkout_ref=$CHECKOUT_REF" >> "$GITHUB_OUTPUT" + echo "release_channel=$CHANNEL" >> "$GITHUB_OUTPUT" echo "relay_image_only=${INPUT_RELAY_IMAGE_ONLY:-false}" >> "$GITHUB_OUTPUT" # ── Build per platform ───────────────────────────────────────────── @@ -98,12 +143,14 @@ jobs: env: NODE_OPTIONS: --max-old-space-size=6144 BITFUN_ENABLE_UPDATER_ARTIFACTS: ${{ needs.prepare.outputs.upload_to_release }} - TAURI_UPDATER_ENDPOINT: https://github.com/GCWing/BitFun/releases/latest/download/latest.json + BITFUN_RELEASE_CHANNEL: ${{ needs.prepare.outputs.release_channel }} + TAURI_UPDATER_ENDPOINT: ${{ github.repository != 'GCWing/BitFun' && needs.prepare.outputs.release_channel == 'beta' && format('https://github.com/{0}/releases/download/channel-beta/latest.json', github.repository) || '' }} + TAURI_UPDATER_FALLBACK_ENDPOINT: ${{ github.repository != 'GCWing/BitFun' && needs.prepare.outputs.release_channel == 'beta' && format('https://github.com/{0}/releases/download/channel-beta/latest.json', github.repository) || '' }} TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} # Same trust root, compiled into the Desktop binary so one-click relay # deploy can verify the signed checksum locally and hand the remote host # a hash it does not have to trust the mirror for. - BITFUN_RELEASE_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + BITFUN_RELEASE_PUBKEY: ${{ secrets.BITFUN_RELEASE_PUBKEY || secrets.TAURI_UPDATER_PUBKEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} @@ -233,9 +280,35 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Configure Apple Developer ID signing and notarization + if: runner.os == 'macOS' + shell: bash + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + BITFUN_REQUIRE_APPLE_SIGNING: ${{ needs.prepare.outputs.upload_to_release }} + run: bash scripts/ci/setup-macos-signing.sh + + - name: Project beta build version + if: needs.prepare.outputs.release_channel == 'beta' + run: node scripts/set-build-version.mjs --version "${{ needs.prepare.outputs.version }}" + + - name: Verify release version metadata + run: node scripts/verify-release-version-sync.mjs --version "${{ needs.prepare.outputs.version }}" + - name: Build desktop app run: ${{ matrix.platform.build_command }} + - name: Verify Apple signature and notarization + if: runner.os == 'macOS' + shell: bash + run: bash scripts/ci/verify-macos-signing.sh "${{ matrix.platform.target }}" + - name: Verify AppImage fcitx5 GTK module if: runner.os == 'Linux' shell: bash @@ -255,7 +328,9 @@ jobs: linux-binaries: name: Linux CLI and Relay Server needs: prepare - if: needs.prepare.outputs.relay_image_only != 'true' + if: >- + needs.prepare.outputs.relay_image_only != 'true' && + needs.prepare.outputs.release_channel == 'stable' uses: ./.github/workflows/linux-binaries.yml secrets: release_signing_key: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -273,7 +348,8 @@ jobs: needs: [prepare, linux-binaries] if: >- always() && - (needs.prepare.outputs.upload_to_release == 'true' || + ((needs.prepare.outputs.upload_to_release == 'true' && + needs.prepare.outputs.release_channel == 'stable') || needs.prepare.outputs.relay_image_only == 'true') && (needs.prepare.outputs.relay_image_only == 'true' || needs.linux-binaries.result == 'success') @@ -345,7 +421,7 @@ jobs: RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} RELEASE_VERSION: ${{ needs.prepare.outputs.version }} IMAGE_ONLY: ${{ needs.prepare.outputs.relay_image_only }} - RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} + RELEASE_CHANNEL: ${{ needs.prepare.outputs.release_channel }} run: | set -euo pipefail asset_version="${RELEASE_VERSION%%+*}" @@ -360,7 +436,7 @@ jobs: if [[ "${RELEASE_TAG}" == "${latest_release}" ]]; then echo "${IMAGE}:latest" fi - elif [[ "${RELEASE_PRERELEASE:-false}" != "true" ]]; then + elif [[ "${RELEASE_CHANNEL}" == "stable" ]]; then # The normal release workflow can create the GitHub Release only # after packaging, so it cannot rely on /releases/latest yet. echo "${IMAGE}:latest" @@ -466,8 +542,9 @@ jobs: always() && needs.prepare.outputs.upload_to_release == 'true' && needs.package.result == 'success' && - needs.linux-binaries.result == 'success' && - needs.publish-relay-image.result == 'success' + (needs.prepare.outputs.release_channel == 'beta' || + (needs.linux-binaries.result == 'success' && + needs.publish-relay-image.result == 'success')) runs-on: ubuntu-latest env: REQUIRED_UPDATER_PLATFORMS: windows-x86_64,darwin-x86_64,darwin-aarch64,linux-x86_64,linux-aarch64 @@ -484,6 +561,7 @@ jobs: merge-multiple: true - name: Download Linux binary artifacts + if: needs.prepare.outputs.release_channel == 'stable' uses: actions/download-artifact@v7 with: pattern: bitfun-linux-${{ needs.prepare.outputs.release_tag }}-* @@ -491,19 +569,39 @@ jobs: merge-multiple: true - name: Download Relay image descriptor + if: needs.prepare.outputs.release_channel == 'stable' uses: actions/download-artifact@v7 with: name: bitfun-relay-image-${{ needs.prepare.outputs.release_tag }} path: relay-image-assets - name: List release assets + env: + RELEASE_CHANNEL: ${{ needs.prepare.outputs.release_channel }} run: | echo "Release assets:" find release-assets -type f | sort - echo "Linux CLI and Relay Server assets:" - find linux-release-assets -type f | sort - echo "Relay image descriptor:" - find relay-image-assets -type f | sort + if [[ "${RELEASE_CHANNEL}" == "stable" ]]; then + echo "Linux CLI and Relay Server assets:" + find linux-release-assets -type f | sort + echo "Relay image descriptor:" + find relay-image-assets -type f | sort + fi + + - name: Prepare versioned Windows installer + run: | + node scripts/prepare-windows-installer-asset.mjs \ + --assets-dir release-assets \ + --version "${{ needs.prepare.outputs.version }}" \ + --out-dir release-manual-assets + + - name: Sign versioned Windows installer + shell: bash + env: + BITFUN_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + BITFUN_SIGNING_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + BITFUN_SIGNING_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + run: bash scripts/sign-release-assets.sh release-manual-assets/*.exe - name: Collect updater assets run: | @@ -517,9 +615,10 @@ jobs: run: | node scripts/generate-tauri-latest-json.mjs \ --assets-dir release-updater-assets \ + --manual-assets-dir release-manual-assets \ --version "${{ needs.prepare.outputs.version }}" \ --tag "${{ needs.prepare.outputs.release_tag }}" \ - --repo "GCWing/BitFun" \ + --repo "${{ github.repository }}" \ --out release-updater-assets/latest.json \ --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" @@ -528,9 +627,11 @@ jobs: node scripts/verify-tauri-latest-json.mjs \ --manifest release-updater-assets/latest.json \ --version "${{ needs.prepare.outputs.version }}" \ - --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" + --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" \ + --required-manual-platforms "windows-x86_64" - name: Generate Linux binaries manifest + if: needs.prepare.outputs.release_channel == 'stable' run: | node scripts/generate-linux-binaries-manifest.mjs \ --assets-dir linux-release-assets \ @@ -556,7 +657,7 @@ jobs: mapfile -t assets < <( find release-assets -type f \ \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \ - -o -name '*.dmg' -o -name '*bitfun-installer.exe' \) | sort + -o -name '*.dmg' \) | sort ) if [[ "${#assets[@]}" -eq 0 ]]; then echo "No installer packages found to sign." @@ -566,45 +667,85 @@ jobs: # Publish the public key alongside the signatures: a signature nobody # can fetch a key for is not verifiable. - printf '%s' "${BITFUN_SIGNING_PUBKEY}" | base64 -d >release-assets/minisign.pub + node scripts/write-minisign-public-key.mjs \ + --out release-assets/minisign.pub + + - name: Stage stable release assets + if: needs.prepare.outputs.release_channel == 'stable' + shell: bash + run: | + set -euo pipefail + shopt -s globstar + node scripts/stage-github-release-assets.mjs \ + --out-dir release-upload-assets \ + release-updater-assets/* \ + release-manual-assets/*.exe \ + release-manual-assets/*.exe.sig \ + release-assets/**/*.AppImage \ + release-assets/**/*.AppImage.sig \ + release-assets/**/*.deb \ + release-assets/**/*.deb.sig \ + release-assets/**/*.dmg \ + release-assets/**/*.dmg.sig \ + release-assets/**/*.rpm \ + release-assets/**/*.rpm.sig \ + release-assets/minisign.pub \ + linux-release-assets/bitfun-cli-*.tar.gz \ + linux-release-assets/bitfun-cli-*.tar.gz.sha256 \ + linux-release-assets/bitfun-relay-server-*.tar.gz \ + linux-release-assets/bitfun-relay-server-*.tar.gz.sha256 \ + linux-release-assets/*.tar.gz.sig \ + linux-release-assets/*.tar.gz.sha256.sig \ + linux-release-assets/linux-binaries.json \ + relay-image-assets/relay-image.json \ + relay-image-assets/relay-image.json.sig + + - name: Stage beta release assets + if: needs.prepare.outputs.release_channel == 'beta' + shell: bash + run: | + set -euo pipefail + shopt -s globstar + node scripts/stage-github-release-assets.mjs \ + --out-dir release-upload-assets \ + release-updater-assets/* \ + release-manual-assets/*.exe \ + release-manual-assets/*.exe.sig \ + release-assets/**/*.AppImage \ + release-assets/**/*.AppImage.sig \ + release-assets/**/*.deb \ + release-assets/**/*.deb.sig \ + release-assets/**/*.dmg \ + release-assets/**/*.dmg.sig \ + release-assets/**/*.rpm \ + release-assets/**/*.rpm.sig \ + release-assets/minisign.pub - name: Upload to release uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.prepare.outputs.release_tag }} + target_commitish: ${{ needs.prepare.outputs.checkout_ref }} + prerelease: ${{ needs.prepare.outputs.release_channel == 'beta' }} + name: ${{ needs.prepare.outputs.release_channel == 'beta' && format('BitFun {0} Beta', needs.prepare.outputs.version) || format('BitFun {0}', needs.prepare.outputs.version) }} generate_release_notes: true - files: | - release-updater-assets/* - release-assets/**/*.AppImage - release-assets/**/*.deb - release-assets/**/*.dmg - release-assets/**/*.rpm - release-assets/**/*bitfun-installer.exe - release-assets/**/*.sig - release-assets/minisign.pub - linux-release-assets/bitfun-cli-*.tar.gz - linux-release-assets/bitfun-cli-*.tar.gz.sha256 - linux-release-assets/bitfun-relay-server-*.tar.gz - linux-release-assets/bitfun-relay-server-*.tar.gz.sha256 - linux-release-assets/*.tar.gz.sig - linux-release-assets/*.tar.gz.sha256.sig - linux-release-assets/linux-binaries.json - relay-image-assets/relay-image.json - relay-image-assets/relay-image.json.sig + files: release-upload-assets/* fail_on_unmatched_files: true - name: Verify published updater manifest run: | curl -fsSL --retry 5 --retry-delay 3 \ - "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/latest.json" \ + "https://github.com/${{ github.repository }}/releases/download/${{ needs.prepare.outputs.release_tag }}/latest.json" \ -o latest.published.json node scripts/verify-tauri-latest-json.mjs \ --manifest latest.published.json \ --version "${{ needs.prepare.outputs.version }}" \ --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" \ + --required-manual-platforms "windows-x86_64" \ --check-urls true - name: Verify published Linux binaries manifest + if: needs.prepare.outputs.release_channel == 'stable' run: | curl -fsSL --retry 5 --retry-delay 3 \ "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/linux-binaries.json" \ @@ -616,6 +757,7 @@ jobs: done < <(jq -r '.platforms[].cli.url' linux-binaries.published.json) - name: Verify published Relay image descriptor + if: needs.prepare.outputs.release_channel == 'stable' run: | curl -fsSL --retry 5 --retry-delay 3 \ "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/relay-image.json" \ @@ -627,16 +769,87 @@ jobs: "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/relay-image.json.sig" \ -o /dev/null + - name: Resolve beta channel promotion + id: beta-channel + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_CHANNEL: ${{ needs.prepare.outputs.release_channel }} + run: | + set -euo pipefail + channel_status="$(curl -sS --retry 5 --retry-delay 3 \ + --output channel.release.json \ + --write-out '%{http_code}' \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/tags/channel-beta")" + case "${channel_status}" in + 200) + echo "channel_exists=true" >>"$GITHUB_OUTPUT" + curl -fsSL --retry 5 --retry-delay 3 \ + "https://github.com/${GITHUB_REPOSITORY}/releases/download/channel-beta/latest.json" \ + -o current.beta.json + ;; + 404) + echo "channel_exists=false" >>"$GITHUB_OUTPUT" + if [[ "${RELEASE_CHANNEL}" == "stable" ]]; then + echo "promote=false" >>"$GITHUB_OUTPUT" + echo "No beta channel exists; stable release does not need to create one." + exit 0 + fi + ;; + *) + echo "Could not inspect channel-beta release (GitHub API returned ${channel_status})." >&2 + exit 1 + ;; + esac + args=(--candidate latest.published.json --github-output "$GITHUB_OUTPUT") + if [[ -s current.beta.json ]]; then + args+=(--current current.beta.json) + fi + node scripts/plan-channel-promotion.mjs "${args[@]}" + + - name: Publish beta channel manifest + if: steps.beta-channel.outputs.promote == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + CHECKOUT_REF: ${{ needs.prepare.outputs.checkout_ref }} + CHANNEL_EXISTS: ${{ steps.beta-channel.outputs.channel_exists }} + run: | + set -euo pipefail + if [[ "${CHANNEL_EXISTS}" != "true" ]]; then + gh release create channel-beta \ + --repo "${GITHUB_REPOSITORY}" \ + --target "${CHECKOUT_REF}" \ + --prerelease \ + --title "BitFun Beta Update Channel" \ + --notes "Mutable updater pointer. Installable beta releases use immutable version tags." + fi + mkdir -p beta-channel + cp latest.published.json beta-channel/latest.json + gh release upload channel-beta beta-channel/latest.json \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + curl -fsSL --retry 5 --retry-delay 3 \ + "https://github.com/${GITHUB_REPOSITORY}/releases/download/channel-beta/latest.json" \ + -o channel-beta.published.json + test "$(jq -r '.version' channel-beta.published.json)" = \ + "${{ steps.beta-channel.outputs.candidate_version }}" + # Nudge the openbitfun.com mirror to sync now instead of on its next # 10-minute cron tick. Until the mirror has these bytes, CN clients have # only the GitHub origin to fall back to. Best effort: the cron run is # still the source of truth, so a failed or unconfigured ping never fails # the release. Receiver setup: scripts/openbitfun-release-sync.sh. - name: Request openbitfun mirror sync + if: github.repository == 'GCWing/BitFun' continue-on-error: true env: SYNC_WEBHOOK_URL: ${{ secrets.OPENBITFUN_SYNC_WEBHOOK_URL }} RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} + RELEASE_CHANNEL: ${{ needs.prepare.outputs.release_channel }} run: | set -euo pipefail if [[ -z "${SYNC_WEBHOOK_URL:-}" ]]; then @@ -645,6 +858,6 @@ jobs: fi curl -fsSL -X POST --retry 3 --retry-delay 5 --max-time 30 \ -H 'Content-Type: application/json' \ - -d "{\"tag\":\"${RELEASE_TAG}\"}" \ + -d "{\"tag\":\"${RELEASE_TAG}\",\"channel\":\"${RELEASE_CHANNEL}\"}" \ "${SYNC_WEBHOOK_URL}" >/dev/null echo "Mirror sync requested for ${RELEASE_TAG}." diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 16473c1eea..e80dd0751b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -68,6 +68,11 @@ jobs: if: needs.check-changes.outputs.should_build == 'true' env: NODE_OPTIONS: --max-old-space-size=6144 + BITFUN_RELEASE_CHANNEL: nightly + # Nightly does not publish a Tauri latest.json feed yet. Preserve its + # existing stable updater endpoints until that publishing path exists. + TAURI_UPDATER_ENDPOINT: https://github.com/GCWing/BitFun/releases/latest/download/latest.json + TAURI_UPDATER_FALLBACK_ENDPOINT: https://openbitfun.com/release/latest.json # Nightly relay archives are signed too (linux-binaries.yml receives the # key), so nightly Desktop needs the same trust root to verify them. BITFUN_RELEASE_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} @@ -162,6 +167,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Generate web API bindings + run: pnpm --dir src/web-ui run gen:types + - name: Type-check web UI run: pnpm run type-check:web @@ -173,20 +181,8 @@ jobs: set -euo pipefail echo "Patching version to $NIGHTLY_VERSION" - - # Patch package.json - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')); - pkg.version = process.env.NIGHTLY_VERSION.split('+')[0]; - fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); - " - - # Patch Cargo workspace version (semver: nightly suffix uses hyphen) - # Cargo.toml only accepts: MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-PRE - CARGO_VERSION="$(echo "$NIGHTLY_VERSION" | sed 's/+.*//')" - sed -i.bak "s/^version = \".*\" # x-release-please-version/version = \"${CARGO_VERSION}\" # x-release-please-version/" Cargo.toml - rm -f Cargo.toml.bak + ASSET_VERSION="${NIGHTLY_VERSION%%+*}" + node scripts/set-build-version.mjs --version "$ASSET_VERSION" echo "package.json version: $(jq -r '.version' package.json)" echo "Cargo.toml version: $(grep 'x-release-please-version' Cargo.toml)" @@ -418,6 +414,15 @@ jobs: --repo "GCWing/BitFun" \ --out linux-release-assets/linux-binaries.json + - name: Prepare versioned Windows installer + env: + NIGHTLY_VERSION: ${{ needs.check-changes.outputs.nightly_version }} + run: | + node scripts/prepare-windows-installer-asset.mjs \ + --assets-dir release-assets \ + --version "${NIGHTLY_VERSION%%+*}" \ + --out-dir release-manual-assets + # The Tauri bundler signs the five updater artifacts during `tauri build`, # but the installers people download by hand from the release page — dmg, # deb, rpm, the Windows installer and the direct AppImages — shipped with @@ -432,11 +437,12 @@ jobs: BITFUN_SIGNING_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} run: | set -euo pipefail - mapfile -t assets < <( + mapfile -t assets < <({ find release-assets -type f \ \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \ - -o -name '*.dmg' -o -name '*bitfun-installer.exe' \) | sort - ) + -o -name '*.dmg' \) + find release-manual-assets -type f -name '*.exe' + } | sort) if [[ "${#assets[@]}" -eq 0 ]]; then echo "No installer packages found to sign." exit 0 @@ -445,7 +451,8 @@ jobs: # Publish the public key alongside the signatures: a signature nobody # can fetch a key for is not verifiable. - printf '%s' "${BITFUN_SIGNING_PUBKEY}" | base64 -d >release-assets/minisign.pub + node scripts/write-minisign-public-key.mjs \ + --out release-assets/minisign.pub - name: Create nightly release uses: softprops/action-gh-release@v3 @@ -466,8 +473,9 @@ jobs: release-assets/**/*.deb release-assets/**/*.dmg release-assets/**/*.rpm - release-assets/**/*bitfun-installer.exe release-assets/**/*.sig + release-manual-assets/*.exe + release-manual-assets/*.exe.sig release-assets/minisign.pub release-assets/**/bitfun-cli-*-apple-darwin.tar.gz release-assets/**/bitfun-cli-*-apple-darwin.tar.gz.sha256 diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 093cce57d1..3bf286f4fc 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -12,7 +12,7 @@ BitFun 是一个由 Rust workspace 与 React 前端组成的项目。 2. 日常开发使用下方主要产品循环;surface 专属的替代命令由最近的应用指南维护。 3. 修改 Rust 文件后,优先使用 `pnpm run fmt:rs`,只格式化已改动或已暂存的 `.rs` 文件。只有在你明确需要更大范围格式化时才使用 `cargo fmt`。 4. 改完后从离改动最近的 `AGENTS.md` 选择 focused 验证命令;下方仓库级验证章节只维护跨模块检查原则。 -5. Rust workspace 依赖应在根清单中统一版本,而由消费 crate 按自身职责声明所需 feature;仅测试所需的 feature 应放入 `dev-dependencies`,受 crate feature 控制的服务能力应只在对应 feature 中启用。禁止使用 `tokio/full` 绕过依赖边界设计。 +5. Rust workspace 依赖应在根清单中统一版本,而由消费 crate 按自身职责声明所需 feature;仅测试所需的 feature 应放入 `dev-dependencies`,受 crate feature 控制的服务能力应只在对应 feature 中启用。第三方依赖的默认 feature 若不是所有 consumer 的稳定契约,应在 `[workspace.dependencies]` 统一关闭,成员只增加自身需要的切片。仓内 crate 的 `default` 已由边界契约保证为空时,不在每条依赖边重复写 `default-features = false`;ACP 这类有意保留兼容默认的 crate 仍由窄 consumer 显式关闭。被单独复制到 Docker 构建上下文的 manifest 无法继承 workspace 根,必须继续维护显式版本和默认策略。禁止使用 `tokio/full` 绕过依赖边界设计。 ## 分层模块索引 @@ -128,13 +128,72 @@ await api.invoke('your_command', { request: { ... } }); - 桌面端专属集成应放在 `src/apps/desktop`,再通过类型化能力接口回流;需要事件投递时,使用已有生产 transport adapter。 - 在共享 core 中避免使用 `tauri::AppHandle` 等宿主 API;优先使用 `bitfun_events::EventEmitter` 等共享抽象。 -### 远程兼容 - -- 新增功能时,从一开始就要考虑远程工作区和远程控制同步适配。只支持本地的行为很容易让远程场景功能缺失。 -- 如果某个功能无法合理支持远程工作区,必须做能力屏蔽,或展示明确的不支持提示,不能让它以通用错误的形式失败。 -- 每个桌面端 Tauri 命令都必须在 - `src/apps/desktop/src/api/remote_workspace_policy.rs` 中声明远程工作区策略; - 该文件的契约测试会拒绝没有显式策略的新命令,并禁止 legacy-unaudited 存量清单增长。 +### 远程场景 + +BitFun 不是只在本地运行的桌面应用:工作区、执行这一轮的 runtime、以及正在操作的人, +可能分别位于三台机器。下面四种场景是每次改动都要一并覆盖的一等目标,不是事后再补的适配。 + +| 场景 | 含义 | 设计入口 | +|---|---|---| +| 远程工作区 | 当前工作区位于 SSH 主机、跳板机链路或 Docker 容器;文件、终端、搜索和 Agent 子进程都必须在那一侧执行 | [remote-workspace-transport.md](docs/architecture/remote-workspace-transport.md)、[remote-workspaces.md](docs/features/remote-workspaces.md) | +| 远程控制 | 手机端 mobile web,或飞书 / Telegram / 微信 Bot,通过 Remote Connect relay 驱动 Desktop 或 CLI 宿主上的会话 | [`src/mobile-web`](src/mobile-web/AGENTS.md)、[services-integrations](src/crates/services/services-integrations/AGENTS.md) 的 `remote_connect`、[relay-service](src/crates/services/relay-service/AGENTS.md) | +| 多端互控(Peer Device Mode) | 同账号的一台设备成为另一台的数据平面:控制端外壳仍在本地,invoke 和事件来自 peer | [peer-device-mode.md](docs/architecture/peer-device-mode.md)、[peer-device README](src/web-ui/src/infrastructure/peer-device/README.md) | +| Dispatch 分离任务 | 控制端把持久化任务提交到另一台 BitFun 宿主后即可断开;目标端拥有 job、session、worktree、事件日志和权限信箱 | [detached-task-dispatch.md](docs/architecture/detached-task-dispatch.md) | + +四种场景共同适用的规则: + +- 远程路径要和功能一起设计。默认 UI、进程和文件系统在同一台机器上的能力属于未完成, + 而不是“第一阶段”。 +- 不支持要显式暴露。确实无法支持时,应屏蔽入口或返回明确的不支持状态;静默回落本地、 + 假成功、空载荷和通用错误都算回归,其中回落本地还会把本地内容泄露给远端控制方。 +- 阻塞式交互必须可以远程应答。新增的权限确认、对话框和选择器都要经既有的 dialog / + 权限信箱编排送达当前操作端;只能靠桌面窗口解除的阻塞会让远程控制和 Dispatch 任务死锁。 +- 要能扛断线。远程形态会重连、按 cursor 重放并重新 hydrate,因此优先使用可恢复 cursor + 和幂等变更,不要依赖“客户端恰好在线”才存在的状态。 +- 远程工作区路径在任何客户端 OS 上都是 POSIX 路径。不得用宿主 `std::path` 语义切分或 + 拼接,也不得把控制端的路径直接拿到 peer 宿主上复用。 + +各场景的具体约束: + +- **远程工作区**:每个桌面端 Tauri 命令都必须在 + [`remote_workspace_policy.rs`](src/apps/desktop/src/api/remote_workspace_policy.rs) + 中声明策略;该文件的契约测试会拒绝没有显式策略的新命令,并禁止 `LegacyUnaudited` + 存量清单增长。 +- **远程控制**:mobile web 和 IM Bot 是通过 `RemoteCommand` wire 协议和 bot command + router / menu 触达会话的,不走 Web UI。新增或迁移会话级能力时——工作区与助手选择、 + 会话生命周期、模式、模型、审批、附件——要同步扩展这些形态,或让它们给出明确的 + 不支持回复。 +- **多端互控**:产品命令默认代理到 peer 执行。必须留在控制端的命令(窗口装饰、更新器、 + 账号身份、本地 OS 自动化)要在三份保持同步的清单中一起禁用: + [`peer_host_invoke.rs`](src/apps/desktop/src/api/peer_host_invoke.rs)、 + [`deny.rs`](src/apps/cli/src/peer_host/deny.rs) 和 + [`peer-device-adapter.ts`](src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts)。 + 改动 session、account 或 hydrate 路径前,先读 peer-device README 的 invariants。 +- **Dispatch 分离任务**:任务在目标端以 CLI delivery profile 无界面运行,没有交互宿主, + 也不保证控制端在线。控制端只是观察者,不是 runtime 或文件系统代理。不要引入依赖提交方 + 常驻的行为;dispatch 协议版本和目标端必备 capability 属于兼容契约——新的目标端要求要走 + 协商 capability,而不是默认假设。 + +改动说明中要写清楚在哪些远程场景下验证过。只跑本地测试不能作为远程行为的证据。 + +### 升级兼容性 + +用户是原地升级的,而上述远程场景经常让两个不同版本的 BitFun 连在同一条链路上。 +任何改动都必须保证已有安装在升级后无需手工修复即可继续工作。 + +- **落盘结构会被新旧两侧代码同时读取。** 配置、设置、会话、连接配置、worktree 和 + dispatch 记录:新增字段要带默认值,反序列化要保持容错,绝不重新定义或收窄已经落盘 + 字段的语义。旧数据给不出的字段,不能变成必填。 +- **不要用删除或重置用户数据的方式来“恢复”解析不了的内容。** 应保留记录、降级功能并 + 给出明确状态。凭证缺失、配置读不出、超时或主机离线,都不构成丢弃会话、工作区或连接 + 的理由;销毁性删除只能是用户的显式操作。 +- **跨版本边界要协商,不能假设。** Peer HostInvoke、dispatch 协议、relay 与 mobile web、 + IM Bot,对面都是你控制不了的构建版本。要先声明 capability 再使用——包版本相同不等于 + 行为相同——并且要让旧版本一侧留在可用路径上,而不是直接判失败。 +- **改名就是一次迁移。** 在所有受支持的对端都不可能再发送旧名称、旧 id 或旧结构之前, + 必须继续兼容读取;被改名对象所引用的数据(vault 条目、工作区指针)要一并迁移。 +- **用测试证明。** 要覆盖旧数据反序列化和旧载荷往返,而不只是新结构。只验证当前代码 + 自己写出的数据,不算升级兼容性覆盖。 ### Agent loop 行为 diff --git a/AGENTS.md b/AGENTS.md index 3494a30943..0fb6666158 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,15 @@ Repository rule: **keep product logic platform-agnostic, then expose it through 5. Workspace Rust dependencies own compatible versions, not broad capability unions. Each crate must select the dependency features it actually uses; keep test-only features in dev-dependencies and attach feature-gated service - capabilities to the owning crate feature. `tokio/full` is forbidden in the - root workspace and workspace members. + capabilities to the owning crate feature. Disable third-party defaults in + `[workspace.dependencies]` when they are not part of every consumer's + contract; members inherit that policy and add only their needed slices. For + internal crates whose guarded `default` is empty, do not repeat + `default-features = false` on every edge. Narrow consumers of an intentional + compatibility default, such as ACP, must still disable it explicitly. + Manifests copied into a standalone Docker build context must keep explicit + versions and default policy because they cannot inherit the workspace root. + `tokio/full` is forbidden in the root workspace and workspace members. ## Layered Module Index @@ -145,14 +152,94 @@ await api.invoke('your_command', { request: { ... } }); - Desktop-only host adapters belong in `src/apps/desktop`, then flow through typed capability interfaces and, when event delivery is needed, the production transport adapter. - In shared core, avoid host-specific APIs such as `tauri::AppHandle`; use shared abstractions such as `bitfun_events::EventEmitter`. -### Remote compatibility - -- When adding features, consider remote workspace and remote control synchronization support from the start. Local-only behavior can silently leave remote scenarios incomplete. -- If a feature cannot reasonably support remote workspaces, gate it or show a clear unsupported-state message instead of letting it fail with a generic error. -- Every desktop Tauri command must declare its remote-workspace policy in - `src/apps/desktop/src/api/remote_workspace_policy.rs`; the contract test there - rejects new commands without an explicit policy and forbids growing the - legacy-unaudited backlog. +### Remote scenarios + +BitFun is not a local-only desktop app. The workspace, the runtime that executes +a turn, and the person driving it can each sit on a different machine. Treat the +four scenarios below as first-class targets of every change, not as a later port. + +| Scenario | What it means | Design entry point | +|---|---|---| +| Remote workspace | The active workspace lives on an SSH host, a jump-host chain, or a Docker container; files, terminal, search, and Agent subprocesses must execute there | [remote-workspace-transport.md](docs/architecture/remote-workspace-transport.md), [remote-workspaces.md](docs/features/remote-workspaces.md) | +| Remote control | Mobile web, or a Feishu / Telegram / WeChat bot, drives a session on a Desktop or CLI host through the Remote Connect relay | [`src/mobile-web`](src/mobile-web/AGENTS.md), `remote_connect` in [services-integrations](src/crates/services/services-integrations/AGENTS.md), [relay-service](src/crates/services/relay-service/AGENTS.md) | +| Peer Device Mode | One same-account device becomes the data plane of another: the controller shell stays local, invokes and events come from the peer | [peer-device-mode.md](docs/architecture/peer-device-mode.md), [peer-device README](src/web-ui/src/infrastructure/peer-device/README.md) | +| Detached Dispatch | A controller submits a durable job to another BitFun host and may then disconnect; the target owns the job, session, worktree, event log, and permission mailbox | [detached-task-dispatch.md](docs/architecture/detached-task-dispatch.md) | + +Rules that apply to all four: + +- Design the remote path together with the feature. A capability that assumes UI, + process, and filesystem share one machine is incomplete, not "phase one". +- Degrade loudly. When a scenario cannot be supported, gate the entry point or + return a clear unsupported state. Silent local fallback, fake success, empty + payloads, and generic errors are all regressions; local fallback additionally + leaks local content to a remote controller. +- Keep blocking interaction answerable from a distance. New permission prompts, + dialogs, and pickers must reach the driving surface through the existing dialog + and permission-mailbox orchestration. A turn that only the desktop window can + unblock deadlocks remote control and dispatch jobs. +- Survive disconnect. Remote surfaces reconnect, replay by cursor, and re-hydrate, + so prefer resumable cursors and idempotent mutations over state that exists only + while a client happens to be attached. +- Remote workspace paths are POSIX on every client OS. Do not split or join them + with host `std::path` semantics, and do not reuse a controller-side path on a + peer host. + +Per-scenario obligations: + +- **Remote workspace**: every desktop Tauri command declares its policy in + [`remote_workspace_policy.rs`](src/apps/desktop/src/api/remote_workspace_policy.rs). + The contract test there rejects new commands without an explicit policy and + forbids growing the `LegacyUnaudited` backlog. +- **Remote control**: mobile web and IM bots reach sessions through the + `RemoteCommand` wire protocol and the bot command router / menu, not through the + Web UI. When a session-level capability is added or moved — workspace or + assistant selection, session lifecycle, mode, model, approval, attachment — + extend those surfaces or make them answer with an explicit unsupported reply. +- **Peer Device Mode**: product commands are proxied to the peer by default. A + command that must stay on the controller (window chrome, updater, account + identity, local OS automation) has to be denied in all three lists that are kept + in sync: [`peer_host_invoke.rs`](src/apps/desktop/src/api/peer_host_invoke.rs), + [`deny.rs`](src/apps/cli/src/peer_host/deny.rs), and + [`peer-device-adapter.ts`](src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts). + Read the peer-device README invariants before changing session, account, or + hydrate paths. +- **Detached Dispatch**: jobs run headless on the target under the CLI delivery + profile, with no interactive host and no guaranteed controller connection. The + controller is an observer, never a runtime or filesystem proxy. Do not add + behavior that requires a live submitter, and treat the dispatch protocol version + and required target capabilities as a compatibility contract — a new target-side + requirement needs a negotiated capability, not an assumption. + +State which remote scenarios a change was exercised in. Local-only tests are not +evidence of remote behavior. + +### Upgrade compatibility + +Users upgrade in place, and the remote scenarios above routinely put two +different BitFun versions on the same connection. Every change must keep +existing installs working without manual repair. + +- **Persisted shapes are read by older and newer code.** Config, settings, + sessions, connection profiles, worktree and dispatch records: add fields with + defaults, keep deserialization tolerant, and never repurpose or narrow the + meaning of a field that is already on disk. A field old data cannot supply + must not become required. +- **Never delete or reset user data to recover from something you cannot + parse.** Keep the record, degrade the feature, and surface a clear state. + Missing credentials, an unreadable profile, a timeout, or an offline host are + not reasons to drop a session, workspace, or connection. Destructive removal + stays an explicit user action. +- **Cross-version boundaries negotiate; they do not assume.** Peer HostInvoke, + the dispatch protocol, relay and mobile web, and IM bots all talk to a build + you do not control. Advertise a capability and check it before using it — + package version equality is not evidence of behavior — and keep the older + side on a working path instead of failing it. +- **A rename is a migration.** Keep reading the old name, id, or record shape + until no supported peer can still send it, and migrate referenced data + (vault entries, workspace pointers) together with the thing being renamed. +- **Prove it with tests.** Cover legacy deserialization and an old-payload + round trip, not just the new shape. A test that only exercises data written + by the current code is not upgrade coverage. ### Agent loop behavior diff --git a/BitFun-Installer/package-lock.json b/BitFun-Installer/package-lock.json index 6ed36d14ff..08ffb33d52 100644 --- a/BitFun-Installer/package-lock.json +++ b/BitFun-Installer/package-lock.json @@ -1,12 +1,12 @@ { "name": "bitfun-installer", - "version": "0.2.16", + "version": "0.2.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bitfun-installer", - "version": "0.2.16", + "version": "0.2.17", "dependencies": { "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.6.0", diff --git a/BitFun-Installer/package.json b/BitFun-Installer/package.json index bd00376873..7f9d1759c0 100644 --- a/BitFun-Installer/package.json +++ b/BitFun-Installer/package.json @@ -1,6 +1,6 @@ { "name": "bitfun-installer", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "description": "BitFun Custom Installer - Modern branded installation experience", diff --git a/BitFun-Installer/src-tauri/Cargo.toml b/BitFun-Installer/src-tauri/Cargo.toml index ecf66ffbc8..55d8d2525a 100644 --- a/BitFun-Installer/src-tauri/Cargo.toml +++ b/BitFun-Installer/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitfun-installer" -version = "0.2.16" +version = "0.2.17" authors = ["BitFun Team"] edition = "2021" description = "BitFun Custom Installer - Modern branded installation experience" @@ -22,19 +22,11 @@ tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["full"] } -tokio-stream = "0.1" anyhow = "1.0" log = "0.4" dirs = "5.0" zip = "0.6" -flate2 = "1.0" -tar = "0.4" chrono = "0.4" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } -urlencoding = "2" -futures = "0.3" -eventsource-stream = "0.2" bitfun-ai-adapters = { path = "../../src/crates/adapters/ai-adapters" } [target.'cfg(windows)'.dependencies] diff --git a/BitFun-Installer/src-tauri/src/installer/ai_config.rs b/BitFun-Installer/src-tauri/src/installer/ai_config.rs index 675ff83f9f..70e59feec5 100644 --- a/BitFun-Installer/src-tauri/src/installer/ai_config.rs +++ b/BitFun-Installer/src-tauri/src/installer/ai_config.rs @@ -1,7 +1,7 @@ //! Map installer `ModelConfig` to shared AI adapter config. use crate::installer::types::ModelConfig; -use bitfun_ai_adapters::types::{resolve_request_url, AIConfig, ReasoningMode}; +use bitfun_ai_adapters::types::{resolve_request_url, AIConfig}; use log::warn; /// Build `AIConfig` for the shared AI client. @@ -47,13 +47,10 @@ pub(super) fn ai_config_from_installer_model(m: &ModelConfig) -> Result [!NOTE] > These are BitFun's initial evaluation results, with each case run once. Benchmarks fluctuate with task sampling, model versions, runtime environment, and single-run variance, so treat these as an initial sanity signal that the Agent is already reasonably capable — not as a fixed ranking claim or a final ceiling. Full benchmark details will follow. -**1. Completion results** — BitFun leads Open Code and Claude Code on both **SWE-Bench-Pro** (complex software engineering) and **SWE-Bench-Verified** (human-verified GitHub issue fixes). +**1. Initial completion snapshot** — The chart below compares the current single-run results on **SWE-Bench-Pro** (complex software engineering) and **SWE-Bench-Verified** (human-verified GitHub issue fixes). ![Agent benchmark scores](./png/agent_benchmark_scores.svg) @@ -160,7 +169,7 @@ Please submit PRs directly to the `main` branch. For more details, see [CONTRIBU ## Disclaimer 1. This project is spare-time exploration and research into next-generation human-machine collaboration, not a commercial profit-making project. -2. This project is 97%+ built through Vibe Coding. Code feedback is welcome, and AI-assisted refactoring and optimization are encouraged. +2. AI-assisted development is part of this project's workflow. Contributions are reviewed as code, and AI-assisted PRs should disclose their testing level; see [CONTRIBUTING.md](./CONTRIBUTING.md). 3. This project depends on and references many open-source projects. Thanks to all open-source authors. **If your rights are affected, please contact us for remediation.** --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 035055a2c9..53a4cb7ec1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,18 +4,18 @@ ![BitFun](./png/BitFun_title.png) -### 开源桌面 AI Agent —— 每个任务,都给你一个能打开的应用 +### 桌面 AI Agent —— 每个任务,都给你一个能打开的应用 -能写代码、能做文档、能操控桌面。小应用、Runtime、多设备互控的服务器,全部归你。MIT。 +能写代码、能做文档、能操控桌面,并提供小应用、Rust Runtime 和可自部署的多设备互控服务器。 -[**⬇ 下载 macOS · Windows · Linux 版**](https://github.com/GCWing/BitFun/releases/latest) +[**⬇ 下载 macOS · Windows · Linux 版**](https://github.com/GCWing/BitFun/releases/latest) · [校验下载](./docs/verify-downloads.zh-CN.md) -[官网](https://openbitfun.com/) · [文档](./docs) · [讨论区](https://github.com/GCWing/BitFun/discussions) · [参与贡献](./CONTRIBUTING_CN.md) +[官网](https://openbitfun.com/) · [快速开始](#第一次运行) · [安全策略](./SECURITY_CN.md) · [讨论区](https://github.com/GCWing/BitFun/discussions) · [参与贡献](./CONTRIBUTING_CN.md) [![GitHub release](https://img.shields.io/github/v/release/GCWing/BitFun?style=flat-square&color=blue)](https://github.com/GCWing/BitFun/releases) [![Downloads](https://img.shields.io/github/downloads/GCWing/BitFun/total?style=flat-square&color=brightgreen)](https://github.com/GCWing/BitFun/releases) [![Stars](https://img.shields.io/github/stars/GCWing/BitFun?style=flat-square&color=yellow)](https://github.com/GCWing/BitFun/stargazers) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](https://github.com/GCWing/BitFun/blob/main/LICENSE) +[![Core code: MIT](https://img.shields.io/badge/core_code-MIT-yellow?style=flat-square)](https://github.com/GCWing/BitFun/blob/main/LICENSE) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-blue?style=flat-square)](https://github.com/GCWing/BitFun/releases) [![Trendshift](https://trendshift.io/api/badge/repositories/44672)](https://trendshift.io/repositories/44672) @@ -36,11 +36,11 @@ | **Agentic Mini App** | 为任务生成专属界面——图表、看板、表单、面板——对话绑定该界面的实时状态 | | **自部署多设备互联互控** | 账号登录、跨设备会话同步、设备间操控,全部走你自己部署的 relay。零知识加密,不经第三方云 | | **编码交付** | 在真实 Git 仓库里规划、改代码、跑测试、提交。Agentic、Plan、Debug、Deep Review、长程任务 | -| **办公交付** | 调研、写作、PPT、DOCX、XLSX、PDF、会议纪要、报告 | +| **办公交付** | 调研、写作、演示文稿、会议纪要、报告 | | **桌面执行层** | 浏览器、终端、桌面软件、文件系统、远程工作区 | | **四层可定制** | 自定义 Agent → MCP / Skills / Hooks → Mini App → 源码级改造 | | **性能** | KV Cache 平均命中率 98.67%;flashgrep 在千万行仓库上搜索平均快约 36 倍 | -| **跨平台开源** | Windows、macOS、Linux 三端。MIT。模型自选,不绑定厂商 | +| **跨平台、模型自选** | Windows、macOS、Linux 三端,不绑定模型厂商 | --- @@ -50,6 +50,8 @@ ![小应用 Gallery](./png/miniapps_gallery_CN.png) +[浏览公开 Mini App Gallery →](https://market.openbitfun.com/miniapp/) + **自部署的多设备互联互控。** 账号登录、跨设备会话与配置同步、用一台设备操控另一台已登录设备,全部走**你自己部署**的 relay,不经任何第三方云中转——这往往直接决定了它在企业内网里能不能用。relay 是零知识设计:密钥在客户端本地派生,服务端只保存 Argon2id 哈希和 AES-GCM 封装后的材料。 **可以改到底的 Runtime。** 从一个 Markdown 文件到 fork 整个 Runtime,四层连续:自定义 Agent → MCP / Skills / 兼容 Codex 的 Hooks → Mini App → 源码级改造。你可以用 BitFun 来扩展 BitFun。 @@ -73,6 +75,13 @@ pnpm run desktop:dev 前置依赖:[Node.js](https://nodejs.org/) 22.12+(推荐 LTS)、[pnpm](https://pnpm.io/) 10.15.0(建议通过 Corepack 使用)、[Rust 工具链](https://rustup.rs/)、[Tauri 前置依赖](https://v2.tauri.app/start/prerequisites/)。更多说明见 [CONTRIBUTING_CN.md](./CONTRIBUTING_CN.md)。 +### 第一次运行 + +1. 启动 BitFun,在欢迎页点击**打开**,选择一个项目文件夹。 +2. 打开**更多选项(…)→ 设置 → 模型 → 创建第一个配置**。 +3. 选择服务商,填写 API Key,选择一个或多个模型,然后点击**保存**。第一个保存的模型会自动成为主模型,并自动测试连接。 +4. 回到**会话**页,输入一个具体任务,按 Enter 或点击**发送**。 + --- ## 你可以把什么交给 BitFun @@ -82,7 +91,7 @@ pnpm run desktop:dev | 场景 | 目标交付 | 典型能力 | | --- | --- | --- | | **编码** | 从真实仓库推进到可合并结果。 | Agentic、Plan、Debug、测试、Git、Deep Review、长程任务、Benchmark。 | -| **办公** | 从资料推进到可交付文档。 | Research、PPT、DOCX、XLSX、PDF、总结、写作、会议纪要、报告。 | +| **办公** | 从资料推进到实用的文字和视觉交付物。 | 调研、演示文稿、总结、写作、会议纪要、报告。 | **通用能力** @@ -99,7 +108,7 @@ pnpm run desktop:dev > [!NOTE] > 当前数据为每个 case 跑 1 次得到的 BitFun 初始评测结果。评测会受到任务抽样、模型版本、运行环境和单次执行偶然性的影响,存在一定波动;这组数据仅用于说明当前 Agent 已具备可用的基础竞争力,并不代表固定排名或最终上限。后续会持续优化并放出完整评测详情。 -**1. 完成效果** —— BitFun 在 **SWE-Bench-Pro**(复杂软件工程)和 **SWE-Bench-Verified**(人工验证的 GitHub issue 修复)上均领先 Open Code 与 Claude Code。 +**1. 初始完成效果快照** —— 下图对比了 **SWE-Bench-Pro**(复杂软件工程)和 **SWE-Bench-Verified**(人工验证的 GitHub issue 修复)当前的单次运行结果。 ![Agent benchmark scores](./png/agent_benchmark_scores.svg) @@ -160,5 +169,5 @@ BitFun 的扩展路径从轻到重连续展开: ## 声明 1. 本项目为业余时间探索、研究构建下一代人机协同交互,非商用盈利项目。 -2. 本项目 97%+ 由 Vibe Coding 完成,代码问题欢迎指正,也欢迎通过 AI 进行重构优化。 +2. AI 辅助开发是本项目工作流的一部分。贡献以代码和验证结果为准;AI 辅助 PR 请说明测试程度,详见 [CONTRIBUTING_CN.md](./CONTRIBUTING_CN.md)。 3. 本项目依赖和参考了众多开源软件。感谢所有开源作者。如侵犯您的相关权益,请联系我们整改。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index eeea41fcc9..284b7f2ba3 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,8 +1,8 @@ # Third-Party Notices -BitFun redistributes selected material from the following third-party project. -The corresponding license and provenance metadata are included with BitFun's -Desktop and CLI release packages. +BitFun redistributes selected material and links libraries from the following +third-party projects. These notices are included with BitFun's Desktop and CLI +release packages. ## models.dev catalog data @@ -21,3 +21,34 @@ complete upstream license text is preserved in `models-dev.LICENSE.txt`, which is shipped as `third-party/models.dev/LICENSE.txt`. Source distributions keep the canonical copies of both files beside the bundled snapshot under `src/crates/services/services-integrations/assets/`. + +## anydoc + +- Project: anydoc +- Source: https://github.com/firecrawl/anydoc +- Version: 0.1.6 +- License: MIT +- Copyright: Copyright (c) 2026 Sideguide Technologies Inc. + +BitFun links anydoc to convert supported office documents, OpenDocument files, +RTF, EPUB, CSV, and PDFs into Markdown for the Agent Read tool. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index 0275e7e322..0bacb6376c 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -4,9 +4,9 @@ Agent Runtime 的模块职责见 [`agent-runtime-services-design.md`](agent-runtime-services-design.md),公开 SDK 见 [`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md),第三方 JS/TS 进程见 -[`extensions/plugin-runtime-design.md`](extensions/plugin-runtime-design.md)。Rich Client 的 App Server 协议、Embedded/Shared Host -和 transport 提案见 [`app-server-architecture.md`](app-server-architecture.md)。该提案通过架构评审前,当前部署和调用路径以本文及 -已接线代码为准。 +[`extensions/plugin-runtime-design.md`](extensions/plugin-runtime-design.md)。Rich Client 的 Embedded direct-runtime 决策、App Server 协议 +和 Shared transport 提案见 [`app-server-architecture.md`](app-server-architecture.md)。Embedded 迁移完成前,当前调用路径以本文及 +已接线代码为准;Shared transport 未通过独立评审前继续使用 v17。 ## 1. 决策与当前状态 @@ -19,7 +19,7 @@ flowchart TB Desktop["Desktop GUI"] --> DesktopAdapter["Desktop / Tauri adapter"] Web["Web UI"] --> WebAS["loopback WebSocket App Server"] TUI["Interactive TUI"] --> Backend["TuiBackend"] - Backend -->|"Embedded"| EmbeddedAS["in-process App Server"] + Backend -->|"Embedded current"| EmbeddedAS["in-process App Server"] Backend -->|"--shared"| SharedIPC["private Runtime IPC v17"] Other["Headless CLI · ACP · Peer Host · SDK Host"] --> Adapter["独立 first-party adapters"] DesktopAdapter --> API["Agent Runtime API / owner ports"] @@ -40,42 +40,52 @@ flowchart LR Runtime -. "injects Runtime API and owner ports" .-> Host ``` -两张图中的实线表示当前业务请求,虚线只表示启动期构造与依赖注入。 +Current 图只表示已经接线的业务请求路径;下方 composition 图中的虚线只表示启动期构造与依赖注入。 -### 1.2 Proposed Rich Client target +### 1.2 Approved Embedded target ```mermaid flowchart TB - Rich["Desktop GUI · Web UI · Interactive TUI"] --> Host["Rich Client Host"] - Host --> Client["App Server client"] - Client --> Transport["Host-selected Embedded / Shared transport"] - Transport --> AppServer["App Server"] - Other["Headless CLI · ACP · Peer Host"] --> Adapter["独立 first-party adapters"] - SDK["Public Agent SDK"] --> SDKHost["SDK Host adapter"] - AppServer --> API["Agent Runtime API / owner ports"] - Adapter --> API - SDKHost --> API + TUI["Embedded interactive TUI"] --> Composition["TuiBackend composition"] + Composition --> RuntimePort["TuiRuntimePort"] + RuntimePort --> Direct["DirectRuntimeTuiRuntime"] + Direct --> API["Agent Runtime API / owner ports"] + Composition --> Management["owner service/provider interfaces"] + API --> Owners["Session / Tool / Permission / MCP owners"] +``` + +这是已批准但尚未交付的 Embedded direct-runtime 目标,属于 Phase 5;在 Host 完成迁移和验证前,Current 图中的 Embedded App Server 路径保持不变。 + +### 1.3 Optional Shared App Server proposal + +```mermaid +flowchart LR + C1["Shared Rich Client 1"] --> Transport["candidate private Pipe / UDS"] + C2["Shared Rich Client 2"] --> Transport + Transport --> Host["Shared App Server Host"] + Host --> Runtime["one Agent Runtime owner"] + Runtime --> Storage["Workspace / Session storage"] ``` -该图是待评审目标,不是当前调用链。Shared App Server 只有达到 v17 的连接治理、安全、恢复、取消、限制、性能和回滚门槛后, -才可替换 compatibility transport;评审也可以决定保留 private v17 作为 Shared TUI 的物理 wire。 +这是 Phase 6 的待评审提案,不是当前 Shared TUI 的必经链路,也不改变 Current 图中的 private Runtime IPC v17。只有完成连接治理、安全、恢复、取消、限制、性能和回滚门槛后,才可评审是否替换 v17;评审也可以决定长期保留 v17。 -### 1.3 Current implementation facts +### 1.4 Current implementation facts | 范围 | 当前状态 | |---|---| -| Embedded Desktop GUI | 继续使用 Desktop 事件投影和 Tauri adapter;按实际打开的本机 workspace 延迟取得并持有 Embedded ownership,不增加后台进程;目标迁入同进程私有 App Server | -| Embedded interactive TUI | 已组装同进程私有 App Server,通过 in-memory transport、`AppServerClient` 和 `AppServerTuiBackend` 完成当前核心聊天与 Session 路径;剩余管理面继续迁移 | +| Embedded Desktop GUI | 当前继续使用 Desktop 事件投影和 Tauri adapter;按实际打开的本机 workspace 延迟取得并持有 Embedded ownership,不增加后台进程;Embedded direct Runtime 是后续 Host/infrastructure 目标,不在此处宣称已接线 | +| Embedded interactive TUI | 当前仍通过同进程私有 App Server、in-memory transport、`AppServerClient` 和 `AppServerTuiBackend`;目标切换为 backend composition,由 `DirectRuntimeTuiRuntime` 直接调用 `AgentRuntime` typed API | | Embedded Headless CLI/Peer Host | 保留各自独立 Runtime adapter、展示和断流策略;不因交互式 TUI 迁移而强制使用 App Server | | ACP/SDK Host | 使用同一个 Runtime 事件入口的 session-scoped 订阅;各自协议和进程生命周期保持独立 | | Runtime ownership | Desktop、CLI、ACP、SDK Host 和现有 Server agent bootstrap 共用 Core owner;Embedded 取得共享锁,Shared TUI 取得独占锁,同一 workspace 上两种 deployment 互斥 | | Session 写入 | BitFun Runtime 的持久化 Session 由 `SessionManager` 管理;同一存储位置中的同一 Session 同时只允许一个本机进程写入,list/view 等只读操作不受影响 | | 当前 HTTP Server | 已组装 Embedded Runtime 和 `BitfunAppServer`,每个 `/ws` 连接通过 WebSocket transport 运行一条 App Server connection;当前固定 loopback、单用户且缺少连接级身份与作用域绑定,不构成远程或多用户 Server API | | Shared local IPC | 未发布的 v17 本机协议已有 discovery、实例锁、严格握手、Session 控制权、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI compatibility adapter;是否由 Shared App Server 替换仍待评审与等价证据 | -| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,删除未被控制的空闲非当前 Session,通过 `/fork` 从完整历史或选中提示词之前创建分支,重命名当前 Session,读取 transcript,通过 **View subagents** 只读查看当前根 Session 的子会话并定向取消子会话活动 Turn,切换当前 Session 的 Agent mode/model,通过 `/reload [skills|instructions]` 刷新声明式上下文,通过 `/compact` 或 `/summarize` 压缩当前 Session 上下文,在 Turn 空闲时通过 `/diff` 读取 Runtime 绑定工作区的只读差异,提交/取消 Turn,处理 Permission 和 UserInput;默认仍是 Embedded | +| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,删除未被控制的空闲非当前 Session,通过 `/fork` 从完整历史或选中提示词之前创建分支,重命名当前 Session,读取 transcript,通过 **View subagents** 只读查看当前根 Session 的子会话并定向取消子会话活动 Turn,切换当前 Session 的 Agent mode/model,通过 `/reload [skills|instructions]` 刷新声明式上下文,通过 `/compact` 或 `/summarize` 压缩当前 Session 上下文,在 Turn 空闲时通过 `/diff` 读取 Runtime 绑定工作区的只读差异,提交/取消 Turn,处理 Permission 和 UserInput;当前管理面具体接线为 `SharedTuiBackend -> concrete AppManagementService -> existing product owners`,其中 `AppManagementService` 是现有 owner 上的 adapter;按 domain 拆分 owner service/provider 是 Phase 5 的后续工作;Account/Settings Sync、Worktree 和后续 External Application V2 未由当前 Shared Host 提供,默认仍是 Embedded | | Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;Replay、Observer、通用 Controller transfer 和 Session archive 同样不在当前协议中 | -因此当前交付的是 Embedded TUI App Server 与一条窄的、显式启用的 Shared TUI compatibility deployment,不是通用本机 Server。 +因此当前交付的是 Embedded TUI App Server 与一条窄的、显式启用的 Shared TUI compatibility deployment; +下一步将先把 Embedded TUI 切换为 direct Runtime adapter,不把它扩展成通用本机 Server。 具体 `EventQueue` 仍由 Core 产品装配;当前 Shared IPC 只把 TUI 必需的强类型操作和事件映射到同一个 Runtime owner, 没有公开协议承诺。是否以 App Server Shared transport 替换并删除它,由行为等价、性能、安全和回滚证据决定。 @@ -86,7 +96,8 @@ flowchart TB | Agent Runtime | 负责 Session、Turn、Tool、MCP、Permission、Hook、事件和持久化行为的既有模块 | 进程名、Server 或 SDK | | Embedded deployment | Runtime 与调用入口位于同一 Rust 进程 | 简化版 Runtime | | Shared deployment | 同一 Runtime 由一个本机进程承载,多个第一方 Client 通过私有 IPC 使用 | 新 Runtime、公开 Server 或 Agent SDK | -| Embedded App Server | 与 Rich Client Host 同进程的私有 App Server 实例和 in-memory transport | Runtime 直连、后台进程或网络 Server | +| Embedded direct Runtime | Host 通过稳定 Rust typed facade 调用同进程 Runtime owner | 第二套 Runtime、Core singleton 直连或 App Server wire | +| Embedded App Server | 当前 TUI 使用、仅作迁移前基线的同进程 App Server 实例和 in-memory transport | Embedded 的目标默认路径、后台进程或网络 Server;Phase 5 切换后删除 | | Shared App Server | 独立本机 Host 承载、由多个已认证 Rich Client 通过受控 transport 使用的 App Server | 公网 API 或每个 Client 一个 Runtime | | Agent SDK Host | 将公开 SDK 合同映射到 Runtime API 的私有进程/adapter | CLI、Shared deployment 或 Plugin Host | | Plugin Host | 运行 Node/Bun 和第三方插件代码的受监督子进程 | Agent Runtime 或 Rust IPC client | @@ -106,19 +117,26 @@ flowchart TB Desktop["Desktop GUI"] --> DesktopAdapter["Desktop / Tauri adapter"] Web["Web UI"] --> AppServer["loopback WebSocket App Server"] - EmbeddedTUI["Embedded TUI"] --> AppServer + EmbeddedTUI["Embedded TUI"] --> EmbeddedCurrent["in-process App Server · current"] + EmbeddedTUI -. "target" .-> Composition["TuiBackend composition"] + Composition --> RuntimePort["TuiRuntimePort"] + RuntimePort --> DirectRuntime["DirectRuntimeTuiRuntime"] + Composition --> Management["owner service/provider interfaces"] DesktopAdapter --> API AppServer --> API + DirectRuntime --> API SharedCompat["Shared Runtime IPC · temporary compatibility"] --> API Headless["Headless / ACP adapters"] --> API SDK["SDK Host adapter"] --> API Remote["Remote adapter"] --> API ``` -当前复用的是 Runtime API、权威事实和 owner;Web 与 Embedded TUI 额外复用 App Server wire,Shared TUI 使用 private v17,Desktop -仍使用自己的 adapter。第 1.2 节目标只有通过评审并完成迁移后才扩大 App Server 复用范围。各入口不复用 renderer、CLI 参数、SDK -wire、远程认证或平台窗口生命周期。任何新能力必须先进入既有 Runtime owner,再由 App Server 或需要它的独立 adapter 映射,禁止 -在 Embedded、Shared 或其他入口复制业务实现。 +当前复用的是 Runtime API、权威事实和 owner;Web 额外使用 App Server wire,Embedded TUI +当前使用 App Server、目标改为 direct Runtime adapter,Shared TUI 的共同 Runtime 行为使用 +private v17。TUI 管理面由 composition 按 domain 注入 owner service/provider,不随 Runtime +port 进入 Shared wire。各入口不复用 renderer、CLI 参数、SDK wire、远程认证或平台窗口生命周期。 +任何新能力必须先进入既有 Runtime 或 owner service,再由 App Server、direct adapter 或其他独立 +adapter 映射,禁止在 Embedded、Shared 或其他入口复制业务实现。 ### 3.1 Embedded 事件交付 @@ -126,9 +144,11 @@ wire、远程认证或平台窗口生命周期。任何新能力必须先进入 flowchart LR Queue["EventQueue"] --> Owner["Core product event queue owner"] Owner -->|"injects read-only AgentEventSource"| Runtime["Agent Runtime API"] - Runtime --> AppServer["Embedded App Server"] - AppServer --> TUI["Interactive TUI client"] - AppServer --> GUI["Desktop GUI client · target"] + Runtime --> AppServer["App Server · Web/Shared when needed"] + Runtime --> Direct["Embedded direct Runtime adapter"] + AppServer --> TUI["Rich Client connection"] + Direct --> TUIEmbedded["Embedded TUI client"] + AppServer --> GUI["Desktop/Web client"] Runtime --> Exec["Headless adapter"] Runtime --> Peer["Peer fanout adapter"] Runtime --> ACP["ACP adapter"] @@ -136,7 +156,8 @@ flowchart LR ``` - Core product assembly 创建事件 source,并维持旧消费队列的排空 task;第一方产品入口不再获得第二个订阅 API。 -- App Server server 从注入的 `AgentEventSource` 转发 Rich Client 权威事件;Rich Client 不得从 `AgentRuntime` 或 Core `EventQueue` 旁路订阅。 +- App Server server 从注入的 `AgentEventSource` 转发需要连接边界的 Rich Client 权威事件;这些 client 不得绕过 App Server 订阅 Core `EventQueue`。 +- Phase 5 的 Embedded direct adapter 将通过 `AgentRuntime` 提供的 typed event/Permission subscription 直接订阅同一 Runtime owner,再映射为 `TuiRuntimePort` semantic event;它不得创建第二个 Core `EventQueue` owner 或把 Runtime 内部 receiver 暴露给 TUI。 - Headless CLI、Peer Host、ACP 和 SDK Host 从各自独立 Runtime adapter 订阅,不能直接持有 Core-specific event source。 - `bitfun-core` 的旧 event-source/builder API 仅保留为 deprecated 源码兼容 facade;它们委托给同一个 Core owner,不形成第二套运行时或第一方调用路径。 - 各 adapter 继续拥有自己的失败投影:TUI 标记当前视图不可信,Headless CLI 返回非成功终态,Peer Host 中断其拥有的 turns,ACP 取消 turn 并返回协议错误,SDK Host 终结 Query 并提供 `RestartHost` recovery。 @@ -145,7 +166,7 @@ flowchart LR 持久化 replay/resume:重连后的旧 cursor 不能继续消费,client 必须重新 initialize 并执行权威 sync。 - Shared Runtime IPC v17 不复用 App Server cursor。它按自己的有界队列规则处理 lag/closed:Agent 流失效后 fail closed;Permission lag 尝试从 Runtime 的 pending 集合重建,重建失败或流关闭时取消当前 Turn 并退出。任何路径都不能把流失效伪装成透明恢复。 -- 这条链路仍全部位于当前 Embedded 进程;Rich Client 使用 private in-memory transport,不增加 SDK Host、跨进程 IPC 或后台进程依赖。 +- 当前旧 Embedded App Server 链路位于 Embedded 进程;direct-runtime 目标会删除 private in-memory transport、App Server task/thread 和其 JSON-RPC 编解码,不增加 SDK Host、跨进程 IPC 或后台进程依赖。 ## 4. Process View · Level 1 @@ -284,13 +305,13 @@ sequenceDiagram - v15 为后代 transcript 读取增加 `required_settled_turn_ids` 一致性前置条件:Runtime 必须确认这些 Turn 已由 owner 持久化为终态,否则返回 `outcome_unknown`,由 TUI 在同一绝对期限内退避重试;TUI 只保留事件投影和该读屏障,不合并或重写权威 transcript。后代取消同时携带用户实际看到的 `expected_active_turn_id`,并在 owner 锁内拒绝已经切换的 Turn,避免迟到操作取消后续执行。lineage 查询和 transcript 读取是每连接至多一个的可抢占推测读取;更新的请求会取消旧读取,使后代取消和 Session 切换不会排在慢 transcript I/O 之后。该行为不放宽 controller 校验,不引入 observer 或通用多路复用。 - v16 增加只读、workspace-scoped main Agent 摘要,用于 Shared TUI 与 Runtime host 的 selector 投影一致。启动页以 Runtime 启动工作区查询且不取得 Session lease;已有 Session 由 Runtime owner 解析其执行工作区并要求当前 controller。响应只包含逻辑 ID、描述、可选固定 model ID 与 ecosystem-neutral 的 external-source 分类;发现、审批、冲突消解、generation 与执行仍由既有 Agent Registry 和 external-source owner 负责,不经 IPC 暴露安装、变更、激活、Subagent 管理或 runtime lifecycle API。 - v17 扩展原子 restore,使响应带回 Runtime Session state;增加结构化 Session usage、等待指定 Turn settlement,以及记录本地命令 - transcript turn 的 operation。它只补齐当前 Shared TUI 与 `TuiBackend` 的行为等价,没有增加 replay、observer、通用 controller - transfer、多 Session multiplex 或公开 SDK 能力。 + transcript turn 的 operation。它补齐当前 Shared TUI 的行为,并为 Phase 5 将冻结的 `TuiRuntimePort` 提供 operation 基线;没有增加 + replay、observer、通用 controller transfer、多 Session multiplex 或公开 SDK 能力。 - 一个连接最多控制一个 Session、同时最多提交一个活动 Turn;一个 Session 同时只有一个 controller。create/restore/fork 在完整结果通过大小检查后才原子切换控制权,失败时保留原 Session。fork 只接受当前 controller 的空闲 Session;无选中 Turn 时复制到最新持久化 Turn,指定 `before_turn_id` 时只复制该 Turn 之前的历史。活动 Turn 期间不能切换或 fork Session,也不能修改其名称、Agent mode 或 model;删除只作用于非当前且未被任何连接控制的 Session。 - Submit 与手动 context compaction 都使用调用方已有的 `turn_id` 标识不确定结果;若操作超时,返回 `outcome_unknown`、关闭连接并按该 ID 取消。手动 compaction 要求当前 controller 且 Session 空闲,由 Core 通过与普通对话 Turn 共用的原子准入路径创建一个可审计 maintenance Turn,并在取得所有权后读取压缩上下文:planning 阶段允许取消,atomic commit 开始后忽略晚到取消并保持 Processing 直至终态持久化完成。maintenance Turn 保留在权威 transcript 中但不进入模型上下文,live/restored payload 使用同一 compression ID 和 `applied` 事实;commit 后的持久化故障发布明确失败终态而不是遗留 Processing。断连取消只有得到确认后才释放 Session 控制权;无法确认时继续隔离该 Session,直到 Runtime 进程退出。 - Session delete/rename 和 Agent mode/model update 复用既有 Runtime 端口和校验,Runtime 对最终结果保持权威并拒绝无效目标。它们都是有副作用操作;发送前编码或 frame 上限失败表示请求未执行,连接仍可使用。rename 写入失败时恢复旧 metadata:确认恢复后返回明确失败,无法确认时返回 `outcome_unknown`。Shared Client 在请求写入后响应超时或丢失连接时也返回 `outcome_unknown` 并断开连接。两种情况都不自动重试:rename 由用户恢复 Session 并核对当前值;delete 由用户重新打开 `/sessions` 核对目标是否仍存在。模型目录以及完整 Agent/Subagent 管理仍是同版本第一方产品事实,不加入 IPC;v16 的 main Agent 摘要只是 host-owned selector 所需的最小只读投影。 - 声明式上下文 reload 只失效当前 Session 的 instructions 缓存,并按目标复用 Skill Registry 刷新;它可在活动 Turn 中执行但不改写该 Turn,generation 保护保证下一条消息重建上下文。它不引入 watcher、热替换或第二套 Runtime owner。 -- Shared TUI 的模型选择器复用 Client 已有的只读产品配置来显示同版本模型目录;它只把选中的 model ID 通过 `update current Session model` 交给 Runtime。Client 不持有 Session 写入权,也不通过 IPC 管理模型目录或默认值。 +- v17 保留 `update current Session model` operation 及其 controller/idle/unknown-outcome 合同,但模型目录和默认值不进入该 wire。Phase 3 移除 TUI controller 对本机产品配置 owner 的直连后,`SharedTuiBackend` 通过其持有的具体 `AppManagementService` 保留模型选择和配置;Phase 5 再拆成 Host 注入的 owner service/provider adapter。该 capability 只描述当前本机 Shared CLI adapter,不伪装成 v17 或 Remote capability。 - Agent 事件流 lag/closed 后 fail closed;Permission lag 先从 Runtime 权威 pending 集合重建,重建失败或流关闭时取消当前 Turn 并退出。路由到父 Session 的嵌套 Permission 与 AskUserQuestion 复用现有 TUI 交互,不新增第二套 UI 状态。 - Windows Shared Runtime 在初始化前把自身放入 kill-on-close Job;Unix 仅在应用内优雅退出路径中通过受管子进程组回收后代。Runtime 被 `SIGTERM`、`SIGKILL` 或崩溃直接终止后的 Unix 后代回收不在当前保证内。两者都只负责生命周期,不是安全沙箱。 - 最后一个连接离开后等待 30 秒再退出;新连接会取消 idle 退出。退出只删除自己发布的 discovery;Unix 下继任 owner 会在持有实例锁后清理同一 identity 的陈旧 socket。 @@ -313,7 +334,8 @@ flowchart LR | 路径 | 数据边界 | 性能约束 | |---|---|---| -| Embedded Rich Client | `AppServerClient` 通过 private in-memory transport 调用同进程 App Server | 不初始化跨进程 IPC 或后台进程;保持与 Shared 相同的 JSON-RPC、DTO、错误和事件语义,编解码成本通过测量优化而不增加直连旁路 | +| Embedded Rich Client(目标) | TUI backend composition 通过 `TuiRuntimePort` 和 owner service/provider adapter 调用同进程 Agent Runtime | 不初始化 App Server client/server、in-memory transport、跨进程 IPC 或后台进程;Runtime port 保持与 Shared/Web 相同的行为、错误、权限和事件语义,但不要求共享 JSON-RPC;管理 capability 单独按 provider 可用性验证 | +| Embedded App Server(当前迁移基线) | 迁移前由旧 `AppServerClient` 通过 private in-memory transport 调用同进程 App Server | 仅在 Phase 5 切换前有效;切换到 direct adapter 后删除,不保留回滚路径 | | Embedded non-Rich Client | Headless、ACP、Peer 和 SDK Host 的独立 adapter 以 Rust 类型调用 Runtime API | 不因 Rich Client 合同承担 App Server wire;保持各自协议和生命周期 | | Shared request | Client 将 operation 编码一次并写入一个长度前缀 frame | 请求保持 128 KiB 上限;业务层只接收类型化 operation | | Shared response/event | Server 将结果或事件编码一次后写出 | 响应/事件保持 8 MiB 上限;超限使事件流明确失效,不能无界分配 | @@ -355,56 +377,82 @@ flowchart TB ```mermaid flowchart LR - TUI["Interactive TUI"] --> Backend["TuiBackend"] - Backend -->|"Embedded"| Client["AppServerClient"] - Client --> Memory["in-memory transport"] - Memory --> AppServer["BitfunAppServer"] - Backend -->|"Shared compatibility"| IPC["adapters/agent-runtime-ipc v17"] + TUI["Interactive TUI · Phase 5 target"] --> Composition["TuiBackend composition"] + Composition --> Port["TuiRuntimePort"] + Port -->|"Embedded default"| Direct["DirectRuntimeTuiRuntime"] + Direct --> Runtime["AgentRuntime typed API"] + Composition --> Management["owner service/provider interfaces"] + Port -->|"Shared compatibility"| SharedAdapter["SharedIpcTuiRuntime"] + SharedAdapter --> IPC["adapters/agent-runtime-ipc v17"] IPC --> Handler["CLI Shared handler"] - AppServer --> Runtime["execution/agent-runtime / owners"] Handler --> Runtime ``` -CLI Host 负责命令解析、TUI 状态、错误文案、App Server 组装和 transport 生命周期;`TuiBackend` 隔离当前 Shared compatibility adapter。 -App Server 或私有 IPC 只负责协议、连接控制和类型映射;Agent Runtime 与 owner 负责 Session 校验、持久化和权威结果。 -TUI 业务代码不根据部署形态复制业务分支,Shared 达到 App Server 语义等价后替换 compatibility adapter。 +第二张图只描述已批准但尚未交付的 Phase 5 TUI composition 目标。当前生产接线仍以第一张图为准: +Embedded 使用 `AppServerTuiBackend`,Shared 使用 `SharedTuiBackend -> Runtime IPC v17`。Phase 5 中, +CLI Host 将负责命令解析、TUI 状态、错误文案、direct Runtime adapter 选择和生命周期; +backend composition 将由 `TuiRuntimePort` 承载共同 Runtime 行为,并按 domain 注入 owner service/provider +接口承载管理面。Direct adapter 负责 typed request/result/event 映射,Shared adapter 负责私有 IPC +的协议与连接控制;Agent Runtime 与 owner 负责 Session 校验、持久化和权威结果。TUI 业务代码 +不根据部署形态复制业务分支,Shared 是否替换 v17 仍按独立门槛决定。这里不定义一个总括性的 +`TuiManagementPort`。 - CLI 不依赖 SDK Host,GUI/TUI 也不依赖公开 SDK package。 -- 交互式 TUI 的启动页和会话页复用 app-local `TuiBackend`;Embedded backend 使用正式 `AppServerClient`,Shared backend 暂时映射 private Runtime IPC v17。TUI 不直接依赖 Rust Runtime SDK、Core/Service owner 或 IPC operation。 +- Phase 5 完成后,交互式 TUI 的启动页和会话页将复用 app-local backend composition;Embedded Runtime 调用使用 `DirectRuntimeTuiRuntime`,Shared Runtime 调用经 `SharedIpcTuiRuntime` 映射 private Runtime IPC v17。TUI 不直接依赖 Rust Runtime SDK、Core/Service owner 或 IPC operation。 +- 管理面由 composition 按 domain 直接注入 owner-owned 的稳定 service/provider trait;原始接口若暴露内部类型,或需要 TUI DTO、权限/上下文和 capability 裁剪,才抽取薄 facade。不得创建 `TuiManagementPort` 总接口,也不得把 App Server 的 `AppManagementService` 原样迁入 CLI/TUI。 +- Web 当前独立使用自己的 loopback WebSocket App Server Host,不进入 TUI backend composition。Shared 当前只有 private Runtime IPC v17;Shared App Server 只存在于第 1.3 节的 Phase 6 candidate 图中。 - Headless CLI 和 Peer Host 使用同一 Runtime 订阅入口,但分别保留确定性退出与 Peer fanout 语义;共享订阅入口不等于共享 renderer 或产品生命周期。 -- TUI 不是 Server;Embedded Host 在同进程组装私有 App Server,是否连接 Shared deployment 是部署选择,不改变 TUI 的 renderer/键位职责或 App Server 行为合同。 +- TUI 不是 Server;Phase 5 目标中的 Embedded Host 在同进程直接调用 Runtime,是否连接 Shared deployment 是部署选择,不改变 TUI 的 renderer/键位职责或行为合同。 - Agent SDK Host 只服务外部 SDK 合同,不成为第一方 rich-client 的通用底座。 - Headless CLI 默认继续 Embedded;CI 或测试可保持独立进程和独立 workspace,不承担后台实例成本。 -- Tauri 仍负责窗口和桌面能力,并逐步收窄为 App Server Host adapter;未来它可以管理 Shared process 的启动/重连,但不拥有 Agent Runtime 业务生命周期。 +- Tauri 仍负责窗口和桌面能力,并逐步收窄为 product Host adapter;Embedded 产品请求可由 direct Runtime adapter 承载,需要连接边界时再使用 App Server,未来也可以管理 Shared process 的启动/重连,但不拥有 Agent Runtime 业务生命周期。 ### 5.2 Physical View +#### 5.2.1 Current production + ```mermaid flowchart TB - subgraph Embedded["默认 Embedded"] - TUI["Interactive TUI"] --> AppServer["private in-process App Server"] + subgraph Embedded["Embedded · current"] + TUI["Interactive TUI"] --> AppServer["in-process App Server"] AppServer --> Runtime["in-process Agent Runtime"] Headless["Headless / CI"] --> Runtime end - subgraph Shared["显式 --shared"] + subgraph Shared["Shared · current explicit --shared"] Clients["one or more TUI processes"] -->|"Named Pipe / UDS · current compatibility"| SharedRuntime["Shared Runtime Host process"] end Runtime --> Data["workspace + Session storage"] SharedRuntime --> Data ``` -默认交互式 TUI、Headless CLI 和 CI 保持 Embedded;交互式 TUI 通过 private in-process App Server,Headless/CI 保留独立 adapter。 -只有显式 `--shared` 的交互式 TUI 进入 Shared;同一 workspace 的两种部署互斥。多开 TUI 增加 Client 进程和有界连接, -不按 Client 数量复制 Runtime、Session owner 或 Plugin Host。 +当前默认交互式 TUI 通过同进程 App Server,Headless CLI 和 CI 通过各自 adapter 调用同进程 +Runtime。只有显式 `--shared` 的交互式 TUI 进入 Shared;同一 workspace 的两种部署互斥。 +多开 TUI 增加 Client 进程和有界连接,不按 Client 数量复制 Runtime、Session owner 或 Plugin Host。 + +#### 5.2.2 Approved Phase 5 Embedded target + +```mermaid +flowchart LR + TUI["Embedded interactive TUI"] --> Direct["Direct Runtime adapter · not yet delivered"] + Direct --> Runtime["in-process Agent Runtime"] + Runtime --> Data["workspace + Session storage"] +``` + +Phase 5 只把 Embedded interactive TUI 从当前 App Server 一次性切到 direct Runtime adapter,并删除 +旧 in-process App Server;Headless/CI 保留独立 adapter,Shared 继续使用前一张 Current 图中的 +private Runtime IPC v17。旧 App Server 不作为回滚配置保留。 + +### 5.3 Scenario (+1) · Phase 5 target: rename current Session -### 5.3 Scenario (+1) · Rename current Session +该场景将尚未交付的 Embedded direct adapter 与当前 Shared v17 行为放在同一等价目标中;它不表示 +Embedded Phase 5 已完成。 ```mermaid sequenceDiagram participant U as User participant T as TUI adapter participant B as TuiBackend - participant E as Embedded App Server adapter + participant E as Embedded direct Runtime adapter participant S as Shared Runtime IPC v17 adapter participant R as Agent Runtime @@ -412,8 +460,8 @@ sequenceDiagram T->>T: trim + require idle Session T->>B: typed TuiBackend request alt Embedded - B->>E: typed App Server request - E->>R: owner port call + B->>E: typed Runtime request + E->>R: AgentRuntime method R->>R: validate ownership + persist R-->>E: applied / failed / outcome_unknown E-->>B: mapped typed result @@ -430,14 +478,14 @@ sequenceDiagram Embedded 和 Shared 最终调用同一 `AgentRuntime::rename_session`。Runtime 只有在确认旧名称已保留时才返回明确失败;持久化恢复无法确认时,两种部署都返回 `outcome_unknown`。Shared 还会在请求已发送但权威响应丢失时返回该结果并关闭连接。用户恢复 Session、检查当前名称后再决定是否重试。 -### 5.4 Scenario (+1) · Delete an idle Session +### 5.4 Scenario (+1) · Phase 5 target: delete an idle Session ```mermaid sequenceDiagram participant U as User participant T as TUI adapter participant B as TuiBackend - participant E as Embedded App Server adapter + participant E as Embedded direct Runtime adapter participant S as Shared Runtime IPC v17 adapter participant R as Agent Runtime @@ -445,8 +493,8 @@ sequenceDiagram T->>T: reject current or active target T->>B: typed TuiBackend request alt Embedded - B->>E: typed App Server request - E->>R: owner port call + B->>E: typed Runtime request + E->>R: AgentRuntime method R->>R: existing delete owner R-->>E: applied / failed / outcome_unknown E-->>B: mapped typed result @@ -509,17 +557,18 @@ Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理 | [Codex App Server](https://developers.openai.com/codex/app-server/) | App Server 为 rich client 和 remote TUI 提供 JSON-RPC;自动化继续使用 SDK;WebSocket transport 仍是实验性接口 | Rich Client 使用 App Server,自动化/公开 SDK 保持独立,并为 Shared 入口保留有界本机 transport | 不复制其完整 schema,也不把实验性远程 transport 当作已交付公网 API | | [Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/typescript) | Agent loop 由长期运行的 CLI 子进程承载,并提供 `startup()` 预热以减少首次请求成本 | 长期 Shared 交互可以复用已启动进程,空闲后回收 | Embedded Rich Client 不增加子进程,多 TUI 也不映射为多个 Runtime | -三种产品说明了不同部署的有效边界:稳定 Rich Client 合同可以同时承载进程内和多客户端 transport,长期子进程适合 Shared +三种产品说明了不同部署的有效边界:稳定产品行为合同可以分别映射到进程内 direct adapter 和多客户端 transport,长期子进程适合 Shared 交互或语言 SDK,独立强类型 adapter 适合 Headless/ACP 等非 Rich Client。BitFun 采用混合部署,不把 App Server 强制成所有入口的 公共底座;当前也没有为了追赶功能表一次性增加 Session/Tool/Permission 超集。 ## 9. 不变量 - 只有一套 Agent Runtime 业务实现;部署差异不能产生第二套 Session、Tool、Permission 或 MCP owner。 -- 当前入口使用第 1.1 节列出的 adapter;若第 1.2 节目标通过评审并迁移完成,Desktop GUI、Web UI 和交互式 TUI 才统一使用 App Server。 +- 当前入口使用第 1.1 节列出的 adapter;Embedded TUI 的下一步是从 App Server 切换到 direct Runtime adapter,Web/需要连接边界的 Rich Client 继续使用 App Server。 - Client、窗口、Session 或 workspace 数量不会自动等量增加 Runtime 或 Plugin Host 进程。 - 当前 Shared Runtime IPC 是第一方 TUI 的 private compatibility transport,不成为公开 SDK、Remote、Peer、HTTP 或浏览器协议;是否由 App Server Shared transport 替换仍待评审。 -- 默认 GUI/TUI/Headless CLI、ACP 与 SDK Host 保持 Embedded;只有交互式 TUI 的显式 `--shared` 选择 Shared。互斥按 `workspace + product` 生效,不再按入口名称缩窄。 +- Shared TUI 的 Model、Skill、Subagent、MCP、External Source V1 和 Hook 管理当前由 `SharedTuiBackend` 委托其持有的具体 `AppManagementService`;Phase 5 再拆成 CLI Host 显式装配的 owner service/provider adapter。这些管理 capability 不进入 Phase 5 的 `TuiRuntimePort` 或 v17 wire。Account/Settings Sync、Worktree 和后续 External Application V2 未由当前 Shared Host 提供并返回 typed unsupported。这不扩展 v17,不改变 Shared Runtime 对 Session/chat 的权威性,也不能用于 Remote workspace 的控制端本机回退。MCP service 的进程状态和 tool registry 只属于当前 CLI 进程,不即时重配已经运行的 Shared Runtime Host;跨进程 MCP 管理需要单独的同步/restart contract。 +- 默认 GUI/TUI/Headless CLI、ACP 与 SDK Host 保持 Embedded;Phase 5 完成后,只有 Embedded TUI 产品请求切换为直接调用同进程 Runtime,并删除旧 Embedded App Server。Desktop direct Runtime 是独立的已批准迁移步骤,不由 TUI Phase 5 的完成状态代替;Headless CLI、ACP 与 SDK Host 继续使用各自 adapter。只有交互式 TUI 的显式 `--shared` 选择 Shared。互斥按 `workspace + product` 生效,不再按入口名称缩窄。 - Account/session cloud sync 仍使用既有 Core compatibility 边界,不属于 Shared Runtime 支持。 - Remote workspace 的文件、凭据、进程和 Runtime 位于目标执行域,禁止静默回落本机。 - 未经真实 consumer 验证的接口不进入 wire;当前 wire 只包含表中列出的 Shared TUI 操作。 diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index e767ebfd01..9c9c5b28d4 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -748,7 +748,8 @@ Rust Runtime SDK,不注册未实现的 `RuntimeServices` 能力,也不宣称 本地工作区快照 owner port;Peer Host 只用它完成本地工作区准备、会话文件清单、类型化统计和工作区文件回滚。 账号同步、富历史读取及 Peer Host/ACP 的其余维护等产品操作仍由 `assembly/core` 的单一兼容接口转发。 `doctor` 与 `health` 校验真实组装结果及必需注册完整性; -Core 的 Network、Git 和 MCP Catalog 当前仍含兼容 marker,因此该诊断不等于对这些外部服务做实时探活。 +Core 只为当前 feature closure 真正组装的 Network、Git、MCP Catalog 和 +Remote Workspace 注册 capability marker;该诊断仍不等于对外部服务做实时探活。 该切换仍是 `product-full` 兼容组装,不是完整 ToolPipeline owner 迁移。 协调器、调度器、持久化、工具管线和 Agentic Event Queue 仍由 Core 唯一持有。 diff --git a/docs/architecture/agent-sdk-product-architecture.md b/docs/architecture/agent-sdk-product-architecture.md index 6d046d3ee8..5d72ba2275 100644 --- a/docs/architecture/agent-sdk-product-architecture.md +++ b/docs/architecture/agent-sdk-product-architecture.md @@ -206,7 +206,7 @@ flowchart TB 与运行第三方 JS/TS 的 Node/Bun Plugin Host 不同;三者不能共享名称或业务归属。 当前代码已经交付显式启用的 Shared TUI 最小切片,包含本机 IPC、身份、握手、Session/Turn、当前 Session 的 name/Agent mode/model、Permission/UserInput、 -ownership 和生命周期治理;GUI、Headless CLI、ACP、SDK Host、Server/Remote 仍没有 Shared consumer。该图中的多入口逻辑复用是 +ownership 和生命周期治理;Model、Skill、Subagent 和 MCP 管理暂由 Shared CLI adapter 的本地 compatibility provider 保留,不属于 v17 或公开 SDK 合同。GUI、Headless CLI、ACP、SDK Host、Server/Remote 仍没有 Shared consumer。该图中的多入口逻辑复用是 当前事实,除 Shared TUI 外的跨进程 Shared deployment 仍是目标架构。 ### 4.3 各形态能做什么 diff --git a/docs/architecture/app-server-architecture.md b/docs/architecture/app-server-architecture.md index 765d7afc61..a459383461 100644 --- a/docs/architecture/app-server-architecture.md +++ b/docs/architecture/app-server-architecture.md @@ -1,42 +1,61 @@ # App Server 架构设计 -> 状态:Proposed target;关键决策与替换门槛尚待架构评审。 +> 状态:Embedded direct-runtime 已确定为下一步实现方向;Shared App Server 仍是待评审提案。 > -> 基线日期:2026-08-05。 +> 基线日期:2026-08-13。 > -> 本文记录 BitFun Rich Client 与产品后端之间的候选 App Server 边界,不是已批准的权威架构。具体 TUI 迁移阶段、接口盘点和当前缺口见 +> 本文记录 Embedded direct-runtime 决策,以及需要连接边界时的 App Server 约束和 Shared transport 提案。具体 TUI 迁移阶段、接口盘点和当前缺口见 > [`tui-app-server-decoupling-refactor-plan.md`](../plans/tui-app-server-decoupling-refactor-plan.md);Agent Runtime 的进程、所有权和实例隔离见 > [`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md);产品 owner 与分层依赖见 -> [`product-architecture.md`](product-architecture.md)。评审完成前,当前调用路径以已接线代码和上述稳定架构文档为准。 - -## 1. Proposed decision - -当前首选候选是用 App Server 统一第一方 Rich Client 的产品后端接口。它尚未批准;下列约束只描述该候选被选中后的目标状态: - -- Desktop GUI、Web UI 和交互式 TUI 都是 App Server Rich Client。 -- Rich Client 的 Embedded deployment 也必须经过 App Server;它创建同进程私有 App Server,并通过私有 in-memory transport 连接。 -- Embedded 不表示直连 Runtime,也不要求独立后台进程、网络监听或跨客户端实例发现。 -- Embedded 与 Shared 复用同一 App Server client、协议版本、method、DTO、类型化错误、能力发现、事件和取消语义。 -- Embedded 与 Shared 只在 transport、App Server 实例所有权、客户端数量、连接治理和资源生命周期上不同。 -- Headless CLI/CI、ACP、Peer Host 和公开 Agent SDK 不是 Rich Client,不因该候选被强制改用 App Server;它们继续使用各自经评审的 adapter。 -- App Server 是协议适配层,不接管 Agent Runtime、Service 或 Product Domain 的业务所有权。 - -若选择该候选,不能用“Embedded 位于同一进程”作为 Rich Client 绕过 App Server 的理由,也不能用“统一 GUI/TUI 接口”把所有自动化和外部协议强制收敛到 App Server。 - -### 1.1 Alternatives under review +> [`product-architecture.md`](product-architecture.md)。迁移完成前,当前调用路径仍以已接线代码为准;Shared transport 未通过独立评审前继续使用 v17。 + +## 1. Decision and remaining proposal + +本次重构采用候选 B/C 的受限组合:**Embedded deployment 直接调用同进程 +Agent Runtime 的 typed API;需要进程间或网络边界的 Rich Client 才使用 App +Server**。因此,Rich Client 这一产品分类不再意味着所有部署都必须经过同一 +wire。 + +- Embedded Host 直接持有产品组装得到的 `AgentRuntime` 和必要的 owner/provider + facade,通过 Rust 类型调用 Runtime 方法;不创建 `BitfunAppServer`、 + `AppServerClient` 或 in-memory transport。 +- Embedded TUI 仍只依赖 app-local backend composition。TUI 不直接依赖 Runtime、Core + singleton、具体 Service 实现或私有 IPC operation;composition 内部由窄的 + `TuiRuntimePort` 承载跨 Embedded/Shared 的 Runtime 行为,并由 + `DirectRuntimeTuiRuntime` 完成 direct request/result/event/error 映射。 +- Model、Skill、Subagent、MCP、Account、Settings Sync、Worktree、External Source 和 + Hook 等管理面不进入 `TuiRuntimePort`,也不定义总括性的 `TuiManagementPort`。它们由 + backend composition 按 domain 直接注入 owner-owned 的稳定 service/provider trait;只有 + 原始接口暴露内部类型,或需要 TUI DTO、权限/上下文和 capability 裁剪时,才增加最薄的 + owner/provider facade。`AppManagementService` 继续是 App Server wiring,不原样迁移到 CLI。 +- Web UI / WebSocket Host 继续使用 App Server,因为它们需要连接、transport、 + 认证、作用域和事件转发边界。 +- Shared TUI 继续使用 private Runtime IPC v17,直到 Shared App Server 通过 + 独立的鉴权、controller/lease、恢复、取消、背压、限制、性能和回滚门槛;本次 + Embedded 重构不自动替换或删除 v17。 +- Headless CLI/CI、ACP、Peer Host 和公开 Agent SDK 继续使用各自经评审的 + adapter,不因 Rich Client 的 App Server 合同被强制改用 App Server。 +- App Server 仍是协议适配层,不接管 Agent Runtime、Service 或 Product Domain + 的业务所有权。直接 Runtime adapter 也只能调用既有 owner,不得复制业务状态。 + +Embedded 直调不是绕过安全或行为合同。它省略的是进程内无意义的 wire 和连接治理, +仍必须传递明确的 workspace、execution domain、权限和 request context,并复用同一 +Runtime owner、错误语义、事件语义和持久化规则。 + +### 1.1 Decision rationale and remaining alternatives | 候选 | 结构 | 收益 | 成本与风险 | 采用门槛 | | --- | --- | --- | --- | --- | -| A. App Server-first Rich Clients(当前首选) | Desktop、Web、Embedded/Shared TUI 复用一个 wire 与 typed client | 跨 Rich Client 合同和 fixture 最集中 | Embedded 编解码与 runtime/thread 成本;Desktop/Web 迁移面大;Shared 必须重新交付连接治理 | 真实 Desktop/TUI consumer、跨 transport parity、性能和安全门槛全部通过 | -| B. Deployment-specific product adapters | Desktop、Web、Embedded TUI、Shared TUI 各保留窄 adapter,共享 owner ports | 每个 Host 可按自身生命周期优化,迁移风险较低 | DTO、错误、恢复和行为 fixture 可能分叉;跨入口一致性需额外治理 | 证明长期重复成本低于统一 wire 成本,并建立跨 adapter 行为合同 | -| C. Shared Runtime use cases with separate wires | 提取稳定用例/结果,Embedded 使用 Rust adapter,Shared 保留 v17 或后继 wire,Web 使用 App Server | 业务语义集中,同时允许 deployment-specific framing、安全和性能 | 需要清晰区分 use-case DTO 与 wire DTO;client 不能假装同一协议 | 证明共享 use case 不泄漏 Runtime 实现,并分别验证每条 wire 的故障语义 | +| A. App Server-first Rich Clients | Desktop、Web、Embedded/Shared TUI 复用一个 wire 与 typed client | 跨 Rich Client 合同和 fixture 最集中 | Embedded 编解码与 runtime/thread 成本;Shared 必须重新交付连接治理 | 仅适用于确实需要进程/网络边界的 Host;不再作为 Embedded 默认方案 | +| B. Deployment-specific product adapters(Embedded 采用) | Embedded 直接调用 Runtime API;Web/Remote/Shared 按部署选择 transport adapter,共享 owner ports | 消除同进程编解码和 server task,保持 Host 生命周期简单 | DTO、错误、恢复和行为 fixture 可能分叉;需要统一行为合同 | Direct adapter 无 owner 复制,且通过跨入口行为等价测试 | +| C. Shared Runtime use cases with separate wires(保留) | 提取稳定用例/结果,Embedded 使用 Rust adapter,Shared 保留 v17 或后继 wire,Web 使用 App Server | 业务语义集中,同时允许 deployment-specific framing、安全和性能 | 需要清晰区分 use-case DTO 与 wire DTO;client 不能假装同一协议 | Shared transport 只有通过独立门槛后才可替换 v17 | -评审可以选择 A、B、C 或其受限组合。已有 `TuiBackend`、App Server 和 v17 是评估证据,不自动决定最终架构。 +Embedded 已选择 B/C 的受限组合,A 不再是 Embedded 默认方案。Shared 仍可选择经门槛验证的 App Server transport,或把 private v17/后继协议保留为部署专用 wire;已有 DTO 或 adapter 不能替代该评审。 ### 1.2 Costs of the preferred candidate -- Embedded Rich Client 需要承担 App Server client/server、JSON-RPC 编解码、事件队列和专用 runtime/thread 的启动、内存与延迟成本;必须以基准证明该成本可接受。 -- 迁移期会同时维护 App Server 与 Runtime IPC v17 两条 wire;新增核心用例需保持 `TuiBackend` 行为等价,不能让双写期形成两个业务 owner。 +- Embedded direct adapter 需要维护 Runtime typed API 与 TUI-facing contract 的映射;必须以行为 fixture 证明它没有复制 Session、Permission、Config 或 capability 状态。 +- 迁移前继续使用现有 Embedded App Server 作为行为基线;Phase 5 切换到 direct-runtime 后删除旧路径,不保留回滚 adapter,也不能在 direct adapter 返回 unsupported 后静默回退。 - Shared App Server 需要重新交付 v17 已有的 framing、方向性 limits、鉴权、实例身份、controller/lease、断连取消、未知结果和空闲退出,不能只复用 method/DTO。 - Desktop 迁移必须划清 controller-local capability、Tauri 生命周期和工作区 Host capability;Web/Remote 扩展还需要独立的认证、授权和多租户资源治理。 @@ -46,18 +65,30 @@ | 范围 | 当前状态 | 目标 | | --- | --- | --- | -| Embedded TUI | 已创建私有 `BitfunAppServer`,通过 in-memory transport 连接 `AppServerClient` | 完成剩余管理面迁移和行为等价验证 | -| Shared TUI | 仍通过私有 Runtime IPC v17 连接独立 Runtime Host | App Server Shared transport 达到可靠性等价后迁移 | -| Desktop GUI | 主要仍使用 Tauri command 和桌面事件投影 | Tauri 收窄为 Host adapter,产品请求统一进入 App Server | +| Embedded TUI | 当前仍通过私有 `BitfunAppServer`、in-memory transport 和 `AppServerClient` 运行 | 切换为 backend composition,由 `TuiRuntimePort`、同进程 `AgentRuntime` 和 owner service/provider 直接提供用例 | +| Shared TUI | 仍通过私有 Runtime IPC v17 连接独立 Runtime Host | 保留 v17;是否迁入 Shared App Server 由可靠性、安全、性能和回滚证据决定 | +| Desktop GUI | 主要仍使用 Tauri command 和桌面事件投影 | Embedded 时使用 direct Runtime adapter;需要连接边界时使用 App Server,Tauri 保留平台能力 | | Web Host | 当前 Server 已组装 Embedded Runtime,WebSocket 直接承载 `BitfunAppServer`;仅适用于 loopback 单用户模式 | 补齐连接身份、作用域绑定和 Host allowlist 后才能扩展部署范围 | | App Server protocol/client | 已拆为 behavior-light crate,已有版本、能力、限制、错误和部分事件恢复类型 | 补齐 Host 注入能力、可靠性语义及跨 transport 合同测试 | | App Server server | 已注册 app、agent、session、permission、TUI/workspace、git、config 和 i18n handler | 按真实 owner 和 Host 装配收窄能力,不以已存在 DTO 代替可用性证据 | -Shared TUI 继续使用 Runtime IPC 是当前 compatibility boundary。只有候选 A 获批且替换门槛通过后,才迁移或删除该 IPC;候选 B/C 可能将 private v17 或后继协议保留为受控的长期物理 wire。 +Shared TUI 继续使用 Runtime IPC 是当前 compatibility boundary。Embedded direct-runtime +迁移不改变该边界。只有 Shared App Server 通过替换门槛后,才评审迁移或删除 v17;候选 +B/C 允许将 private v17 或后继协议保留为受控的长期物理 wire。 -### 1.4 Decision and replacement gates +### 1.4 Migration and replacement gates -在满足下列门槛前,不得把候选 A 标记为 approved,也不得用 Shared App Server 替换 v17: +Embedded direct-runtime 完成迁移前必须满足: + +| 门槛 | 必需证据 | +| --- | --- | +| Typed facade 与 owner | direct adapter 只调用稳定 Runtime/owner-provider facade;不暴露内部类型,不复制 Session、Permission、Config、capability 或事件状态 | +| 上下文与能力 | workspace、execution domain、permission、remote facts 和 capability 来自真实 Host/Runtime 组装;不从 UI 猜测,不静默本机回退 | +| 行为与事件 | 请求结果、事件顺序、pending Permission、取消、`unsupported`、lag/closed 和 `outcome_unknown` 与既有行为合同等价 | +| 生命周期与性能 | 不创建 App Server client/server、in-memory transport 或额外 Runtime;订阅和任务可回收,并记录启动、延迟和内存对比 | +| 迁移与删除 | 同一 TUI fixture 覆盖 direct 与旧 App Server;升级/降级读取兼容,旧路径仅作为迁移基线并在切换后删除 | + +Shared App Server 只有满足以下连接治理门槛后才可替换 v17: | 门槛 | 必需证据 | | --- | --- | @@ -67,7 +98,7 @@ Shared TUI 继续使用 Runtime IPC 是当前 compatibility boundary。只有候 | 事件恢复 | 明确 snapshot/replay owner、连接内 cursor、跨连接是否持久化、lag/closed/invalidation 和 resync 行为 | | 取消与未知结果 | disconnect/shutdown 取消、迟到响应、operation identity、`outcome_unknown` 查询/恢复和禁止盲重试 | | Host capability | Desktop local effect 与工作区 capability 边界、provider 注入、Remote unsupported 和 Web/Remote auth 已定稿 | -| 生命周期与性能 | discovery、startup、idle exit、crash cleanup、延迟、吞吐、内存和 Embedded thread/runtime 成本有预算与测量 | +| 生命周期与性能 | discovery、startup、idle exit、crash cleanup、延迟、吞吐和内存有预算与测量 | | 迁移与回滚 | 同一第一方 consumer 完成 opt-in 双栈 parity;升级/降级和 v17 rollback 可重复验证;删除条件有明确 owner 批准 | ## 2. 问题与目标 @@ -80,14 +111,14 @@ GUI、Web 和 TUI 若分别围绕 Tauri command、WebSocket route、CLI/Core 直 - UI 组件与 Tauri、Core singleton 或私有 Runtime IPC 绑定,无法验证跨入口行为等价。 - “handler 已存在”“DTO 已生成”或“能力被硬编码为 available”被误当成端到端能力已交付。 -App Server 的目标是提供一个可版本化、可生成 client、可跨 Embedded/Shared transport 验证的 Rich Client 合同,同时保持业务 owner 平台无关。它统一的是产品后端行为,不统一 GUI/TUI renderer、布局、键位、窗口、终端或 controller-local effect。 +BitFun 的目标是让 direct adapter 与连接型 App Server adapter 共享可验证的产品后端行为合同,同时保持业务 owner 平台无关。App Server 为需要连接边界的 Host 提供可版本化、可生成 client 的 wire;它不再是 Embedded 的统一 transport,也不统一 GUI/TUI renderer、布局、键位、窗口、终端或 controller-local effect。 ## 3. 范围与非目标 本文范围包括: - Rich Client 的请求、响应、notification、错误、取消和恢复合同。 -- Embedded、Shared 和 WebSocket Host 的 transport 与生命周期边界。 +- Embedded direct adapter、Shared/WebSocket transport 与 Host 生命周期边界。 - Host 能力、transport limit、身份和执行域的协商。 - Desktop/Tauri、Web 和 TUI 的接入规则。 - App Server crate、Runtime owner 和产品装配之间的依赖方向。 @@ -99,17 +130,18 @@ App Server 的目标是提供一个可版本化、可生成 client、可跨 Embe - 强制 Headless CLI/CI、ACP、Peer Host 或公开 Agent SDK 使用 App Server。 - 统一 GUI 与 TUI 的状态机、renderer、布局、主题键或键位模型。 - 把 WebSocket transport 宣称为已具备多用户或公网安全性的公开 API。 -- 不允许临时兼容路径在没有明确决策、维护责任、版本规则和退出条件的情况下意外变成永久协议。若最终选择候选 B/C,应把保留的 Shared wire 明确定义为正式的部署专用协议,而不是继续称为临时兼容路径。 +- 不把缺少维护责任、版本规则和退出条件的临时兼容路径默认为永久协议;若保留 Shared wire,应将其明确定义为正式的部署专用协议。 ## 4. 术语 | 名词 | 含义 | 不等于 | | --- | --- | --- | -| App Server | 将版本化 Rich Client wire 映射到 Runtime API、Service 和 Product Domain owner 的协议适配层 | 业务 owner、通用 RPC 总线、必然独立的进程 | +| App Server | 将版本化 Rich Client wire 映射到 Runtime API、Service 和 Product Domain owner 的协议适配层;只用于需要该边界的 Host | 业务 owner、通用 RPC 总线、Embedded 的必经路径 | | App Server Client | 只依赖 wire contract、由 Host 提供 transport 的类型化客户端 | Runtime SDK、Server 构造器、UI 状态 owner | | Rich Client | 需要持续会话、交互事件和产品管理面的第一方 GUI/Web/TUI | Headless automation、ACP、公开 SDK | -| Host | 组装 App Server、选择 transport、注入能力并管理生命周期的产品入口 | 新业务层、普通用户必须管理的 Server 产品 | -| Embedded App Server | 与 Rich Client Host 位于同一 OS 进程的私有 App Server 实例 | Runtime 直连、网络 Server、共享后台进程 | +| Host | 组装 direct Runtime adapter 或 App Server、选择 transport、注入能力并管理生命周期的产品入口 | 新业务层、普通用户必须管理的 Server 产品 | +| Embedded direct Runtime | 与 Rich Client Host 同进程、由 `AgentRuntime` typed API 和 owner/provider facade 提供用例的部署方式 | 第二套 Runtime、App Server wire、跨进程后台服务 | +| Embedded App Server | 迁移前 TUI 使用的同进程私有 App Server 实例和 in-memory transport | Embedded 的目标默认路径、网络 Server、共享后台进程;Phase 5 完成后删除 | | Shared App Server | 由独立本机 Host 承载、允许多个已认证第一方 client 使用的 App Server 实例 | 公网 API、Agent SDK Host、每个 client 一个 Runtime | | Runtime owner | 持有 Session、Turn、Permission、Tool/MCP、Hook、事件和持久化事实的既有模块 | App Server handler 或 UI read model | | Host capability | 当前 Host 确实组装并允许调用的产品能力 | schema 中存在的方法全集 | @@ -128,16 +160,25 @@ flowchart LR GUI --> Host["Host adapter"] Web --> Host TUI --> Host - Host --> Client["App Server Client"] + Host --> Route{"Deployment route"} + Route -->|"Embedded direct · approved target"| Direct["Direct Runtime adapter"] + Route -->|"Web / candidate Shared · Phase 6"| Client["App Server Client"] Client --> Transport["Host-selected transport"] Transport --> Server["App Server"] - Server --> API["Runtime API / owner ports"] + Direct --> API["Runtime API / owner ports"] + Server --> API API --> Owners["Runtime · Services · Product Domains"] ``` -依赖和调用方向始终从入口流向 owner。Host 负责 transport 认证、连接作用域、capability/allowlist 和平台能力;App Server handler -负责 method 合同校验、handler 注册、DTO 转换和 Runtime/domain error 到 wire error 的映射。业务一致性、权限上限、持久化和权威状态 -仍由对应 owner 提交。 +依赖和调用方向始终从入口流向 owner。Embedded Host 负责 direct adapter 的 context +构造、能力选择和生命周期;需要连接边界的 Host 负责 transport 认证、连接作用域、 +capability/allowlist 和平台能力。App Server handler 负责 method 合同校验、handler +注册、DTO 转换和 Runtime/domain error 到 wire error 的映射。业务一致性、权限上限、 +持久化和权威状态仍由对应 owner 提交。 + +图中的 Web 分支是当前 loopback WebSocket App Server 路径;Shared 分支仅表示 Phase 6 +candidate,不是当前或已批准的必经链路。当前 Shared TUI 仍只使用 private Runtime IPC v17, +Web 也不经过 TUI backend composition。 ### 5.1 四层合同 @@ -148,37 +189,51 @@ flowchart LR | Host 合同 | transport、可用能力、限制、身份、作用域、生命周期和 controller-local provider | 复制业务规则或权威状态 | | Owner 合同 | Runtime/Service/Product Domain 的业务事实、校验和提交 | JSON-RPC、Tauri、WebSocket、Ratatui | -行为合同是 Embedded 与 Shared 等价的核心。仅复用 JSON 字段但在断连、超时、事件落后或权限上表现不同,不算统一 App Server 接口。 +行为合同是 Embedded direct-runtime 与 Shared wire 等价的核心;迁移前的 Embedded App Server +仅作为 direct-runtime 切换前的行为基线,不构成迁移后的第三条运行路径。其中,Phase 5 将引入的 +`TuiRuntimePort` 是 Shared IPC operation 集合在 TUI 侧的窄语义边界;管理 service/provider +不是 Shared Runtime wire 的组成部分。行为合同不要求三者共享 JSON;只要 Runtime port +在断连、超时、事件落后、权限、取消和 unknown outcome 上保持明确且可验证的语义, +即可共享同一 Runtime 用例合同。管理面按各自 service/provider 的可用性单独验证。 -## 6. Embedded deployment +## 6. Phase 5 Embedded interactive TUI target -Embedded Rich Client 的标准路径是: +本节只定义交互式 TUI 的 Phase 5 目标路径。它不描述 Desktop 或 Web 的迁移完成状态; +Desktop direct Runtime 是独立的已批准迁移步骤,尚未实施。 ```text -Rich Client +Embedded interactive TUI -> Host adapter - -> AppServerClient - -> private in-memory transport - -> private App Server instance + -> TuiBackend composition + -> TuiRuntimePort -> DirectRuntimeTuiRuntime -> AgentRuntime typed API + -> owner-owned service/provider interfaces (management only) -> Runtime API / owners ``` Embedded Host 必须: -1. 组装 Runtime 和 App Server,并将同一 owner 的端口注入 server。 -2. 创建方向固定的私有 in-memory transport pair。 -3. 通过正式 App Server Client 完成 initialize、请求、事件和 shutdown。 -4. 保证 server task/thread 在 Host 退出时被取消并回收。 -5. 使用与 Shared 相同的 schema、错误和行为测试。 - -Embedded 可以省略只对跨进程多客户端有意义的机制:endpoint discovery、进程 token、外部实例锁、多客户端 controller lease 和空闲后台退出。省略这些机制不能改变请求结果、事件顺序、取消结果或 capability 语义。 - -进程内 transport 仍可能执行 JSON-RPC 编解码。该成本是候选 A 必须测量的工程取舍;只有基准、资源预算和真实 consumer 证明可接受后, -才能把强制经过 App Server 作为批准约束。允许评估 transport buffer、生成代码和批量事件优化,但不能用未经验证的性能假设提前排除候选 B/C。 +1. 从产品组装结果取得唯一的 `AgentRuntime` 和必要的 owner/provider facade。 +2. 通过稳定 Rust Runtime API 构造 typed request,补齐 workspace、execution domain、 + permission 和 remote facts;不得从 UI 或全局环境猜测这些事实。 +3. 将 `AgentRuntime` 的事件/Permission receiver 映射为 `TuiRuntimePort` 可消费的 + semantic event, + 不创建第二个 Core `EventQueue` 订阅或第二份 read model 权威状态。 +4. 将 Runtime/domain error 映射为 Runtime port 或对应 owner service 的 TUI error,保留 `unsupported`、取消、 + `outcome_unknown` 等可观察语义。 +5. 在 Host 退出时取消并回收由 direct adapter 创建的订阅和任务,并使用与 Shared + 和 Web 路径相同的行为 fixture。 + +Embedded 可以省略只对跨进程多客户端有意义的机制:endpoint discovery、进程 token、 +外部实例锁、多客户端 controller lease、frame 编解码和空闲后台退出。省略这些机制 +不能改变请求结果、事件顺序、取消结果、权限边界或 capability 语义。 + +迁移窗口内可以用现有 Embedded App Server 与 direct adapter 做行为对照,但不保留可选的 +rollback adapter,也不得在 direct adapter 返回 unsupported 后静默回退。完成 direct-runtime +行为、性能和升级兼容验证后,Phase 5 必须删除旧路径。 ## 7. Shared deployment -Shared deployment 由一个本机 App Server Host 承载一个 Runtime owner,多个第一方 Rich Client 通过受控 Pipe、UDS 或等价私有 transport 连接: +当前 Shared deployment 由独立 Runtime Host 通过 private Runtime IPC v17 服务交互式 TUI,不运行 App Server。若后续采用 Shared App Server,则目标拓扑为一个本机 App Server Host 承载一个 Runtime owner,多个第一方 Rich Client 通过受控 Pipe、UDS 或等价私有 transport 连接: ```mermaid flowchart LR @@ -190,7 +245,7 @@ flowchart LR R --> D["Workspace and Session storage"] ``` -Shared Host 在基础 App Server 合同之外必须提供: +采用 App Server 的 Shared Host 在基础 App Server 合同之外必须提供: - 安全 endpoint discovery、实例身份和同用户认证材料。 - initialize-first 握手、协议版本和 client identity 校验。 @@ -206,24 +261,32 @@ Shared Host 在基础 App Server 合同之外必须提供: ## 8. Desktop GUI 与 Tauri -Desktop 的目标调用路径是: +Desktop 的调用路径按部署选择: ```text React UI -> frontend infrastructure / generated App Server client -> Desktop Host transport adapter - -> Embedded or Shared App Server + -> App Server(Web/Shared 或明确需要连接边界时) + +Embedded Desktop 的同进程产品请求可以走: + +React UI + -> frontend infrastructure + -> Desktop direct Runtime adapter + -> Runtime API / owner ports ``` -Tauri 继续拥有窗口、菜单、系统托盘、文件选择器、剪贴板、通知和进程级生命周期。Session、Turn、Workspace、Permission、Config、MCP、Skill、Hook 等产品后端能力必须迁入 App Server。 +Tauri 继续拥有窗口、菜单、系统托盘、文件选择器、剪贴板、通知和进程级生命周期。Session、Turn、Workspace、Permission、Config、MCP、Skill、Hook 等产品后端能力必须迁入既有 Runtime/owner;Embedded 时由 direct adapter 调用,需要连接边界时再由 App Server 做协议适配。 迁移规则: - UI 组件不得直接调用 Tauri API;调用进入前端 infrastructure/adapter。 -- Tauri command 若只承载产品后端用例,应由 App Server method 替代并逐步删除。 -- 必须由桌面原生 API 完成的 client-local capability 保留 Host-native 实现;需要与工作区或 Runtime 交互时拆成 App Server 数据流和本地 effect 两段。 -- Tauri event bridge 只能投递 App Server typed notification 或桌面专属事件,不能形成第二套 Runtime 事件语义。 -- Desktop Host 可在 Embedded 与 Shared 之间切换,但 UI 和生成 client 不包含 Runtime 直连分支。 +- Tauri command 若只承载产品后端用例,应由稳定 Runtime typed facade 或需要连接边界时的 App Server method 替代并逐步删除。 +- 必须由桌面原生 API 完成的 client-local capability 保留 Host-native 实现;需要与工作区或 Runtime 交互时拆成 direct/App Server 数据流和本地 effect 两段。 +- Tauri event bridge 只能投递 direct Runtime/App Server typed notification 或桌面专属事件,不能形成第二套 Runtime 事件语义。 +- Desktop Host 可在 direct Embedded 与 Shared/App Server 之间切换,但 UI 不包含部署分支; + route 选择留在 Host/infrastructure。 ## 9. Web 与远程 Host @@ -263,7 +326,7 @@ WebSocket 是 App Server 的一种 transport,不是另一套业务 API。Web H ## 11. 事件、恢复与取消 -权威 Runtime 事件通过同一 App Server connection 以 typed notification 发送。Host 不得让 client 绕过 App Server 直接订阅 Core `EventQueue`,也不得用有损 frontend projection 替代权威事件流。 +需要连接边界的 client 通过其 App Server connection 接收 typed Runtime notification;Embedded direct adapter 则通过 Runtime typed subscription 订阅同一权威 owner。App Server client 不得绕过连接直接订阅 Core `EventQueue`,direct adapter 也不得持有 Core queue receiver 或创建第二个事件 owner;两条路径都不得用有损 frontend projection 替代权威事件流。 每个事件流至少需要: @@ -307,7 +370,7 @@ App Server 是完整产品控制面,安全决策必须绑定到连接和业务 | 资源治理 | 连接、请求、frame、队列、并发、速率和任务生命周期有界 | | 远程边界 | 凭据、文件、进程和 Runtime 留在目标执行域;禁止本地 fallback | -Embedded 的私有 transport 可以依赖同进程构造身份,但仍必须传递明确的 Host/connection context,不能让 handler 从全局环境猜测调用主体。 +Embedded direct invocation 可以依赖同进程构造身份,但仍必须传递明确的 Host/request context,不能让 adapter 或 owner 从全局环境猜测调用主体。App Server connection 继续使用显式 connection context。 ## 14. Crate 与所有权边界 @@ -332,13 +395,28 @@ Embedded 的私有 transport 可以依赖同进程构造身份,但仍必须传 迁移按行为闭环推进,不按 method 数量推进: -1. **锁定合同基础**:稳定 protocol/client crate、版本、错误、能力、限制和事件 envelope;增加 Embedded contract test。 -2. **完成 Embedded TUI**:所有交互式 TUI 产品请求经 `TuiBackend -> AppServerClient`;移除 Core、Runtime SDK、Service singleton 和 Runtime IPC 的 TUI-facing 依赖。 -3. **迁移 Desktop GUI**:按 Session/Turn/Permission、Workspace、Config/MCP/Extension 等垂直切片迁移;每片完成后删除重复 Tauri DTO/handler。 -4. **补齐 Shared 语义**:把 authentication、instance identity、controller/lease、framing、背压、断连取消、idle exit、event recovery 和 `outcome_unknown` 纳入 App Server Host/transport。 -5. **评审 Shared TUI 迁移**:候选 A 获批且 1.4 节门槛通过后,才用同一 client/schema 替换 Runtime IPC compatibility adapter;旧 wire 仅在 rollback 窗口结束并获得 owner 批准后删除。若选择 B/C,则记录 v17 的长期 owner、版本和删除条件。 -6. **收紧 Web Host**:由 Host 注入 allowlist、作用域和真实 limits;完成安全绑定前保持 loopback 单用户限制。 -7. **删除旁路**:移除 Rich Client 的 Core/Runtime 直连、重复事件投影和无生产消费方的旧 route。 + 1. **锁定 Runtime 行为合同**:按当前 Shared IPC v17 operation 集合冻结窄 + `TuiRuntimePort`,稳定 Runtime request/response/event 类型、错误、能力、取消和事件语义; + 为 direct Embedded 和 Shared 增加同一 Runtime 行为 fixture,迁移前可用旧 App Server 建立行为基线。 + 2. **拆分 TUI backend composition**:将当前单体 `TuiBackend` 拆为 `TuiRuntimePort` 与按 + domain 注入的 owner service/provider 接口;不定义总括性的 `TuiManagementPort`。管理 service + 能直接复用稳定 owner trait 的直接注入;只有需要 TUI DTO、权限/上下文或 capability 裁剪时 + 才增加薄 facade。 + 3. **迁移 Embedded TUI**:实现 `DirectRuntimeTuiRuntime`,将 Runtime 调用从 + `AppServerTuiBackend` 移出;再移除 in-memory transport、Embedded `BitfunAppServer`、 + server thread 和 TUI-facing App Server client 依赖。管理面使用 owner-owned service/provider, + 不把具体 `AppManagementService` 原样搬入 CLI/TUI。 +4. **迁移 Desktop GUI**:Embedded 时使用 direct Runtime adapter;Web/Shared 或需要连接治理 + 的场景保留 App Server。Tauri 继续承载平台能力与生命周期。 +5. **补齐 Shared 语义**:把 authentication、instance identity、controller/lease、framing、 + 背压、断连取消、idle exit、event recovery 和 `outcome_unknown` 纳入 App Server Host/transport。 +6. **评审 Shared TUI 迁移**:Shared App Server 只有通过 1.4 节门槛后才可替换 Runtime IPC + compatibility adapter;v17 是否删除由独立评审和验证证据决定。若选择 B/C, + 记录 v17 的长期 owner、版本和删除条件。 +7. **收紧 Web Host**:由 Host 注入 allowlist、作用域和真实 limits;完成安全绑定前保持 loopback + 单用户限制。 +8. **删除旁路**:移除 Rich Client 的重复 Runtime 事件投影、旧 Embedded App Server route 和 + 无生产消费方的兼容代码。 迁移期间不得在 App Server 返回 unsupported 后静默调用旧 Tauri/Core/IPC 路径。需要暂存旧路径时,必须由 Host 在启动时明确选择完整 adapter,且 UI 只看到一个 `TuiBackend` 或 frontend infrastructure 接口。 @@ -347,7 +425,11 @@ Embedded 的私有 transport 可以依赖同进程构造身份,但仍必须传 ### 16.1 必需验证 - protocol serialization、版本上下界、未知字段和类型化错误合同测试。 -- 同一用例在 Embedded in-memory 与 Shared process transport 上的行为等价测试。 +- 同一用例在 Embedded direct-runtime、Shared process transport 和 WebSocket App Server(适用时) + 的行为等价测试;迁移前旧 Embedded App Server 仅用于建立基线,不作为持续测试路径。 +- `TuiRuntimePort` coverage:当前 Shared IPC v17 的每个 Runtime operation 都有对应的 + port 行为测试;管理 service/provider 则单独验证 capability、权限、unsupported 和 Remote + fail-closed,不以 Runtime port parity 代替管理面验证。 - Host capability/provider/allowlist 组合测试,以及真实 transport limit 测试。 - request identity、取消、断连、超时和 `outcome_unknown` 测试。 - 事件顺序、lag、invalidated、cursor/snapshot resync 和慢 client 测试。 @@ -356,26 +438,36 @@ Embedded 的私有 transport 可以依赖同进程构造身份,但仍必须传 - Cargo 依赖闭包和 `product-full` 禁止规则。 - TypeScript/Rust client 生成结果与 schema 一致性检查。 -### 16.2 完成定义 - -若选择候选 A,只有同时满足以下条件,App Server Rich Client 架构才算完成: - -1. Desktop GUI、Web UI 和交互式 TUI 的产品后端请求与订阅均经过 App Server。 -2. Embedded 与 Shared 使用同一 client、method、DTO、错误和事件恢复合同,UI 不包含部署分支。 -3. Shared transport 达到现有 Runtime IPC 的鉴权、lease、取消、背压、限制、失效和生命周期等价。 -4. capability 和 limits 来自 Host 的真实装配与 transport,不再由通用 handler 无条件硬编码。 -5. Rich Client 不直接依赖 Core singleton、Runtime SDK、Tauri 业务 command 或私有 Runtime IPC。 -6. App Server handler 不持有业务权威状态,不复制 owner 校验和策略。 -7. Remote workspace 和多用户连接具有明确身份、作用域、授权和 fail-closed 行为。 -8. 重复 Tauri/Web/IPC DTO、旧 handler 和事件旁路已删除,或有明确的兼容期限与删除证据。 -9. 上述合同、行为、安全、依赖和跨入口测试全部通过。 - -## 17. Proposed constraints and open decisions - -### 17.1 Proposed target constraints - -- 若候选 A 获批,Rich Client Embedded 必须经过私有 in-process App Server。 -- Embedded 和 Shared 只有部署与连接治理差异,不产生第二套产品行为。 +### 16.2 TUI/App Server 解耦完成定义 + +只有交互式 TUI 同时满足以下条件,TUI/App Server 解耦才算完成;这一定义不涵盖 +Desktop 的独立 direct Runtime 迁移: + +1. Embedded TUI 的产品请求和订阅经过 `TuiAgentClient` 的 backend composition;Runtime + 行为经过 `TuiRuntimePort -> DirectRuntimeTuiRuntime`,管理能力经过对应 owner service/provider, + TUI view/reducer 不执行 backend I/O。 +2. direct adapter 只调用既有 Runtime API、owner-owned service/provider 和必要的薄 facade, + 不复制 Session、Permission、Config、capability 或事件权威状态,也不定义 `TuiManagementPort`。 +3. Embedded direct-runtime 与 Shared v17 的行为合同覆盖请求结果、事件顺序、 + 取消、权限、unsupported、断连和 unknown outcome。 +4. capability、作用域和 remote facts 来自真实 Host/Runtime 组装;Remote workspace 不存在 + controller-local fallback。 +5. Embedded 不创建 App Server client/server、in-memory transport 或额外 Runtime 进程; + direct adapter 的任务和订阅在 Host 退出时可回收。 +6. App Server 仍可被 Web/Shared Host 使用,且 handler 不持有业务权威状态或复制 owner 策略。 +7. 旧 Embedded App Server 路径已删除且不再作为 rollback adapter;70 方法单体 `TuiBackend` + 不再作为稳定接口边界。 +8. Shared transport 若要替换 v17,仍需单独满足鉴权、lease、取消、背压、限制、失效和生命周期 + 等价门槛。 +9. 上述合同、行为、安全、依赖、升级兼容和跨入口测试全部通过。 + +## 17. Constraints and open decisions + +### 17.1 Accepted target constraints + +- Embedded 默认直接调用同进程 Runtime typed API;只有需要连接/进程边界的 Rich Client 才经过 App Server。 +- Embedded direct-runtime 和 Shared 只有适配与连接治理差异,不产生第二套产品行为;旧 + Embedded App Server 仅是迁移前基线。 - App Server 只映射 owner,不成为 Session、Turn、Permission、Tool/MCP、Config 或事件 owner。 - Host capability 必须由真实装配、授权和 transport 共同决定。 - 事件丢失、断连和未知副作用结果必须显式可见,不能用轮询或盲重试掩盖。 @@ -391,4 +483,7 @@ Embedded 的私有 transport 可以依赖同进程构造身份,但仍必须传 - Desktop client-local capability 的请求方向:App Server 反向 request、Host provider port,或显式两段式工作流。 - Web/Remote 的认证凭据来源、刷新、撤销和多租户资源配额。 -这些待决项会影响候选选择,不能被实现默认值或迁移进度替代。评审结论必须记录所选候选、拒绝其他候选的理由、门槛 owner、验证证据和回滚/删除条件;在此之前,当前 Embedded App Server 与 Shared v17 路径都保持有效。 +这些待决项会影响 Shared transport 的选择,不能被实现默认值或迁移进度替代。Shared 评审结论必须记录 +所选 transport、拒绝其他方案的理由、门槛 owner、验证证据和回滚/删除条件。Embedded direct-runtime +迁移完成后,旧 Embedded App Server 必须删除且不保留回滚路径;WebSocket App Server 与 Shared v17 +按各自部署合同继续有效。 diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index fa98bb87d1..a4492d158a 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -10,8 +10,6 @@ - 公开 Agent SDK:[`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md) - 产品定制:[`product-customization-blueprint.md`](product-customization-blueprint.md) - 外部 AI 工作来源:[`extensions/external-ai-work-sources-design.md`](extensions/external-ai-work-sources-design.md) -- 外部 AI 应用连接体验:[`extensions/external-ai-app-connection-experience-design.md`](extensions/external-ai-app-connection-experience-design.md) -- 外部 AI 应用连接执行计划:[`../plans/external-ai-app-connection-experience-plan.md`](../plans/external-ai-app-connection-experience-plan.md) - OpenCode 兼容矩阵:[`extensions/opencode-extension-compatibility.md`](extensions/opencode-extension-compatibility.md) - 插件 Runtime:[`extensions/plugin-runtime-design.md`](extensions/plugin-runtime-design.md) - Detached Dispatch:[`detached-task-dispatch.md`](detached-task-dispatch.md) @@ -146,7 +144,7 @@ SHELL composer - `stream-json` stdout 每行是一个完整 Agent event。 - 日志与诊断进入 stderr 或日志文件。 - 默认拒绝需要人工确认的操作;只有显式调用级策略可以自动批准。 -- 目标连接体验交付后,只有 Agent Runtime 沿现有事件流返回与当前执行域、工作区作用域、根会话和根轮次完全匹配的依赖结果时,CLI 才投影类型化 `action-required`;当前实现尚未提供该结果。子代理必须通过现有父子关系事件证明仍属于根依赖链,无关待办或后台子代理不得改变退出结果。 +- 非交互入口不等待人工确认,也不从全局外部来源状态推断特殊任务结果。能力不可用时返回普通失败;能够可靠归属到 Tool、Agent 或 MCP owner 时,错误只给出对应管理入口。 - 取消、事件失步、失败完成和 Patch 失败不能报告成功。 ## 5. TUI 内部边界 @@ -174,9 +172,10 @@ CLI-local 配置只保存终端形态偏好与调用入口设置。共享权限 CLI 通过 `DeliveryProfile::Cli` 消费经过校验的产品 Runtime parts。产品定义、Delivery Profile、Runtime Configuration 和 Capability Availability 是不同概念: -- 编译期由 CLI 显式选择 `agent-runtime`、`canvas-runtime`、`external-sources`、 - `plugin-runtime` 与 `ssh-remote` owner feature;这保持现有 CLI capability plan, - 但不继承 Desktop 后续加入 `product-full` 的能力。 +- 编译期由 CLI 显式选择 `agent-runtime` 生命周期基线、实际 service owner、 + `external-sources` / `plugin-runtime` / `ssh-remote` 和九组 `tools-*`;这保持现有 + CLI capability plan,但不再从 Core 基线暗带具体能力,也不继承 Desktop 后续加入 + `product-full` 的能力。 - 隐藏入口不证明后端依赖被移除。 - CLI 不读取 authoring product definition 作为运行时业务配置。 - 品牌、资源、数据 namespace、更新渠道和内置扩展由产品定制 owner 生成,CLI 只消费结果。 @@ -185,10 +184,8 @@ CLI 通过 `DeliveryProfile::Cli` 消费经过校验的产品 Runtime parts。 CLI 只消费 typed summary 与 typed action: -> **目标状态,尚未交付:** 当前 `/extensions` 只支持来源状态、刷新、Safe Mode 和来源开关;没有应用级连接动作、`/extensions review` 或任务相关 `action-required`。以下入口必须完成执行计划 P1-P6 及端到端验证后,才能更新为当前能力。 - -- `/extensions` 是应用级摘要、首次连接和状态恢复入口;`/extensions review` 提供与 GUI 等价的单页批量确认。 -- `/tools`、`/agent`、`/mcp` 和 `/hooks` 保留能力专项或高级管理职责,不复制应用级连接流程。 +- `/extensions` 只提供外部应用/来源的简短状态、启停和刷新,不拥有审批、冲突或批量决策。 +- `/tools`、`/agent`、`/mcp` 和 `/hooks` 是对应能力的直接管理入口;需要用户允许时由真实 owner 在该入口处理,不再增加跨能力复审流程。 - 静态发现不等于代码执行或服务健康。 - 配置导入不授予插件执行权限。 - ACP、MCP import、Hook import、可执行插件和 TUI contribution 使用独立状态与生命周期。 diff --git a/docs/architecture/extensions/capability-runtime-integration-design.md b/docs/architecture/extensions/capability-runtime-integration-design.md index 5b5bde6229..f02b9484bc 100644 --- a/docs/architecture/extensions/capability-runtime-integration-design.md +++ b/docs/architecture/extensions/capability-runtime-integration-design.md @@ -435,8 +435,8 @@ OpenCode,多语言协议与发布一致性参考 Copilot SDK。最终结构和 ## 10. 产品体验要求 -1. **不阻塞正常工作**:发现、准备、兼容检查和无关待确认项在后台进行;只有当前操作真正依赖待确认能力时返回 - 类型化 `action-required`。 +1. **不阻塞正常工作**:发现、准备、兼容检查和无关待确认项在后台进行;当前操作真正依赖不可用能力时,由该能力 owner + 返回普通失败并指向对应的权限或配置入口,不增加跨能力任务结果类型。 2. **能力状态可解释**:设置页、CLI 和 SDK 能看到来源范围、执行位置、外部宿主、native/degraded 状态、最终 Provider、权限 上限、最近错误和恢复动作;默认界面只显示需处理项和聚合摘要。 3. **不重复打扰**:同一来源/能力/候选内容摘要只询问一次;内部 `prepare/ready/activate` 阶段不逐层重复审批。 diff --git a/docs/architecture/extensions/external-ai-app-connection-experience-design.md b/docs/architecture/extensions/external-ai-app-connection-experience-design.md deleted file mode 100644 index 30da8581d4..0000000000 --- a/docs/architecture/extensions/external-ai-app-connection-experience-design.md +++ /dev/null @@ -1,497 +0,0 @@ -# 外部 AI 应用连接与管理详细设计 - -本文定义“外部 AI 应用”在 Desktop Settings、交互式 TUI 和非交互 CLI 中的应用级连接与管理体验。稳定架构、归属模块和运行视图见[外部 AI 工作内容架构](external-ai-work-sources-design.md),实施顺序见[外部 AI 应用连接体验执行计划](../../plans/external-ai-app-connection-experience-plan.md)。 - -本文只描述交互、应用级读模型、动作语义和宿主投影,不重定义生态解析、能力归属、执行权限或插件运行时。 - -> **实现状态:目标设计,尚未交付。** 当前生产协议是严格校验的 `ExternalSourceControlSnapshotV1`,Desktop/TUI 仍使用来源与能力级状态;应用级连接、批量确认和任务相关 `action-required` 必须完成对应执行计划并取得端到端证据后,才能作为当前能力引用。 - -## 1. 问题与设计目标 - -当前 Settings 页面把接入策略、物理来源、Tool、Subagent、MCP、冲突、诊断和 Safe Mode 平铺在同一页面。用户必须理解内部能力分类,才能完成“使用另一个 AI 应用中的能力”这一主任务。 - -目标是: - -1. 以外部应用而不是能力类型作为首次连接和日常管理入口。 -2. 明确区分发现、连接和加载,避免“发现即运行”。 -3. 对低风险声明式内容采用低摩擦默认路径,对可执行或权限扩大的内容集中确认。 -4. 给连接动作明确完成反馈,说明已启用、待确认和受限内容。 -5. 适配 Settings 约 600px 的正文宽度,采用纵向单列和渐进披露。 -6. 提示低侵入、一次性、状态驱动;用户已决定后不重复打扰。 -7. GUI 与 TUI 共享产品语义、状态、默认策略和决策结果,不共享布局与渲染实现。 - -## 2. 范围与非目标 - -本设计覆盖: - -- Desktop Web UI 的应用首页、详情、批量确认和高级设置; -- TUI `/extensions` 的应用摘要、连接和批量确认; -- 非交互 CLI 的任务相关 `action-required`; -- Peer Host / Server 对共享应用级读模型和类型化动作的投影; -- 默认连接产品事实、提示去重和跨宿主决策一致性。 - -本设计不包含: - -- 外部聊天历史或项目迁移; -- 将持续来源复制成 BitFun 原生配置; -- 自动连接或加载所有检测到的应用; -- 自动运行所有 Tool、Subagent、MCP、Hook、进程或网络能力; -- 改变生态配置解析、能力归属、权限归属或安全上限; -- GUI/TUI 共享布局、组件、主题 key、快捷键或渲染 schema; -- 无法可靠实现的全局撤销; -- 扩展 OpenCode legacy managed-package 路径为目标运行时模型。 - -“导入”只用于真正复制或迁移数据的独立能力。持续兼容来源统一使用“发现、连接、加载、断开连接”。 - -### 2.1 核心术语 - -正文优先使用中文,协议字段保留代码名: - -| 术语 | 含义 | -|---|---| -| 执行域(`execution_domain_id`) | 外部事实被读取、能力被加载的真实宿主边界 | -| 工作区作用域(`workspace_scope_id`) | 宿主为当前工作区计算的不透明策略键,只在所属执行域内有效 | -| 用户默认(`user_default`) | 同一执行域内,没有工作区覆盖时使用的缺省决定 | -| 工作区覆盖(`workspace_override`) | 只影响当前工作区、且优先于用户默认的决定 | -| 发现代次(`generation`) | 一次不可变发现结果的版本,用于拒绝过期操作 | -| 偏好版本(`preference_revision`) | 用户决定文档的版本,用于并发保护 | - -## 3. 产品状态模型 - -### 3.1 发现 - -发现是只读扫描:识别外部应用及其用户级、项目级或工作区级候选,生成脱敏摘要、支持范围和风险事实。 - -发现不得注册运行时能力、启动外部进程、建立网络连接、读取凭据值、改写配置,或把候选加入模型可调用集合。 - -### 3.2 连接 - -连接表示用户或产品默认策略允许 BitFun 在明确的执行域和策略作用域内持续读取并同步某个生态。连接是应用级、作用域相关的状态,不等同于允许其全部内容运行,也不能从一个工作区或宿主外溢到另一个执行域。 - -连接结果必须包含: - -- 已连接的应用; -- 已自动启用的低风险内容; -- 等待确认的类别和数量; -- 被安全上限阻止或暂不可用的内容; -- 唯一下一步主操作。 - -### 3.3 加载 - -加载表示将策略允许或用户确认的具体能力注册到真实归属模块。只有同时满足以下条件的内容可以加载: - -- 低风险声明式内容已被共享策略允许自动应用,或用户已确认该能力; -- 未超过产品、组织、宿主能力、Safe Mode 和安全上限; -- 发现代次、偏好版本、决策键与行为版本仍有效; -- 对应能力归属模块已完成自身校验、准备和注册。 - -下图是目标产品流,不代表当前 V1 已具备这些能力: - -```mermaid -flowchart LR - A["只读发现
生成应用摘要"] --> B["作用域连接决定
默认仅当前工作区"] - B --> C["加载低风险内容
归属模块最终校验"] - B --> D["待确认摘要"] - D --> E["有界分页读取
每页最多 128 项"] - E --> F["用户确认"] - F --> C - C --> G["更新应用结果摘要"] - C -. "当前任务实际受阻" .-> H["只提示当前会话与轮次"] -``` - -### 3.4 面向用户的应用级状态 - -首页只展示五种应用级摘要: - -| 状态 | 含义 | 默认主操作 | -|---|---|---| -| 已连接 | 连接有效,当前没有必须处理的应用级事项 | 查看 | -| 发现可用配置 | 已发现候选,但尚未连接 | 连接 | -| 未发现配置 | 支持该应用,但当前执行域没有配置 | 无强调操作 | -| 需要处理 | 存在待确认、权限扩大、阻断性冲突或应用级恢复事项 | 检查 | -| 暂时不可用 | 连接、同步或宿主状态失败,且存在恢复路径 | 重试或查看原因 | - -这些是从底层发现、期望连接、确认、运行、支持、健康和冲突事实派生的持久产品摘要,不替代架构文档定义的正交生命周期。优先级为:`需要处理 > 暂时不可用 > 已连接 > 发现可用配置 > 未发现配置`;Safe Mode 作为全局显著状态单独展示,不被该优先级隐藏。当前轮次的任务依赖作为短期、作用域化导航上下文单独呈现,不写回应用状态。 - -“已启用”只描述能力结果,不替代“已连接”。应用可以已连接,同时仍有部分能力等待确认或被限制。 - -## 4. 默认连接与推荐集合 - -### 4.1 默认连接产品事实 - -默认连接由 Product Assembly 提供的生态能力事实决定,不能在 React、TUI 或协议 adapter 中按 `ecosystemId` 硬编码。 - -首期策略: - -- OpenCode:允许默认连接;低风险声明式能力按策略自动加载;Tool、Subagent、MCP、进程、网络、环境变量或权限扩大仍进入确认。 -- Codex、Claude Code:默认只发现,不连接、不加载;用户可主动连接。 - -读模型同时给出默认值和原因,例如适配成熟度、支持范围、产品策略或当前宿主限制。明确的“断开连接”或“暂不使用”优先于后续默认连接,不能被自动发现覆盖。 - -### 4.2 推荐集合 - -批量确认默认选中共享控制面计算的推荐集合,高风险项默认不选。推荐计算至少考虑: - -- 能力类别和行为风险; -- 本地进程、网络、环境变量、文件范围和权限扩大; -- 来源、作用域与适配支持范围; -- 宿主能力、Safe Mode、产品/组织安全上限; -- 冲突、诊断和兼容状态; -- 用户既有决策及其绑定的行为版本。 - -宿主只能展示推荐、允许用户在安全上限内调整并提交选择,不能自行提高推荐等级或放宽上限。 - -### 4.3 作用域与旧偏好迁移 - -连接决定沿用现有集成策略的两级语义,而不是建立一个跨工作区的全局布尔值: - -- `user_default` 绑定 `execution_domain_id + application_id`,不带工作区作用域,只作为同一执行域内工作区的缺省值; -- `workspace_override` 绑定 `execution_domain_id + workspace_scope_id + application_id`,优先于 user default; -- `workspace_scope_id` 直接复用 `assembly/core` 现有 `workspace_policy_key` 生成的不透明键:`workspace:` 加规范化工作区 SHA-256 的前 16 字节十六进制。它由事实所在宿主计算并随快照返回,控制端只原样回传;它不是路径、没有反查索引,也不建立新的全局工作区注册表。Peer/Remote 宿主必须在自身执行域计算,控制端不得用本机目录代算; -- 现有 `workspace_overrides` 已以同一不透明键为键,迁移可以原样枚举,不需要也不得反查绝对路径。宿主身份或执行域改变后旧键不能跨域复用;显式无工作区使用 `none`,不是任意工作区的通配符; -- 偏好版本、提示键和确认计划都在同一作用域内解释,不能跨作用域去重或重放。 - -现有 `ExternalSourcesConfig` 已保存 integration policy、来源抑制、Tool/Subagent/MCP 审批和冲突决定,但没有应用连接字段。`integration_policy.enabled=false` 同时表示结构体默认值和用户显式关闭,而且现有 MCP revision-key 初始化可能把默认对象自动写成文件;因此不能再用“有文件/无文件”或 `false` 单独还原用户意图。升级必须先读取原始存储状态,再进入会物化默认文件的 helper,并在现有原子读改写路径中执行可重入迁移: - -1. `WorkspaceExternalSourceService` 的启动迁移关口必须成为偏好存储的第一次访问:它先读取原始文件存在性和 `schema`,完成或保留迁移后,才允许发现、MCP 版本键初始化或 V2 接口继续。只有确认从未存在过偏好文件的新安装才写入 `config_origin=fresh_v2`,保持“无用户决定”并应用新的产品默认。已有旧文件或不兼容策略重置都不能重新归类为 fresh V2。 -2. 迁移关口在内存中一次计算所有旧用户默认和 `workspace_overrides` 的连接决定;每项都按 `(execution_domain_id, application_id, workspace_scope_id?)` 写入真实连接状态与 `decision_origin`,无法归属的项写为 `needs_review`。`connection_schema_migration_version` 只表示整份文档已完成一次原子转换,不引入逐作用域的迁移生命周期。 -3. 任何旧文件中的 `integration_policy.enabled=false` 都保守迁移为该作用域的显式未连接,`decision_origin=legacy_safety`;这包括由旧版自动生成、无法与用户显式关闭区分的默认文件。该规则优先于“已有有效使用”判断,保证升级不意外启用能力;可能要求从未手动关闭的旧用户重新连接一次,并应在迁移说明中明确,而不能用 OpenCode 新默认覆盖。 -4. 仅当旧策略的 `integration_policy.enabled=true`,且该作用域已有效使用某生态——至少一项能力的实际访问级别为 `ask_before_use`/`auto`,或存在可归属到该生态的有效审批、冲突决定或活动路由——才迁移为已连接,避免升级静默撤下现有 Claude Code/Codex/OpenCode 能力。现有 `workspace_overrides` 直接按不透明 `workspace_scope_id` 逐项迁移。 -5. 审批、拒绝和冲突记录不因连接迁移而删除;重新连接时仍需决策键与行为版本匹配,权限扩大继续重新确认。无法可靠归属到应用、执行域或某一作用域的旧记录写为连接状态 `needs_review`,该作用域继续使用 V1 路径,不得猜测连接、静默停用或用新默认接管。 -6. 若读取到未知未来 `schemaMajor`,必须沿用现有不兼容策略的安全拒绝语义:不迁移、不应用默认、不写任何 V2 决定,也不触发偏好文件重写,逐字节保留包含不透明策略的原文件。用户执行既有“备份并重置”时,在同一原子更新中保存原策略、写入 `config_origin=incompatible_reset` 和显式未连接决定;该来源永不应用默认连接,只有用户随后显式连接才能启用能力。 -7. 全部作用域决定、`connection_schema_migration_version` 和既有审批/冲突事实必须在同一次锁内原子替换中提交。成功时不存在“部分迁移”;失败则保持原文件和完整 V1 运行路径,重启后重新计算并重试整次转换。 - -Instruction、Skill、Hook 和显式复制成 BitFun 原生配置的内容继续由各自归属模块决定。只有归属模块已提供来源限定的激活/撤下端口时,应用连接才能协调其持续外部来源;否则应用摘要必须标记 `managed_separately` 或部分支持,断开连接不得虚假宣称已卸载。已经复制的原生 Hook/MCP 等快照不随外部应用断开而删除。 - -## 5. Desktop Settings 信息架构 - -### 5.1 首页 - -首页沿用现有约 600px 正文最大宽度,按以下顺序纵向排列: - -1. 标题和一句说明; -2. “需要处理”摘要,仅在有真实待办时显示; -3. “已发现的应用”列表; -4. “高级设置”折叠入口。 - -首页不再平铺 Tool、Subagent、MCP、来源路径、冲突和完整诊断。 - -“需要处理”只聚合: - -- 已连接应用的可执行能力等待确认; -- 已确认内容发生实质权限扩大; -- 当前可见 session/turn 的作用域化 dependency outcome;该项作为临时导航上下文展示,不改变应用快照; -- 连接失效且存在恢复动作; -- 必须解决的冲突。 - -纯信息更新、无关诊断和未连接应用的候选变化不进入该区。 - -每个应用行包含应用名、一个应用级状态、一句结果摘要和唯一主操作。存在当前工作区时,连接、断开和“暂不使用”的主操作默认且明确标注“仅当前工作区”,写入 `workspace_override`;即使当前连接来自 `user_default`,断开也只创建当前工作区覆盖,不修改其他工作区。没有工作区上下文时,首页不直接执行全局变更,而是进入详情选择作用域。次要操作进入详情或菜单。 - -### 5.2 应用详情 - -详情页采用“结果优先、控制后置”: - -1. 当前连接结果; -2. 已启用内容摘要; -3. 等待确认或受限内容; -4. 健康状态与必要恢复动作; -5. 管理连接; -6. 技术详情、来源位置和能力级控制。 - -默认视图只回答:是否连接、正在使用什么、还需要做什么。命令、环境变量、路径、诊断码、冲突候选和逐能力策略默认折叠。 - -连接成功必须显示持久的结果摘要和生效范围,例如:“OpenCode 已连接到当前工作区。已启用 8 项低风险设置,3 项能力等待确认。”无待办时不制造额外确认。跨当前执行域的 `user_default` 只在详情/高级设置中提供,使用“此执行位置的所有工作区”等明确文案,并在提交前再次展示影响范围。 - -### 5.3 单页批量确认 - -Tool、Subagent、MCP 和需要用户决策的冲突进入同一批量确认页面,不使用连续弹窗。 - -默认展示: - -- 类别和数量; -- 主要风险; -- 推荐选中状态; -- 被安全上限阻止的数量和原因。 - -展开后才展示名称、来源、路径、命令、环境变量名、网络目标、冲突和行为变化。敏感值、完整 prompt、完整 URL query 和未经脱敏的绝对路径不进入公共快照。 - -批量确认页在标题和提交按钮旁持续显示生效范围,默认继承发起连接的 `target_scope`,不能在无提示时切换到 `user_default`。提交不要求客户端读取全部分页:`review_id` 绑定同一不可变确认计划,`selection_baseline` 只能是共享推荐集合或空集合,`selection_overrides` 只携带与基线不同的稳定项目引用和选择结果。服务端从同代权威计划还原完整选择,依次应用基线和改动项,再校验作用域、偏好版本、发现代次、决策键、行为版本、安全上限和最大选择数。计划过期或引用不属于该计划时整批拒绝,不能把不同页面或不同代次拼接。 - -批量语义: - -- stale revision、无效 generation 或宿主能力整体不兼容时,整个请求不应用; -- owner 允许逐项业务拒绝时,响应返回逐项结果;宿主只把成功项标为已启用; -- 未知结果不能假定成功; -- 失败项保留可行动原因与恢复动作。 - -### 5.4 高级设置 - -以下内容后置到详情或高级设置:全局/项目 scope、生态与能力策略、物理来源开关、冲突选择、完整诊断、配置位置、Safe Mode 和兼容说明。 - -Safe Mode 生效时必须在首页和详情显著显示,不能只藏在折叠区。 - -## 6. 提示、去重和恢复 - -### 6.1 首次发现 - -不使用启动弹窗。允许的入口是: - -- 聊天区一次性非阻塞轻提示; -- Settings 导航低侵入状态; -- Settings 内应用摘要。 - -文案只说明“发现了可连接的应用”,不能暗示能力已经加载。 - -### 6.2 持久化去重 - -提示与用户决定由共享持久化事实驱动,不能只保存在某个 GUI/TUI 进程。去重键至少包含: - -- execution domain ID; -- `user_default` 或 `workspace_override`;workspace override 还包含 Host 返回的 `workspace_scope_id`; -- application / ecosystem ID; -- 内容或行为版本; -- 风险摘要版本; -- 用户决定状态。 - -用户关闭、完成确认、断开连接或选择“暂不使用”后,同一作用域、同一有效版本不再主动提示。仅数量变化但行为和风险未扩大时,只更新 Settings 摘要。用户级决定可以作为同一执行域的缺省值,workspace override 只影响对应 `workspace_scope_id`;任何决定都不能跨执行域传播。 - -### 6.3 再次主动提示 - -仅允许: - -1. 当前任务真正依赖待确认能力并因此受阻或降级; -2. 已确认内容发生实质权限扩大,需要重新确认。 - -权限扩大包括新增进程执行、网络访问、环境变量读取、更宽文件范围、工具集合扩大、模型或 Subagent 行为变化。行为等价刷新、普通路径变化和未连接应用更新不构成主动提示理由。 - -“当前任务受影响”不是持久化应用快照字段,也不参与应用级提示去重。能力归属模块在实际解析或调用依赖时,如果被连接策略或批量确认阻止,就返回类型化依赖事实;Agent Runtime 负责把它关联到根轮次并沿现有 Agent 事件流发布。现有 `session_id + turn_id` 已唯一标识根任务,不再新增一套任务身份。一个轮次的待确认能力不能改变另一个并发轮次的状态或退出结果。 - -子代理结果不得只凭“来自当前会话树”就使根任务失败。Runtime 使用现有 `SubagentSessionLinked` 的父 session、父 turn 和父 tool-call 关系追溯来源:只有根 turn 仍在等待该子代理调用时,子代理的阻断事实才聚合到根任务;无关、后台或已经脱离等待链的子代理结果保留在其来源 turn。事件在对应根任务结束事件之前发出,CLI/Host 只消费与当前根 session、turn 完全匹配的结果。 - -### 6.4 错误与恢复 - -必须区分发现失败、连接失败、同步暂时失败但沿用上一版本、stale revision、Host/Remote 不支持、Safe Mode 或 safety ceiling 阻止。 - -读模型提供类型化恢复动作,例如刷新、重试、重新连接、重新审阅、解决冲突、安装运行时、升级/重连 Host 或退出 Safe Mode。宿主不得解析错误文本决定控制流。 - -## 7. TUI 与非交互 CLI - -### 7.1 TUI - -`/extensions` 是应用级摘要和首次连接主入口,展示与 Settings 首页等价的状态、默认策略、数量和主操作。 - -`/extensions review` 提供与 GUI 等价的批量确认语义:共享推荐集合、高风险默认不选、可展开技术详情并调整。现有 `/tools`、`/agent`、`/mcp` 保留为专项管理和高级入口,不承担完整首次连接流程。 - -首次发现只显示一次非阻塞摘要;无关待办不阻塞聊天输入。 - -### 7.2 非交互 CLI - -非交互命令不等待确认输入。只有当前操作真正依赖待确认能力时返回类型化 `action-required`,包含: - -- 受影响应用和能力摘要; -- 风险原因; -- 可执行的后续动作或交互入口; -- 当前操作是否可降级继续。 - -与当前操作无关的待确认能力不能导致命令失败。 - -## 8. 应用级读模型 - -产品级协调 owner 应通过独立 V2 协议提供宿主可直接投影的版本化应用级读模型: - -```text -ExternalApplicationSnapshotV2 - schema_version = 2 - execution_domain_id - workspace_scope_id? # 复用宿主的 workspace_policy_key;none 表示无工作区,不是通配符 - effective_connection_scope - refresh_generation - preference_revision - safe_mode - host_capabilities - applications[] - application_id / ecosystem_id / display_name - discovery / connection / health - effective_status / primary_action - default_connection_policy + reason - enabled / pending_review / blocked / conflict counts - risk_summary - notice_key / user_decision - recovery_actions - review_summary - review_id / total_count / category_counts / max_selection_count - risk_summary / recommendation_summary / safety_ceiling -``` - -应用级对象是对同一生态多个物理来源和能力事实的聚合。它不携带可执行载荷,不取代现有目录与能力专属 DTO。首页快照只携带批量确认摘要,不能内嵌完整项目列表;否则每次轮询都会重复序列化与首页无关的大量候选。 - -用户进入批量确认页后,客户端再调用有界只读接口取得稳定引用: - -```text -ExternalApplicationReviewPageV2 - schema_version = 2 - execution_domain_id / workspace_scope_id? / target_scope - review_id / preference_revision / expected_generations - cursor / next_cursor / total_count - items[] # 每页最多 128,只含 item reference、显示摘要、推荐与安全上限 -``` - -分页游标必须绑定作用域、`review_id`、偏好版本和发现代次;任一事实变化都返回过期并重新读取,不能把旧页与新页拼接。详细页通过稳定项目引用关联现有 Tool、Subagent、MCP 和冲突投影;总量继续服从现有归属模块上限,完整提示词、命令正文、凭据和可执行载荷不进入分页响应。 - -状态和主操作由共享归属模块派生;React、TUI、Peer 和 Server 不重复实现优先级规则。 - -任务依赖通过执行路径单独返回,不进入可轮询、可持久化的应用快照: - -```text -AgenticEvent::ExternalDependencyActionRequired - schema_version = 2 - execution_domain_id / workspace_scope_id? - session_id / turn_id # 根任务身份 - origin_session_id / origin_turn_id / origin_tool_call_id? - dependency_refs[] / risk_summary / can_degrade - recovery_actions -``` - -该契约归 `bitfun-events` 所有,而不是应用快照归属模块或 CLI。`AgentSubmissionResult` 仍只表示轮次已被接收;Runtime 在真实能力解析路径产生事件,现有 App Server `agent/event` 与 Shared Runtime IPC `RuntimeIpcEvent::Agent` 承载 `AgenticEventEnvelope`。新增事件前必须补齐 App Server 协议/客户端、Shared IPC 协议版本兼容处理和 Embedded/Shared 等价测试。 - -该事件与外部来源 V1/V2 接口是两个版本边界,不能因为应用快照是 V2,就假设旧 App Server 客户端能解析新的 `AgenticEvent` 类型。实现必须提升 App Server 协议版本,并按每条连接协商出的版本过滤新事件;旧协议连接不得收到未知类型。若无法可靠过滤,则提升最低协议版本并在初始化阶段安全拒绝旧客户端。Shared IPC 同步提升其严格 `PROTOCOL_VERSION`。新客户端连接旧宿主时必须明确返回“任务依赖结果不支持”,不能从结束文本推断。只有根会话和根轮次完全匹配的任务可以据此返回 `action-required`;Settings 可把它作为短期导航上下文读取,但不能合并成所有任务共享的应用状态。 - -### 8.1 V1/V2 协议边界与协商 - -现有 `ExternalSourceControlSnapshotV1`、`ExternalSourceControlActionV1`、`ExternalSourceRecoveryActionV1` 和 V1 `hostCapabilities` 保持字段与闭合枚举不变。应用级快照、连接动作、批量确认、`upgrade-host` 语义以及新增能力位不得追加到 V1 对象。 - -`get_external_application_snapshot_v2` 本身无副作用,直接承担版本探测,不再增加单独的版本信息接口: - -- 新宿主返回严格的 V2 快照和 `host_capabilities`;客户端校验成功后,才可读取分页确认项或发送 V2 写操作; -- 旧宿主对 V2 快照返回传输层 method-not-found 时,客户端回退显示 V1 来源/能力管理并禁用 V2 写操作; -- “升级宿主”由新客户端根据 method-not-found 本地投影,不能向旧宿主发送未知 V2 动作,也不能要求旧宿主返回 V1 不认识的恢复类型; -- 旧客户端只调用原 V1 接口,因此新宿主必须继续生成严格 V1 响应;V1/V2 快照不得拼接成混合数据结构; -- 数据结构不匹配、宿主身份变化或重连后,所有未完成 V2 写操作和分页游标失效并重新读取快照。 - -兼容测试必须覆盖旧客户端 → 新宿主、新客户端 → 旧宿主、V2 同代成功、未知数据结构/枚举安全拒绝,以及重连后旧响应不能覆盖新执行域或工作区作用域。 - -### 8.2 性能与演进约束 - -- 应用快照和确认分页必须从当前不可变发现结果派生;读取不能重新扫描文件、启动外部进程或持有偏好写锁。 -- 首页只返回摘要,确认页每页最多 128 项。完整候选总量继续服从各归属模块已有上限,不建立第二套无界缓存。 -- 共享缓存只允许按执行域、工作区作用域、发现代次和偏好版本精确失效;React、TUI、Peer 与 Server 不得各自维护产品状态机。 -- 归属模块的加载与卸载在锁外执行;迁移关口只阻塞外部来源读写,不阻塞项目打开或无关 Agent 任务。 -- 实现 PR 必须记录 V1/V2 快照大小和聚焦读取延迟的前后对比。没有基线时不宣称性能提升;出现明显回退时先减少返回数据或重复计算,再考虑新增缓存。 -- 后续只有出现真实消费者和独立兼容要求时,才增加新的版本化接口;不提前扩展 V1,也不为单一 V2 接口建立通用协议目录。 - -## 9. 类型化动作 - -V2 控制协议应提供闭合动作: - -- `ConnectApplication`; -- `DisconnectApplication`; -- `SetApplicationDeferred`(暂不使用); -- `SubmitApplicationReview`; -- `Refresh`; -- V2 投影需要的来源开关、策略更新和 `SetSafeMode`;既有 V1 action 保持原样,不扩充枚举。 - -每个 V2 写操作信封必须携带 `execution_domain_id`、`target_scope`、`operation_id` 和该作用域的 `expected_preference_revision`;`workspace_override` 必须携带 `workspace_scope_id`,`user_default` 必须省略它。无工作区的读取使用显式 `none`,不能当作通配符。宿主必须确认这些身份与当前连接绑定一致,不能使用控制端当前目录推断目标。宿主默认动作只能提交当前工作区范围;全执行域默认必须来自用户明确选择。 - -`operation_id` 只用于请求/响应关联和界面中的待处理操作排序,不提供业务幂等、结果缓存或跨重启重放。客户端不得在同一活动连接内为并发请求复用它;服务端也不会因 ID 相同而重放旧结果。偏好版本是唯一写并发保护:响应丢失后,客户端必须重新读取权威快照,再决定是否发起新操作;不能用相同 `operation_id` 绕过过期版本。`SubmitApplicationReview` 还必须携带 `review_id`、选择基线和有界改动项,服务端从该计划取得各归属模块的发现代次、决策键和行为版本。 - -断开连接必须停止继续同步、卸载由该连接注册的运行能力、保留必要审计与用户决定、不改写外部配置、不影响其他生态,并返回不再可用的能力摘要。重新连接只复用仍与 decision key / behavior version 匹配且策略允许的决定;权限扩大重新确认。 - -## 10. Web UI 组件边界 - -现有 `ExternalSourcesConfig` 收敛为页面 controller,并拆分为: - -- `ExternalAppsOverview`:应用首页; -- `ExternalAttentionSummary`:真实待办; -- `ExternalAppDetail`:单应用结果与管理; -- `ExternalAppReview`:批量确认; -- `ExternalAdvancedSettings`:scope、来源、冲突、诊断和 Safe Mode; -- controller/hook:读取、轮询、mutation sequencing 和恢复; -- presentation helpers:格式化展示,不做策略判断。 - -拆分必须保留现有请求序列、accepted sequence、pending mutation、scope mutation 栅栏、stale read/mutation 防护和失败恢复。UI 继续通过 infrastructure API,不直接调用 Tauri。 - -## 11. 关键场景 - -### 11.1 首次发现 OpenCode - -1. 只读发现; -2. 产品事实允许默认连接; -3. 建立持续连接; -4. 加载策略允许的低风险内容; -5. 生成高风险推荐集合; -6. 一次性显示连接结果和待办; -7. 用户提交批量 review 后加载成功项;同一行为版本不重复提示。 - -### 11.2 首次发现 Codex 或 Claude Code - -1. 只读发现; -2. 显示“发现可用配置”; -3. 不连接、不加载; -4. 一次性轻提示或 Settings 状态; -5. 用户主动连接后进入相同风险确认流程。 - -### 11.3 多应用并存 - -- 发现多个应用只增加候选; -- 只有产品事实允许且未被用户拒绝的生态可默认连接; -- 未连接应用不注册运行能力,也不参与运行时冲突; -- 一个应用的连接、审批或断开不隐式改变另一个应用; -- 已连接应用之间的真实冲突由共享归属模块生成待办。 - -### 11.4 内容更新 - -- 行为等价且风险不扩大:保持决定,静默更新摘要; -- 是否可复用旧决定由共享策略判定,宿主不猜测; -- 权限扩大:扩大部分安全拒绝,生成重新确认; -- 偏好版本过期:刷新权威状态后重新确认。 - -## 12. 可访问性、文案与 i18n - -- 保持 600px 单列阅读轴,不依赖宽屏左右主从布局; -- 每行只有一个强调主操作; -- 状态不能只靠颜色,必须有文本或图标标签; -- 批量选择、展开和恢复动作支持键盘与清晰焦点; -- 使用现有主题令牌,不新增无归属色值; -- 统一文案:“发现、连接、等待确认、已启用、需要处理、断开连接”; -- 用户可见文案进入对应 i18n namespace;日志保持英文且无 emoji。 - -## 13. 验收标准 - -### 13.1 共享契约与运行时 - -- OpenCode 默认连接,其他生态默认只发现; -- 默认策略来自共享产品事实,而不是宿主生态 ID 分支; -- 发现不注册运行能力; -- 连接只自动加载允许的低风险内容; -- 推荐集合、高风险默认不选和 safety ceiling 可验证; -- 批量确认的偏好版本/发现代次、整体失效与逐项结果可验证; -- 断开或暂不使用后不被默认策略覆盖; -- 权限扩大重新确认; -- 未连接应用不参与运行时冲突; -- 断开卸载对应能力且不改写外部配置; -- Safe Mode、旧宿主、Remote/只读场景继续安全拒绝。 -- 旧偏好迁移保留显式 disabled/discover-only、已有效使用的能力、审批与冲突决定,并以升级/重启 fixture 证明不会静默改变行为; -- user default、workspace override、本机/Peer/Remote 在 execution domain 与 workspace scope 上相互隔离; -- V1 枚举和字段保持不变,V2 只在独立协商成功后使用,双向新旧组合测试通过; -- 任务相关 `action-required` 绑定 session/turn outcome,不从全局应用快照推断。 - -### 13.2 GUI - -- 首页按应用呈现,“需要处理”只在真实待办时出现; -- 状态和唯一主操作正确; -- 连接完成显示已启用、待确认和受限摘要; -- 批量默认选择与共享推荐一致; -- 技术详情默认折叠; -- 过期读取或写操作不覆盖新状态; -- 现有 Safe Mode、审批、冲突、诊断和脱敏测试保持通过; -- type-check、i18n 和主题治理通过。 - -### 13.3 TUI 与非交互 CLI - -- GUI/TUI 对同一 fixture 的应用状态、默认策略和数量一致; -- `/extensions review` 提交同一批量决定; -- 提示去重跨进程和宿主生效; -- 无关待办不阻塞交互; -- 非交互仅在当前任务受影响时返回 `action-required`; -- Host/Remote 差异通过共享能力与恢复动作表达。 diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index f222f4d54a..ecf60b044b 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -5,14 +5,13 @@ 适配器负责,本文不建立跨生态通用配置格式或脚本 SDK。BitFun 自身能力如何通过 MCP、Skill、Plugin、Hook、 SDK 或 Server 输出到外部宿主,以及内部能力组合、状态、事件和并发边界,见 [`capability-runtime-integration-design.md`](capability-runtime-integration-design.md);两条方向共用适用的身份事实和能力归属模块, -但不共用一个大一统 adapter 或状态模型。外部应用的 Settings/TUI 信息架构、默认连接、批量确认和提示去重见 -[`external-ai-app-connection-experience-design.md`](external-ai-app-connection-experience-design.md),对应实施顺序见 -[`../../plans/external-ai-app-connection-experience-plan.md`](../../plans/external-ai-app-connection-experience-plan.md)。 +但不共用一个大一统 adapter 或状态模型。Settings 和 TUI 只能把本文的来源与 integration policy 事实压缩为简短概览; +审批、冲突和可执行能力状态继续由 Tool、Agent、MCP、Hook 等真实 owner 负责。 本文同时记录当前可用端到端能力与目标架构。当前 BitFun 已具备通用外部来源目录、四条能力专属发现通道,并由 `ExternalSourceControlPlane` 负责 provider-neutral 调度、generation fencing 和故障隔离;`assembly/core` 的 `WorkspaceExternalSourceService` 负责产品级策略、偏好、聚合和运行装配,`contracts/product-domains` 提供版本化控制事实、固定动作与错误语义, -Desktop、交互式 TUI 和 Peer Host 只显示宿主所需状态,不再各自派生另一套状态机。Server 仓库中保留了只读 external-source dispatch helper,但当前 `/ws` 已直连 in-process App Server,external-source 方法尚未进入 App Server schema,生产请求会得到 `method_not_found`;因此不能把 Server 只读投影列为已交付。OpenCode Prompt Command +Desktop、交互式 TUI 和 Peer Host 只显示宿主所需状态,不再各自派生另一套状态机。App Server 已注册 external-source schema 与 handler;Embedded TUI 注入 management owner 后可以调用,通用 Server `/ws` 当前没有注入绑定可信工作区的 management owner,因此请求会得到类型化 `unsupported`,不能把通用 Server 只读投影列为已交付。OpenCode Prompt Command 适配器已接入本地用户全局/项目来源;Desktop 可查看、刷新、抑制和处理跨来源冲突,交互式 TUI(ChatMode)可列出并执行 Prompt Command;静态文件和经审阅的本地 shell 输出由共享归属模块完成装配。第二条端到端能力已让受支持的单文件 OpenCode `.js` standalone Tool 经静态 预览、来源/能力确认和同名冲突选择后进入现有 Tool Runtime;Desktop 与交互式 TUI(ChatMode)使用同一决策状态。第三条纵向 @@ -120,7 +119,7 @@ stale,界面替换为服务端返回的新 plan,并只保留“旧选择与 ### 3.1 首次发现 发现始终在后台进行。Desktop、交互式 TUI(ChatMode)和 Peer 控制界面消费事实所在 Host 的同一来源状态,但按 -宿主展示;Peer 控制界面只代理 Peer Host,不读取控制端同名来源。当前 Server `/ws` 尚未接入 external-source App Server 方法;未来只读 Web 入口必须先通过版本化 App Server schema 接入 Host 能力,不能由浏览器扫描来源: +宿主展示;Peer 控制界面只代理 Peer Host,不读取控制端同名来源。当前 Server `/ws` 已注册 external-source App Server 方法但未注入可信工作区 owner;未来只读 Web 入口必须先绑定 Host 持有的工作区范围,不能由浏览器提供任意路径或扫描来源: ```text 已发现 OpenCode 工作内容 @@ -162,8 +161,8 @@ stale,界面替换为服务端返回的新 plan,并只保留“旧选择与 Safe Mode 是执行域/工作区实例内的易失控制状态,不写入来源偏好,也不把来源伪装成 `disabled`。进入后继续发现和 展示 Command、Tool、Subagent 与 MCP,但立即撤下外部 Tool、Subagent 和 MCP 的新调用路由;Prompt Command 作为 静态模板继续可见。退出后基于当前来源版本重新协调,不能恢复已删除、已撤销或已过期审批的旧路由。 -GUI 和 TUI 都通过同一个 `SetSafeMode` 动作请求该变化,Peer Host 在事实所在 Host 执行;目标只读 Server 在完成 App Server 接线后通过 -`hostCapabilities` 明确拒绝变更,当前 Server external-source 方法仍是 `method_not_found`。所有偏好写操作携带 `expectedPreferenceRevision`,旧视图必须得到 `stale_revision` +GUI 和 TUI 都通过同一个 `SetSafeMode` 动作请求该变化,Peer Host 在事实所在 Host 执行;目标只读 Server 在注入绑定可信工作区的只读 owner 后通过 +`hostCapabilities` 明确拒绝变更,当前通用 Server 因没有 management owner 而返回类型化 `unsupported`。所有偏好写操作携带 `expectedPreferenceRevision`,旧视图必须得到 `stale_revision` 并重新读取,不能用界面本地状态覆盖并发进程的新决定。 ### 3.3 兼容来源与显式导入 @@ -299,7 +298,7 @@ generation lease 的模型绑定形态,不建立第二套 Agent Runtime。 Desktop、交互式 TUI 以及未来通过 Host 能力访问该状态的界面必须同时展示:来源请求、实际绑定、绑定方式和受影响候选数。 例如“来源请求 `sonnet`;当前工作区由用户绑定到 Primary(实际为已配置模型 X);影响 71 个 Agent”。用户可以选择其他 已配置模型、`primary`、`fast` 或保持相关候选禁用。界面不得把用户选择的替代模型描述成来源原始要求,也不得逐项重复确认 -同一绑定。目标只读 Server 在完成 App Server V1 前置切片后只投影脱敏状态,不获得写入能力;当前 Server 尚不能消费该投影。 +同一绑定。目标只读 Server 在注入绑定可信工作区的只读 management owner 后只投影脱敏状态,不获得写入能力;当前 App Server 方法已经注册,但通用 Server 尚未注入该 owner。 绑定目标的配置 ID 与 `model_runtime_binding_fingerprint` 进入既有激活审批 envelope。来源引用改变、绑定目标被删除或停用, 或者同一配置 ID 下的 provider、模型名、endpoint、认证来源及其他运行身份发生变化时,旧激活决定失效;进行中的调用继续 @@ -452,9 +451,9 @@ Theme、Keybind、完整插件清单,以及各生态新增的 managed/session/ ## 6. 架构与职责 -当前 V1 生产路径是 `Desktop/TUI/Peer Host adapter → WorkspaceExternalSourceService → ExternalSourceControlPlane → 能力专属 provider`,宿主消费 `ExternalSourceControlSnapshotV1`、目录和既有能力级动作。它没有应用级连接状态、统一批量确认计划或任务依赖结果;当前 Server App Server 也尚未暴露这条只读路径。以下 6.1-6.3 全部是连接体验交付后的目标视图,不能作为现状证据。 +当前生产路径是 `Desktop/TUI/Peer Host adapter → WorkspaceExternalSourceService → ExternalSourceControlPlane → 能力专属 provider`。宿主消费现有的 `ExternalSourceControlSnapshotV1`、公共目录、integration policy 和能力级动作。这里不再建立第二套应用连接状态、跨能力批量确认或任务依赖事件。 -### 6.1 目标逻辑视图 +### 6.1 逻辑视图 ```mermaid flowchart TB @@ -463,12 +462,11 @@ flowchart TB Ports["能力专属 provider 契约"] Discovery["ExternalSourceControlPlane\nprovider-neutral discovery"] ProductCoordinator["WorkspaceExternalSourceService\n产品级协调"] - Policy["产品能力事实 / 接入策略 / 安全上限"] + Policy["Integration policy / Safe Mode"] Store["现有原子偏好存储"] - AppView["版本化应用级读模型 + 批量确认计划"] - Catalog["公共 catalog + 能力专属详情"] + Catalog["来源控制 + 公共 catalog"] Owners["Command / Tool / Subagent / MCP / Config owner"] - Surfaces["Desktop / TUI / Peer / future read-only Server"] + Surfaces["Desktop / TUI / Peer"] Sources --- Adapters Adapters --- Ports @@ -476,35 +474,31 @@ flowchart TB Discovery --- ProductCoordinator Policy --- ProductCoordinator Store --- ProductCoordinator - ProductCoordinator --- AppView ProductCoordinator --- Catalog ProductCoordinator ---|窄 typed owner boundary| Owners Owners --- Catalog - AppView --- Surfaces Catalog --- Surfaces ``` -图中连线只表示目标稳定逻辑关系,不表示调用顺序或用户动作;连接、断开和批量确认时序只在 6.3 描述。目标中,发现、连接和加载是三个独立阶段:适配层只产生候选;现有 `ExternalSourceControlPlane` 继续只协调能力专属提供方的发现、期限、代次和故障隔离;现有 `WorkspaceExternalSourceService` 增加产品级协调职责,结合产品事实、作用域化用户决定和安全上限派生应用级连接状态与确认计划,并通过窄类型化端口请求真实能力归属模块加载或撤下。应用级读模型不携带可执行载荷,也不取代公共目录或能力专属 DTO。这里不新增第二个公开控制面类型,也不声称这些新增职责已经接线。 +图中连线表示稳定逻辑关系,不表示调用顺序。适配层只产生候选;`ExternalSourceControlPlane` 负责 provider discovery、期限、代次和故障隔离;`WorkspaceExternalSourceService` 组合 integration policy 与目录,并把真正的批准、冲突选择、加载和撤下交给能力 owner。Desktop 可以按生态对这些事实分组,但分组只属于展示,不是第二个业务状态或协议对象。 -### 6.2 目标开发视图 +### 6.2 开发视图 ```mermaid flowchart TB - ProductDomains["contracts/product-domains\n应用级状态、确认、类型化动作"] + ProductDomains["contracts/product-domains\nV1 来源、policy 与能力契约"] AssemblyExternal["assembly/external-sources\nprovider-neutral 协调器"] AssemblyCore["assembly/core\nWorkspaceExternalSourceService / 产品装配"] EcosystemAdapters["adapters/*\n生态解析与原生覆盖"] Services["services/*\n文件观察、原子存储、进程/网络"] CapabilityOwners["execution / services / core owners\nCommand、Tool、Subagent、MCP"] DesktopAdapter["apps/desktop\nTauri / Peer Host adapter"] - WebUi["web-ui\nOverview / Detail / Review / Advanced"] - Cli["apps/cli\n/extensions 与 action-required"] - Server["server / remote adapters\n目标能力约束与只读投影"] + WebUi["web-ui\n简短概览 / 能力专项设置"] + Cli["apps/cli\n/extensions /tools /agent /mcp /hooks"] WebUi --> DesktopAdapter DesktopAdapter --> AssemblyCore Cli --> AssemblyCore - Server --> AssemblyCore AssemblyCore --> AssemblyExternal AssemblyCore --> EcosystemAdapters AssemblyCore --> Services @@ -515,9 +509,9 @@ flowchart TB CapabilityOwners --> ProductDomains ``` -箭头表示目标编译期/模块依赖方指向被依赖方,不是运行时数据流。依赖方向继续遵守 interfaces/apps → assembly → adapters/services/execution → contracts;`assembly/external-sources` 只依赖 product-domain 契约,不反向依赖 Core、app 或具体生态 adapter。产品默认连接事实由 assembly 选择并通过稳定 contract 投影;React、TUI 和远端 adapter 不按生态 ID 重算默认值、应用状态或推荐集合。Server 节点只有在先把 V1 external-source 只读方法接入 App Server schema、handler/client translation 并通过 WebSocket round-trip 后才能进入这张目标图。 +箭头表示编译期依赖方指向被依赖方,不是运行时数据流。依赖方向继续遵守 interfaces/apps → assembly → adapters/services/execution → contracts;`assembly/external-sources` 只依赖 product-domain 契约,不反向依赖 Core、app 或具体生态 adapter。React 和 TUI 不复制审批、冲突或 capability owner 状态机。 -### 6.3 目标运行视图 +### 6.3 运行视图 ```mermaid sequenceDiagram @@ -525,34 +519,27 @@ sequenceDiagram participant Product as WorkspaceExternalSourceService participant Discovery as ExternalSourceControlPlane participant Adapter as Ecosystem Adapter - participant Policy as Product Policy + participant Policy as Integration Policy participant Owner as Capability Owner participant Store as Preference Store - Surface->>Product: 读取作用域化应用级 snapshot + Surface->>Product: 读取来源 control/catalog Product->>Discovery: 按 execution domain / workspace scope 刷新 Discovery->>Adapter: 只读发现候选 Adapter-->>Discovery: 来源、版本、风险摘要 Discovery-->>Product: 同代能力专属发现结果 - Product->>Policy: 计算默认连接、推荐集合与安全上限 - Policy-->>Product: OpenCode 可默认连接;其他生态只发现 - Product-->>Surface: 应用状态、主操作、review plan - Surface->>Product: ConnectApplication(scope, expected revision) - Product->>Store: 原子保存连接决定并推进权威 preference revision - Product->>Owner: 仅请求允许自动应用的低风险内容 - Owner-->>Product: 已启用 / 受限 / 失败结果 - Product-->>Surface: 连接完成摘要 - Surface->>Product: SubmitApplicationReview(scope, generations, decision keys) - Product->>Product: 重验身份、revision、generation 与 safety ceiling - Product->>Owner: 按能力类型提交批准项 - Owner-->>Product: 逐项权威结果 - Product->>Store: 原子保存有效决定 - Product-->>Surface: 同代 snapshot 与逐项结果 + Product->>Policy: 读取作用域化启停和 capability access + Product-->>Surface: 来源/应用简短概览 + Surface->>Product: 更新 integration policy 或来源启停 + Product->>Store: 校验 preference revision 后原子保存 + Surface->>Owner: 通过 /tools、/agent、/mcp 或 /hooks 处理精确对象 + Owner->>Owner: 重验 identity、version、scope 与 generation + Owner-->>Surface: 权威批准、拒绝、冲突或恢复结果 ``` -目标运行语义中,发现不会产生执行副作用。连接先在目标执行域和工作区作用域中持久化应用级决定,再只协调共享策略允许的低风险内容;批量确认仍分派到各能力归属模块,并在提交前重新校验身份、作用域、偏好版本、发现代次、决策键、行为版本、宿主能力、Safe Mode 和安全上限。 +发现不会产生执行副作用。启停只改变 integration policy 或来源状态;可执行内容在真正的能力 owner 中按精确对象确认。跨能力页面不能代替 owner,也不能批量扩大权限。 -### 6.4 现有能力与连接体验的边界 +### 6.4 现有能力边界 | 部分 | 负责 | 不能承担 | |---|---|---| @@ -563,8 +550,8 @@ sequenceDiagram | 文件观察服务 | 提供可订阅、去抖的文件变化事实 | 解释生态路径、决定优先级、提交业务状态。 | | 本地 JSON 存储服务 | 提供跨进程锁、锁内读改写和同卷原子替换;替换失败时保留旧文件 | 定义外部来源偏好 schema、冲突策略或生态语义。 | | `ExternalSourceControlPlane` | 四类来源分别刷新;同一 provider 同一时间只扫描一次;超时只影响该 provider;旧结果不能覆盖新刷新;确认最新结果后,再通知对应能力模块切换 | 按生态 ID 分支业务行为、把四类数据合并为通用资产、解析生态文件、直接提交配置、工具、权限或界面状态。 | -| `WorkspaceExternalSourceService` / 产品级协调 | 绑定执行域与工作区路由;组合产品事实、现有偏好和控制面发现结果;派生应用级投影;通过窄类型化端口分派连接、撤下和批量确认,并汇总归属模块的权威结果 | 复制提供方调度器、能力审批/冲突存储或 Runtime 归属;把无法撤下的能力宣称为已断开;成为新的公共跨生态执行 API。 | -| 版本化控制状态视图 | 根据 discovery/desired/review/runtime/support 事实生成一级状态;向宿主提供同一版本的 control/catalog、`hostCapabilities`、恢复动作和固定通用操作 | 保存第二份权威状态、携带 Prompt/凭据/可执行数据、替代能力专属审批和冲突 DTO,或让 GUI/TUI 自行推导生命周期。 | +| `WorkspaceExternalSourceService` / 产品级协调 | 绑定执行域与工作区路由;组合 integration policy、现有偏好和控制面发现结果;向能力 owner 提交窄类型化请求 | 复制提供方调度器、能力审批/冲突存储或 Runtime 归属;派生第二套应用连接状态;成为新的公共跨生态执行 API。 | +| 来源控制状态视图 | 根据 discovery、desired、owner decision、runtime 和 support 事实提供 control/catalog、`hostCapabilities`、恢复动作和固定通用操作 | 保存第二份权威状态、携带 Prompt/凭据/可执行数据、替代能力专属审批和冲突 DTO。 | | 界面状态 | 按使用范围、工作区或用户目录关系统一生成安全来源位置,清理诊断文本中的已知绝对路径,并按 `Source / Command / Tool / Subagent` 资源类型路由诊断 | 让 GUI/TUI 解析 provider 诊断码前缀、识别 `.opencode`、`.claude` 等私有目录结构,或接收原始用户/工作区路径。 | | 冲突解析 | 对独立 provider 或产品本地可执行能力的同名候选建立版本敏感内容摘要;未选择时不激活,选择后只在内容摘要不变时复用。现有 Skill 固定根顺序由 Skill 归属模块独立维护 | 用 adapter 优先级静默覆盖另一生态或本地可执行能力,或把选择写回外部文件。 | | 激活策略与能力归属模块 | 根据风险、用户选择、组织上限和执行位置决定自动应用、等待确认或限制 | 修改生态加载顺序或把策略拒绝伪装成解析失败。 | @@ -589,10 +576,7 @@ provider discovery 必须是可独立调度的 request/result,不在协调器 未来网络 provider 仍应实现协作式超时和取消, 但不改变目录、冲突或产品入口契约。 -控制请求保持闭合且类型化:严格 V1 只保留已有 `Refresh`、`SetSourceEnabled` 和 `SetSafeMode`;应用级连接体验在独立协商后的 V2 增加 -`ConnectApplication`、`DisconnectApplication`、`SetApplicationDeferred` 和 `SubmitApplicationReview`。批量 review 只封装 -一组带执行域、workspace route、能力类型、generation、decision key 和 behavior version 的选择,并由产品级协调 owner 分派给现有能力归属模块;它不能成为携带 -任意数据的通用执行 API。能力专属执行参数和调用时权限继续由各归属模块的类型明确契约承担。错误以 `code + stage + retryable + +控制请求保持闭合且类型化:现有来源 control 保留 `Refresh`、`SetSourceEnabled` 和 `SetSafeMode`,应用/生态启停复用 integration policy mutation。能力审批、冲突和执行参数继续由各归属模块的类型明确契约承担,不增加跨能力批量动作。错误以 `code + stage + retryable + correlationId/causationId + recoveryActions` 表达;`detail` 只用于有界诊断,界面和远端协议不得解析文本 决定控制流。日志只记录动作、阶段、关联 ID、错误类别和脱敏对象身份;产品打点可在同一结果上叠加,但不得反向改变状态。 @@ -602,9 +586,7 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 ## 7. 状态与提示规则 -本节定义底层来源/能力的正交生命周期状态;面向 Settings 首页和 TUI `/extensions` 的五种应用级摘要、优先级和主操作,统一见 -[外部 AI 应用连接与管理详细设计](external-ai-app-connection-experience-design.md#3-产品状态模型)。宿主不得把底层状态直接拼成第二套 -应用级规则,也不能用应用摘要替代底层事实。 +本节定义底层来源/能力的正交生命周期状态。Settings 首页和 TUI `/extensions` 可以隐藏不必要的技术细节并生成简短摘要,但不得建立第二套应用级状态规则,也不能用摘要替代底层事实。 | 用户状态 | 含义 | |---|---| @@ -625,8 +607,7 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 - 用户关闭、确认、断开连接或选择暂不使用后,同一内容/行为与风险摘要版本不再主动提示;普通数量变化只更新应用摘要。 - 再次主动提示仅限当前任务确实因待确认能力受阻或降级,或者已确认内容发生实质权限扩大。与当前任务无关的更新失败、来源删除和未连接应用变化只更新状态与恢复动作。 - 普通文件变化、多个同源错误和多项目全局更新按应用/来源聚合;详情进入设置页或 CLI 状态,每次重载最多产生一条摘要,不用 Toast 展示字段级错误。 -- 非交互入口只有在当前操作实际依赖待确认资产时才返回类型化 `action-required`;无关待办只进入结构化状态或 - `stderr` 摘要,不阻塞当前操作,也不自动批准。 +- 非交互入口不等待人工确认,也不从全局待办推断特殊任务结果;能力不可用时返回普通失败且不自动批准。 ## 8. 分阶段落地与验收 diff --git a/docs/architecture/extensions/plugin-runtime-design.md b/docs/architecture/extensions/plugin-runtime-design.md index bcaada2a78..968f4d8b67 100644 --- a/docs/architecture/extensions/plugin-runtime-design.md +++ b/docs/architecture/extensions/plugin-runtime-design.md @@ -281,11 +281,11 @@ plugin、Hook、完整 Client 或 TUI 插件入口。与其独立的 standalone 当前 Rust 边界调整至少运行: -- `cargo test -p bitfun-runtime-ports --test plugin_runtime_contracts` -- `cargo test -p bitfun-runtime-ports --test plugin_runtime_diagnostics_contracts` +- `cargo test --locked -p bitfun-runtime-ports --no-default-features --features plugin-runtime --test plugin_runtime_contracts plugin_runtime_contracts` +- `cargo test --locked -p bitfun-runtime-ports --no-default-features --features plugin-runtime --test plugin_runtime_contracts plugin_runtime_diagnostics_contracts` - `cargo test -p bitfun-plugin-runtime-client` - `cargo test -p bitfun-opencode-adapter --test opencode_source_adapter` -- `cargo test -p bitfun-core plugin_runtime::tests --lib` +- `cargo test -p bitfun-core --no-default-features --features plugin-runtime --lib plugin_runtime::tests` - `node scripts/check-core-boundaries.mjs` 目标 Plugin Host 还必须使用固定版本真实 fixture 验证: diff --git a/docs/architecture/i18n.md b/docs/architecture/i18n.md index 4d43ae96b4..3783df3b8a 100644 --- a/docs/architecture/i18n.md +++ b/docs/architecture/i18n.md @@ -52,6 +52,14 @@ Do not edit generated files manually. Shared contract does not mean shared bundle. Smaller product shapes must not pay for Web UI resource size. +The backend follows the same split at compile time. `LocaleId`, locale aliases, +fallback facts, metadata, and model-facing language copy are feature-free Core +contracts. Fluent parsing, backend bundles, `I18nService`, and its global mutable +state are enabled only by the Core `i18n-runtime` owner feature. A host that +initializes or updates the backend service must select that feature explicitly; +`product-full` and App Server do so, while contract-only Core consumers do not +compile Fluent. + ## Lookup And Override Order Resource intent has three layers: diff --git a/docs/architecture/platform-portability-design.md b/docs/architecture/platform-portability-design.md index 44761b9964..0b4b3294ec 100644 --- a/docs/architecture/platform-portability-design.md +++ b/docs/architecture/platform-portability-design.md @@ -93,8 +93,8 @@ Cargo package `bitfun-cli` 的 `aarch64-unknown-linux-ohos` 目标依赖解析 | 问题域 | 当前识别结果 | 主要风险 | 后续专题需要回答 | |---|---|---|---| -| 产品依赖闭包 | CLI 已显式选择 `agent-runtime`、`canvas-runtime`、`external-sources`、`plugin-runtime` 与 `ssh-remote` Core owner feature,不再继承 `product-full`;当前闭包仍主动保留 remote、browser、canvas、plugin、watch、Git、SQLite、PTY 等现有能力 | 无关平台依赖阻塞构建;为过编译而破坏共享 owner | 在不改变现有 CLI 规格的前提下,哪些 owner 还应继续拆分或针对目标平台隔离 | -| Rust 与依赖解析 | 仓库无根 `Cargo.lock`;Rust 1.94.1 探针先被要求 Rust 1.95 的 `oxc-browserslist`、`oxc_sourcemap` 阻塞 | 把通用 MSRV/解析问题误判为 OHOS 问题;构建不可复现 | 仓库认可的 Rust、依赖解析和构建基线 | +| 产品依赖闭包 | CLI 已显式选择 `agent-runtime` 生命周期基线、实际 service owner、external/plugin/SSH owner 和九组 `tools-*`,不再继承 `product-full`;当前 CLI 闭包仍主动保留 remote、browser、canvas、plugin、watch、Git、SQLite、PTY 等现有能力 | 无关平台依赖阻塞构建;为过编译而破坏共享 owner | 在不改变现有 CLI 规格的前提下,哪些 owner 还应继续拆分或针对目标平台隔离 | +| Rust 与依赖解析 | 当前仓库已有根 `Cargo.lock`;旧的 Rust 1.94.1 探针曾先被要求 Rust 1.95 的 `oxc-browserslist`、`oxc_sourcemap` 阻塞,必须在真正启动 OHOS 适配时按届时 lock 与工具链重跑 | 把通用 MSRV/解析问题误判为 OHOS 问题;使用过期解析结论 | 仓库认可的 Rust、依赖解析和构建基线 | | TUI/TTY | `ratatui/crossterm` 依赖 `mio`、rustix、signal-hook 和终端系统调用 | 能编译但 raw mode、输入、resize、信号或恢复不可用 | 真实系统终端支持范围与 TUI 退化边界 | | 剪贴板与语法高亮 | `arboard -> x11rb` 带入 X11;`syntect-tui` 重新带入 `onig_sys` | 桌面 Linux/C 原生依赖进入 OHOS 产物 | 这些能力是否必需,以及各自可维护的鸿蒙化路线 | | 进程与交互终端 | `portable-pty -> termios` 依赖 openpty、shell、信号、进程组和 `/dev` 语义 | 交互 shell、取消和子进程回收不成立 | OHOS 公开进程/PTY 能力与产品可接受的能力范围 | diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index ed904873d6..7be8042776 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -7,10 +7,8 @@ 内置扩展边界见 [`product-customization-blueprint.md`](product-customization-blueprint.md);CLI 产品入口和配置 兼容见 [`cli-product-line-design.md`](cli-product-line-design.md);HarmonyOS PC 原生 CLI/TUI 平台规约见 [`platform-portability-design.md`](platform-portability-design.md)。跨专题实施顺序见 -[`../plans/product-architecture-evolution-plan.md`](../plans/product-architecture-evolution-plan.md)。外部 AI 工作内容架构、应用级连接详细设计与对应执行计划分别见 -[`external-ai-work-sources-design.md`](extensions/external-ai-work-sources-design.md)、 -[`external-ai-app-connection-experience-design.md`](extensions/external-ai-app-connection-experience-design.md)和 -[`external-ai-app-connection-experience-plan.md`](../plans/external-ai-app-connection-experience-plan.md);OpenCode 扩展总矩阵、配置资产、插件执行、 +[`../plans/product-architecture-evolution-plan.md`](../plans/product-architecture-evolution-plan.md)。外部 AI 工作内容架构见 +[`external-ai-work-sources-design.md`](extensions/external-ai-work-sources-design.md);OpenCode 扩展总矩阵、配置资产、插件执行、 终端插件和外部集成适配分别见 [`opencode-extension-compatibility.md`](extensions/opencode-extension-compatibility.md)、 [`opencode-config-assets-adapter-design.md`](extensions/opencode-config-assets-adapter-design.md)、 @@ -22,10 +20,10 @@ Headless CLI 与各产品入口的统一心智见 [`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md);多个 GUI/TUI/Remote/CLI/SDK 实例共存时的 Agent Runtime 部署、 状态共享、隔离、容量与 Plugin Host 关系见 -[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md);Desktop GUI、Web UI 和交互式 TUI 的统一产品后端协议、 -Embedded/Shared App Server 边界及迁移约束见 -[`app-server-architecture.md`](app-server-architecture.md)。该专题当前是待评审的目标提案;在决策门槛通过前,当前调用路径和稳定 -owner 边界仍以本文及已接线代码为准。其他已批准的详细设计与本文件冲突时,以本文件为准。 +[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md);Desktop GUI、Web UI 和交互式 TUI 的产品后端边界、 +Embedded direct-runtime、Shared App Server 及迁移约束见 +[`app-server-architecture.md`](app-server-architecture.md)。Embedded direct-runtime 已确定为下一步实现方向;Shared App Server +仍是待评审提案。在迁移或决策门槛通过前,当前调用路径和稳定 owner 边界仍以本文及已接线代码为准。其他已批准的详细设计与本文件冲突时,以本文件为准。 Cargo feature、第三方依赖 owner、测试目标和本地/CI 验证分工见 [`rust-build-dependency-boundaries.md`](rust-build-dependency-boundaries.md)。该文档补充本架构的构建视图,不改变本文定义的运行时 owner 和分层依赖方向。 @@ -310,9 +308,9 @@ flowchart LR ### 2.4 Physical View · Level 0 -Physical View 展示当前可执行单元到设备、主机和存储的映射。Desktop、CLI、ACP 和 SDK Host 使用 Embedded Runtime; -Embedded 交互式 TUI 已在同一 CLI 进程内通过私有 App Server 使用 Runtime,交互式 TUI 也可以显式连接当前 Shared Runtime IPC。 -Desktop GUI 的 App Server 迁移尚未完成。当前 loopback Web Server 已承载 Embedded Runtime 和 WebSocket App Server;Relay Server +Physical View 展示当前生产环境中可执行单元到设备、主机和存储的映射。Desktop、CLI、ACP 和 SDK Host 使用 Embedded Runtime; +Embedded 交互式 TUI 当前仍在同一 CLI 进程内通过私有 App Server 使用 Runtime;交互式 TUI 也可以显式连接当前 Shared Runtime IPC。 +Desktop GUI 当前仍使用 Tauri adapter;独立 direct Runtime 迁移尚未实施。当前 loopback Web Server 已承载 Embedded Runtime 和 WebSocket App Server;Relay Server 不承载 Agent Runtime。 ```mermaid @@ -376,8 +374,8 @@ flowchart LR | Deployment unit | Main contents | |---|---| -| Desktop App | Web UI、Tauri Host、embedded Agent Runtime;Rich Client App Server 迁移尚未完成 | -| CLI App | 交互式 TUI 通过 private in-process App Server 使用 Embedded Runtime;Headless、Peer 保留独立 adapter;可显式使用 Shared TUI | +| Desktop App | Web UI、Tauri Host、embedded Agent Runtime;当前 Desktop 产品请求使用现有 Tauri adapter | +| CLI App | 交互式 TUI 当前通过 in-process App Server 使用 Embedded Runtime;Headless、Peer 保留独立 adapter;可显式使用 Shared TUI | | Shared Runtime | 私有本机 IPC;当前只有交互式 TUI consumer;是否迁入 Shared App Server transport 仍待评审与等价证据 | | ACP | Embedded Agent Runtime、ACP 协议生命周期 | | SDK Host | 私有跨进程 adapter;公开 SDK 产品尚未交付 | @@ -425,11 +423,19 @@ flowchart TB ## 3. 接口边界 BitFun 只保留四个稳定业务接口边界;工具、事件和权限作为归属子接口被复用,不在插件层重复定义。App Server -是 Agent Runtime API 和其他 owner 接口面向当前 Web/Embedded TUI 以及候选 Rich Client 目标的版本化 wire adapter,不新增第五个 -业务 owner 或能力分类。是否扩大到全部 Rich Client 由 4.2 节所述评审决定。本文使用 +是 Agent Runtime API 和其他 owner 接口面向当前 Web/Embedded TUI,以及未来确实需要连接边界的 Rich Client 的版本化 wire adapter,不新增第五个 +业务 owner 或能力分类。Embedded TUI 的目标改为 direct Runtime adapter;Shared 是否使用 App Server 由 4.3 节所述评审决定。本文使用 “接口”描述可被调用或依赖的能力面;只有描述跨进程消息封装、结构化 schema、序列化对象或强兼容约束时才使用 “契约”;只读状态视图表示从权威状态派生出的查询结果。 +Phase 5 将为 Embedded/Shared TUI 冻结窄的 `TuiRuntimePort`,其范围按 Shared IPC v17 +实际承载的 Session、Turn、Permission/UserInput、Workspace、lineage、usage/settlement、 +model/mode 更新、agent mode catalog 和事件订阅确定。Model/Skill/Subagent/MCP、Account、 +Settings Sync、Worktree、External Source 和 Hook 等管理面不组成一个 `TuiManagementPort`, +也不因为存在 TUI 用例就进入 Shared Runtime wire;它们由 TUI backend composition 按 domain +直接依赖 owner-owned 的稳定 service/provider trait。只有需要 TUI DTO、权限/上下文适配、 +内部类型隔离或 capability 裁剪时,才增加薄 facade。 + | 接口边界 | 谁使用 | 提供 | 不包含 | |---|---|---|---| | Agent Runtime API | App Server、Headless CLI、ACP、Server、Remote、SDK 等 adapter | Query、Session、Tool/MCP、Permission、Hook、Event、Usage | UI、Rich Client wire、协议和具体服务实现 | @@ -482,17 +488,18 @@ client 或未来 CLI/HarmonyOS 计划,不能证明同名 Rust transport adapte 前后端契约按能力语义归属,不按 Tauri command 名称归属。稳定的请求、响应、状态事实和类型化错误放在对应 `contracts/*`、Agent Runtime API 或能力归属模块。当前 Desktop GUI 仍使用 Tauri adapter,Web UI 使用 loopback WebSocket -App Server,Embedded TUI 使用 in-process App Server,Shared TUI 通过 `TuiBackend` 映射 private Runtime IPC v17。待评审目标是让 -Desktop GUI、Web UI 和交互式 TUI 复用同一 Rich Client App Server 行为与 wire contract;Tauri 和各 Rich Client Host 负责 -transport、平台能力及生命周期。ACP、Headless CLI、Peer Host 与公开 SDK 继续由各自 adapter 映射到稳定 owner 接口,不因该目标 -复用 App Server wire。该规则降低框架耦合,但不要求把 controller-local Desktop DTO 搬进共享 crate。 +App Server,Embedded TUI 当前使用 in-process App Server,Shared TUI 通过 `TuiBackend` 映射 private Runtime IPC v17。目标是让 +Embedded GUI/TUI 通过 Host-owned direct adapter 调用 Runtime typed API,需要连接边界的 Web/Shared Rich Client 使用 App Server; +Tauri 和各 Rich Client Host 负责 adapter、transport、平台能力及生命周期。ACP、Headless CLI、Peer Host 与公开 SDK 继续由各自 +adapter 映射到稳定 owner 接口,不因该目标复用 App Server wire。该规则降低框架耦合,但不要求把 controller-local Desktop DTO +搬进共享 crate。 | 层 | 允许 | 禁止 | |---|---|---| | 能力归属模块 / Agent Runtime API | 字段明确的请求和响应、状态事实、权限/取消规则、与框架无关的用例方法 | `tauri::State`、`AppHandle`、窗口/菜单对象、command 宏、HTTP/WebSocket/ACP/SDK Host 消息结构 | -| Desktop Tauri / proposed App Server Host adapter | 当前组装 Tauri adapter;目标组装 transport、注入真实 capability 与平台 provider、管理窗口和桌面生命周期、投递 App Server typed notification 或桌面专属事件 | 复制业务校验、持有第二份权威状态、在目标迁移完成后为同一能力保留第二条 Runtime 旁路、把 Tauri 类型传入下层 | +| Desktop Tauri / product Host adapter | 当前组装 Tauri adapter;目标按部署组装 direct Runtime adapter 或 App Server transport、注入真实 capability 与平台 provider、管理窗口和桌面生命周期、投递 typed Runtime/App Server notification 或桌面专属事件 | 复制业务校验、持有第二份权威状态、在目标迁移完成后为同一能力保留第二条 Runtime 旁路、把 Tauri 类型传入下层 | | Server / Remote adapter | 路由鉴权、协议消息、连接生命周期、流量控制与取消转换 | 为同一能力另建业务含义不同的 DTO 或 handler | -| GUI / Web / TUI frontend | 当前依赖各自 infrastructure 或 `TuiBackend`;目标依赖生成的 App Server client、稳定读模型和 Host-local capability adapter;各自保留渲染状态 | 在 UI component/view 中直接依赖 Runtime/Core/Service、公开 Python/TypeScript SDK、Tauri 业务 command 或私有 Shared IPC | +| GUI / Web / TUI frontend | 当前依赖各自 infrastructure 或 `TuiBackend`;目标依赖 frontend/app infrastructure,由其组合 `TuiRuntimePort`、owner service/provider adapter 和需要时的 App Server client;各自保留渲染状态 | 在 UI component/view 中直接依赖 Runtime/Core/Service、公开 Python/TypeScript SDK、Tauri 业务 command 或私有 Shared IPC | 本文其他章节和历史设计中出现的“Runtime SDK”,如果指 `agent-runtime::sdk`,统一称为 **Rust Runtime SDK(当前 preview)**;它是共享 **Agent Runtime API** 的当前 Rust 入口。只有 @@ -540,8 +547,9 @@ Desktop command 使用的序列化对象继续留在 `src/apps/desktop`;即使 ## 4. 运行协作细节 -本节在 Process View Level 0 之下展开产品入口、插件调用和平台能力。Current 图只描述当前已接线请求路径;Proposed target 图 -描述待评审方向。两者都只描述组件协作,不构成新的 4+1 视图。 +本节在 Process View Level 0 之下展开产品入口、插件调用和平台能力。Current 图只描述当前已接线请求路径;Approved Embedded target +和 Optional Shared proposal 分别描述已批准但未交付的 Embedded direct-runtime,以及仍待评审的 Shared App Server。三者都只描述组件协作, +不构成新的 4+1 视图。 ### 4.1 Current product entry paths @@ -550,7 +558,7 @@ flowchart LR Desktop["Desktop GUI"] --> Tauri["Desktop / Tauri adapter"] Web["Web UI"] --> WebHost["loopback WebSocket App Server"] TUI["Interactive TUI"] --> Backend["TuiBackend"] - Backend -->|"Embedded"| EmbeddedAS["in-process App Server"] + Backend -->|"Embedded current"| EmbeddedAS["in-process App Server"] Backend -->|"--shared"| SharedIPC["private Runtime IPC v17"] Other["Headless CLI · ACP · Server · Remote"] --> Adapter["独立入口适配器"] SDK["Rust Runtime SDK / SDK Host preview"] --> SDKAdapter["独立 SDK adapter"] @@ -563,43 +571,52 @@ flowchart LR API --> Runtime["共享 Runtime"] ``` -当前 Embedded TUI 核心路径经过 App Server,Shared TUI 则由 `TuiBackend` compatibility adapter 映射到 private Runtime IPC v17。 -Desktop GUI 尚未完成 App Server 迁移;当前 loopback Web Host 已通过 WebSocket 承载 App Server。Headless CLI/CI、ACP、Peer Host -和 SDK Host 保留独立 adapter。所有路径最终消费同一 Runtime API 或 owner port,部署选择不能进入业务 owner。 +当前 Embedded TUI 核心路径经过 in-process App Server,Shared TUI 通过 private Runtime IPC v17;Web UI 通过 loopback WebSocket App Server, +Desktop GUI 通过 Tauri adapter。Headless CLI/CI、ACP、Peer Host 和 SDK Host 保留独立 adapter。所有路径最终消费同一 Runtime API 或 owner +port,部署选择不能进入业务 owner。目标路径不在本图中展开。 Server bootstrap 和产品组装只创建对象并注入依赖,不是客户端请求的第二条旁路: ```mermaid flowchart LR - Assembly["产品组装"] -. "constructs" .-> Host["Host-owned App Server + transport"] + Assembly["产品组装"] -. "constructs" .-> Host["Host-owned adapter + transport when needed"] Assembly -. "constructs" .-> Runtime["Runtime / owner implementations"] Runtime -. "injects owner ports" .-> Host ``` 图中的虚线全部表示启动期 composition;业务请求仍只沿前一张 Current 图中的实线进入 Runtime API 或 owner port。 -### 4.2 Proposed target product entry paths +### 4.2 Approved Embedded target ```mermaid flowchart LR - Rich["Desktop GUI · Web UI · Interactive TUI"] --> Host["Rich Client Host"] - Host --> Client["App Server client"] - Client --> Transport["Host-selected transport"] - Transport --> AppServer["App Server"] - Other["Headless CLI · ACP · Peer Host"] --> Adapter["独立入口适配器"] - SDK["Public Agent SDK"] --> SDKHost["SDK Host"] - AppServer --> API["Runtime API / owner ports"] - Adapter --> API - SDKHost --> API + TUI["Embedded interactive TUI"] --> Composition["TuiBackend composition"] + Composition --> RuntimePort["TuiRuntimePort"] + RuntimePort --> DirectRuntime["Direct Runtime adapter"] + DirectRuntime --> API["Runtime API / owner ports"] + Composition --> Management["owner service/provider adapters"] API --> Runtime["共享 Runtime"] ``` -提案目标是让 Desktop GUI、Web UI 和交互式 TUI 复用 App Server 行为与 wire contract,并让 Embedded/Shared 只在 Host 与 -transport 层不同。是否用 Shared App Server 替换 v17,仍取决于鉴权、实例身份、controller/lease、事件恢复、取消、限制、性能和 -回滚门槛;目标图不表示这些能力已经交付。各入口仍各自拥有 renderer、平台能力和生命周期。Headless CLI/CI、ACP、Peer Host 和 -公开 SDK 不共享 App Server wire。 +这是已批准但尚未交付的 Embedded direct-runtime 目标,属于 Phase 5。迁移完成前,Embedded TUI 继续使用 Current 图中的 in-process +App Server;管理能力按 domain 由 composition 注入 owner service/provider,不组成 `TuiManagementPort`。 + +### 4.3 Optional Shared App Server proposal + +```mermaid +flowchart LR + C1["Shared Rich Client 1"] --> Transport["candidate private Pipe / UDS"] + C2["Shared Rich Client 2"] --> Transport + Transport --> Host["Shared App Server Host"] + Host --> Runtime["one Agent Runtime owner"] + Runtime --> Storage["Workspace / Session storage"] +``` + +这是 Phase 6 的待评审提案,不是当前 Shared TUI 的必经链路,也不改变 Current 图中的 private Runtime IPC v17。只有完成鉴权、实例身份、 +controller/lease、事件恢复、取消、限制、性能和回滚门槛后,才可评审是否替换 v17;评审也可以决定长期保留 v17。Web UI 不经过 TUI +composition,而是继续通过自己的 loopback WebSocket App Server 入口。 -### 4.3 插件调用 +### 4.4 插件调用 ```mermaid flowchart LR @@ -616,7 +633,7 @@ flowchart LR Adapter["生态 adapter"] --> Provider["能力 Provider"] --> Owner["能力归属模块"] ``` -### 4.4 平台能力 +### 4.5 平台能力 ```mermaid flowchart LR @@ -627,7 +644,7 @@ flowchart LR 关键规则: -- Current 产品请求遵循 4.1 节;4.2 节的 Rich Client App Server 统一路径只有在相应 Host 完成迁移和验证后才成为当前路径。 +- Current 产品请求遵循 4.1 节;4.2 节的 Embedded direct 路径只有在相应 Host 完成迁移和验证后才成为当前路径,Shared App Server 分支还需独立评审。 其他产品入口先经过自己的 adapter,再消费 Agent Runtime API、owner port 和只读视图;公开 SDK 只多一层 SDK Host 跨进程适配。 Agent Runtime API 是一组小而明确的用例接口,不是必须实例化的总入口;adapter 可以调用对应归属模块的少量接口, 但不能访问内部状态、绕过既有编排或复制业务规则。任何入口都不直接调用 Plugin Host。 @@ -638,8 +655,8 @@ flowchart LR Node/Bun 和第三方 JS/TS 的子进程;插件启停与贡献生命周期仍由既有来源和能力归属模块管理。 - 外部来源的 Command、Tool、Subagent、MCP 仍保留能力专属 DTO 和 owner,但它们的发现调度统一由 `ExternalSourceControlPlane` 持有;当前 Desktop/TUI/Peer 的控制事实只通过版本化的 product-domain 只读视图共享, - 不复制生态 payload、界面状态机或远端专用 DTO。Server 的 external-source helper 当前未接入 App Server schema,生产 `/ws` - 返回 `method_not_found`;只有完成 V1 read-only schema、handler/client translation 和 WebSocket round-trip 后,Server 才进入该共享边界。 + 不复制生态 payload、界面状态机或远端专用 DTO。App Server 已注册 external-source schema、handler 和 client translation;Embedded Host + 注入 management owner 后可以调用。通用 Server `/ws` 当前没有绑定可信工作区的 management owner,因此返回类型化 `unsupported`;只有注入 Host 持有的作用域化 owner 并通过 WebSocket round-trip 后,Server 才交付该共享边界。 - 每个生态适配层独立保留该生态的外部格式、来源顺序和调用语义,并映射到 BitFun 归属模块;它本身不成为新的 业务归属模块,也不能依赖或修改兄弟生态 adapter。通用目录、`ExternalSourceControlPlane` 和能力归属模块只依赖开放生态 ID、 来源限定身份与能力专属 provider 契约,不按 OpenCode、Codex 或 Claude Code 分支行为。 @@ -651,7 +668,7 @@ flowchart LR 可以选择下层提供方,但不能依赖 app crate;需要同时被独立应用和嵌入式模式复用的实现必须下沉到可复用 owner, 再由各 app 和 assembly 组合。 -### 4.5 名词与定义归属 +### 4.6 名词与定义归属 全仓人工维护文档、AGENTS、README 和代码注释遵守以下规则: @@ -761,6 +778,8 @@ flowchart LR - 声明一个 Delivery Profile、生成测试计划或通过 crate 单测,不等于该产品形态已经接入生产。只有入口实际提交 唯一 profile、消费组装结果和统一能力可用性,并通过入口级行为验证后,才能把该 profile 标为已接入。 - 产品入口向组装根提交唯一 Delivery Profile;组装根只校验并派生静态计划,不在内部再次选择交付形态。 +- 入口必须在任何配置规范化或全局工具 registry 首次读取之前提交 Delivery Profile,避免进程级 registry 被兼容默认值提前锁定。Desktop 提交 `Desktop`;当前 loopback Server Host 仍承载完整兼容能力,因此提交 `ProductFull`,空的 `Server` profile 仍表示尚未交付的独立 Server 产品形态。 +- Agent Runtime 的最小工具计划不是 Delivery Profile。Product Assembly 单独生成 `ProductToolPlan`,显式列出工具 owner;基线只选择 `Basic` 与 `AgentControl`,完整交付计划由已提交的 Delivery Profile 派生。 - Runtime Configuration 承载用户、项目、工作区和本次运行的可变配置;不能启用产品定义 未组装的能力,也不能放宽产品或组织策略。 - Capability Availability 是根据产品计划、服务健康和当前策略计算出的能力状态;所有入口读取同一状态, @@ -817,10 +836,11 @@ flowchart LR | 当前入口 | 已有能力 | 明确边界 | |---|---|---| -| Desktop | 使用 `product-full`;显示外部来源、审批、冲突、诊断和 Host 能力;目标增加应用级读模型、默认连接事实和批量确认 DTO | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | -| CLI / TUI | 使用显式 Core owner feature closure(`agent-runtime`、`canvas-runtime`、`external-sources`、`plugin-runtime`、`ssh-remote`);现有 `/extensions` 支持来源状态、刷新、Safe Mode 和来源开关,统一 `/hooks`(旧 `/hooks_external` 为别名)、`/tools` 和 `/agents` 保留各自专项职责 | 应用级摘要、首次连接、`/extensions review` 批量确认和任务相关 `action-required` 仍是目标能力,完成条件以对应详细设计和 P6 端到端证据为准;生态解析仍在适配器,不启动第二套 Agent Runtime;远程能力未接入时不回退本机 | -| ACP | 使用 `DeliveryProfile::Acp`、Runtime Parts,以及 `agent-runtime`/`canvas-runtime`/`external-sources`/`ssh-remote` Core owner feature | load 成功后才发布活动状态;close 排空后再卸载;完整历史、Canvas 工具物化、兼容指令来源和配置仍由 Core/ACP 管理 | -| Peer / Server | Peer Host 执行真实工作区操作;当前 HTTP Server 使用 `product-full` 组装 Embedded Runtime,并通过 `/ws` 暴露 App Server,但 external-source 方法尚未进入 schema、当前返回 `method_not_found` | 控制端不替远端发现或执行;Server external-source 只读投影须先完成真实 App Server 接线,且 loopback 单用户边界不扩展到远程/多用户;SSH Remote 未接入时返回不支持 | +| Desktop | 使用 `product-full`;Settings 从现有来源目录和 integration policy 生成简短应用概览,具体审批与冲突仍进入 Tool、Agent、MCP 或 Hook owner | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | +| CLI / TUI | 使用显式 Core owner closure:`agent-runtime` 基线、实际 service owner(包括 Remote Connect、DeepResearch、LSP、external/plugin source 与 SSH)以及九组 `tools-*`;`/extensions` 只提供状态、启停和刷新,`/hooks`、`/tools`、`/agent` 和 `/mcp` 处理各自能力 | `agent-runtime` 不再隐式携带完整 MCP/Remote/Browser/Web/Git/LSP/模型目录闭包;非交互不等待权限输入,生态解析仍在适配器,远程能力未接入时不回退本机 | +| ACP | 使用 `DeliveryProfile::Acp`、Runtime Parts、`agent-runtime` 基线、所需 service owner 与九组 `tools-*`,但不选择 CLI 的 plugin runtime 和 Remote Connect owner | load 成功后才发布活动状态;close 排空后再卸载;完整历史、Canvas 工具物化、兼容指令来源和配置仍由 Core/ACP 管理;未选择的能力不得借 Cargo feature union 偶然出现 | +| SDK Host(preview) | 使用 `DeliveryProfile::Sdk`、Runtime Parts 和与当前本机协议能力一致的显式 Core owner closure;TLS provider 由 Host 进程入口安装 | 当前协议不暴露远程 workspace/SSH 执行,因此不选择 Remote Connect、SSH 或 Function Agent owner;未来远程 SDK 必须复用 Server/Remote 的认证和执行域,不能回退到本机执行 | +| Peer / Server | Peer Host 执行真实工作区操作;通用 HTTP Server 未绑定可信 workspace owner 时明确返回不支持 | 控制端不替远端发现或执行;loopback 单用户边界不扩展到远程/多用户;SSH Remote 未接入时返回不支持 | | Web / Mobile Web | 依赖现有后端入口 | 不持有插件执行单元,也不能据空 profile 宣称独立能力 | | HarmonyOS 手机 Remote | phone-only ArkTS 远程入口 | 不等于 HarmonyOS PC 本地 Runtime、CLI/TUI 或 GUI | @@ -837,7 +857,7 @@ Shared Agent Runtime 是第一方多实例的目标部署,不是上表新增 底层来源与能力继续使用[外部 AI 工作内容设计](extensions/external-ai-work-sources-design.md#7-状态与提示规则)定义的 已发现、已应用、可用、需确认、更新中、沿用上一版本、部分受限、暂时过期、已移除/已停用和不可用,并附带 -原因与恢复建议。目标状态下,Settings 首页和 TUI `/extensions` 将消费[应用级连接详细设计](extensions/external-ai-app-connection-experience-design.md#34-面向用户的应用级状态)派生的五种摘要;当前生产入口仍消费 V1 来源/能力控制事实,不能据目标文档宣称应用级连接已经交付。目标摘要不能替代底层事实,宿主也不能自行重算优先级。Host 的准备完成、重启、暂停、不支持或失败只作为详情映射。现有代码中的过渡状态只能展示为“静态预览、未执行”,不能因为进入来源清单就误报为已应用、已连接或可用。 +原因与恢复建议。Settings 首页和 TUI 可以把这些事实压缩为简短应用/来源概览,但不能建立第二套连接、审批或任务结果状态机,也不能因为进入来源清单就误报为已应用或可用。 ## 7. 完成判定 diff --git a/docs/architecture/rust-build-dependency-boundaries.md b/docs/architecture/rust-build-dependency-boundaries.md index 8e836364da..c55326667f 100644 --- a/docs/architecture/rust-build-dependency-boundaries.md +++ b/docs/architecture/rust-build-dependency-boundaries.md @@ -47,29 +47,77 @@ Cargo 会统一同一 package 在依赖图中的 feature;workspace dependency ### 3.2 产品入口显式选择 Core 能力 -`src/apps/*`、`src/crates/interfaces/*` 和 installer app 直接依赖 `bitfun-core` 时必须同时: - -1. 设置 `default-features = false`; -2. 声明非空 `features` 列表。 +`src/apps/*`、`src/crates/interfaces/*` 和 installer app 直接依赖 `bitfun-core` 时必须声明非空 +`features` 列表。Core 的 `default` 由 Core 自身和边界检查保证为空,因此仓内 consumer 不重复声明 +`default-features = false`;能力边界仍由 consumer 的显式 feature 集合决定。 入口应选择真实需要的 owner feature;`product-full` 只能描述确实需要完整产品装配的兼容入口,不能作为尚未完成 feature/owner 分解时的占位解法。缩小某个产品的 capability 集合时必须从实际 construction/command path 反推,并保留行为等价或明确 unsupported-state 测试。 +Core library 的默认 feature 集合为空;完整产品必须显式选择 `product-full`。若 interface crate 以多个 +公开角色复用一条 optional Core dependency,则 dependency 声明本身保持无 feature,每个角色 feature +分别激活 Core 并选择自己的非空 owner 闭包;兼容默认值只能组合这些已评审角色,不能重新引入 +`product-full`。每个角色必须独立编译,产品消费者还要显式关闭该 interface crate 的默认 feature 并选择 +真实使用的角色,避免 workspace feature union 掩盖边界缺口。 + +Core 的 `agent-runtime` 只承载 Agent 生命周期基线和明确的基线工具,不得再次把 MCP、Remote Connect、模型目录、Browser/Web、Git/LSP 或产品工具组藏成 capability union。具体 service 由同名 owner feature 选择,内置工具由 `tools-*` 选择;`product-full` 显式相加全部 owner,CLI/ACP 等窄入口则按真实命令与构造路径列出自己的闭包。 + +Owner feature 不等于“无前置依赖”。当实现确实调用较低层基线时,依赖必须按 `owner → baseline` 显式组合,禁止反向把 owner 藏回基线:例如 Core MCP 工具桥和 Remote Connect 依赖 Agent 生命周期,Workspace Search 依赖本地 Workspace Runtime。每个新增或调整后的 owner 闭包都必须单独 `cargo check`,避免被 Desktop/CLI 的 feature union 偶然补齐。 + +只为已经启用的 optional dependency 增加子能力时,使用 Cargo 的弱依赖转发 +`dependency?/feature`,并把 modifier 与 runtime owner 分开命名和看护。modifier 单独启用不得激活 +runtime dependency;真实产品入口必须同时显式选择 owner 与 modifier。不要为了复用一个子 feature +把完整 adapter、service 或 tool runtime 拉回窄闭包。 + +Function Agent 的 Git/AI 适配由 `function-agents` 选择,MiniApp 的 domain/runtime/market +闭包由 `tools-miniapp` 选择;不得再通过一个通用 `product-domains` Core feature 把两者、 +Plugin Source 和完整 domain feature 集合一起带回 Agent Runtime。产品装配计划若声明了当前 +二进制未编译的工具组,必须在 registry materialization 前明确失败,不能静默删掉该组。 + +工具 provider group 只维护稳定分组与注册顺序,不等于 Cargo feature owner。每个内置工具 +必须映射到唯一 `ToolPackFeatureGroup`;Product Assembly 通过 `ProductToolPlan` 明确选择本次 +交付需要的 owner,Core materializer 只物化这些 owner 的工具,并对“计划已选择但二进制未 +编译”的 owner 返回类型化错误。`agent-runtime` 基线计划只选择 `Basic` 与 `AgentControl`; +它不是隐式 Delivery Profile,也不得从当前二进制已编译的 feature union 反推产品能力。 + ### 3.3 Workspace dependency 只提供共同底座 - workspace 声明负责版本和真正跨产品共享的最小 feature; +- 第三方依赖的默认 feature 若不是每个 consumer 的稳定契约,在 workspace 声明统一关闭;成员继承该策略, + 只增加自身实际使用的 feature,不在各 manifest 重复 `default-features = false`; +- 仓内 crate 的空默认值由被依赖 crate 拥有并由边界检查锁定,consumer 不重复关闭;只有 ACP 这类有意保留 + 非空兼容默认的 crate,窄 consumer 才必须显式关闭默认值并选择角色; +- 被 Docker 等独立构建上下文单独复制的 manifest 无法继承 workspace 根,继续显式声明版本与默认策略; - runtime、HTTP、TLS、crypto、codec 等产品特定 feature 留给实际 app/service/adapter owner; - `full` feature 只有在所有真实消费者都需要且更窄集合不能稳定维护时才允许; - target-specific dependency 放在最接近平台实现的 owner,不因单一平台需求污染跨平台 crate; - 修改共享 dependency feature 视为构建影响变更,必须检查真实产品组合的 feature graph。 -### 3.4 Reqwest TLS 后端由客户端 owner 选择 +### 3.4 Reqwest 能力由客户端 owner 选择 -- workspace 级 `reqwest` 只统一版本以及跨产品共享的 HTTP、序列化和流能力,不启用 TLS 后端; -- 真正创建 HTTPS client 的 app、service 或 adapter 必须在自身依赖声明中显式选择 `reqwest/rustls`,只使用 `reqwest::Url` 的 contract/assembly 路径不加载 TLS; -- capability crate 的每个 Reqwest owner feature 必须独立带齐 `reqwest/rustls`,不能依赖 `product-full` 或其他 feature 的 Cargo feature-union 偶然补齐; +- workspace 级 `reqwest` 只统一版本并关闭默认 feature,不替任何客户端选择 HTTP/2、序列化、表单、流、代理或 TLS 能力; +- 真正创建 client 的 app、service 或 adapter 必须在自身依赖声明中显式选择实际使用的 Reqwest feature 和 `reqwest/rustls`;只使用 `reqwest::Url` 的 contract/assembly 路径不加载传输能力; +- capability crate 的每个 Reqwest owner feature 必须独立带齐自己的数据/传输 feature 与 `reqwest/rustls`,不能依赖 `product-full` 或其他 feature 的 Cargo feature-union 偶然补齐; - 边界检查以 Cargo metadata 的解码结果看护全部直接 consumer,并检查 resolved Reqwest feature union,防止传递依赖重新激活 Native TLS; - 不并列启用 native-tls 兼容栈。只有真实产品场景无法由 Rustls 平台证书验证承载时,才以明确行为证据评审替换方案,而不是重新叠加第二后端。 +### 3.5 稳定契约 crate 按消费能力切片 + +稳定 DTO、port 和纯契约继续由原 contract/execution crate 拥有;当同一 crate 的公开表面覆盖多个互不相关的 +能力域时,优先在原 crate 内用 additive owner feature 隔离源码和可选依赖,不为了构建数字迁移 runtime owner +或复制公共类型。feature 关闭时 API 不可见是明确的编译期契约变化;feature 开启后必须保留原公开路径、序列化 +形状和错误语义。 + +- contract crate 的 `default` 保持空并由目标 crate 的边界契约看护;consumer 继承该空默认值,只选择实际 + 消费的切片,不在每条 normal/dev/build/target dependency edge 重复关闭 default features; +- feature 名描述稳定能力,例如 Agent API、workspace/terminal/remote/Git port、协议 bridge 或 Computer Use + contract,不描述某个临时调用方、PR 或测试; +- 不提供 `full`、`service-ports`、`all-contracts` 等重新合并全部表面的 umbrella。只有多个 owner 共同消费且 + 无法合理拆开的稳定复合类型可以保留一个窄 aggregate,并明确列出其组成; +- 同一依赖的 Cargo feature 会在完整图中相加,因此窄 consumer 必须独立 `cargo check/test`。完整产品构建 + 只能证明组合闭包可用,不能证明单个 consumer 的声明完整; +- 边界检查看护目标 crate 的空默认、feature surface、consumer edge 及强/弱 feature 转发;不得依赖包名文本 + 而漏掉 rename、optional、dev/build 或 target-specific edge。 + ## 4. 依赖 owner 与准入检查 第三方库应位于调用外部系统或实现具体能力的最低合理 owner: @@ -80,6 +128,13 @@ Cargo 会统一同一 package 在依赖图中的 feature;workspace dependency - DTO、事件和端口属于 contracts,保持行为轻量且不得依赖上层; - Assembly 选择和连接 capability,不实现具体 adapter、OS 或 service 细节; - app 只拥有入口、平台生命周期和产品呈现,不复制可复用服务逻辑。 +- 稳定 contract 与具体运行时实现位于同一 crate 时,优先用单一 owner feature 隔离实现依赖;不得为 + feature-free DTO、fallback 或纯事实强拉解析器、全局状态、网络或 host adapter。 +- 平台 host 已直接拥有的 adapter 不通过 Assembly facade 二次依赖或 re-export;上层只共享稳定 + contract,具体 emitter/transport 由真实 host 直接构造。 +- Services Core 的 feature-free surface 只保留同步稳定 contract、JSONC 和路径规范化;诊断脱敏、 + Diff 计算和异步 workspace 文本读取分别由 `diagnostics`、`diff`、`workspace-text-runtime` owner + 选择。Assembly 可用同名 feature 保留兼容 facade,但不得把这些实现依赖放回默认闭包。 新增依赖或显著扩大已有依赖 feature 时,PR 描述至少给出: diff --git a/docs/development/i18n.md b/docs/development/i18n.md index 5e254900a2..b77828eab9 100644 --- a/docs/development/i18n.md +++ b/docs/development/i18n.md @@ -41,7 +41,7 @@ pnpm run i18n:audit | Web UI i18n runtime or namespace loading | `pnpm run i18n:contract:test && pnpm run type-check:web && pnpm --dir src/web-ui run test:run src/infrastructure/i18n/core/I18nService.test.ts` | | Mobile Web i18n runtime | `pnpm --dir src/mobile-web run type-check` | | Installer frontend i18n runtime | `pnpm --dir BitFun-Installer run type-check` | -| Backend i18n runtime | `cargo test -p bitfun-core i18n -- --nocapture` | +| Backend i18n runtime | `cargo test -p bitfun-core --no-default-features --features i18n-runtime --lib i18n -- --nocapture` | Do not add process-only execution plans to version control. Keep durable rules in this file and `docs/architecture/i18n.md`; keep temporary rollout notes out diff --git a/docs/development/releasing.md b/docs/development/releasing.md new file mode 100644 index 0000000000..5d2de28337 --- /dev/null +++ b/docs/development/releasing.md @@ -0,0 +1,59 @@ +# Release Channels + +BitFun packages use an immutable build-time release channel. End users do not +switch channels at runtime. + +## Stable + +Stable releases continue to be driven by a version bump on `main`. The +`Release On Version Bump` workflow creates `vMAJOR.MINOR.PATCH` and dispatches +`Desktop Package` with the default `stable` channel. + +## Beta + +Run `Desktop Package` manually with: + +- `tag_name`: the immutable release tag, for example `v0.2.18-beta.1`; +- `checkout_ref`: the commit or branch to build when the tag does not exist; +- `release_channel`: `beta`; +- `upload_to_release`: disabled for internal Actions artifacts, enabled for a + public GitHub pre-release. + +Beta versions target the next stable version. If stable is `0.2.17`, the first +candidate is `0.2.18-beta.1`, not `0.2.17-beta.1`. Do not use SemVer build +metadata in a published package version; the release already records the Git +commit separately. + +Public beta assets are stored on the immutable version tag. After every asset +and signature is verified, the workflow updates only the `latest.json` asset on +the `channel-beta` pre-release. Beta Desktop builds read that pointer and fall +back to `https://openbitfun.com/release/beta/latest.json`. +The beta release contains Desktop and Installer assets only. CLI and Relay +floating releases remain stable-only. + +The selected ref must resolve to a commit in the protected `main` history. The +workflow pins that SHA before dispatching platform jobs and rejects an existing +release tag if it points somewhere else. Configure the signing secrets and the +public beta approval policy so untrusted pull-request code cannot access them. +This protected-history requirement applies to the canonical `GCWing/BitFun` +repository; forks may run packaging from their own test branches. A fork beta +uses that fork's `channel-beta` release as both updater origins, so it cannot +silently consume or mutate the canonical beta channel. + +A stable release promotes the beta pointer only when its version is not older +than the current beta. This lets beta users move from `0.2.18-beta.N` to +`0.2.18` without allowing a late workflow to roll the channel backward. + +Beta and stable currently share the same bundle identity and data directories. +Installing beta replaces stable; side-by-side installation is not supported. + +## Mirror + +The mirror script defaults to stable. Run a separate beta sync with: + +```bash +BITFUN_RELEASE_CHANNEL=beta scripts/openbitfun-release-sync.sh +``` + +The beta invocation writes below `/release/beta` and intentionally skips the +stable-only CLI and Relay floating manifests. diff --git a/docs/features/session-runtime-usage-report-design.md b/docs/features/session-runtime-usage-report-design.md index 08d32d3cd9..2c423febd9 100644 --- a/docs/features/session-runtime-usage-report-design.md +++ b/docs/features/session-runtime-usage-report-design.md @@ -689,8 +689,8 @@ Risk and drift controls: Required verification before merging P0: -- `cargo check -p bitfun-core` -- `cargo test -p bitfun-core session_usage -- --nocapture` +- `cargo check -p bitfun-core --no-default-features --features agent-runtime` +- `cargo test -p bitfun-core --no-default-features --features agent-runtime --lib session_usage -- --nocapture` - Focused CLI command tests or manual CLI smoke if no existing helper test harness exists. - `pnpm run lint:web` - `pnpm run type-check:web` @@ -1038,8 +1038,8 @@ Risks and mitigations: Verification: -- `cargo test -p bitfun-core session_usage -- --nocapture` once tests exist. -- `cargo check -p bitfun-core`. +- `cargo test -p bitfun-core --no-default-features --features agent-runtime --lib session_usage -- --nocapture` once tests exist. +- `cargo check -p bitfun-core --no-default-features --features agent-runtime`. - DTO tests for workspace identity, report scope, in-progress reports, cache-unavailable coverage, and redaction metadata. ### Task 2: Non-model-visible local report item diff --git a/docs/performance/01-compile-performance.md b/docs/performance/01-compile-performance.md index b5e036b186..a8037a5939 100644 --- a/docs/performance/01-compile-performance.md +++ b/docs/performance/01-compile-performance.md @@ -1,87 +1,329 @@ # BitFun 编译与依赖治理计划 -> 最近核实:2026-08-04 +> 最近核实:2026-08-13 > -> 快照基线:`gcwing/main@061024fb2` 加权限规划 owner 迁移 +> 实现复核基线:`gcwing/main@a4d944e5b` +> +> 性能 A/B 基线:`gcwing/main@1f538b96d` +> +> 依赖闭包 A/B 基线:`gcwing/main@4781e453c` > > 稳定规则:[Rust 构建与依赖边界](../architecture/rust-build-dependency-boundaries.md) -这份文档只回答三个问题:当前主要成本在哪里、下一步先做什么、每轮治理如何证明有效。 -模块边界以架构文档为准,具体本地命令由最近的 `AGENTS.md` 维护,PR 只记录实际运行过的验证。 +这份文档只维护长期有用的信息:主要成本、已验证收益、下一步顺序和停止条件。模块边界以架构文档为准,具体本地命令由最近的 `AGENTS.md` 维护,单次 PR 的完整命令和日志留在 PR 中。 ## 1. 当前结论 | 结论 | 说明 | |---|---| -| 本轮收益是测试隔离,不是产品构建瘦身 | 权限纯策略测试从 Core 约 449 节点的闭包迁到 Agent Runtime 约 78 节点的闭包;产品依赖图不变 | -| 不再用 `product-full` 解决 focused test | Core 权限编排测试当前最小闭包是 `agent-runtime,canvas-runtime`;纯策略直接在 Agent Runtime 验证 | -| 不新增 CI 或测试入口 | 继续使用现有 test target 和 CI job;治理 PR 不复制同一闭包的验证 | -| 下一优先级是 App Server / Server | 先核实真实生产调用链,再收敛其 Core `product-full` 边界;收益不足则停止 | -| 依赖多版本不能按数量批量清理 | 只处理仓库能控制、行为等价且能缩小真实构建图的版本路径 | +| 集成测试链接拓扑已收敛 | Services 两个 crate 的集成 target 总数从 33 降到 25;External Sources 的 adapter/assembly target 从 22 降到 7;五个 Contracts/AI/Assembly crate 又从 28 降到 10,feature、平台和外部系统失败域保持独立 | +| Agent Runtime 基线不再隐藏重型 capability | `bitfun-core/agent-runtime` 只保留生命周期和基础工具 owner;文档转换与订阅认证也改为产品显式 modifier。在最新主线 A/B 中,三平台 normal/build 闭包进一步减少 69/64/110 个版本化 package instance | +| App Server 不继承未消费能力 | App Server 保持现有 Agent/Git/外部来源 handler 边界,不再因 Core 基线携带文档转换和本地订阅凭据,三平台闭包减少 61/56/78 | +| SDK Host 使用显式能力闭包 | SDK Host 保留当前本机协议和工具能力,但不再通过 `product-full` 携带协议未暴露的 Remote Connect、SSH、Function Agent 等能力;Windows/macOS/Linux normal/build 闭包减少 66/68/76 | +| Core 默认值不再代表完整产品 | Core library 的默认 feature 集合为空,能力内部实现依赖回到实际 owner;最新三平台 feature-free 闭包继续减少 30/31/31 个 package instance | +| ACP 按实际宿主拆分角色 | 兼容默认值仍为 client + server;Desktop 只选择 client,CLI 选择两者。Desktop 独立构建不再编译 ACP 的 4,211 行 server/runtime 源码,产品协议与远程行为不变 | +| 完整产品行为保持 | `product-full` 显式组合全部 capability owner;Core 自身不再携带 Desktop host transport而减少 1 个 package,Desktop 闭包不变。CLI 显式保留原先实际生效的 Oniguruma 高亮后端,ACP 默认组合保持原能力 | +| 默认 feature 责任已集中 | 仓内空默认由被依赖 crate 和边界检查负责;workspace member 的 `default-features = false` 从 70 处降到 6 处,仅保留 ACP 两个窄 consumer 与 Relay 独立 Docker 上下文的 4 处必要声明。第三方默认策略尽可能回到 workspace 根,根 lock 只删除 11 个 package、无新增 | +| Installer 删除未使用的直接能力 | 独立 manifest 的直接 dependency 从 18 降到 10,Windows normal/build 闭包减少 6;不把 Installer 并入根 workspace,本 PR 按要求不提交其生成 lockfile | +| focused test 仍保持精确 | 同 owner、feature、平台和进程语义的源文件进入分组 target;使用 `--test ::` 运行单模块 | -权限 owner 的长期边界和功能不变量见 -[Agent Runtime 服务设计](../architecture/agent-runtime-services-design.md)。这里不重复维护行为规格。 +## 2. 治理门槛 -## 2. 治理原则 - -目标是缩短常用开发、focused test、CI 和打包路径,同时保持产品行为与分层边界稳定。 -每个治理 PR 必须同时满足以下门槛: +目标是缩短常用开发、focused test、CI 和打包路径,同时保持产品行为与分层边界稳定。每个治理 PR 必须同时回答: | 门槛 | 必须回答的问题 | |---|---| -| Owner | 逻辑属于哪个现有 owner?是否存在真实生产消费者? | -| 行为 | 本地、远程和平台差异如何保持?哪些等价测试保护它? | -| 构建图 | 哪个产品或测试闭包实际退出了哪些依赖? | -| 耗时 | 若宣称性能收益,是否在同机器、同命令、同缓存状态下测量? | +| Owner | 逻辑属于哪个现有 owner?是否有真实生产消费者? | +| 行为 | 本地、远程、平台、进程和失败语义如何保持? | +| 构建图 | 哪个真实产品或测试闭包退出了哪些依赖或 target? | +| 耗时 | 若宣称提速,是否在同机器、同命令和同缓存状态下测量? | | 增量成本 | 是否新增 dependency、feature、test target、CI job 或长期兼容层? | 以下做法不属于优化: - 用 `product-full`、`all-features` 或 workspace 全量测试掩盖 feature 边界; - 为减少重复版本数字强制 patch 平台依赖、宏生态或第三方兼容窗口; -- 新建第二套 Agent、Tool、Permission Runtime 或无消费者抽象; -- 未测量就引入 sccache、替换链接器、合并 Installer workspace 或增加 CI job; -- 删除跨平台行为保护来换取表面 CI 时长。 +- 为统一形式新建第二套 Runtime、状态 owner、传输层或无消费者抽象; +- 未测量就引入 sccache、替换链接器、合并独立 workspace 或增加 CI job; +- 删除跨平台、负向能力或异常进程行为保护来换取表面时长。 ## 3. 当前基线 -### 3.1 Rust 构建图 +### 3.1 集成测试链接拓扑 + +#### Services + +本轮只合并 owner 和运行边界相同的测试。`session_write_lock_contracts` 依赖当前测试 executable 启动异常退出子进程,因此继续保持独立;不同 feature 的服务测试也不合并。 + +| 范围 | 变更前 target | 变更后 target | 集成测试数 | +|---|---:|---:|---:| +| `services-core` 全部 | 20 | 13 | 不变 | +| `services-core/local-storage` | 12 | 5 | 58 | +| `services-integrations` 全部 | 13 | 12 | 不变 | +| MCP | 2 | 2 | 45 | +| 基础 Remote SSH | 2 | 1 | 11 | + +Windows、Cargo 1.97.1 的同机独立 `CARGO_TARGET_DIR` A/B 如下。冷构建、无变更重跑和 +单叶文件 mtime 触发各测一次;“owner 重建”在依赖已热后对 owner package 执行三轮 +clean/rebuild,表中为均值。时间是方向性证据,不是硬阈值。 + +| 闭包 | 冷构建前→后 | 无变更前→后 | 单叶变更前→后 | owner 重建前→后 | +|---|---:|---:|---:|---:| +| local-storage | 22.14s → 22.04s | 0.56s → 0.55s | 0.99s → 1.06s | 8.30s → 8.16s | +| 基础 Remote SSH | 27.60s → 28.35s | 0.61s → 0.62s | 1.22s → 1.30s | 3.49s → 3.49s | + +这些单轮数据不支持“编译明显提速”的结论,也不足以把小幅差值与机器波动区分开。依赖编译仍占 +冷路径主导;分组后单叶变更会重链整个职责 target,模块过滤只减少实际运行的测试,不减少该 target +的编译和链接。分组还会降低测试进程级故障隔离粒度,因此当前只合并相同失败域,没有继续扩大。 + +MCP 的 2→1 candidate 也做过同口径 A/B,但冷构建和 owner 重建均无可区分的提速;streamable HTTP +测试还拥有真实 loopback TCP/SSE/超时失败域,因此最终继续保持两个 target,不计入本轮收益。 + +#### External Sources adapters 与 assembly + +同一 crate 内、相同依赖和运行边界的静态来源合同通过 wrapper target 收敛;测试正文逐字迁移,仍可用 +`--test ::` 聚焦到单个来源模块。OpenCode 的 MCP 子进程、受管插件服务和 Node 脚本 +runtime 分别保留独立 target,避免为了减少链接次数混合不同环境、超时和故障语义。 + +| 范围 | 变更前 target | 变更后 target | 集成测试数 | +|---|---:|---:|---:| +| OpenCode adapter | 8 | 4 | 130 | +| Claude Code adapter | 4 | 1 | 51 | +| Codex adapter | 3 | 1 | 47 | +| External Sources assembly | 7 | 1 | 30 | +| 合计 | 22 | 7 | 258 | + +这部分只减少 15 个重复链接的 test executable;未新增 dependency、feature 或 CI 命令,也不以当前证据 +宣称 wall-clock 提速。Cargo 边界检查锁定显式 target、wrapper-only root、leaf 唯一引用和 crate-level cfg, +避免后续新增测试静默绕过分组拓扑。 -| 路径 | 当前快照 | 判断 | -|---|---:|---| -| `bitfun-core` | 约 493 个 Rust 文件、243,900 行 | 仍是最大的高频失效面;只按真实 owner 做纵向迁移 | -| Core 直接消费者 | ACP、App Server、CLI、Desktop、SDK Host、Server | 每次只迁移一个有真实调用方的服务切片 | -| Agent Runtime focused test | 约 78 个唯一 package/version 节点 | 适合无 IO 的 Agent Runtime 纯决策测试 | -| Core `agent-runtime` check | 约 391 个节点 | 窄 owner feature 可独立编译 | -| Core 权限编排测试 | `agent-runtime,canvas-runtime`,约 449 个节点 | 保留真实 scope、Hook、请求生命周期和 Tool 执行 | -| Core `product-full` test | 约 516 个节点 | 仅用于确实需要完整产品装配的兼容路径 | -| Agent Runtime integration target | 5 个显式 target | 已完成收敛;平台和进程边界继续独立 | +可重复确认的产物变化如下;`test executable` 包含每个 crate 的 lib test harness,因此比 integration +target 多 1。PDB 大小会随工具链变化,只比较同次 A/B: -节点数来自同一 Windows 环境下的 `cargo tree --locked` 相对统计,不是实际耗时,也不是跨平台阈值。 -权限纯策略路径理论上少进入约 371 个节点;产品构建闭包没有变化。 +| 闭包 | test executable | EXE | PDB | +|---|---:|---:|---:| +| local-storage | 13 → 6 | 25.2 → 19.2 MiB | 135.7 → 91.9 MiB | +| 基础 Remote SSH | 3 → 2 | 3.9 → 2.8 MiB | 53.5 → 43.8 MiB | + +#### Contracts、AI adapters 与 Product Assembly + +五个纯合同/组装 owner 使用显式 wrapper target;AI 的纯协议测试与真实 loopback SSE 测试继续分成两个 +失败域,Product Domains 的默认、Plugin Source、External Sources、Function Agent 与 MiniApp 也继续按 +owner feature 分开。270 个 integration tests 不变,模块过滤仍可聚焦单个 leaf: + +| 范围 | 变更前 target | 变更后 target | 集成测试数 | +|---|---:|---:|---:| +| `core-types` | 4 | 1 | 10 | +| `runtime-ports` | 5 | 1 | 21 | +| `product-domains` | 9 | 5 | 179 | +| `ai-adapters` | 7 | 2 | 29 | +| `product-capabilities` | 3 | 1 | 31 | +| 合计 | 28 | 10 | 270 | + +对应五个 lib test harness 的 test executable 总数从 33 降到 15;workspace integration target 从 +91 降到 73。该变化减少 18 次重复链接,但单叶变更会重链所属分组,因此这里只报告确定的拓扑收益, +不在缺少同机多轮 A/B 时宣称 wall-clock 提速。边界检查锁定 exact leaf、owner feature 和空 +`required-features` 的默认 target,避免以后用 `product-full` 扩大测试闭包。 ### 3.2 依赖与 feature +闭包使用 `cargo tree -e normal,build` 按目标平台统计版本化 package instance;它衡量进入编译图的 +package/version,不等同于实际秒数。路径 package 因 A/B worktree 路径不同不参与集合差值。 + +| 产品闭包 | Windows | macOS | Linux | 说明 | +|---|---:|---:|---:|---| +| Core `agent-runtime` | 449 → 343 | 435 → 330 | 485 → 375 | 基线退出具体 service/tool capability,不改变 Runtime 生命周期 owner | +| Core `product-full` | 570 → 570 | — | — | 完整产品显式恢复所有 owner;Windows 抽样闭包不变 | +| CLI | 649 → 649 | — | — | 入口显式选择其现有能力,Windows 闭包不变 | +| ACP | 599 → 589 | 587 → 574 | 616 → 594 | 退出过去由 Core 基线暗带、但 ACP 未选择的能力 | +| Desktop | 792 → 792 | 807 → 807 | 892 → 892 | 完整产品继续使用既有跨平台截图行为,本轮不以扩大根 lock 依赖宇宙换取单平台闭包下降 | +| Installer | 333 → 327 | — | — | Windows 独立 workspace;直接 dependency 18 → 10 | + +下表前五项延续 `gcwing/main@734e5b05f` 的已核实 A/B,SDK Host 行以 +`gcwing/main@22f5411e7` 为变更前基线。三平台 target 分别为 `x86_64-pc-windows-msvc`、 +`aarch64-apple-darwin` 和 `x86_64-unknown-linux-gnu`;计数先移除 Cargo tree 的重复展示标记 +`(*)`,再按 package/version 去重: + +| 本轮闭包 | Windows | macOS | Linux | 行为边界 | +|---|---:|---:|---:|---| +| Core `agent-runtime` | 343 → 274 | 330 → 266 | 375 → 265 | 文档扩展识别保留;转换和本地订阅凭据明确不可用 | +| App Server | 490 → 429 | 477 → 421 | 508 → 430 | 现有 handler/DTO 保持,未消费的两个能力退出 | +| Core `product-full` | 570 → 570 | 557 → 557 | 601 → 601 | 显式恢复 `document-read` 与 `subscription-auth` | +| CLI | 649 → 649 | 649 → 649 | 672 → 672 | 显式保持原有能力 | +| ACP | 589 → 587 | 574 → 572 | 594 → 592 | 保持原有能力,同时退出 Reqwest 未使用的 `mime_guess`/`unicase` | +| SDK Host | 578 → 512 | 565 → 497 | 609 → 533 | 保留本机 SDK profile、九组工具 owner、外部静态来源和 ring TLS;退出未暴露的 Remote Connect、SSH、Function Agent 与完整产品附属能力 | + +本轮没有新增 crate 或第三方 package;SDK Host 只把已有测试依赖 `rustls` 调整为进程入口实际使用的 +normal dependency,根 lock package 集合不变。前两类收益来自 `anydoc` 及其文档解析/压缩依赖, +以及订阅凭据的 keyring/加密/本地存储依赖;SDK Host 的收益来自未公开远程能力对应的 +SSH、密钥和连接子图退出。完整产品 package 集合不变, +因此这里只报告依赖图收敛,不宣称 `product-full` wall-clock 提速。 + +以下是以 `gcwing/main@3d8ee4bc0` 为变更前基线、使用同样三个 target triple 和去重口径复算的最新 A/B: + +| 最新闭包 | Windows | macOS | Linux | 行为边界 | +|---|---:|---:|---:|---| +| Core `--no-default-features` | 104 → 102 | 93 → 91 | 92 → 90 | 删除 Core 不再消费的 `tokio-stream`、`urlencoding` 直接边 | +| Core `product-full` | 570 → 570 | 557 → 557 | 601 → 601 | 完整产品仍从真实 adapter/service owner 获得两项依赖 | +| CLI | 649 → 643 | 649 → 642 | 672 → 665 | 删除未调用的 `syntect-tui`/`dashmap`;显式保留既有 Oniguruma 高亮后端 | +| Desktop | 792 → 790 | 807 → 805 | 892 → 887 | 删除从未注册、没有调用方的 global-shortcut 插件和 ACL | +| MiniApp Market | 205 → 204 | 208 → 207 | 206 → 205 | 删除服务从未消费的 `urlencoding` 直接边 | +| Page Function tests | 38 → 35 | 38 → 35 | 38 → 35 | 删除同步 Rust 测试未使用的 dev-only Tokio 闭包 | + +以下继续以 `gcwing/main@7345619ac` 为变更前基线,记录 Core 默认值与 ACP 角色边界收敛。三平台、 +依赖类型与去重口径与上表一致: + +| 本轮闭包 | Windows | macOS | Linux | 行为边界 | +|---|---:|---:|---:|---| +| Core 隐式默认 | 570 → 93 | 557 → 82 | 601 → 81 | library 默认不再冒充完整产品;只保留 feature-free facade 与 build dependency | +| Core `--no-default-features` | 102 → 93 | 91 → 82 | 90 → 81 | 四条 Core 直接边退出并回到实际 owner;package 集合净减 9 | +| Core `product-full` | 570 → 570 | 557 → 557 | 601 → 601 | Desktop/Server 等完整产品入口仍显式恢复全部能力 | +| ACP 默认兼容组合 | 587 → 587 | 572 → 572 | 592 → 592 | 默认仍精确组合 client + server,独立 ACP 测试与外部兼容行为不缩小 | +| Desktop | 790 → 790 | 805 → 805 | 887 → 887 | package 集合不变;ACP 仅编译 client 模块,server/runtime 4,211 行退出该 package build | +| CLI | 643 → 643 | 642 → 642 | 665 → 665 | 显式选择 ACP client + server,既有 CLI-hosted server 行为不变 | + +ACP 两个新角色的当前独立闭包为 client 397/390/391、server 533/518/539(Windows/macOS/Linux)。 +它们不能直接相加:Cargo 会对共同依赖去重。该拆分的确定收益是 Desktop 独立构建不再编译 ACP server +模块,而不是 Desktop package 数下降;因此不宣称完整 Desktop wall-clock 提速。 + +Core 空闭包减少的 9 个 package instance 主要来自 `base64` 与 `futures` 的独有子图;`regex` 和 +`tokio-util` 的 Core 直接边虽然已经移除,但 package 仍由 feature-free contracts/services 路径传递保留。 +因此本轮证明的是 direct owner 边界收敛,不能把四条 direct edge 都描述成 package 完全退出。 + +Core 的 bare/default 编译契约本轮发生了有意变化:仓内产品消费者此前已经全部关闭默认 feature 并显式 +选择 owner,因此运行行为不变;仓外若有 path/git consumer 依赖旧的隐式完整表面,需要显式选择 +`product-full`,或改为列出实际使用的 owner。该迁移属于编译期契约变化,不能描述成对未知外部 consumer +完全无影响。 + +Syntect 不能机械地只删适配层:旧 feature union 同时启用 `regex-fancy` 与 `regex-onig` 时,实际由 +Oniguruma 后端处理。当前 manifest 直接选择 `regex-onig`,因此运行后端、默认 syntax/theme 和 +Syntect→Ratatui 样式转换保持不变,同时让未生效的 fancy 后端与未消费的 YAML loader 退出。 + +Package instance 会低估“同一个大 crate 少编译了多少 feature 代码”。在 Windows +`agent-runtime` 闭包中,`bitfun-services-integrations` 的 Cargo active feature 从 61 个降到 6 个, +只保留 `workspace-search` 及其 5 个直接依赖 feature;`bitfun-product-domains` 从 13 个降到 5 个, +只保留 Agent Runtime 实际使用的 external-subagent contract slice。Function Agent、MiniApp、 +Plugin Source 由各自 owner 选择,完整产品仍经 `product-full` 显式恢复。 + +上一轮根 `Cargo.lock` 从 1176 降到 1169,精确删除 `syntect-tui`、`custom_error`、`fancy-regex`、 +`yaml-rust`、`linked-hash-map`、`tauri-plugin-global-shortcut` 和 `global-hotkey`;没有新增、升级或 +降级 package。本轮 feature/角色边界调整保持该 lockfile 字节不变,也没有新增第三方 package。 +Installer 自己生成的 `BitFun-Installer/src-tauri/Cargo.lock` 不提交。 + +以下以 `gcwing/main@a4e06cae3` 为变更前基线,完成 Core feature-free 依赖基线的剩余 owner +收敛。统计仍使用相同三个 target triple、`normal,build` 边和版本化 package instance 去重口径: + +| 最新闭包 | Windows | macOS | Linux | 行为边界 | +|---|---:|---:|---:|---| +| Core `--no-default-features` | 93 → 63 | 82 → 51 | 81 → 50 | Fluent runtime、Tool Contracts、host transport、诊断/Diff 实现与非基线 Tokio 子图退出;locale/config/path 稳定契约保留 | +| Core `product-full` | 570 → 569 | 557 → 556 | 601 → 600 | 显式恢复 I18n、Agent 与全部产品 owner;仅不再经 Core 携带 Desktop host transport | +| Desktop | 790 → 790 | 805 → 805 | 887 → 887 | Desktop 原本已直接拥有 transport,完整宿主依赖图和行为不变 | +| Services Core feature-free | 22 → 15 | 22 → 15 | 22 → 15 | Regex、Similar 与 Tokio 全部退出;只保留同步稳定 contract、JSONC 与路径规范化 | +| Codex Adapter | 79 → 72 | 78 → 71 | 78 → 71 | 只使用同步 workspace path contract,不再继承 Services Core 的文本实现依赖 | +| Static Hook Support | 72 → 65 | 73 → 66 | 72 → 65 | feature-free Services Core 依赖边不再携带未消费的文本实现依赖 | +| Claude Code Adapter | 85 → 82 | 84 → 81 | 84 → 81 | Markdown owner 仍保留 Regex;Diff 与 Tokio 退出 | +| OpenCode Adapter | 141 → 140 | 140 → 139 | 140 → 139 | 既有 Markdown/Tokio owner 保留,仅未消费的 Similar 退出 | + +Core 的直接 Tokio capability 从 `fs/io-util/macros/net/rt/sync/time` 收敛为 `fs/sync`;异步宏、 +网络以及产品 runtime 能力由现有 `agent-runtime`、`browser-control`、`debug-log`、`lsp` owner +显式选择。feature-free Core 仍通过 `bitfun-services-core/json-io` 获得其原子 JSON 写入所需的 +`rt/time`,测试使用的多线程运行时只留在 dev-dependency。`runtime-ports/permission` 没有被机械移出:`GlobalConfig` 与项目权限文件公开 DTO +确实在 feature-free facade 中使用它,继续保留比制造条件 API 更符合契约稳定性。 + +Services Core 的 feature-free profile 进一步不再依赖 Tokio。同步路径规范化仍可直接使用;异步受限 +workspace 读取由 `workspace-text-runtime` 选择,诊断日志脱敏与本地 Diff 分别由独立 owner feature +选择。Core 继续通过同名 feature 保留原 facade,`product-full` 显式组合两者,避免把依赖收敛变成 +完整产品的源码或行为回归。 + +本轮有意收紧了数项编译期契约。直接使用 feature-free Core 的仓外 consumer 若调用 +`I18nService`,必须显式选择 `i18n-runtime`;旧的 +`bitfun_core::infrastructure::events::TransportEmitter` 导入路径不再提供,host adapter 应直接从 +`bitfun-transport` 导入。直接依赖 feature-free `bitfun-services-core` 的 consumer 若调用诊断脱敏、 +本地 Diff 或异步 workspace 文本 API,必须分别选择 `diagnostics`、`diff` 或 +`workspace-text-runtime`;通过 Core 兼容 facade 调用前两者时选择同名 Core feature。仓内真实 consumer +已全部迁移,完整产品的运行时事件、翻译和服务行为不变,但这些源码迁移不能描述为对未知外部 +consumer 零影响。 + +该轮结束时根 lock package 为 1169,新增、升级、降级 package 均为 0。由于 Core 删除本地 +`bitfun-transport` 直接边,`Cargo.lock` 的 Core dependency record 同步删除这一行;这是依赖边 +收敛,不是 package 集合增长,也不通过保留无 owner 的 optional dependency 伪造字节不变。 + +以下以 `gcwing/main@aeb8099ae` 为变更前基线,继续收敛两个稳定契约 crate 的源码与依赖可见面。 +统计仍使用 `x86_64-pc-windows-msvc`、`aarch64-apple-darwin`、`x86_64-unknown-linux-gnu`,并按 +`cargo tree -e normal,build --no-dedupe` 的版本化 package instance 去重: + +| 契约/消费闭包 | Windows | macOS | Linux | 边界结果 | +|---|---:|---:|---:|---| +| Runtime Ports feature-free | 21 → 13 | 21 → 13 | 21 → 13 | Agent API、服务端口、插件与脚本端口按 owner feature 退出;common marker/结果契约保留 | +| Tool Contracts feature-free | 25 → 18 | 25 → 18 | 25 → 18 | ACP/MCP bridge、Computer Use 与 element-token 源码按独立 feature 退出 | +| Terminal Core | 94 → 92 | 82 → 80 | 81 → 79 | 只选择 terminal port,不再编译无关 Session/Git/Remote workspace 表面 | +| Plugin Runtime Client | 22 → 16 | 22 → 16 | 22 → 16 | 只选择 plugin-runtime contract,不再继承完整 Runtime Ports 表面 | +| Tool Runtime | 78 → 78 | 66 → 66 | 65 → 65 | 真实 owner 仍消费稳定 handle 组合,package 闭包不变 | +| ACP client | 385 → 385 | 378 → 378 | 379 → 379 | 依赖重叠使 package 数不变;组合闭包启用 ACP bridge 与 Core 所需 Computer Use contract,不启用 MCP/element-token | +| ACP server | 522 → 522 | 507 → 507 | 528 → 528 | 组合闭包启用 Core 所需 Computer Use/MCP contract,不继承 ACP client bridge 或 element-token | + +Package instance 会低估同一 crate 内源码切片的收益。Runtime Ports 变更前约 6,045 行源码全部进入任意 +consumer;当前 feature-free 非测试表面约 314 行,约 95% 的 Agent/服务/plugin/script 端口源码退出最小编译。 +Tool Contracts 约 6,846 行源码中,ACP/MCP bridge、Computer Use 与 element-token 共约 2,861 行 +(41.8%)由各自 feature 控制。这里报告的是解析、类型检查和增量重编译输入收敛;没有重复的冷/热构建 +样本,因此不宣称完整产品 wall-clock 提速。完整产品和真实 owner 显式恢复原 API,runtime ownership、 +wire shape 与行为不变。 + +`bitfun-agent-runtime` 的直接 Runtime Ports 边也不再选择其源码未消费的 `remote-exec-port` 与 +`tool-runtime-handles`;Core 的 `agent-runtime` owner 仍显式选择二者,因为具体 assembly 路径确实使用这些句柄。 + +这次切片包含有意的编译期可见性收紧:直接依赖 Runtime Ports 或 Tool Contracts 的仓外 consumer 需要 +显式选择所用 port/bridge/Computer Use/element-token feature。启用相应 owner 后原公开路径保持不变, +仓内 consumer 已逐项闭合;不能把 feature-free 构建下的源码不可见描述为对未知外部 consumer 零影响。 + +本轮没有新增、升级或降级第三方 package,根 `Cargo.lock` 保持字节不变;也没有新增 CI job、矩阵或命令。 +Runtime Ports 的 owner-specific integration targets 保持彼此独立,避免为了减少 executable 数重新制造 +feature union。 + +以下以 `gcwing/main@a4d944e5b` 为变更前基线,统一默认 feature 的责任位置。该主线相对前次测量 +没有 Cargo 输入变化;统计继续使用同样三个 +target triple 和 `normal,build` 版本化 package instance 去重口径;它只描述编译图,不直接代表墙钟时间: + +| 闭包 | Windows | macOS | Linux | 结果 | +|---|---:|---:|---:|---| +| Core feature-free | 63 → 63 | 51 → 51 | 50 → 50 | 空默认与显式 owner feature 不变;只删除 consumer 侧冗余开关 | +| Core `product-full` | 569 → 569 | 556 → 556 | 600 → 600 | 完整产品 owner 集合与运行能力不变 | +| CLI | 642 → 640 | 641 → 639 | 664 → 662 | Markdown 只使用 parser,退出未消费的 `getopts` 与 HTML renderer | +| Desktop | 790 → 790 | 805 → 793 | 887 → 887 | BitFun 的 macOS Objective-C 直接依赖边按实际 imports 选择 feature;Tauri/wry 等第三方仍合并自身所需 feature | +| Relay Service | 190 → 190 | 193 → 193 | 192 → 192 | 独立 Docker manifest 保持显式版本与默认策略,闭包不变 | +| Services Integrations feature-free | 26 → 26 | 28 → 28 | 27 → 27 | Qrcode 的 PNG/SVG 能力仍由 `remote-connect` owner 显式选择 | + +workspace member 中显式 `default-features = false` 从 70 处降到 6 处:62 个仓内空默认重复声明删除, +MiniApp/Skin Market 的 2 个 SQLx 声明改为继承 workspace 根策略。剩余 6 处中,两处是 Desktop/CLI 对 ACP +的必要例外,因为 ACP 有意保留 `client + server` 兼容默认,而两个产品入口必须分别选择 client-only 和 +双角色;另 4 处属于 Relay Server、Relay Service 和 Page Function Runtime,它们会被 Docker 单独复制、 +无法继承 workspace 根,因此继续显式声明 Tokio、SQLx 与 RquickJS 的默认策略。 + +第三方默认收敛只处理有源码与构建证据的依赖:Futures 保留 `std`,Tracing 保留 `std`,Chrono 保留 +`serde + clock + std`;Tokio Stream 的真实 consumer 只使用 feature-free 的 Receiver/iter wrappers; +Remote Connect 显式选择 qrcode 的 `image + svg`;Pulldown-Cmark 不启用 CLI 未使用的命令行/HTML renderer; +BitFun 的 macOS Objective-C binding 直接依赖边只选择源码导入的类型和所需 `std`;Cargo 的最终 +feature union 仍包含 Tauri、wry、notification、updater 等第三方路径的需求。根 `Cargo.lock` 从 1169 +降到 1158,新增 package 为 0;删除项仅来自 +Pulldown-Cmark 的 `getopts`/HTML escape 子图和未使用的 Objective-C framework binding。未关闭 Axum、Tauri、 +Clap、Tracing Subscriber、Notify 等默认即产品契约或缺少独立收益证据的依赖。 + | 状态 | 范围 | 处理结论 | |---|---|---| -| 已稳定 | 根 `Cargo.lock`、Reqwest Rustls 单栈、Desktop 直接 `image 0.25`、workspace Tokio 最小基线 | 不重复治理 | -| 下一步核实 | App Server / Server 的 Core `product-full` | 按生产 construction path 收敛,不先写 feature 清单 | -| 可独立治理 | Installer 的 Reqwest 0.12、独立 lockfile、疑似无消费者的 `tokio/full` | 保持 Installer 独立 workspace,不顺手合并 | -| 等待上游 | `screenshots 0.8.10 -> image 0.24.9` | 只有受维护且行为等价的上游替代出现后再处理 | +| 已稳定 | 根 `Cargo.lock`、Reqwest Rustls 单栈、workspace Tokio 最小基线 | 不重复治理 | +| 本轮完成 | Core 空默认与 capability-local 工具依赖、ACP client/server 角色、Core Agent Runtime capability、文档转换与订阅认证 modifier、SDK Host 显式 owner closure、Installer/CLI/Desktop/Core/MiniApp Market/Page Function 未使用直接依赖 | 以真实入口 closure 收敛,不建立新的产品 umbrella;根 lock 不增加 package | +| 当前不动 | App Server / Server | 只为保持现有 handler 编译显式声明其已消费的 Core owner;不在改造稳定前继续拆其生产路径 | +| 明确保留 | Desktop screenshots backend | 替换方案必须同时保持三平台坐标/权限/区域捕获语义且不增加根 lock package;当前候选不满足 | | 明确保留 | `portable-pty 0.8/0.9` | 非 OHOS 与 OHOS 的平台兼容选择,不为去重破坏 | -根 lockfile 约有 116 个名称存在多版本。这个数字只用于发现候选,不能直接转化为治理任务。 -`oxc`、`rquickjs`、vendored `git2`、`sherpa-onnx` 等重依赖都有真实 capability owner;只有某个产品入口 -不消费对应能力时,才允许让它退出该入口的构建图。 +重复版本数量只用于发现候选,不能直接转化为治理任务。`oxc`、`rquickjs`、vendored `git2`、`sherpa-onnx` 等重依赖都有真实 capability owner;只有某个产品入口不消费对应能力时,才允许让它退出该入口的构建图。 ### 3.3 CI 与本地验证 -- 现有 CI 已覆盖 workspace check、Core/Desktop lib、平台敏感 owner 测试和独立 runtime/CLI 验证; - 不再为治理 PR 叠加同闭包 job。 -- 本地先运行 owner 文档维护的最小 package/target/feature 命令。广泛 build、workspace suite、打包和 - 平台矩阵由 CI 承担,除非改动直接影响这些路径或需要复现 CI 故障。 -- CI 收敛必须基于多次 job/step 耗时、缓存状态、平台事实和失败历史。测试名称相似不等于覆盖重复, - `SKIPPED`、未触发或只编译未运行也不等于通过。 +- 现有 CI 已覆盖 workspace check、Core/Desktop lib、平台敏感 owner 测试和独立 runtime/CLI 验证;本轮不新增 job、矩阵或 changed-path 分类器。 +- CI 不负责穷举所有测试;新增验证只有具备独立 owner、平台矩阵或失败归因价值时才进入既有流水线,否则由最近模块的 focused command 维护。 +- 本地从 owner 文档的最小 package/target/feature 入口开始;仅名称过滤不能阻止无关 target 编译。 +- CI 收敛必须先有多次 job/step 耗时、缓存状态和失败历史;`SKIPPED`、未触发或只编译未运行都不算通过证据。 ## 4. 已完成,不再重复实施 @@ -93,54 +335,45 @@ | 可复现解析 | 根 lockfile 已提交,普通 CI 使用 `--locked`;build.rs 输出已排序 | | CI 拓扑 | Rust job 不再等待完整前端构建,自建 Tauri 检查所需资源目录 | | 依赖收敛 | Desktop 直接 image 版本和 Reqwest TLS 双栈已治理 | +| Agent Runtime 闭包 | Core 基线不再暗带具体 capability;完整产品和 CLI 显式保持原能力,ACP 退出未选择闭包 | +| Core/ACP 默认与角色 | Core 默认 feature 为空;ACP 默认精确保持 client + server,Desktop client-only、CLI 双角色均由现有边界检查锁定 | +| 重型可选能力 | 文档转换和本地订阅凭据由弱 modifier 细化已有 runtime owner;Core 基线和 App Server 退出未消费闭包 | +| Installer 闭包 | 删除 8 个未使用直接 dependency;独立 workspace 和发布生命周期不变,本 PR 不提交其生成 lockfile | +| SDK Host 闭包 | 从 `product-full` 改为与当前协议/构造路径一致的显式 Core owner closure;保留 ring TLS 初始化,本机 SDK 行为不变,未交付的远程执行能力不再进入构建图 | | Agent Runtime 测试 | 28 个 integration executable 已收敛为 5 个职责/平台 target | +| Services 测试 | 两个服务 crate 使用显式 target;选中闭包少 8 个 integration executable,进程/feature/external-system 边界保持独立 | +| External Sources 测试 | 四个 adapter/assembly crate 从 22 个 target 收敛到 7 个;MCP、插件服务和脚本 runtime 继续独立 | +| Contracts/AI/Assembly 测试 | 五个 crate 从 28 个 target 收敛到 10 个;AI loopback 与纯协议、Product Domains 各 owner feature 保持独立 | +| 未使用直接依赖 | 删除 CLI/Desktop/Core/MiniApp Market/Page Function 的失效直接边;保留 Syntect 实际 Oniguruma 后端,根 lock 只减 7 个 package | -内置 Agent 内容已经移到无第三方依赖的 `bitfun-agent-content`,减少了 Core build-script 工作;但 Core -仍直接依赖该 crate。没有足够产品收益前,不为消除这一编译指纹引入动态 provider、运行时文件读取或资源协议。 +内置 Agent 内容已经移到无第三方依赖的 `bitfun-agent-content`,减少了 Core build-script 工作; +但 Core 仍直接依赖该 crate。没有足够产品收益前,不为消除这一编译指纹引入动态 provider、 +运行时文件读取或资源协议。 ## 5. 后续顺序 -### R1:收敛 App Server / Server 的 `product-full` 边界 - -这是下一 PR 的推荐范围,也是唯一需要优先设计的核心入口改造。 - -先回答: +本轮之后先观察,不立即再开同类“小修补”PR。需要真实 CI 样本或上游条件成熟后,按以下顺序重新核实: -- App Server 与 Server 的真实 construction、command 和 schema 路径分别消费哪些 Core owner? -- Server 对未实现能力应保持什么 typed unsupported 行为? -- 哪些能力由 Server 直接消费,哪些只是经 App Server 间接带入? - -实现边界: - -- 只替换一个端到端 capability slice 的依赖路径,不一次迁移全部 Core 调用; -- 优先显式选择已有 owner feature,或消费现有 Runtime SDK/service port; -- 不复制 Session、Tool、Permission、Hook、Event 状态,不建立第二 Runtime; -- 未迁移能力保留现有兼容路径或明确 unsupported,禁止静默本机回退。 - -验收必须覆盖 Server WebSocket/App Server round-trip、权限、取消、事件与恢复语义,并对比 App Server、 -Server 的 normal/build/test closure。若构建图收益不足或行为等价无法证明,则不删除兼容边界。 - -### 后续队列 - -| 顺序 | 范围 | 启动条件 | -|---|---|---| -| R2 | 从 ACP 迁移一个已有 Services owner 的 host-service 切片 | 明确真实调用方,并能保持 Windows 进程树、SSH、取消和远程身份语义 | -| R3 | Installer lockfile、Reqwest 0.13 与无消费者依赖治理 | 下载、SSE/进度、取消、代理、证书失败和三平台 packaging 可验证 | -| R4 | 消除 `screenshots -> image 0.24` | 有受维护、无需 fork/vendoring 且屏幕枚举/DPI/权限行为等价的上游路径 | +| 范围 | 启动条件 | +|---|---| +| CI 收敛 | 先积累多次相同 owner 的 step wall-clock、cache hit/miss 和失败历史;只有能证明收益且不会静默缩小覆盖时再独立设计 | +| Desktop 截图后端 | 新候选同时满足三平台行为等价、区域捕获无性能回退、系统依赖可 feature-gate,且根 lock package 不增加 | +| App Server / Server | 当前改造合入并稳定后,重新锁定最新生产调用链和可信 owner 边界 | +| 其他产品入口重型 capability | 证明入口不消费该能力,具备 typed unsupported/fallback 行为,并能让一个真实重依赖子图退出 | +| 重复 native/sys 库版本 | 同一 owner 能升级收敛且三平台打包/ABI 有证据;不因版本数字重复强行 patch | 每一步都在前一 PR 合入后的最新 main 重新测量。无法证明边界或收益时停止,不为了完成清单继续重构。 ## 6. 每轮 PR 的证据 -PR 描述只需维护一张简表,不新增全仓依赖台账: +PR 描述维护一张简表即可,不新增全仓依赖台账: | 证据 | 变更前 | 变更后 | |---|---:|---:| | 真实产品 normal/build closure | | | -| owner focused-test closure | | | -| 目标重复版本或重型依赖路径 | | | +| owner focused-test closure/target | | | | 冷、热或增量耗时(同机器、命令、缓存状态) | | | +| 产物数量/大小 | | | | 新增 dependency、feature、test target、CI job | | | -同时记录功能不变量、远程/平台差异、实际运行的最小验证和未运行的 CI。若产品 closure 不变,只能说明 -focused-test 或 owner 边界收益,不能宣称产品构建已经变快。 +同时记录功能不变量、远程/平台差异、实际运行的最小验证和未运行的 CI。若产品 closure 不变,只能说明测试拓扑或 owner 边界收益,不能宣称产品构建已经变快。 diff --git a/docs/plans/external-ai-app-connection-experience-plan.md b/docs/plans/external-ai-app-connection-experience-plan.md deleted file mode 100644 index 48581faf84..0000000000 --- a/docs/plans/external-ai-app-connection-experience-plan.md +++ /dev/null @@ -1,635 +0,0 @@ -# 外部 AI 应用连接体验执行计划 - -> 本计划把[外部 AI 工作内容总体架构](../architecture/extensions/external-ai-work-sources-design.md)和[外部 AI 应用连接与管理详细设计](../architecture/extensions/external-ai-app-connection-experience-design.md)拆成可独立评审、验证和回退的实施阶段。本文不扩大任何生态的能力兼容范围;OpenCode 具体能力路线仍以[OpenCode 扩展兼容计划](opencode-extension-compatibility-plan.md)为准。 - -> **实现状态:全部为目标工作。** 当前生产只提供严格 V1 来源/能力控制协议;应用级连接、V2 协议、批量确认、跨宿主应用快照和任务相关 `action-required` 尚未交付。只有完成对应阶段的生产接线和退出条件,架构现状文档才能更新。 - -## 1. 目标与执行原则 - -目标是在保留现有 Command、Tool、Subagent、MCP、Safe Mode、冲突和远端保护语义的前提下,把“外部 AI 应用”从能力平铺页调整为应用级连接与管理体验: - -1. 后台发现、应用连接和能力加载明确分离; -2. OpenCode 可由产品事实默认连接,Codex 与 Claude Code 默认只发现; -3. 低风险声明式内容按共享策略自动应用,可执行或权限扩大的内容进入单页批量确认; -4. Desktop、TUI、Peer 和 Server 消费同一应用级读模型、默认策略和决策结果; -5. 提示一次性、持久化去重,只在任务受阻/降级或实质权限扩大时再次主动出现; -6. 不把应用级聚合对象变成新的配置、权限或执行归属模块。 - -执行遵守以下原则: - -- 每个阶段形成可独立评审的纵向结果,不能用仅有 DTO、固定假数据或未接线组件宣称完成; -- 先以测试冻结共享契约和策略,再接宿主,再替换信息架构; -- 当前 `ExternalSourceControlSnapshotV1`、V1 动作/恢复闭合枚举、V1 宿主能力和能力专属 DTO 保持字段与行为不变;应用级读写使用独立版本化 V2 接口,无副作用 V2 快照直接用于能力探测; -- 所有 V2 写操作携带 `execution_domain_id`、`target_scope`、`operation_id` 和与该作用域绑定的 `expected_preference_revision`;`workspace_override` 必须携带宿主快照返回的 `workspace_scope_id`,`user_default` 必须省略。`operation_id` 只做请求/响应关联,不承诺幂等重放;偏好版本是唯一写并发保护; -- 宿主能力、Safe Mode、组织/产品安全上限和 Remote/只读限制只能收紧结果; -- React、TUI、Desktop 适配层和 Server 适配层不按生态 ID 重算默认连接、推荐集合或应用级状态; -- 不建立第二套审批存储、冲突存储、监听系统、调度器或运行时注册表; -- 先完成版本化旧偏好迁移,再启用新的默认连接;升级不能静默撤下已有效使用的能力或覆盖显式 disabled/discover-only; -- GUI 与 TUI 共享语义和契约样例,不共享布局、组件、主题键、快捷键或渲染数据结构。 - -## 2. 变更地图 - -| 责任 | 主要文件 | 计划内变更 | -|---|---|---| -| 共享应用级契约 | `src/crates/contracts/product-domains/src/external_source_control.rs` | 保持 V1 不变,独立定义 `ExternalApplicationSnapshotV2`、五种摘要状态、主操作、默认连接事实、确认计划、逐项结果和 V2 类型化动作;任务依赖结果归 Agent 事件契约,不塞入可轮询应用快照。 | -| 产品默认与能力上限 | `src/crates/assembly/core/src/external_sources.rs` 及 assembly 中现有产品能力事实归属模块 | 提供 OpenCode 默认连接、Codex/Claude Code 默认只发现的产品事实;派生推荐集合、安全上限与应用状态。 | -| 偏好、迁移与提示去重 | `src/crates/assembly/core/src/external_sources.rs` | 在现有原子偏好存储中加入作用域化连接、暂不使用、提示决定和一次性 `connection_schema_migration_version`;每个旧作用域直接生成真实连接决定,不新增第二个迁移状态机或存储。 | -| 批量确认编排 | `src/crates/assembly/core/src/external_sources.rs` | 预检整批偏好版本、发现代次和宿主条件,按能力类型分派现有归属模块,汇总逐项权威结果。 | -| Desktop/Peer/App Server 投影 | `src/apps/desktop/src/api/external_sources_api.rs`、`src/apps/desktop/src/api/remote_workspace_policy.rs`、Peer 适配层、`src/crates/interfaces/app-server{,-protocol,-client}` 与 `src/apps/server` | 保持薄适配层;先把当前缺失的 V1 Server 只读投影接入 App Server 协议、客户端和处理器,再增加独立 V2 协商和接口;声明远端策略;旧宿主保持 V1 并拒绝 V2 写操作。 | -| Runtime 任务依赖结果 | `src/crates/contracts/events/src/agentic.rs`、`src/crates/assembly/core/src/agentic`、`src/crates/interfaces/app-server{,-protocol,-client}`、`src/crates/adapters/agent-runtime-ipc`、CLI 执行生命周期 | 能力归属模块产生依赖事实,Agent Runtime 关联根/来源轮次并发布 `ExternalDependencyActionRequired`;App Server 与 Shared IPC 传输同一事件,CLI 只投影匹配当前根轮次的结果。 | -| TypeScript 基础设施 | `src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts`、`ExternalSourcesAPI.test.ts` | 保持 V1 转换不变,新增独立 V2 转换,并对作用域、发现代次、偏好版本和协议协商安全拒绝。 | -| Web UI | `src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx` 及同目录拆分组件、样式和测试 | 收敛页面控制器,增加首页、待办、详情、批量确认和高级设置的纵向单列体验。 | -| TUI/CLI | `src/apps/cli/src/modes/chat/external_review.rs`、`external_hooks.rs`、`external_sources.rs`、`src/apps/cli/src/actions.rs` | `/extensions` 应用级入口、`/extensions review`、共享提示去重和任务相关 `action-required`。 | -| i18n 与主题 | 外部来源设置页现有命名空间、CLI 自有本地化资源、现有 SCSS/主题令牌 | 新文案进入归属模块的命名空间,复用 600px 布局和主题令牌,不提高治理基线。 | - -具体文件可在实施阶段按仓库当时结构做最小调整,但责任归属和依赖方向不得改变。 - -## 3. 阶段依赖 - -```mermaid -flowchart LR - P1["P1 应用级契约与产品事实"] --> P2["P2 连接偏好与提示去重"] - P2 --> P3["P3 批量确认编排"] - P1 --> P4["P4 宿主与协议投影"] - P3 --> P4 - P4 --> P5["P5 Desktop Web UI"] - P4 --> P6["P6 TUI 与非交互 CLI"] - P5 --> P7["P7 跨宿主回归与迁移清理"] - P6 --> P7 -``` - -P1-P4 是共享语义和协议前置;P5 与 P6 可以在 P4 稳定后并行,但必须以同一契约样例验证。P7 只在 Desktop 与 TUI 都消费共享读模型后执行,不能提前删除旧投影。 - -## 4. P1:应用级契约、产品事实与状态派生 - -### 归属与范围 - -- 归属:`contracts/product-domains` 与 Product Assembly; -- 主要文件: - - `src/crates/contracts/product-domains/src/external_source_control.rs` - - `src/crates/assembly/core/src/external_sources.rs` - - 对应 crate 内已存在的 focused tests。 - -### 实施内容 - -1. 冻结 `ExternalSourceControlSnapshotV1`、V1 动作/恢复枚举和 V1 `hostCapabilities`,另行增加 `ExternalApplicationSnapshotV2`: - - `application_id` 与 `ecosystem_id`; - - `execution_domain_id`、可选但非通配的 `workspace_scope_id` 和实际连接作用域;`workspace_scope_id` 复用当前宿主的 `workspace_policy_key`,不新增路径注册或反查; - - 发现、连接、健康等正交事实; - - `已连接 / 发现可用配置 / 未发现配置 / 需要处理 / 暂时不可用`; - - 唯一 `primary_action`; - - `enabled`、`pending_review`、`blocked`、`conflict` 数量; - - 风险摘要和恢复动作; - - 确认摘要、稳定 `review_id`、推荐数量/风险、`max_selection_count` 和总数,不内嵌项目列表或可执行载荷。 -2. 另行定义 `ExternalApplicationReviewPageV2`:分页游标绑定执行域、工作区作用域、`review_id`、偏好版本和发现代次;每页最多 128 项,只携带稳定项目引用、显示摘要、推荐和安全上限。读取分页不能触发重新发现或能力加载。 -3. 将应用级状态优先级固定在共享归属模块: - `需要处理 > 暂时不可用 > 已连接 > 发现可用配置 > 未发现配置`;Safe Mode 独立投影。 -4. 在 Product Assembly 中定义默认连接事实及原因: - - OpenCode:允许默认连接; - - Codex、Claude Code:默认只发现; - - 未注册生态、旧宿主或受限产品形态:明确不支持或只读,不猜测默认值。 -5. 从现有目录、能力控制事实和归属模块状态派生应用聚合;未连接应用不参与运行时冲突和能力注册。 -6. 推荐集合由共享策略生成,高风险项默认不推荐;宿主只展示,并只允许在安全上限内调整。 -7. 应用快照不持久化或全局聚合任务影响;任务相关结果在 P6 由 Agent Runtime 事件契约单独实现,P1 只定义供其引用的稳定应用/依赖引用。 -8. 应用级纯状态、动作和作用域规则归 `contracts/product-domains`;具体聚合、持久化和归属模块分派留在 `WorkspaceExternalSourceService`,`ExternalSourceControlPlane` 不接收产品状态职责。 - -### 测试优先顺序 - -先增加失败测试,再实现最小派生逻辑: - -- OpenCode、Codex、Claude Code 默认连接事实; -- 五种状态的优先级和 Safe Mode 独立性; -- “已连接但有能力待确认”不会错误显示为全部已启用; -- 未连接应用不进入运行时冲突; -- 未知枚举、不同发现代次或不同偏好版本均安全拒绝; -- 用户默认、工作区覆盖和不同执行域的状态互不污染; -- V1 序列化固定样例完全不变,V2 未协商时不可调用; -- 首页快照不含确认项目;分页单页不超过 128,过期游标不能与新代次拼接; -- 一个轮次的任务依赖结果不能改变另一个轮次的应用状态或退出结果; -- 高风险项默认不进入推荐集合; -- 产品、组织和宿主上限不能被宿主推荐放宽。 - -### 验证 - -```bash -cargo test -p bitfun-product-domains external_source_control -cargo test -p bitfun-core external_source -cargo check --workspace -``` - -实际 package 名以对应 `Cargo.toml` 为准;若 focused test 过滤器不能覆盖新增测试,运行受影响 crate 的完整测试,不用全 workspace 测试代替静态检查。 - -### 用户可见结果 - -无独立用户界面变化;后端能够稳定返回应用级状态、默认策略、主操作和确认计划。 - -### 退出条件 - -- Desktop/TUI 无需生态分支即可渲染同一 fixture; -- V1 消费方保持可编译、golden wire shape 和原有行为; -- 应用级状态完全由共享归属模块派生; -- V2 应用状态按执行域和工作区作用域求值,任务依赖只存在于根会话和根轮次绑定的 Agent Runtime 事件; -- 默认连接事实有产品组装测试,不存在 `ecosystem_id == "opencode"` 的宿主业务分支。 - -### 暂停条件 - -若应用级聚合需要读取能力 owner 尚未公开且无第二个真实消费方的内部状态,先设计最窄只读事实并完成 owner 评审;不得通过公开任意 payload 或复制 owner 状态绕过。 - -## 5. P2:连接、断开、暂不使用与提示去重 - -### 归属与范围 - -- 归属:`assembly/core` 的 `WorkspaceExternalSourceService`(或实施时同一现有产品级服务的私有协调单元)和现有偏好存储;`assembly/external-sources` 的 `ExternalSourceControlPlane` 只提供与提供方无关的发现结果; -- 主要文件: - - `src/crates/assembly/core/src/external_sources.rs` - - `src/crates/contracts/product-domains/src/external_source_control.rs` - - 对应持久化和并发测试。 - -### 实施内容 - -1. 在 V2 endpoint 增加闭合类型化动作: - - `ConnectApplication`; - - `DisconnectApplication`; - - `SetApplicationDeferred`; - - 保持已有 `Refresh`、`SetSourceEnabled` 和 `SetSafeMode`。 -2. 在现有偏好文件和跨进程原子更新路径中持久化: - - execution domain ID; - - `user_default` 或 `workspace_override`;workspace override 携带当前 `workspace_policy_key` 产生的 Host-local `workspace_scope_id`; - - application/ecosystem ID; - - desired connection 状态; - - 明确断开或暂不使用决定; - - notice key、内容/行为版本、风险摘要版本和用户决策状态; - - 一次性 `connection_schema_migration_version`;它只与整份文档的原子转换一起写入; - - 按 `(execution_domain_id, application_id, workspace_scope_id?)` 保存的真实连接决定与 `decision_origin`,无法归属的项直接使用 `needs_review`,不保存逐 scope 迁移进度。 -3. 在启用新默认连接前执行锁内、可重入的旧偏好迁移。`WorkspaceExternalSourceService` 启动时先建立全局迁移 gate;所有 discovery、MCP revision-key helper 和 V2 endpoint 必须等待它完成或返回明确 incompatible/needs-review 状态。该 gate 先读取原始存储存在性和 schema,再调用会通过 MCP secret/revision-key 初始化自动物化默认文件的 `external_sources_config_with_mcp_revision_key`;不得根据已经默认化的对象猜测旧文件来源: - - 新决定已存在时保持不变; - - 只有确认从未存在偏好文件的 V2 新安装写入 `config_origin=fresh_v2`,允许保持“无决定”并应用新产品默认;已有文件、legacy 默认文件和 incompatible-policy reset 都不能获得该 origin; - - 任一 legacy 文件中的 `integration_policy.enabled=false` 都保守迁移为显式未连接,并记录 `decision_origin=legacy_safety`;这包括旧版本自动写出的默认文件。它与用户显式 `SetEnabled(false)` 无法区分,因此不能让 OpenCode 默认连接覆盖。代价是部分从未主动关闭的旧用户需重新连接一次,迁移说明必须明确该安全取舍; - - 目标 user default/workspace override 下该生态明确求得 disabled/discover-only 时,同样迁移为显式未连接; - - 只有旧作用域的 `integration_policy.enabled=true` 且已有效使用某生态时才迁移为已连接;该判断晚于上一条保守未连接规则。“有效使用”要求至少一项实际访问级别为 `ask_before_use`/`auto`,或存在可按来源归属的审批、冲突决定或活动路由; - - 当前 `workspace_overrides` 的键已是 `workspace:` 加规范化工作区 SHA-256 的前 16 字节十六进制;直接把每个键作为 `workspace_scope_id` 逐项迁移,不建立路径反查。无法可靠归属应用、执行域或作用域的旧记录写为 `needs_review`,对应作用域继续由 V1 路径管理; - - 读到未知未来 `schemaMajor` 时沿用当前 incompatible-policy fail-closed:不迁移、不应用默认、不写 V2 决定、不进入偏好 update/atomic replace,byte-for-byte 保留包含 opaque policy 的原文件;用户执行既有“备份并重置”时,在同一原子更新中备份 raw policy、写入 `config_origin=incompatible_reset` 和显式未连接决定,继续保持外部执行关闭,不能转成 fresh V2; - - 先在内存中计算全部旧作用域决定,再把决定、schema migration version 和现有审批/冲突数据一次原子替换。成功时不存在部分迁移;失败保持旧文件和完整 legacy 路径,重启后重试整次转换。 -4. 默认连接只对 `fresh_v2` 或已完成迁移且确实没有显式决定的作用域生效;工作区覆盖优先于同一执行域的用户默认;明确断开、暂不使用、不兼容策略或 `decision_origin=legacy_safety` 不得被监听器、重启或重新发现覆盖。 -5. 连接先在现有权威偏好文档中提交作用域化决定并推进 preference revision,再协调允许自动应用的低风险内容;返回已启用、待确认、受限和失败摘要。 -6. 断开先撤下该 execution domain/workspace scope 上的新调用路由和由该连接注册的能力,再停止持续同步;不改写外部配置,不影响其他作用域或生态。 -7. Instruction、Skill、Hook 和复制后的原生配置仍服从各自 owner。没有来源限定撤下端口的能力必须报告 `managed_separately`/部分支持,并暂停“完整断开”交付,不能由 UI 隐藏冒充卸载。 -8. 提示规则: - - 首次发现只允许一次性非阻塞轻提示; - - 用户关闭、决定或完成处理后,同一版本不再主动提示; - - 仅当前任务受阻/降级或已确认内容权限实质扩大时再次主动提示; - - 普通数量变化、无关更新失败和来源删除只更新状态。 - -### 测试优先顺序 - -- 默认连接与显式断开/暂不使用的优先级; -- fresh V2 无文件时 OpenCode 可应用产品默认;旧版自动物化的默认文件与用户显式 `enabled=false` 都保守保持未连接,且不会被默认连接覆盖; -- legacy 配置中 disabled/discover-only、已有效使用的 Claude Code/Codex/OpenCode、无决定生态分别迁移到预期状态; -- 多个 workspace scope 的迁移要么一次全部提交,要么一个都不提交;写入失败和崩溃重启不会留下部分新状态; -- discovery、MCP revision-key 初始化与 V2 endpoint 并发首次访问时都等待同一 migration gate,不能先物化默认文件或观察半迁移状态; -- 未知未来 `schemaMajor` 保持原始 JSON、拒绝迁移和 V2 mutation;备份并重置后记录 `incompatible_reset` 且仍显式未连接,不应用 OpenCode 默认; -- future-major → backup/reset → restart fixture 证明 raw backup 保留、外部执行仍关闭,只有后续显式 ConnectApplication 才改变状态; -- stale preference revision 整个 mutation 不应用; -- 响应丢失后使用旧偏好版本重试会返回过期;客户端重读权威快照后再决定是否发送新操作,相同 `operation_id` 不能绕过版本检查或重放旧结果;同一活动连接中的并发请求不复用 ID; -- 跨进程并发更新不丢失另一个应用的决定; -- 两个工作区作用域和两个执行域的连接、提示与偏好版本相互隔离; -- watcher 更新不会重新连接用户已断开的应用; -- 断开仅卸载目标生态能力; -- notice key 在 GUI/TUI/重启之间去重; -- 权限扩大产生新风险版本,普通数量变化不产生主动提示。 - -### 验证 - -```bash -cargo test -p bitfun-core external_source -cargo check --workspace -``` - -### 用户可见结果 - -连接、断开和暂不使用具有明确完成结果;同一发现不会在多个项目、进程或宿主反复提示。 - -### 退出条件 - -- 所有连接决定和 `connection_schema_migration_version` 使用现有原子偏好存储,且没有第二套逐 scope 迁移状态机; -- 默认连接与用户显式决定的优先级可由重启测试证明; -- 断开后目标 execution domain/workspace scope 的相关新调用不可达,其他作用域和生态不受影响; -- 旧审批、拒绝、冲突和来源抑制记录在迁移后保持,只有 fingerprint 失效或权限扩大才重新确认; -- 提示去重不依赖 React local storage 或 TUI 进程内集合。 - -### 暂停条件 - -若某能力 owner 无法按来源/生态撤下路由,先补 owner 的类型化撤下能力和行为测试;不得把“UI 显示已断开”作为运行时已卸载的替代证据。 - -## 6. P3:单页批量确认与归属模块分派 - -### 归属与范围 - -- 归属:`assembly/core` 的产品级 `WorkspaceExternalSourceService` 负责预检与分派,各能力归属模块负责最终业务决定;`ExternalSourceControlPlane` 不参与审批、偏好写入或产品状态派生; -- 主要文件: - - `src/crates/contracts/product-domains/src/external_source_control.rs` - - `src/crates/assembly/core/src/external_sources.rs` - - 现有 Tool、Subagent、MCP 审批与冲突测试。 - -### 实施内容 - -1. 定义 `GetApplicationReviewPage` 只读请求: - - `execution_domain_id`、`target_scope` 与可选 `workspace_scope_id`; - - `review_id`、cursor 和页面大小;服务端将页面大小限制为 128; - - 响应只含同一偏好版本/发现代次的稳定 item reference 和脱敏显示摘要;stale cursor 要求从第一页重读; - - 从当前不可变发现结果派生,不重新扫描文件、不启动能力,也不持有偏好写锁。 -2. 定义 `SubmitApplicationReview` 请求: - - `execution_domain_id`、`target_scope`;仅 workspace override 携带 Host 快照返回的 `workspace_scope_id`; - - `review_id`; - - `operation_id`,仅用于请求/响应关联; - - `expected_preference_revision`; - - 相关 provider/owner generations; - - `selection_baseline = recommended | none`; - - 有界 `selection_overrides[]`,每项只含稳定项目引用和与基线不同的选择结果。 -3. 请求不携带命令正文、提示词、凭据值、任意执行载荷或整份确认项目。服务端用 `review_id` 查找同代不可变计划,先应用共享推荐或空集合基线,再应用改动项,并从计划取得能力类型、决策键、行为版本和归属模块代次。最终选择数量服从现有归属模块/协议上限,并由确认摘要返回 `max_selection_count`;改动项也不得超过该上限。 -4. 整批预检以下条件: - - V2 schema/协议协商、Host identity 和 capability; - - execution domain、workspace scope 与当前 Host 连接绑定; - - preference revision; - - review plan/generation; - - application connection 状态; - - Safe Mode 和 safety ceiling。 -5. 整批预检失败时不应用任何项;通过后按能力类型分派现有单项审批/冲突 owner。 -6. owner 可以逐项拒绝业务请求;响应必须返回每项 `applied / rejected / blocked / stale / failed` 等闭合结果及恢复动作,未知结果不得视为成功。 -7. 只持久化实际成功且仍与 decision key/behavior version 匹配的决定;返回与最终 preference revision 同代的新快照。 - -### 测试优先顺序 - -- stale revision、generation 或 Host capability 导致整批零应用; -- snapshot 只含 review summary;分页大小、总量上限、cursor 绑定和 stale 重读均按契约执行,翻页不触发重新发现; -- 推荐项跨越多页且用户未读取后续页面时,`recommended` 基线仍选择同代完整推荐集合;已查看页面的改动项准确覆盖基线,不为提交强制拉取全部页面; -- `none` 基线加选择改动项可以表达从空集合开始的选择;改动项越界、未知引用或来自另一 `review_id` 时整批拒绝; -- 作用域身份不匹配或从另一 workspace scope/Host 重放导致整批零应用; -- 两个不同 owner 的成功项共同提交; -- 一个 owner 业务拒绝时另一个成功项的逐项结果准确; -- 未知 item reference 和未知能力类型 fail closed; -- safety ceiling 阻止宿主选择高于上限的项; -- 高风险默认未选,但用户可在上限允许时显式选择; -- 重放旧 review plan 不恢复旧权限; -- 逐项结果与最终快照状态一致。 - -### 验证 - -```bash -cargo test -p bitfun-core external_source -cargo test -p bitfun-core external_tool -cargo test -p bitfun-core external_subagent -cargo test -p bitfun-core external_mcp -cargo check --workspace -``` - -过滤器以实际测试模块为准,至少覆盖本次触及的所有 owner。 - -### 用户可见结果 - -用户可以在一个 review 页面确认推荐集合;无需连续处理 Tool、Subagent、MCP 和冲突弹窗,并能看到逐项真实结果。 - -### 退出条件 - -- 整批并发保护与逐项业务结果边界清楚; -- 没有通用任意 payload API; -- owner 仍是最终批准、注册和失败事实的权威; -- 旧单项入口在迁移期间仍可工作,并与批量入口共享决定。 - -### 暂停条件 - -如果无法定义跨 owner 的原子回滚,不得宣称批量业务执行原子化;保留“整批预检原子、owner 逐项结果”的明确语义,并确保响应与快照可解释。 - -## 7. P4:Desktop、Peer、App Server 与 Server 协议投影 - -### 归属与范围 - -- 归属:各应用/传输适配层与 `interfaces/app-server` 线协议适配层; -- 主要文件: - - `src/apps/desktop/src/api/external_sources_api.rs` - - `src/apps/desktop/src/api/remote_workspace_policy.rs` - - `src/apps/cli/src/peer_host/commands/external_sources.rs` - - `src/crates/interfaces/app-server-protocol/src/external_sources.rs` 及 `method.rs`/`lib.rs` 注册 - - `src/crates/interfaces/app-server-client/src/lib.rs` - - `src/crates/interfaces/app-server/src/server/handlers/external_sources.rs` 及 Runtime/domain-to-wire conversion - - `src/apps/server/src/app_server.rs`、`src/apps/server/src/routes/external_sources.rs` 与 WebSocket round-trip tests - - `src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts` - - `src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts`。 - -### 实施内容 - -1. 保持现有 V1 DTO、Desktop/Peer endpoint、TypeScript union/allowlist 和 wire fixtures 不变;不得向 V1 action、recovery action 或 `hostCapabilities` 追加应用级字段。当前 Server `/ws` 经 `BitfunAppServer` 处理,仓库中的旧 `routes/external_sources.rs::dispatch` 已脱离生产路径并返回 `method_not_found`,不能把它当作“现有 Server adapter”。 -2. 先完成 P4a Server V1 只读前置切片: - - 在 `app-server-protocol` 定义独立的 V1 snapshot/control-snapshot method、wire DTO 和错误;`AppServer`/`AppClient` role 保持 schema-free,不登记领域方法; - - `app-server-client` 增加 typed request/response,`app-server` 只注册 handler、校验 wire contract 并转换 Runtime/domain 类型;handler 注入 `WorkspaceExternalSourceService` 的最窄只读 owner port,不持有第二份状态; - - `interfaces/app-server-client` 与 TypeScript translation 保持 V1 wire shape,Server Host 绑定其真实 workspace,不读取浏览器或控制端路径; - - Server 不注册 write handler;未知/写方法在反序列化 mutation payload 前以 method-not-found/host-capability-unavailable 拒绝; - - 用真实 `/ws` transport 做 Server bootstrap → `BitfunAppServer::serve` → handler → owner → client 的端到端 round-trip。该切片通过前,Server 不进入 V2 共享 fixture,也不得标记为只读 external-source Host。 -3. P4a 后新增无副作用 `get_external_application_snapshot_v2`,直接作为版本探测:成功响应必须是严格 V2 数据结构,并携带宿主读写能力;旧宿主的传输层 method-not-found 等价于“仅 V1”。不增加独立版本信息接口,也不引入“声明支持但接口不可用”的第二种状态。 -4. 客户端只有在 V2 snapshot 校验成功后,才调用 `get_external_application_review_page_v2` 或 `apply_external_application_action_v2`。V2 snapshot/action 不与 V1 对象混合序列化;read-only Server 只登记 snapshot/review read endpoint,不登记 mutation endpoint。 -5. Desktop Tauri command 只映射结构化 request/response,不派生状态、默认策略或推荐集合。 -6. 每个新增 Desktop command 在 remote workspace policy 中声明明确策略;Remote 未支持时返回 V2 类型化 unsupported,不回退本机。 -7. Peer Host 在事实所在 Host 执行相同 V2 typed action;Host 始终校验 `execution_domain_id`,并在 workspace override/上下文存在时校验快照返回的 `workspace_scope_id` 与连接绑定;控制端只原样回传 scope id,再用 Host identity、generation 和 accepted sequence 隔离响应。 -8. 旧 Peer/Host: - - 新客户端回退显示 legacy V1 control/catalog,不把候选误报为应用级已连接; - - V2 mutation 在客户端禁用;“升级 Host”是协商失败后的本地 UI 恢复建议,不发送给旧 Host; - - 不由控制端模拟 mutation。 -9. TypeScript 为 V1/V2 使用独立 normalization;V2 严格检查 schema、作用域身份、generation、preference revision、Host capability 和 item reference,未知字段组合 fail closed。 - -### 测试优先顺序 - -- V1 Rust/TypeScript golden fixtures 在新 Host/客户端中保持完全一致; -- old client → new Host 继续只使用 V1;new client → old Host 经 method-not-found 明确回退 V1 且没有 V2 mutation; -- V2 Rust/TypeScript 序列化字段一致;V2 snapshot 成功、method-not-found 回退和未知 schema 拒绝均有契约测试; -- App Server V1 read-only 方法在真实 Server `/ws` 往返成功,且 wire fixture 与 Desktop/Peer V1 一致; -- control、catalog 和 application snapshot 同代; -- read-only Host 未注册 mutation endpoint,并在 mutation payload 解析前拒绝; -- Remote 不回退本机; -- 旧 Host 降级不会把候选误报为已连接或已启用,也不会收到未知 V2 action/recovery enum; -- Host identity、execution domain 或 workspace scope 不匹配时拒绝响应/结果; -- accepted sequence 防止旧响应覆盖新连接决定; -- 未知状态、动作、逐项结果和恢复动作安全失败。 - -### 验证 - -```bash -cargo check -p bitfun-desktop -cargo test -p bitfun-app-server -cargo test -p bitfun-server external_source -pnpm --dir src/web-ui run test:run src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts -pnpm run type-check:web -``` - -同时运行 Desktop/Peer/Server 中与 external source command 直接对应的 focused tests。 - -### 用户可见结果 - -本机 Desktop、Peer 控制界面和只读 Host 对相同应用事实给出一致状态;不支持的宿主明确说明升级、重连或切换 Host。 - -### 退出条件 - -- adapter 无生态业务分支; -- 新 Desktop commands 全部具备 remote workspace policy; -- TypeScript 对未知、未协商或不同作用域/代快照 fail closed; -- V1 wire contract 冻结,V2 只在独立 endpoint 协商后启用; -- Server V1 read-only App Server 前置切片有真实 WebSocket round-trip,不能由 dead dispatch 单元测试替代; -- 双向新旧 Host/客户端组合有契约测试,旧 Host 降级不产生执行位置 fallback。 - -## 8. P5:Desktop Web UI 信息架构 - -### 归属与范围 - -- 归属:Web UI Settings; -- 主要文件: - - `src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx` - - `src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss` - - `src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx` - - 同目录新增的聚焦组件与测试 - - `src/web-ui/src/infrastructure/config/components/common/config-page-layout.tokens.scss` - - 外部来源设置页现有 i18n namespace。 - -### 实施内容 - -1. 保留 `ExternalSourcesConfig` 作为页面 controller,继续负责读取、轮询、mutation sequencing、accepted sequence、pending mutation、scope mutation 栅栏和错误恢复。 -2. 按责任拆分: - - `ExternalAppsOverview`; - - `ExternalAttentionSummary`; - - `ExternalAppDetail`; - - `ExternalAppReview`; - - `ExternalAdvancedSettings`; - - 无策略判断的 presentation helpers。 -3. 首页使用现有约 600px 单列阅读轴:标题、真实待办、应用列表、高级设置。 -4. 每个应用行只显示应用名、一个状态、一句结果摘要和唯一主操作;有工作区时主操作明确标注“仅当前工作区”,没有工作区时先进入详情选择范围。来源路径、能力清单、冲突和诊断进入详情。 -5. 详情按“结果优先、控制后置”排列;连接完成显示生效范围、已启用、待确认和受限摘要。`user_default` 只在详情/高级设置中提供,并在提交前再次展示会影响同一执行域的所有工作区。 -6. 批量确认页面先使用快照摘要,再按需分页读取项目引用;按类别展示数量、主要风险、共享推荐状态和安全上限,技术详情按需展开,高风险默认未选。提交使用同代推荐/空集合基线和用户改动项,不为提交强制读取全部页面;首页轮询不读取项目页面。 -7. Safe Mode 在首页和详情显著展示,高级设置保留现有 source、scope、冲突、诊断和能力级管理。 -8. 首次发现只使用一次性轻提示和 Settings 导航状态;不增加启动 Modal 或常驻 banner。 -9. 所有文案进入现有 i18n namespace,颜色与状态复用主题 token,不提高主题治理基线。 - -### 测试优先顺序 - -- 五种应用状态和唯一主操作; -- “需要处理”仅在真实待办时出现; -- OpenCode 默认连接结果与 Codex/Claude 主动连接路径; -- 连接完成摘要; -- 当前工作区主操作不会改写 `user_default`;无工作区时不会直接执行全局连接;全局连接必须明确选择并二次确认范围; -- 批量默认选择严格等于共享推荐;跨页未读取项由同代推荐基线表达,已修改项只作为覆盖提交; -- 首页请求不携带 review items;打开/翻页才读取 bounded page,stale page 会整体刷新而不是混合显示; -- stale response/mutation 不覆盖新状态; -- review 整体失败和逐项失败; -- 断开与暂不使用; -- Safe Mode 显著状态; -- 旧 Host/read-only/Remote 降级; -- 键盘焦点、展开、批量选择和状态非颜色表达; -- 现有审批、冲突、诊断、scope 和脱敏回归保持通过。 - -### 验证 - -```bash -pnpm --dir src/web-ui run test:run src/infrastructure/config/components/ExternalSourcesConfig.test.tsx src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts -pnpm run type-check:web -pnpm run i18n:audit -pnpm run theme:color-audit:all -``` - -若组件拆分出独立测试文件,将这些文件加入同一次 focused test 命令。 - -### 用户可见结果 - -Settings 以应用为主入口,采用纵向单列;用户先看到连接结果和唯一下一步,高级能力管理仍可访问但不占据首页。 - -### 退出条件 - -- 首页不再平铺 Tool、Subagent、MCP、来源和诊断; -- controller 的竞态保护有回归测试; -- UI 不包含 OpenCode/Codex/Claude 默认策略分支; -- 现有高级操作没有被隐藏为不可达; -- type-check、focused tests、i18n 和主题治理通过。 - -### 暂停条件 - -若拆分组件需要重写现有 controller 并改变 mutation 顺序,先保留 controller,仅提取纯展示组件;不得以视觉改版为由同时重构请求状态机。 - -## 9. P6:TUI `/extensions` 与非交互 CLI - -### 归属与范围 - -- 归属:能力归属模块产生阻塞事实,Agent Runtime 拥有根任务结果与父子关系;App Server/Shared IPC 只传输,`src/apps/cli` 只投影交互和退出结果; -- 主要文件: - - `src/crates/contracts/events/src/agentic.rs` - - `src/crates/contracts/runtime-ports/src/lib.rs` - - `src/crates/assembly/core/src/agentic/coordination/coordinator.rs` 及真实外部能力解析/调用 owner - - `src/crates/interfaces/app-server-protocol/src/tui.rs`、`src/crates/interfaces/app-server-protocol/src/event.rs`、`src/crates/interfaces/app-server-client/src/lib.rs` 与 event round-trip tests - - `src/crates/interfaces/app-server/src/server/event_forwarder.rs` 及 handler/conversion tests - - `src/crates/adapters/agent-runtime-ipc/src/protocol.rs` 及 Shared Runtime client/server tests - - `src/apps/cli/src/modes/chat/external_review.rs` - - `src/apps/cli/src/modes/chat/external_hooks.rs` - - `src/apps/cli/src/modes/chat/external_sources.rs` - - `src/apps/cli/src/actions.rs` - - `src/apps/cli/src/peer_host/commands/external_sources.rs` - - `src/apps/cli/src/modes/exec/lifecycle.rs` - - 对应 parser、action registry、snapshot、事件和输出测试。 - -### 实施内容 - -1. `/extensions` 使用共享应用级快照展示应用、状态、数量、默认策略、主操作和 Safe Mode。 -2. 增加连接、断开、暂不使用和详情动作;默认命令作用于当前工作区并在输出中显示范围,全执行域默认必须使用明确参数/确认路径。parser、help、palette/action registry 与 dispatch 从同一 action 定义保持一致。 -3. `/extensions review` 使用共享 review summary,并按需读取有界 item page: - - 默认采用推荐集合; - - 高风险默认不选; - - 支持查看技术详情和调整; - - 用同代推荐/空集合基线和有界改动项提交同一类型化批量动作,不强制读取全部页面; - - 逐项展示权威结果。 -4. `/tools`、`/agent`、`/mcp` 和 `/hooks` 保留专项/高级管理,不复制首次连接向导。 -5. 删除仅进程内有效的重复提示判断,改为读取共享 notice/user decision facts;首次发现不阻塞聊天。 -6. 复用现有提交身份,不新增任务 ID:`AgentSubmissionResult` 仍只返回 accepted/turn ID;根 `session_id + turn_id` 唯一标识本次任务,子代理来源由现有 `SubagentSessionLinked` 追溯。 -7. 能力 owner 在真实解析或调用路径因未连接、待批量确认或权限扩大而阻止一个被请求的外部依赖时,返回类型化依赖事实。Agent Runtime 用 turn-local collector 聚合并发布新的 `AgenticEvent::ExternalDependencyActionRequired`,事件至少包含: - - `execution_domain_id` 与可选、非通配的 `workspace_scope_id`; - - 根 `session_id + turn_id`; - - `origin_session_id + origin_turn_id + origin_tool_call_id?`; - - 依赖引用、风险摘要、`can_degrade` 与闭合恢复动作。 -8. Runtime 使用现有 `SubagentSessionLinked(parent_session_id, parent_dialog_turn_id, parent_tool_call_id)` 递归追溯子代理来源。只有根 turn 仍在等待来源 tool call 时,子代理阻断事实才聚合给根;无关、后台或已脱离等待链的子代理结果不改变根任务。聚合事件必须在对应根任务结束事件前发出;并发根 turn 之间不共享 collector。 -9. 通过已有 Agent 事件路径端到端传输,而不是新增 CLI 私有旁路: - - `bitfun-events` 拥有事件 wire contract;应用级 product-domain DTO 只提供稳定 dependency reference,不拥有任务结果; - - App Server 继续通过 `agent/event` 的 `AgenticEventEnvelope` 转发,但新闭合事件是 wire 扩展:提升 `app-server-protocol::PROTOCOL_VERSION`,按每连接协商版本过滤 `ExternalDependencyActionRequired`。旧协议连接继续接收其已知事件但绝不能收到新 variant;若实现无法可靠逐连接过滤,就必须同步提升 `MIN_PROTOCOL_VERSION` 并在 initialize 时拒绝旧客户端,不能让其在事件流中反序列化失败; - - `app-server-client` 只有在 Host 协商到新增版本后才解释该事件;新客户端连接旧 App Server Host 时明确报告“任务依赖结果不支持”,不从结束文本猜测; - - Shared Runtime 继续通过 `RuntimeIpcEvent::Agent` 转发。由于 IPC 是严格版本协议,新增事件时同步提升 `PROTOCOL_VERSION`,旧 client/server 在握手失败后明确降级,不能混读; - - Peer/Remote fanout 必须保留根任务和来源身份,不得重写为控制端 workspace。 -10. 非交互 CLI 只缓存与当前 `execution_domain_id + workspace_scope_id? + root session + root turn` 全部匹配的事件;不可降级的事件在根任务结束后投影为类型化 `action-required`,可降级事件保留为结构化警告并沿用真实结束结果。不得从轮询应用快照、任意子代理事件或错误文本推断退出状态。 -11. stdout/stderr 与现有结构化输出契约保持不变;不得把交互式选择提示写入非交互 stdout。 - -### 测试优先顺序 - -- `/extensions` parser、help、palette 和 dispatch 一致; -- GUI/TUI 对同一 fixture 的状态、默认策略、数量和主操作一致; -- GUI/TUI 默认连接或断开只改变当前 workspace scope;全执行域操作必须明确选择,结果摘要显示最终生效范围; -- `/extensions review` 默认选择与共享推荐一致,跨页未访问项与用户改动项的结果和 GUI 相同; -- stale review 重新读取,不重放旧决定; -- 首次提示跨进程去重; -- 无关待办不阻塞聊天或非交互任务; -- 根 turn 直接命中 pending capability 时,在 terminal event 前收到匹配的 `ExternalDependencyActionRequired` 并返回 `action-required`; -- 通过 `SubagentSessionLinked` 证明依赖的 child blocking fact 聚合到根;无关/后台 child、错误 parent tool-call 或已断开的依赖边不影响根; -- 两个并发根 turn 的事件不串扰,来自另一 execution domain、workspace scope、session 或 turn 的事件被拒绝; -- App Server Embedded 与 Shared Runtime IPC 对同一事件 fixture 的字段、顺序和 terminal 结果等价;Shared 新旧协议版本在握手处 fail closed; -- new App Server Host → protocol v2/v3 client 不发送未知 outcome variant;新版本 client → old Host 不提交/不期待该能力;协商新版本时完整 round-trip; -- Peer/Remote 转发保留 root/origin identity 且不回退控制端 workspace; -- read-only/Remote/旧 Host 输出明确恢复动作; -- `/tools`、`/agent`、`/mcp`、`/hooks` 原有职责和兼容别名保持通过。 - -### 验证 - -```bash -cargo test -p bitfun-cli external -cargo test -p bitfun-cli action -cargo test -p bitfun-events external_dependency -cargo test -p bitfun-app-server agent_event -cargo test -p bitfun-agent-runtime-ipc agent_event -cargo check -p bitfun-cli -``` - -同时运行 action registry 和相关 slash command 的现有 focused tests。 - -### 用户可见结果 - -TUI 与 Desktop 共享“发现—连接—加载”的心智和决定;CLI 用户通过 `/extensions` 完成首次连接和批量确认,能力专项入口继续可用。 - -### 退出条件 - -- GUI/TUI golden fixture 一致; -- 交互提示不阻塞普通输入; -- 非交互只对当前 execution domain、workspace scope、根 session/turn 的不可降级任务相关待办返回 `action-required`; -- direct root、linked subagent、unrelated/background subagent、并发 roots、Embedded/Shared 和 Peer/Remote 路径都有端到端事件证据; -- TUI 无生态默认策略分支,且不共享 GUI 布局或组件 schema。 - -## 10. P7:跨宿主回归、迁移与清理 - -### 归属与范围 - -- 归属:Product Assembly、Desktop、Web UI、CLI 共同完成; -- 范围:共享 fixtures、i18n、主题、旧投影退场和文档同步。 - -### 实施内容 - -1. 建立同一组跨宿主 fixture,至少覆盖: - - 首次发现并默认连接 OpenCode; - - 首次发现但不连接 Codex/Claude Code; - - 多应用并存; - - 已连接且部分待确认; - - 权限扩大; - - 连接失败、沿用上一版本和 Host 不支持; - - Safe Mode; - - stale revision/generation; - - 断开后重新发现; - - 当前任务相关与无关待办; - - user default 与 workspace override; - - 本机、Peer、Remote execution domain 隔离; - - old client/new Host、new client/old Host; - - fresh V2 无文件、legacy 自动物化默认文件/显式 false、disabled/discover-only、已有效使用、无法归属、多个 workspace scope 原子转换和 future-major incompatible policy。 -2. 对比 Rust read model、TypeScript normalization、Desktop 展示和 TUI 文本中的状态、默认策略、数量、主操作及恢复动作。 -3. 验证 P2 的原位旧偏好迁移和切换: - - 全部 scope 决定与 `connection_schema_migration_version` 一次原子提交,崩溃/写失败保持完整旧文件,不存在部分完成状态; - - legacy 自动物化默认文件、显式 false 和 disabled/discover-only 均不被新默认覆盖;只有明确 `fresh_v2` 无文件初始化可应用 OpenCode 默认;已有效使用的 Claude Code/Codex/OpenCode 能力、审批和冲突决定不因升级静默撤下; - - future-major incompatible policy byte-for-byte 保留包含 opaque policy 的原文件,拒绝迁移、默认连接、MCP secret 自动写入和 V2 mutation;备份并重置后写入 `incompatible_reset` 并保持显式未连接,直到用户主动连接; - - 无法归属的旧状态继续留在 legacy V1 路径并要求审阅,直到有明确迁移决定; - - Instruction、Skill、Hook 和复制后的原生配置按各自 owner 验证,不被应用连接误删。 -4. 迁移旧入口: - - 保留能力专项操作; - - 删除 React/TUI 中重复的状态优先级、默认连接和提示去重逻辑; - - 只有所有生产宿主切换且回归通过后,才删除不再消费的 legacy 聚合字段或 action; - - 只要仍有旧 Host/客户端,V1 wire contract 和 endpoint 就继续保留;V2 不复用或扩展 V1 闭合枚举;Server 只有在 App Server V1 read-only round-trip 交付后才计入生产宿主。 -5. 更新架构、详细设计、CLI 架构和实现状态;不能把目标能力写成已交付。 -6. 记录首页快照和确认分页的序列化大小、聚焦读取延迟及前后对比;读取不得重新扫描、启动外部能力或持有偏好写锁。明显回退必须先减少返回数据或重复计算,不能无基线地增加缓存。 -7. 复核远端策略、日志脱敏、i18n、主题、仓库卫生和未跟踪生成文件。 - -### 综合验证 - -```bash -pnpm run fmt:rs -cargo check --workspace -cargo test -p bitfun-core external_source -cargo test -p bitfun-cli external -cargo check -p bitfun-desktop -pnpm --dir src/web-ui run test:run src/infrastructure/config/components/ExternalSourcesConfig.test.tsx src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts -pnpm run type-check:web -pnpm run i18n:contract:test -pnpm run i18n:audit -pnpm run theme:color-audit:all -pnpm run check:repo-hygiene -``` - -仅在实际触及对应范围时运行 i18n contract 或全主题审计;Rust 和 Web UI 的最小必需检查仍按仓库根 `AGENTS.md` 执行。 - -### 退出条件 - -- Desktop、TUI、Peer 与已完成 App Server 前置切片的 Server 对共享 fixture 的应用事实一致; -- 连接、批量确认、断开、提示去重和任务相关 `action-required` 均有端到端证据; -- 现有 Safe Mode、能力审批、冲突、诊断、脱敏和竞态测试保持通过; -- 无宿主按生态 ID 重算产品事实; -- 未连接应用不加载能力、不参与运行时冲突; -- Remote/read-only 不回退本机; -- legacy 升级不改变显式策略、已有效使用的能力、审批或冲突决定,失败可重试且不产生半迁移; -- 连接、提示和确认按执行域与工作区作用域隔离;任务结果按执行域、工作区作用域、根会话和根轮次隔离,并沿用子代理来源关系; -- 首页快照与确认分页保持有界,且性能对比没有未解释的明显回退; -- V1 wire fixtures 不变,V2 协商及双向新旧组合通过; -- 旧字段和逻辑只在确认无生产消费方后删除; -- 文档明确区分当前能力与目标状态。 - -## 11. 提交与评审边界 - -建议按 P1-P7 分为独立提交或 PR;P5 与 P6 可以在 P4 后并行。每个提交必须: - -1. 包含自己的失败测试、实现和最小验证; -2. 说明修改了哪个稳定 contract/owner,是否影响旧 Host; -3. 不混入新的生态能力解析、OpenCode package runtime、聊天历史迁移或显式配置导入; -4. 不提高 i18n/theme 治理基线来掩盖新增债务; -5. 不删除与旧消费方仍有关联的公共 V1 符号; -6. 在评审描述中列出实际执行的 focused commands 和剩余由 CI 覆盖的范围。 - -出现以下任一情况应停止当前阶段并回到架构评审: - -- 需要让 UI/TUI 解析生态原始 payload; -- 需要新增跨能力任意执行 DTO; -- 需要通过本地 fallback 掩盖 Remote/Host 不支持; -- 需要绕过 owner 才能批量批准或卸载; -- 需要为连接体验建立第二套偏好、权限、冲突或 watcher 系统; -- 无法在不改变现有能力运行语义的情况下实现应用聚合。 diff --git a/docs/plans/tui-app-server-decoupling-refactor-plan.md b/docs/plans/tui-app-server-decoupling-refactor-plan.md index 6f1c566843..3f0b96f22b 100644 --- a/docs/plans/tui-app-server-decoupling-refactor-plan.md +++ b/docs/plans/tui-app-server-decoupling-refactor-plan.md @@ -1,8 +1,8 @@ # TUI 与 App Server 解耦重构计划 -> 状态:Phase 0-2 已完成当前定义的边界、协议基础和核心聊天迁移;Phase 3-5 尚未开始。 +> 状态:Phase 0-4 已完成当前定义的边界、协议基础、核心聊天、配置管理和外部集成接口迁移;Phase 5 Embedded direct-runtime 迁移待实现,Shared App Server 目标待评审。 > -> 当前状态基线:2026-08-05。一次性的运行证据保留在对应 PR/Actions 记录中;本文不绑定会因 rebase 失效的提交 SHA。 +> 当前状态基线:2026-08-13。一次性的运行证据保留在对应 PR/Actions 记录中;本文不绑定会因 rebase 失效的提交 SHA。 > > 本文只记录当前差距、阶段和完成证据。稳定架构约束见相邻架构文档;Phase 0 的历史盘点已失效,不再作为当前能力清单。 @@ -18,8 +18,8 @@ 本计划只迁移交互式 TUI 的产品后端调用: 1. TUI 保留终端输入、状态、渲染和 controller-local effect。 -2. TUI 通过 app-local `TuiBackend` 使用产品后端,不直接依赖 Core、Runtime 实现、Service、全局 singleton 或私有 IPC operation。 -3. Embedded TUI 使用 `AppServerTuiBackend`;Shared TUI 在 Shared App Server 交付前使用 `SharedTuiBackend` compatibility adapter。 +2. TUI 当前通过 app-local `TuiBackend` 使用产品后端;Phase 5 再把它拆成 Runtime port 与按 domain 注入的管理接口。`TuiAgentClient` 不直接依赖 Core、Runtime 实现、具体 Service、全局 singleton 或私有 IPC operation;view/reducer 也不执行 backend I/O。 +3. Embedded TUI 当前使用 `AppServerTuiBackend`;Shared TUI 当前使用 `SharedTuiBackend` 映射 private Runtime IPC v17。Phase 5 才引入 `TuiRuntimePort`、`DirectRuntimeTuiRuntime` 和 Shared IPC Runtime adapter,并把管理用例移到 backend composition 的 owner service/provider 接口。 4. App Server 只适配稳定合同,不接管 Runtime、Service 或 Product Domain 的业务所有权。 5. Headless `exec`、ACP、Peer Host 和公开 SDK 保留各自经评审的 adapter。 @@ -38,7 +38,7 @@ 当前 head 有两条交互式 TUI 后端路径: ```text -Embedded TUI +Embedded TUI(当前) -> TuiAgentClient -> TuiBackend -> AppServerTuiBackend @@ -56,60 +56,93 @@ Shared TUI (--shared) -> Runtime API / owners ``` -两条路径统一的是 TUI 可见的行为端口。Shared compatibility adapter 会把 Runtime IPC 的结果和事件映射为 `TuiBackend` 使用的类型,但它没有运行 `BitfunAppServer`,也不是 Shared App Server transport。 +当前两条路径共用的是包含 Runtime 与管理方法的单体 `TuiBackend`。`AppServerTuiBackend` +把 Embedded 请求委托给 App Server client;`SharedTuiBackend` 把 Session/chat 请求映射到 +private Runtime IPC v17,并直接持有具体 `AppManagementService` 承接 Host-local 管理能力。 +Phase 3/4 已让 controller 使用 typed backend API,不代表 Phase 5 的 port 拆分或 adapter 接线已经完成。 -Phase 3/4 尚未迁移的配置、MCP、Skill、Subagent、Hook、外部来源、Account 和 Worktree 管理面仍可能通过 CLI Host 中的现有 Core/Service compatibility 路径完成。它们是当前剩余差距,不能据 Phase 2 的核心聊天完成状态宣称整个 TUI 已解耦。 +### 2.2 Approved Embedded target -### 2.2 Proposed target +Phase 5 将当前单体 backend 拆成下面的 app-local composition: -若 [App Server 目标架构](../architecture/app-server-architecture.md) 通过评审,交互式 Rich Client 的目标路径为: +```text +TuiAgentClient + -> TuiBackend composition + -> TuiRuntimePort + -> DirectRuntimeTuiRuntime + -> SharedIpcTuiRuntime + -> owner-owned service/provider interfaces (management only) +``` + +这是已批准但尚未交付的 Phase 5 目标。`TuiRuntimePort` 将只覆盖 Embedded 和 Shared +都需要、且当前 private Runtime IPC v17 已经承载的 Runtime 行为:initialize/health、 +Session、Turn、Permission/UserInput、shell、compact/undo/redo/reload、usage/settlement、 +workspace reference/diff、lineage、fork、当前 Session 的 model/mode 更新、agent mode +catalog 和事件订阅。Phase 5 完成后,`DirectRuntimeTuiRuntime` 将 direct Runtime 映射到 +该 port,Shared IPC adapter 将 v17 的结果和事件映射到同一组 TUI semantic types;后者 +不运行 `BitfunAppServer`,也不是 Shared App Server transport。 + +Model catalog/CRUD、Skill、Subagent、MCP、Account、Settings Sync、Worktree、External +Source 和 Hook 不因为被 TUI 使用就进入 `TuiRuntimePort`,也不需要一个总括性的 +`TuiManagementPort`。backend composition 按 domain 注入各 owner 已有的稳定 service/provider +trait;Embedded 直接使用同进程 owner service,Shared 使用 Host-local service/provider +adapter。只有原始 service 接口暴露内部类型、无法表达 TUI 所需的权限/上下文/unsupported, +或需要跨部署稳定 DTO 时,才在 owning crate 或 adapter 内增加最薄的 facade。不得把 +`AppManagementService` 整体搬入 direct adapter,也不得让 controller/view 直接依赖具体 +service 实现。缺少 provider 的 Shared/Remote 场景返回 typed unsupported,禁止静默回落控制端本机。 + +Phase 3 已将 Mode/Model、Skill、Subagent 和 MCP 管理面迁移到 TUI-facing typed API。Phase 4 进一步迁移了 External Source、native/external Hook、Account、Settings Sync 和 Worktree 管理面。当前这些方法仍位于单体 `TuiBackend`;Embedded 经 App Server 的 `AppManagementService` wiring 调用 owner,Shared 则由 `SharedTuiBackend` 委托其持有的具体 `AppManagementService`。Phase 5 将按 owner 归属把管理用例拆到 service/provider 接口,不建立管理总接口;TUI controller 仍不直接访问 compatibility owner。 + +Phase 5 中,`DirectRuntimeTuiRuntime` 将实现 `TuiRuntimePort`,可以依赖 Rust Runtime SDK 暴露的稳定 typed facade,但不得把 Runtime 内部类型、Core singleton 或事件队列 owner 暴露给 TUI。管理面 backend 可以直接调用 owner-owned 的稳定 service/provider trait;若该接口暴露内部类型,或需要 TUI-specific DTO、权限、上下文和 capability 裁剪,才在 owning crate 抽取薄 facade。不得把 `AppManagementService` 原样搬到 CLI,也不得由 direct adapter 复制其业务状态或策略。 + +当前 Shared 的 Session/chat/mode authority 由 `SharedTuiBackend` 映射 v17;Phase 5 完成后才由 Shared IPC Runtime adapter 实现 `TuiRuntimePort`。Host 实际提供的本机管理 capability 当前由 `SharedTuiBackend` 委托具体 `AppManagementService`,Phase 5 再改为 backend composition 按 domain 调用 Host-local service/provider adapter。当前 Shared Host 提供 Phase 4 的 External Source V1 和 Hook 管理,但不注入 Account/Settings Sync 或 Worktree owner;这些能力返回 typed unsupported。Remote workspace 对所有 controller-local management capability fail closed,不回落到控制端本机。Phase 4 完成表示 typed API 和 wiring 已迁移,不表示所有 deployment 的 capability 完全相同,也不表示 Phase 5 已完成。Phase 4 之后新增的 External Application V2 控制面目前只在 Embedded App Server 接线,Shared Runtime 明确 unsupported,不重新打开 Phase 4 的旧 owner 直连预算。 + +### 2.3 Optional Shared App Server proposal + +Web 当前继续通过自己的 loopback WebSocket App Server 入口,不经过 TUI composition: ```text -TUI renderer / input / state / local effects - | - v - TuiBackend trait - | - v - AppServerTuiBackend adapter - | - v - AppServerClient - | - Host-selected transport - / \ - in-memory Embedded controlled Shared local - \ / - v - App Server - | - v - Runtime API / Services / Product Domain owners +Web UI -> Web Host -> loopback WebSocket App Server + -> Runtime API / owner ports + +Shared Rich Client(Phase 6 candidate) + -> AppServerClient + -> candidate private Pipe / UDS + -> Shared App Server Host + -> Runtime API / owner ports ``` -Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controller/lease、事件恢复、断连取消、`outcome_unknown`、frame 限制和空闲退出达到行为等价前继续保留。是否最终删除 v17 由等价测试、性能数据、真实 Rich Client 消费方和回滚证据决定,不能只依据 schema 相同或 adapter 已存在。 +Shared App Server 仍是 Phase 6 待评审提案,不是 Phase 5 的既定结果。Private Runtime IPC +v17 在候选 transport 的鉴权、实例身份、controller/lease、事件恢复、断连取消、 +`outcome_unknown`、frame 限制和空闲退出达到行为等价前继续保留;评审也可以决定长期保留 +v17。Embedded direct-runtime 不替换 v17,也不要求 direct facade 与 wire DTO 相同;两者只需 +满足同一行为合同。 ## 3. 当前能力矩阵 状态定义: - **已交付**:生产 handler/client 已接线,并被当前 Embedded TUI 路径使用。 -- **兼容映射**:Shared TUI 通过 Runtime IPC v17 和 `SharedTuiBackend` 提供等价 TUI 用例,但没有经过 App Server wire。 +- **兼容映射**:Shared TUI 通过 Runtime IPC v17 和 `SharedTuiBackend` 提供与 Embedded 对照的 TUI Runtime 用例,但没有经过 App Server wire。 - **部分交付**:已有合同或 handler,但 Host 能力、恢复、安全或 TUI 调用路径仍不完整。 - **未迁移**:当前 TUI 仍使用既有 compatibility owner 路径,或尚无生产接口。 - **本地保留**:属于 TUI 或 controller-local effect,不迁移。 +本矩阵的 Embedded 列记录 Phase 5 之前的 App Server 基线。Phase 5 完成后,Runtime 用例由 +`DirectRuntimeTuiRuntime` 调用 Runtime typed facade;管理用例由 backend composition 调用 +对应 owner service/provider。表中的行为合同和 Shared 对照场景保持不变。 + ### 3.1 核心聊天与 Session | TUI 用例 | Embedded App Server | Shared v17 compatibility | 当前结论 | | --- | --- | --- | --- | | 初始化、版本、健康 | `app/initialize`、`app/health` | adapter 根据 v17 握手结果合成 TUI-facing initialize/health | Embedded 已交付;Shared 尚不是 App Server connection | | Agent、Permission 事件 | `agent/event`、`agent/permissionEvent` | IPC 事件桥映射为 `AppServerEvent` | 两边均可驱动当前核心 TUI;底层恢复合同不同 | -| Config 事件 | `config/event` | 当前 Shared bridge 不投影 Config 事件 | Embedded 已接线;Shared 配置管理面仍属 Phase 3 | +| Config 事件 | `config/event` | 当前 Shared bridge 不投影 Config 事件 | Embedded 已接线;Shared 的 TUI-facing 管理 capability 当前由 `SharedTuiBackend` 委托其具体 `AppManagementService`,不代表 v17 已有 Config 事件;Phase 5 再拆成 owner service/provider adapter | | 流失效与重同步 | `app/eventStreamState`、`app/syncEvents`、`session/sync` | adapter 投影 connection-local cursor、invalidation/resync 和 closed | 已有连接内 cursor/sync;没有跨连接持久 replay/resume | | Session list/create/sync | `agent/listSessions`、`agent/createSession`、`session/sync` | list/create/atomic restore operation | 已交付;sync 包含 Runtime 状态、transcript、workspace binding 和 pending Permission | | Session delete/rename/fork | typed App Server methods | v17 controller-scoped operations | 已交付或兼容映射;Shared 继续执行 controller/idle 规则 | -| Model/mode update | `session/updateModel`、`session/updateMode` | v17 current-controller operations | 当前 Session 更新已覆盖;完整目录和默认值仍属 Phase 3 | +| Model/mode update | `session/updateModel`、`session/updateMode` | v17 current-controller operations | Session update wire 已覆盖;当前 Embedded 经 `AppServerTuiBackend` 提交,Shared 经 `SharedTuiBackend -> Runtime IPC v17` 提交;Phase 5 完成后再分别由 Direct/Shared Runtime adapter 实现 `TuiRuntimePort` | | Submit/cancel/steer | typed Agent methods | v17 Turn operations | 已交付或兼容映射 | | User Shell/UserInput | `agent/runUserShellCommand`、`agent/submitUserAnswers` | v17 typed operations | 已交付或兼容映射;执行和权限仍由 Runtime owner 持有 | | Permission pending/respond | typed Permission methods/events | v17 pending/respond and event stream | 已交付或兼容映射 | @@ -125,18 +158,18 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll 当前未交付的是跨连接持久化 cursor、历史事件 replay 和断线后的透明 resume。Shared Runtime IPC v17 仍按自己的 lag/closed、断连取消和 controller 隔离规则工作;`SharedTuiBackend` 只为当前 TUI connection 投影单调 cursor,不能把该投影描述为底层 IPC 已有 replay。 -### 3.3 尚未迁移的管理面 +### 3.3 管理面状态 -| Domain | 当前状态 | Phase 3/4 需要完成 | +| Domain | 当前状态 | 当前结论 / 后续 | | --- | --- | --- | -| Mode/Model 管理 | 当前 Session mode/model 更新已交付;目录、secret-safe CRUD 和 defaults 未形成完整 App Server 用例 | Runtime-resolved catalog、secret-safe mutation、默认值与 availability | -| Skill/Subagent | 仍使用既有 CLI/Core 管理路径 | visible/manageable read model、override/model binding、context reload 触发规则 | -| MCP | 仍使用既有 CLI/Core/Service 路径 | catalog/status、CRUD、restart、approval、conflict 和 events | -| External Source/Tool/Command/Agent | 当前 App Server production fallback 明确不支持旧 external route | owner snapshot、mutation、review、conflict、generation 和 typed events | -| Hooks | 仍使用既有 native/external hook 管理路径 | native overview 与 external import lifecycle;保持两类 Hook 分离 | -| Account/Settings Sync | 尚无 TUI App Server 闭环 | secret-safe auth flow、sync operation identity、冲突、取消和 snapshot recovery | -| Worktree | Session workspace binding 已进入 sync;bind/release/status 管理未迁移 | owner-scoped worktree lifecycle 和 remote unsupported | -| Desktop/Web Host 安全 | WebSocket Host 仅为 loopback 单用户;Desktop 尚未迁移为 App Server Host | Host allowlist、身份/作用域、真实 limits 与平台 capability provider | +| Mode/Model 管理 | Embedded App Server 提供 typed mode catalog 和 model list/get/add/update/delete/default API;read DTO 只含 secret configured metadata,mutation 使用 preserve/replace/clear | Phase 3 已完成 typed API 与 wiring;Shared mode catalog 来自 Runtime Host,model 目录/管理由 `SharedTuiBackend` 持有的具体 `AppManagementService` 提供,Session model mutation 由 `SharedTuiBackend` 提交给 v17 owner;Phase 5 再拆分 owner service/provider 与 Runtime port | +| Skill/Subagent | 当前 `TuiBackend` 提供 typed list/toggle API 和 visible/manageable read model;Embedded 经 App Server wiring,Shared 由 `SharedTuiBackend` 委托其具体 `AppManagementService` | Phase 3 已完成 typed API 与 wiring;Shared capability 明确属于本机 CLI compatibility scope,Phase 5 再拆成 owner service/provider adapter | +| MCP | 当前 `TuiBackend` 提供 typed catalog/status/toggle/add/delete/external decision/conflict API;read projection 与 Debug 输出不暴露凭据 | Phase 3 已完成当前定义;Shared 由 `SharedTuiBackend` 委托当前 CLI 进程的具体 `AppManagementService`,以本地 MCP compatibility service 保留迁移前管理行为。该 service 的 MCP 进程状态和 tool registry 不会即时重配已经运行的 Shared Runtime Host;要取得 Host 侧新状态仍需显式的同步/restart contract,不能把本地 toggle 描述成 v17 远端控制 | +| External Source/Tool/Command/Agent | 当前 `TuiBackend` 提供 typed snapshot/control/review、conflict choice、command expansion 和事件接口;Embedded 经 App Server wiring,Shared V1 由 `SharedTuiBackend` 委托其具体 `AppManagementService` | Phase 4 当前定义已完成;Shared 保留 V1 本机 compatibility,V2 明确 unsupported,Remote 不回落本机;Phase 5 再拆成 owner service/provider adapter | +| Hooks | 当前 `TuiBackend` 提供 typed native overview 与 external snapshot/plan/apply/mutate API;Embedded 经 App Server wiring,Shared 由 `SharedTuiBackend` 委托其具体 `AppManagementService` | Phase 4 已完成 typed API 与 wiring;native user hooks、compiled-in `post_call_hooks` 和 external hook catalog 继续分离,Remote 明确 unsupported;Phase 5 再拆成 owner service/provider adapter | +| Account/Settings Sync | typed snapshot/login/finalize/logout 与 sync start/snapshot/cancel/local-changed 已接线;凭据不进入 read model 或 Debug 输出 | Phase 4 接口迁移已完成;Embedded Host 注入共享 `AccountRuntime`,App Server 直接做 domain-to-wire 适配;当前 Shared Host 未注入并返回 typed unsupported | +| Worktree | typed repository status、bind/release 和 operation identity 已接线 | Phase 4 接口迁移已完成;Embedded Host 注入 Worktree owner,当前 Shared Host 与 Remote workspace 明确 unsupported | +| Desktop/Web Host 安全 | WebSocket Host 仅为 loopback 单用户;Desktop 当前仍使用 Tauri adapter,独立 direct Runtime 迁移尚未实施 | Host allowlist、身份/作用域、真实 limits 与平台 capability provider | ### 3.4 本地保留 @@ -162,7 +195,7 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll | `src/crates/interfaces/app-server-protocol` | behavior-light method、DTO、wire error、event envelope 和角色定义 | | `src/crates/interfaces/app-server-client` | 类型化请求、事件分发和 host-supplied transport 抽象 | | `src/crates/interfaces/app-server` | server 生命周期、生产 handler 注册、Runtime/domain 与 wire 转换、错误映射 | -| `src/apps/cli` | `TuiBackend`、Embedded/Shared adapter 选择、transport 和进程生命周期、TUI-local effect | +| `src/apps/cli` | 当前拥有 `TuiAgentClient`、单体 `TuiBackend`、`AppServerTuiBackend`、`SharedTuiBackend`、transport 和进程生命周期、TUI-local effect;Phase 5 再引入 Runtime port 与按 domain 注入的 owner service/provider composition | | Runtime/Service/Product Domain owners | Session、Turn、Permission、Workspace、配置和其他业务权威事实 | 边界规则: @@ -171,6 +204,10 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll - `bitfun-app-server` 可依赖生产 handler 所需的明确 owner feature,但禁止选择 `bitfun-core/product-full`。 - Host 负责 transport、认证、作用域、真实 capability/limits、平台能力和进程生命周期。 - handler 只做合同校验、DTO 转换和错误映射,不持有第二份业务权威状态。 +- Phase 5 引入的 `TuiRuntimePort` 只抽取 Embedded/Shared 共同需要的 Runtime 行为;不定义总括性的 `TuiManagementPort`。 +- Phase 5 将管理面按 domain 拆到 owner-owned 的稳定 service/provider trait;只有需要 DTO、权限/上下文 + 适配或 capability 裁剪时才增加薄 facade,不能把具体 service 实现或 `AppManagementService` + 整体暴露给 TUI。 - DTO 提取不代表 Runtime owner 迁移。 ## 5. 分阶段状态 @@ -181,17 +218,18 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll | --- | --- | --- | --- | --- | | Phase 0:边界 | `TuiBackend`、behavior-light protocol/client crate、source/Cargo guard 已建立 | Core boundary tests 和 dependency checks | 已完成 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | | Phase 1:协议基础 | initialize/health、typed events、connection-local cursor、resync、稳定错误和 Embedded connection 已接线 | App Server protocol/client/server focused tests | 已完成 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | -| Phase 2:核心聊天 | Embedded 核心用例经 App Server;Shared 经同一 `TuiBackend` 映射 v17;TUI 核心不引用 Runtime SDK/IPC operation | CLI、App Server、Runtime IPC 和 boundary focused tests | 已完成当前定义 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | -| Phase 3:配置管理 | TUI 不再访问 config/registry/MCP compatibility owner;secret-safe typed APIs 完成 | owner tests、App Server contract tests、CLI behavior tests | 未开始 | - | -| Phase 4:外部集成 | External Source、Hook、Account、Worktree 管理面经 typed backend;remote 不回落本机 | owner/remote/security contract tests | 未开始 | - | -| Phase 5:Shared App Server | Shared Host 达到 v17 治理等价,opt-in 双栈验证完成,并有回滚与删除证据 | 跨 transport parity、故障、性能和安全测试 | 未开始,目标待评审 | - | +| Phase 2:核心聊天(旧路径) | Embedded 核心用例经 App Server;Shared 经同一 `TuiBackend` 映射 v17;TUI 核心不引用 Runtime SDK/IPC operation | CLI、App Server、Runtime IPC 和 boundary focused tests | 已完成当前定义,作为迁移基线 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | +| Phase 3:配置管理 | TUI controller 不再访问 config/registry/MCP compatibility owner;secret-safe typed APIs 完成,CLI Host adapter 可保留显式 compatibility forwarding | owner tests、App Server contract tests、CLI behavior tests | 已完成当前定义 | 本变更的 protocol/client/server/CLI focused tests 与 Core boundary checks | +| Phase 4:外部集成 | External Source、Hook、Account、Settings Sync、Worktree 管理面经 typed backend;remote 不回落本机 | owner/remote/security contract tests | 已完成当前定义 | [PR #2146 checks](https://github.com/GCWing/BitFun/pull/2146/checks)、zero-budget contract 与 Core boundary checks | +| Phase 5:Embedded direct-runtime | `TuiRuntimePort` 与 owner service/provider 接入边界拆分完成;Embedded TUI 删除 App Server client/server 与 in-memory transport,改用 direct Runtime adapter;管理面不进入 Shared IPC,旧路径随切换删除 | Runtime port 对 Shared v17 operation 的覆盖测试;direct-runtime 与 Shared v17 的 Runtime 行为测试;各管理 service/provider 的 capability/unsupported 测试 | 待实现 | - | +| Phase 6:Shared App Server | Shared Host 达到 v17 治理等价,opt-in 双栈验证完成,并有回滚与删除证据 | 跨 transport parity、故障、性能和安全测试 | 未开始,目标待评审 | - | ### 5.1 Phase 0-2 已交付摘要 -- `TuiAgentClient`、Startup 和 `ChatMode` 只消费 app-local `TuiBackend`。 -- Embedded Host 在专用 OS 线程的 current-thread Tokio runtime + `LocalSet` 中运行 private `BitfunAppServer`,TUI 保持在原多线程 runtime。 -- `AppServerTuiBackend` 通过正式 `AppServerClient` 和 in-memory transport 完成核心用例。 -- `SharedTuiBackend` 将相同用例映射到 private Runtime IPC v17;TUI client/controller 不引用 IPC operation。 +- `TuiAgentClient`、Startup 和 `ChatMode` 只消费 app-local `TuiBackend`;Runtime 调用和管理调用均不下沉到 view/reducer。 +- Embedded Host 当前在专用 OS 线程的 current-thread Tokio runtime + `LocalSet` 中运行 private `BitfunAppServer`,TUI 保持在原多线程 runtime;这是 direct-runtime 迁移前的基线。 +- `AppServerTuiBackend` 通过正式 `AppServerClient` 和 in-memory transport 完成核心用例,后续由 direct Runtime composition 替换。 +- `SharedTuiBackend` 将相同 Runtime 行为映射到 private Runtime IPC v17;TUI client/controller 不引用 IPC operation。将这些方法抽取为 Runtime port 属于 Phase 5。 - App Server 核心 handler 覆盖 sync、turn、Permission、revert、context、usage、settlement、Workspace 和 lineage;Config 事件也已在 Embedded connection 接线。 - Runtime IPC v17 为当前 parity 增加 restore Runtime 状态、usage、settlement 和本地命令 transcript 记录;没有增加 replay、observer、通用 controller transfer 或公开 SDK 能力。 - capability 声明列出当前注册方法,但 Host-specific availability 和方向性 limits 仍是后续收紧项。 @@ -200,17 +238,29 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll 目标:移除 TUI 对全局 config、registry 和 MCP service 的直接访问。 +状态:已完成当前定义。 + 完成条件: - 模型、Mode、Skill、Subagent 和 MCP 使用 owner-specific typed APIs。 - secret 不出现在 read model、日志或 generic config payload 中。 -- capability 由 Host 注入的 provider、授权和健康状态决定。 -- 管理面 unsupported 不静默回退既有直连路径。 +- capability 由 Host 注入的 management service、授权和健康状态决定。 +- management service unavailable 时返回明确 unsupported;Shared 的本机 compatibility forwarding 必须显式装配并发布真实 capability,不能在 Remote workspace 静默回落控制端本机。 + +交付摘要: + +- `app-server-protocol` 提供 Mode、Model、Skill、Subagent 和 MCP 的 owner-specific DTO 与 method;model read model 不返回 secret 值,model mutation 使用 preserve/replace/clear 语义。 +- App Server 由 Host 显式注入具体 `AppManagementService`,按 `tui.modes`、`tui.models`、`tui.skills`、`tui.subagents` 和 `tui.mcp` 发布真实 availability;service 缺失或 unavailable 时返回带 capability id 的 structured unsupported。 +- `AppManagementService` 位于 App Server server wiring,复用现有 config、registry、MCP 和 external-source owner,不成为第二个业务 owner;Startup 与 Chat controller 只调用 `TuiBackend` 的 typed 方法。Phase 5 才把这些方法按 domain 拆到 owner service/provider,且不会建立 direct TUI 的总管理接口。 +- `SharedTuiBackend` 继续映射 v17 mode catalog,并将 Model、Skill、Subagent 和 MCP 管理委托其持有的具体 `AppManagementService`。v17 不承载这些目录、CRUD 或 defaults;Shared 发布的是 adapter-scoped 本地 capability,current-Session model update 仍按 v17 的 controller/idle/outcome-unknown 合同提交给 Runtime Host。Phase 5 再以 owner service/provider adapter 和 Shared IPC Runtime adapter 替换这段单体 wiring。Shared MCP service 的运行态只属于当前 CLI 进程,不宣称可以即时控制已经运行的 Shared Runtime Host。 +- Core boundary budgets 已移除 Phase 3 owner 直连债务,并要求 Startup 的 Subagent 管理继续使用 typed backend。 ### 5.3 Phase 4 目标:迁移外部来源、Hook、Account、Settings Sync 和 Worktree 管理面。 +状态:已完成当前定义。 + 完成条件: - mutation 有 identity/revision、stale、取消和 audit 语义。 @@ -218,9 +268,26 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll - native user hooks、compiled-in `post_call_hooks` 和 external hook catalog 保持分离。 - remote workspace 不支持的能力返回 typed unsupported,不在 controller 本机执行。 +交付摘要: + +- `app-server-protocol`、client 和 production handlers 已提供 External Source、native/external Hook、Account、Settings Sync 与 Worktree 的 owner-specific typed API;side-effecting 请求使用 operation identity,External Source 与 Hook mutation 保留 owner revision/stale 合同,Settings Sync 提供显式取消与 snapshot。 +- `TuiAgentClient`、Startup 和 Chat controller 只经单体 `TuiBackend` 的 typed API 调用这些用例;Embedded 由 `AppServerTuiBackend` 委托 App Server wiring,Shared 由 `SharedTuiBackend` 委托 v17 或其具体 `AppManagementService`。Phase 4 涉及的 `bitfun_core`、account/account-sync compatibility marker 已从 controller 文件移除,对应 Core boundary budget 固定为零;Runtime port 与按 domain 的 management composition 仍留给 Phase 5。 +- Embedded Host 显式注入共享 `AccountRuntime` 并启用 App Server 内建的本机 Worktree 映射;App Server management service 直接适配 owner,不定义 `AccountManagementHost` 或持有第二份账户、同步、外部来源、Hook、Worktree 权威状态。CLI 的窄 `AccountRuntimeHost` 只实现 daemon、Relay/Peer 路由宿主效果,Session 备份通过独立端口读取 Agent Runtime compatibility owner。 +- Shared adapter 只发布 Host 实际可用的 capability。External Source V1 与 Hook 管理可使用当前本机 compatibility service;Account/Settings Sync、Worktree、Remote workspace 和后续未接线的 External Application V2 返回 typed unsupported,不静默回落本机。 +- Phase 4 未扩展 private Runtime IPC v17,也未改变 Phase 6 的 Shared transport 评审门槛。 + ### 5.4 Phase 5 -Phase 5 不以“删除 v17”为起点。建议顺序: +Embedded direct-runtime Phase 5 建议顺序: + +1. 按当前 Shared IPC v17 operation 集合冻结窄 `TuiRuntimePort`,定义统一的 TUI semantic request/result/event/error;direct facade 与 v17 wire 不要求共享 DTO。 +2. 将当前包含 Runtime 和管理面方法的单体 `TuiBackend` 拆为 backend composition:Runtime 调用进入 `TuiRuntimePort`,Model/Skill/Subagent/MCP/Account/Settings/Worktree/External Source/Hook 按 domain 进入各自 owner service/provider 接口;不创建 `TuiManagementPort` 总接口。 +3. 逐项检查管理 service 的暴露面:能直接复用稳定 owner-owned trait 的直接注入;暴露内部类型或需要 TUI DTO、权限/上下文、capability 裁剪的,才抽取最薄 facade。`AppManagementService` 仅保留为 App Server wiring,不原样迁入 CLI。 +4. 为 `DirectRuntimeTuiRuntime` 和 Shared IPC adapter 实现 Runtime port;再将 Embedded Host 从 `EmbeddedAppServerHost` 切换为 direct Runtime Host,删除 in-memory transport、App Server thread 和 initialize/health wire handshake。 +5. 按 Chat、Session、Permission/UserInput、Workspace、Config/Management 垂直切片迁移并验证事件订阅、pending Permission、取消、unknown outcome、workspace/execution binding、错误映射和 Host shutdown 回收。 +6. 完成 direct Runtime 的性能、升级兼容和跨入口行为验证后删除旧 App Server;不保留 rollback adapter,direct adapter 不支持的能力必须返回 typed unsupported。 + +Shared App Server Phase 6 不以“删除 v17”为起点。建议顺序: 1. 在 Shared Host 中增加默认关闭的 App Server local transport。 2. 两条 transport 复用同一 Host-scoped connection authority、controller registry、Session 事件过滤、operation identity/deadline/cancel 和未知结果登记。 @@ -237,14 +304,15 @@ Phase 5 不以“删除 v17”为起点。建议顺序: ```bash cargo check -p bitfun-app-server --offline cargo test -p bitfun-app-server --offline -cargo test -p bitfun-app-server-protocol -cargo test -p bitfun-app-server-client -cargo check -p bitfun-cli -cargo test -p bitfun-cli +cargo test -p bitfun-app-server-protocol --offline +cargo test -p bitfun-app-server-client --offline +cargo test -p bitfun-agent-runtime-ipc --offline +cargo check -p bitfun-cli --bin bitfun --offline +cargo test -p bitfun-cli --bin bitfun --offline pnpm run check:core-boundaries ``` -Phase 0-2 的具体命令结果和 CI 状态保留在 [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) 中,本文只保留可重复执行的验证命令和阶段状态。后续阶段必须在各自变更中重新记录验证结果,不能沿用一次性提交 SHA 作为证据。 +Phase 0-2 的具体命令结果和 CI 状态保留在 [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) 中。Phase 3 和 Phase 4 分别运行了对应的 protocol、client、server、CLI binary、owner contract 与 Core boundary focused checks;Phase 4 另有 zero-budget contract 防止 TUI controller 恢复旧 owner 直连。一次性结果保留在对应 PR/Actions 记录中,本文只保留可重复执行的验证命令和阶段状态,后续阶段必须重新记录自己的验证结果。 ### 6.2 行为等价场景 @@ -255,19 +323,19 @@ Phase 0-2 的具体命令结果和 CI 状态保留在 [PR #2034 checks](https:// | Workspace | binding、references、diff、remote facts | | Lineage | tree、descendant transcript、settlement、targeted cancellation | | Failure | unsupported、lag、invalidated、disconnect、deadline、`outcome_unknown` | -| Deployment | Embedded App Server 与 Shared v17 compatibility 的 TUI behavior parity | +| Deployment | 当前覆盖 Embedded App Server 与 Shared v17 compatibility;Phase 5 切换到 direct-runtime 并删除旧 Embedded App Server | -Shared App Server 实现后,同一 fixture 必须增加 Embedded App Server、Shared App Server 和 v17 rollback 三方验证,直到 v17 被正式保留或删除。 +Embedded direct-runtime 实现后,同一 fixture 必须覆盖 direct Runtime 和 Shared v17;迁移前可用旧 Embedded App Server 建立基线,但不维护第三条回滚路径。Shared App Server 实现后再增加候选 transport 路径。 ## 7. 完成定义 只有同时满足以下条件,才能宣布 TUI/App Server 解耦完成: -1. Phase 3/4 管理面已迁移,或从产品范围明确移除。 -2. TUI 产品请求和订阅只经过 `TuiBackend`,TUI view/reducer 不执行 backend I/O。 -3. protocol/client 和 TUI-facing 依赖闭包不包含 Core、Runtime/Service 实现、`product-full` 或 private IPC operation。 -4. capability、limits、身份和作用域来自真实 Host/transport,而不是通用 protocol 默认值。 -5. 事件、断线、恢复、权限、取消和 unknown outcome 有明确合同与故障测试。 +1. Phase 3/4 当前定义的管理面已迁移;Phase 5 direct-runtime 已完成;后续新增 capability 也不得绕过 backend composition 或恢复旧 owner 直连。 +2. TUI 产品请求和订阅经过 `TuiAgentClient` 的 backend composition;Runtime 行为经过 `TuiRuntimePort`,管理能力经过对应 owner service/provider,TUI view/reducer 不执行 backend I/O。 +3. protocol/client 和 TUI-facing 依赖闭包不包含 Core、Runtime/Service 实现、`product-full` 或 private IPC operation;只有 CLI Host/backend composition 可以按 owner 注入已审核的 service/provider。 +4. capability、limits、身份和作用域来自真实 Host/transport,而不是通用 protocol 默认值;管理 service/provider 缺失时返回 typed unsupported。 +5. 事件、断线、恢复、权限、取消和 unknown outcome 有明确合同与故障测试;Runtime port 的每个 Shared v17 operation 都有对应覆盖证据。 6. remote workspace 不存在 controller-local fallback。 -7. 重复 DTO、无效 handler 和无生产消费方的旁路已删除。 -8. 若采用 Shared App Server,迁移满足 Phase 5 的双栈、回滚、性能、安全和删除门槛;否则文档明确 v17 是保留的私有 compatibility transport。 +7. 重复 DTO、无效 handler、70 方法单体管理 trait 和无生产消费方的旁路已删除或不再属于稳定边界。 +8. 旧 Embedded App Server 已删除且不再作为 rollback adapter;若采用 Shared App Server,迁移满足 Phase 6 的双栈、回滚、性能、安全和删除门槛;否则文档明确 v17 是保留的私有 compatibility transport。 diff --git a/docs/verify-downloads.md b/docs/verify-downloads.md new file mode 100644 index 0000000000..05584ed5ac --- /dev/null +++ b/docs/verify-downloads.md @@ -0,0 +1,70 @@ +[中文](./verify-downloads.zh-CN.md) | **English** + +# Verify BitFun downloads + +Signed BitFun releases provide a detached `.sig` file for each covered +desktop installer or CLI archive. Release `v0.2.15`, for example, provides +signatures for its desktop and CLI downloads. + +BitFun uses this pinned minisign public key: + +- Key ID: `50F47CBE6CC0A376` +- Public key: `RWR2o8Bsvnz0UOBc3NoTVW06wdiGM7pLP3LpiL4A3Sp4nxkBsWlJRTxn` + +The same key is published as `minisign.pub` with signed releases and is built +into official BitFun update paths. The commands below pin the key directly so +the signature and key are not both trusted only because they came from the same +download location. + +## macOS or Linux + +Install [minisign](https://github.com/jedisct1/minisign/releases), then run the +following in a new empty directory. Replace both values with the exact tag and +asset name shown on the release page when verifying another download. + +```bash +VERSION=v0.2.15 +ASSET=bitfun-cli-0.2.15-aarch64-unknown-linux-gnu.tar.gz +BASE="https://github.com/GCWing/BitFun/releases/download/$VERSION" +PUBLIC_KEY=RWR2o8Bsvnz0UOBc3NoTVW06wdiGM7pLP3LpiL4A3Sp4nxkBsWlJRTxn + +curl --fail --location --remote-name "$BASE/$ASSET" +curl --fail --location --remote-name "$BASE/$ASSET.sig" +base64 --decode <"$ASSET.sig" >"$ASSET.minisig" +minisign -Vm "$ASSET" -P "$PUBLIC_KEY" -x "$ASSET.minisig" +``` + +A valid download prints `Signature and comment signature verified` and exits +with status 0. Do not run or install the asset if verification fails. + +## Windows PowerShell + +Install minisign, open a new empty directory, and use the exact release tag and +asset name you downloaded: + +```powershell +$Version = "v0.2.15" +$Asset = "BitFun_0.2.15_windows-x86_64-setup.exe" +$Base = "https://github.com/GCWing/BitFun/releases/download/$Version" +$PublicKey = "RWR2o8Bsvnz0UOBc3NoTVW06wdiGM7pLP3LpiL4A3Sp4nxkBsWlJRTxn" + +Invoke-WebRequest "$Base/$Asset" -OutFile $Asset +Invoke-WebRequest "$Base/${Asset}.sig" -OutFile "${Asset}.sig" +$EncodedSignature = (Get-Content "${Asset}.sig" -Raw).Trim() +[IO.File]::WriteAllBytes("${Asset}.minisig", [Convert]::FromBase64String($EncodedSignature)) +minisign -Vm $Asset -P $PublicKey -x "${Asset}.minisig" +if ($LASTEXITCODE -ne 0) { throw "BitFun download signature verification failed" } +``` + +## What the `.sig` file means + +BitFun release `.sig` files are base64-wrapped **minisign signatures**. Decode +one layer before giving the result to the minisign CLI, as shown above. A valid +signature proves that the file's exact bytes match a signature made by the +pinned BitFun release key; changing even one byte makes verification fail. + +This is not platform code signing. In particular, a BitFun `.sig` is not an +Apple Developer ID signature or notarization ticket, and it is not Windows +Authenticode. Gatekeeper and SmartScreen can therefore show their own warnings +independently of a successful minisign check. Signature verification also does +not replace your normal review of the software and its dependencies. diff --git a/docs/verify-downloads.zh-CN.md b/docs/verify-downloads.zh-CN.md new file mode 100644 index 0000000000..d6b46fc471 --- /dev/null +++ b/docs/verify-downloads.zh-CN.md @@ -0,0 +1,67 @@ +**中文** | [English](./verify-downloads.md) + +# 校验 BitFun 下载文件 + +带签名的 BitFun Release 会为覆盖到的桌面安装包或 CLI 归档提供独立的 +`<文件名>.sig`。例如,`v0.2.15` 已为桌面端和 CLI 下载文件提供签名。 + +BitFun 固定使用以下 minisign 公钥: + +- Key ID:`50F47CBE6CC0A376` +- 公钥:`RWR2o8Bsvnz0UOBc3NoTVW06wdiGM7pLP3LpiL4A3Sp4nxkBsWlJRTxn` + +带签名的 Release 还会发布包含同一把公钥的 `minisign.pub`,BitFun 官方更新 +路径也内置了这把公钥。下面的命令直接固定公钥,避免仅仅因为签名和公钥来自 +同一个下载位置就同时信任两者。 + +## macOS 或 Linux + +先安装 [minisign](https://github.com/jedisct1/minisign/releases),然后在一个新建 +的空目录中运行以下命令。校验其他版本时,请将两个变量同时替换为 Release 页面 +显示的准确 tag 和文件名。 + +```bash +VERSION=v0.2.15 +ASSET=bitfun-cli-0.2.15-aarch64-unknown-linux-gnu.tar.gz +BASE="https://github.com/GCWing/BitFun/releases/download/$VERSION" +PUBLIC_KEY=RWR2o8Bsvnz0UOBc3NoTVW06wdiGM7pLP3LpiL4A3Sp4nxkBsWlJRTxn + +curl --fail --location --remote-name "$BASE/$ASSET" +curl --fail --location --remote-name "$BASE/$ASSET.sig" +base64 --decode <"$ASSET.sig" >"$ASSET.minisig" +minisign -Vm "$ASSET" -P "$PUBLIC_KEY" -x "$ASSET.minisig" +``` + +校验成功时会输出 `Signature and comment signature verified`,并以状态码 0 退出。 +如果校验失败,请不要运行或安装该文件。 + +## Windows PowerShell + +安装 minisign 后,打开一个新建的空目录,并使用你所下载文件对应的准确 Release +tag 和文件名: + +```powershell +$Version = "v0.2.15" +$Asset = "BitFun_0.2.15_windows-x86_64-setup.exe" +$Base = "https://github.com/GCWing/BitFun/releases/download/$Version" +$PublicKey = "RWR2o8Bsvnz0UOBc3NoTVW06wdiGM7pLP3LpiL4A3Sp4nxkBsWlJRTxn" + +Invoke-WebRequest "$Base/$Asset" -OutFile $Asset +Invoke-WebRequest "$Base/${Asset}.sig" -OutFile "${Asset}.sig" +$EncodedSignature = (Get-Content "${Asset}.sig" -Raw).Trim() +[IO.File]::WriteAllBytes("${Asset}.minisig", [Convert]::FromBase64String($EncodedSignature)) +minisign -Vm $Asset -P $PublicKey -x "${Asset}.minisig" +if ($LASTEXITCODE -ne 0) { throw "BitFun 下载文件签名校验失败" } +``` + +## `.sig` 文件代表什么 + +BitFun Release 的 `.sig` 是经过一层 base64 包装的 **minisign 签名**。交给 +minisign 命令行工具之前,需要像上面的命令一样先解码一层。校验成功表示文件的 +每个字节都与 BitFun 固定发布公钥对应的签名一致;哪怕只修改一个字节,校验也会 +失败。 + +这不是操作系统级代码签名。BitFun 的 `.sig` 既不是 Apple Developer ID 签名或 +公证票据,也不是 Windows Authenticode。因此,即使 minisign 校验成功,Gatekeeper +或 SmartScreen 仍可能独立显示提示。签名校验也不能替代你对软件及其依赖的正常 +审查。 diff --git a/package-lock.json b/package-lock.json index 5b48841532..cbf6d89c91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "BitFun", - "version": "0.2.16", + "version": "0.2.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "BitFun", - "version": "0.2.16", + "version": "0.2.17", "hasInstallScript": true, "dependencies": { "jszip": "^3.10.1", diff --git a/package.json b/package.json index 71bceae0b0..9f0970e629 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "BitFun", "private": true, - "version": "0.2.16", + "version": "0.2.17", "type": "module", "engines": { "node": ">=22.12.0" @@ -10,7 +10,8 @@ "copy-monaco": "copyfiles -u 5 \"src/web-ui/node_modules/monaco-editor/min/vs/**/*\" src/web-ui/public/monaco-editor && node scripts/prune-monaco-nls.cjs", "copy-icons": "copyfiles -f \"src/apps/desktop/icons/Logo-ICON.png\" \"src/web-ui/public/\"", "copy-assets": "pnpm run copy-monaco && pnpm run copy-icons", - "generate-version": "node scripts/generate-version.cjs", + "generate-version": "node scripts/generate-version.cjs --build-env production", + "generate-version:dev": "node scripts/generate-version.cjs --build-env development", "generate-startup-appearance-bootstrap": "node scripts/generate-startup-appearance-bootstrap.mjs", "generate-all": "pnpm run generate-version && pnpm run generate-startup-appearance-bootstrap", "postinstall": "pnpm run copy-assets", @@ -35,15 +36,20 @@ "theme:color-audit:cli": "node scripts/audit-cli-theme-colors.mjs", "theme:color-audit:all": "pnpm run theme:color-audit && pnpm run theme:color-audit:mobile && pnpm run theme:color-audit:installer && pnpm run theme:color-audit:cli", "theme:color-audit:test": "node --test scripts/audit-theme-colors.test.mjs scripts/audit-cli-theme-colors.test.mjs", + "motion:audit": "node scripts/audit-web-motion.mjs", "theme:visual-contract": "node scripts/validate-theme-visual-contract.mjs", "appearance:contract-audit": "node scripts/audit-appearance-contracts.mjs", + "flowchat:log:analyze": "node scripts/diagnostics/analyze-flowchat-log.mjs", + "flowchat:log:analyze:test": "node --test scripts/diagnostics/analyze-flowchat-log.test.mjs", "check:repo-hygiene": "node scripts/check-repo-hygiene.mjs && node scripts/update-models-dev-snapshot.mjs --check", "models-dev:check": "node scripts/update-models-dev-snapshot.mjs --check", "models-dev:update": "node scripts/update-models-dev-snapshot.mjs", "check:build-prereqs": "node scripts/check-build-prereqs.mjs", + "harmony:architecture": "node scripts/check-harmonyos-architecture.mjs", "check:core-boundaries": "node scripts/check-core-boundaries.mjs", "check:core-boundaries:test": "node --test scripts/check-core-boundaries.test.mjs", "check:github-config": "pnpm --dir src/web-ui exec node ../../scripts/check-github-config.mjs && node --test scripts/check-github-config.test.mjs", + "test:release-packaging": "node --test scripts/release-channel.test.mjs scripts/desktop-tauri-build.test.mjs scripts/version-generation.test.mjs scripts/tauri-release-manifest.test.mjs", "fmt:rs": "node scripts/format-changed-rust.mjs", "lint:rs": "cargo clippy --workspace --exclude bitfun-desktop --all-targets", "lint:rs:desktop": "pnpm run prepare:mobile-web && cargo clippy -p bitfun-desktop --all-targets", @@ -54,7 +60,9 @@ "type-check:web": "pnpm --dir src/web-ui run type-check", "build": "pnpm run build:web", "verify:monaco-assets": "node scripts/verify-monaco-assets.cjs", - "build:web": "pnpm run appearance:contract-audit && node scripts/build-web-parallel.mjs && pnpm run verify:monaco-assets", + "verify:webkit-compatibility": "node scripts/verify-webkit-compatibility.cjs", + "verify:webkit-compatibility:test": "node --test scripts/verify-webkit-compatibility.test.mjs", + "build:web": "pnpm run appearance:contract-audit && node scripts/build-web-parallel.mjs && pnpm run verify:monaco-assets && pnpm run verify:webkit-compatibility", "build:mobile-web": "pnpm --dir src/mobile-web build", "build:miniapp-market": "pnpm --dir src/miniapp-market-web build", "type-check:miniapp-market": "pnpm --dir src/miniapp-market-web type-check", diff --git a/patches/mdast-util-gfm-autolink-literal@2.0.1.patch b/patches/mdast-util-gfm-autolink-literal@2.0.1.patch new file mode 100644 index 0000000000..df6196ae29 --- /dev/null +++ b/patches/mdast-util-gfm-autolink-literal@2.0.1.patch @@ -0,0 +1,49 @@ +diff --git a/lib/index.js b/lib/index.js +index c5ca771c24dd914e342f791716a822431ee32b3a..1b159d4cff7588581de342796858811c340a48f4 100644 +--- a/lib/index.js ++++ b/lib/index.js +@@ -132,7 +132,7 @@ function transformGfmAutolinkLiterals(tree) { + tree, + [ + [/(https?:\/\/|www(?=\.))([-.\w]+)([^ \t\r\n]*)/gi, findUrl], +- [/(?<=^|\s|\p{P}|\p{S})([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/gu, findEmail] ++ [/(^|[\s\p{P}\p{S}])([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/gu, findEmail] + ], + {ignore: ['link', 'linkReference']} + ) +@@ -189,27 +189,29 @@ function findUrl(_, protocol, domain, path, match) { + /** + * @type {ReplaceFunction} + * @param {string} _ ++ * @param {string} prefix + * @param {string} atext + * @param {string} label +- * @param {RegExpMatchObject} match +- * @returns {Link | false} ++ * @returns {Array | Link | false} + */ +-function findEmail(_, atext, label, match) { ++function findEmail(_, prefix, atext, label) { + if ( +- // Not an expected previous character. +- !previous(match, true) || ++ // An email should not follow a slash. ++ prefix === '/' || + // Label ends in not allowed character. + /[-\d_]$/.test(label) + ) { + return false + } + +- return { ++ const result = { + type: 'link', + title: null, + url: 'mailto:' + atext + '@' + label, + children: [{type: 'text', value: atext + '@' + label}] + } ++ ++ return prefix ? [{type: 'text', value: prefix}, result] : result + } + + /** diff --git a/png/bitfun_cli_tui.png b/png/bitfun_cli_tui.png new file mode 100644 index 0000000000..4a9c1d7fab Binary files /dev/null and b/png/bitfun_cli_tui.png differ diff --git a/png/github_social_preview.png b/png/github_social_preview.png new file mode 100644 index 0000000000..52fc147302 Binary files /dev/null and b/png/github_social_preview.png differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 769d91a40a..19d9efbb99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + mdast-util-gfm-autolink-literal@2.0.1: + hash: 23ece47d024ddb26beb830db75c7075ea8820bc730565a8e59895957253e5707 + path: patches/mdast-util-gfm-autolink-literal@2.0.1.patch + importers: .: @@ -338,8 +343,8 @@ importers: specifier: ^15.6.6 version: 15.6.6(react@18.3.1) react-virtuoso: - specifier: ^4.14.1 - version: 4.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^4.18.11 + version: 4.18.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rehype-katex: specifier: ^7.0.1 version: 7.0.1 @@ -4666,8 +4671,8 @@ packages: peerDependencies: react: '>= 0.14.0' - react-virtuoso@4.18.1: - resolution: {integrity: sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==} + react-virtuoso@4.18.11: + resolution: {integrity: sha512-Qeeq9vqa5seCxACvzbQTUeq3s2/Bu+VlwOMF0jfy3E/ZKHLiXWwJpXBhv2rckqYkvm1XRVFLCBMCmqCU8JNEJg==} peerDependencies: react: '>=16 || >=17 || >= 18 || >= 19' react-dom: '>=16 || >=17 || >= 18 || >=19' @@ -9645,7 +9650,7 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-gfm-autolink-literal@2.0.1: + mdast-util-gfm-autolink-literal@2.0.1(patch_hash=23ece47d024ddb26beb830db75c7075ea8820bc730565a8e59895957253e5707): dependencies: '@types/mdast': 4.0.4 ccount: 2.0.1 @@ -9693,7 +9698,7 @@ snapshots: mdast-util-gfm@3.1.0: dependencies: mdast-util-from-markdown: 2.0.2 - mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-autolink-literal: 2.0.1(patch_hash=23ece47d024ddb26beb830db75c7075ea8820bc730565a8e59895957253e5707) mdast-util-gfm-footnote: 2.1.0 mdast-util-gfm-strikethrough: 2.0.0 mdast-util-gfm-table: 2.0.0 @@ -10614,7 +10619,7 @@ snapshots: react: 18.3.1 refractor: 3.6.0 - react-virtuoso@4.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-virtuoso@4.18.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d49e3153d8..f9c82b07ad 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,3 +5,6 @@ packages: - "src/skin-market-web" - "BitFun-Installer" - "tests/e2e" + +patchedDependencies: + mdast-util-gfm-autolink-literal@2.0.1: patches/mdast-util-gfm-autolink-literal@2.0.1.patch diff --git a/scripts/audit-appearance-contracts.mjs b/scripts/audit-appearance-contracts.mjs index 8c4d85fd21..7f2d9e10d3 100644 --- a/scripts/audit-appearance-contracts.mjs +++ b/scripts/audit-appearance-contracts.mjs @@ -80,10 +80,15 @@ if (fs.existsSync(retiredOwnershipFile)) { failures.push(`${relative(retiredOwnershipFile)}: directory-level Appearance source ownership is forbidden`); } +const ignoredWalkDirectories = new Set(['node_modules', 'dist', 'build']); + function walk(directory) { return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => { const absolute = path.join(directory, entry.name); - if (entry.isDirectory()) return walk(absolute); + if (entry.isDirectory()) { + if (ignoredWalkDirectories.has(entry.name)) return []; + return walk(absolute); + } return /\.(?:css|scss|ts|tsx)$/.test(entry.name) ? [absolute] : []; }); } @@ -91,7 +96,10 @@ function walk(directory) { function walkContractSources(directory) { return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => { const absolute = path.join(directory, entry.name); - if (entry.isDirectory()) return walkContractSources(absolute); + if (entry.isDirectory()) { + if (ignoredWalkDirectories.has(entry.name)) return []; + return walkContractSources(absolute); + } return /\.(?:css|d\.ts|html|js|json|md|rs|scss|ts|tsx)$/.test(entry.name) ? [absolute] : []; }); } diff --git a/scripts/audit-web-motion.mjs b/scripts/audit-web-motion.mjs new file mode 100644 index 0000000000..1638e16b77 --- /dev/null +++ b/scripts/audit-web-motion.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node + +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; + +const repositoryRoot = path.resolve(import.meta.dirname, '..'); +const sourceRoot = path.join(repositoryRoot, 'src/web-ui/src'); +const sourceExtensions = new Set(['.css', '.scss', '.ts', '.tsx']); +const styleExtensions = new Set(['.css', '.scss']); +const excludedRoots = new Set([ + path.join(sourceRoot, 'generated'), + path.join(sourceRoot, 'component-library/preview'), +]); + +function isAuditedSourceFile(file) { + const name = path.basename(file); + return sourceExtensions.has(path.extname(name)) + && !/\.(?:test|spec)\.(?:ts|tsx)$/.test(name); +} + +async function collectFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all(entries.map(async entry => { + const absolutePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return excludedRoots.has(absolutePath) ? [] : collectFiles(absolutePath); + } + return isAuditedSourceFile(absolutePath) ? [absolutePath] : []; + })); + return nested.flat(); +} + +function lineNumberAt(source, index) { + return source.slice(0, index).split('\n').length; +} + +function relativePath(file) { + return path.relative(repositoryRoot, file); +} + +function findMatches(source, expression) { + return [...source.matchAll(expression)].map(match => ({ + index: match.index ?? 0, + value: match[0], + groups: match.slice(1), + })); +} + +function withoutComments(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' ')) + .replace(/(^|\s)\/\/[^\n]*/g, comment => comment.replace(/[^\n]/g, ' ')); +} + +function printLocations(title, locations, limit = 80) { + console.log(`\n${title}: ${locations.length}`); + locations.slice(0, limit).forEach(location => { + console.log(` ${location.file}:${location.line}${location.detail ? ` (${location.detail})` : ''}`); + }); + if (locations.length > limit) { + console.log(` ... ${locations.length - limit} more`); + } +} + +const files = await collectFiles(sourceRoot); +const transitionAll = []; +const scaleZero = []; +const smoothScroll = []; +const unguardedInfinite = []; +const keyframes = new Map(); +let interactiveHandlers = 0; +let motionOptIns = 0; + +for (const file of files) { + const source = await readFile(file, 'utf8'); + const searchableSource = withoutComments(source); + const fileName = relativePath(file); + const extension = path.extname(file); + + if (extension === '.tsx') { + interactiveHandlers += findMatches(searchableSource, /\bon(?:Click|DoubleClick|ContextMenu|PointerDown|MouseDown|KeyDown)\s*=/g).length; + motionOptIns += findMatches(searchableSource, /\bdata-motion\s*=/g).length; + } + + for (const match of findMatches(searchableSource, /\btransition\s*:\s*all\b/g)) { + transitionAll.push({ file: fileName, line: lineNumberAt(source, match.index) }); + } + for (const match of findMatches(searchableSource, /\bscale\(\s*0(?:\.0+)?\s*\)/g)) { + scaleZero.push({ file: fileName, line: lineNumberAt(source, match.index) }); + } + for (const match of findMatches(searchableSource, /\bbehavior\s*:\s*['"]smooth['"]/g)) { + smoothScroll.push({ file: fileName, line: lineNumberAt(source, match.index) }); + } + + if (!styleExtensions.has(extension)) continue; + + for (const match of findMatches(searchableSource, /@(?:-webkit-)?keyframes\s+([\w-]+)/g)) { + const name = match.groups[0]; + const definitions = keyframes.get(name) ?? []; + definitions.push({ file: fileName, line: lineNumberAt(source, match.index) }); + keyframes.set(name, definitions); + } + + const hasInfiniteAnimation = /\banimation(?:-[\w-]+)?\s*:[^;{}]*\binfinite\b/.test(searchableSource); + const hasReducedMotionRule = /@media\s*\([^)]*prefers-reduced-motion\s*:\s*reduce[^)]*\)/.test(searchableSource); + if (hasInfiniteAnimation && !hasReducedMotionRule) { + unguardedInfinite.push({ file: fileName, line: 1 }); + } +} + +const duplicateKeyframes = [...keyframes.entries()] + .filter(([, definitions]) => definitions.length > 1) + .sort((left, right) => right[1].length - left[1].length); + +console.log('BitFun Web UI motion inventory'); +console.log(`Scanned ${files.length} source files under src/web-ui/src.`); +console.log(`Interactive handler attributes: ${interactiveHandlers}`); +console.log(`Explicit data-motion opt-ins: ${motionOptIns}`); +printLocations('transition: all', transitionAll); +printLocations('scale(0)', scaleZero); +printLocations('JavaScript smooth scrolling (verify keyboard and reduced-motion paths)', smoothScroll); +printLocations('Files with infinite animation and no local reduced-motion rule', unguardedInfinite); + +console.log(`\nDuplicate global keyframe names: ${duplicateKeyframes.length}`); +duplicateKeyframes.slice(0, 40).forEach(([name, definitions]) => { + console.log(` ${name}: ${definitions.length}`); + definitions.forEach(definition => console.log(` ${definition.file}:${definition.line}`)); +}); +if (duplicateKeyframes.length > 40) { + console.log(` ... ${duplicateKeyframes.length - 40} more`); +} + +console.log('\nThis command is an inventory, not a pass/fail gate. Review intent before changing layout transitions or virtualized content.'); diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 0a8d2f133e..067a4578db 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -20,10 +20,23 @@ import { } from './core-boundaries/cargo-dependency-boundaries.mjs'; import { checkCliIntegrationTestTopology, + checkExternalSourceIntegrationTestTopologies, + checkServicesCoreIntegrationTestTopology, + checkServicesIntegrationsIntegrationTestTopology, + claudeCodeAdapterIntegrationTestTargets, cliIntegrationTestTargets, + codexAdapterIntegrationTestTargets, + externalSourcesIntegrationTestTargets, + opencodeAdapterIntegrationTestTargets, validateExplicitIntegrationTestTopology, } from './core-boundaries/explicit-test-topology.mjs'; import { crateLayoutRules } from './core-boundaries/rules/crate-layout.mjs'; +import { + capabilityContractDependencyRules, + coreClosedFeatureProfileRules, + coreProductFullFeatureAssemblyRule, + optionalDependencyFeatureOwnerRules, +} from './core-boundaries/rules/feature-rules.mjs'; const ENTRYPOINT = new URL('./check-core-boundaries.mjs', import.meta.url); const MODULES = [ @@ -44,6 +57,269 @@ const MODULES = [ const TEST_ROOT = join('C:', 'repo'); +test('Core and ACP defaults preserve their explicit assembly contracts', async () => { + const [coreManifest, acpManifest] = await Promise.all([ + readFile(new URL('../src/crates/assembly/core/Cargo.toml', import.meta.url), 'utf8'), + readFile(new URL('../src/crates/interfaces/acp/Cargo.toml', import.meta.url), 'utf8'), + ]); + + assert.deepEqual(parseManifestFeatures(coreManifest).default, []); + assert.deepEqual( + new Set(parseManifestFeatures(acpManifest).default), + new Set(['client', 'server']), + ); +}); + +test('consumers do not repeat guarded empty internal defaults', async () => { + const cargoBoundaries = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + assert.equal( + typeof cargoBoundaries.findRedundantInternalDefaultFeatureDisables, + 'function', + ); + + const emptyOwner = { + ...packageAt('empty-owner', 'src/crates/contracts/empty-owner/Cargo.toml'), + features: { default: [] }, + }; + const compatibilityOwner = { + ...packageAt('compatibility-owner', 'src/crates/interfaces/compatibility-owner/Cargo.toml'), + features: { default: ['client', 'server'] }, + }; + const unguardedEmptyOwner = { + ...packageAt('unguarded-empty-owner', 'src/crates/contracts/unguarded-empty-owner/Cargo.toml'), + features: { default: [] }, + }; + const consumer = packageAt('consumer', 'src/apps/consumer/Cargo.toml', [ + pathDependency('src/crates/contracts/empty-owner', { + name: 'empty-owner', + usesDefaultFeatures: false, + }), + pathDependency('src/crates/interfaces/compatibility-owner', { + name: 'compatibility-owner', + usesDefaultFeatures: false, + }), + pathDependency('src/crates/contracts/unguarded-empty-owner', { + name: 'unguarded-empty-owner', + usesDefaultFeatures: false, + }), + ]); + + const violations = cargoBoundaries.findRedundantInternalDefaultFeatureDisables( + [emptyOwner, compatibilityOwner, unguardedEmptyOwner, consumer], + { + root: TEST_ROOT, + guardedManifests: ['src/crates/contracts/empty-owner/Cargo.toml'], + }, + ); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /empty-owner.*redundant/); +}); + +test('guarded internal defaults stay explicitly empty', async () => { + const cargoBoundaries = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const manifestPath = 'src/crates/contracts/empty-owner/Cargo.toml'; + const owner = { + ...packageAt('empty-owner', manifestPath), + features: { default: [] }, + }; + assert.deepEqual( + cargoBoundaries.findGuardedInternalDefaultFeatureViolations( + [owner], + { root: TEST_ROOT, guardedManifests: [manifestPath] }, + ), + [], + ); + + owner.features.default = ['expanded']; + const violations = cargoBoundaries.findGuardedInternalDefaultFeatureViolations( + [owner], + { root: TEST_ROOT, guardedManifests: [manifestPath] }, + ); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /guarded default feature must stay explicitly empty/); +}); + +test('portable contract crates expose only capability-local feature slices', async () => { + const [runtimePortsManifest, agentToolsManifest] = await Promise.all([ + readFile(new URL('../src/crates/contracts/runtime-ports/Cargo.toml', import.meta.url), 'utf8'), + readFile(new URL('../src/crates/execution/tool-contracts/Cargo.toml', import.meta.url), 'utf8'), + ]); + + const runtimePortFeatures = parseManifestFeatures(runtimePortsManifest); + assert.deepEqual(runtimePortFeatures.default, []); + assert.deepEqual( + new Set(Object.keys(runtimePortFeatures)), + new Set([ + 'default', + 'agent-api', + 'git-port', + 'permission', + 'plugin-runtime', + 'remote-exec-port', + 'remote-workspace-ports', + 'runtime-event-port', + 'script-tool-runtime', + 'terminal-port', + 'tool-runtime-handles', + 'ts', + 'workspace-ports', + ]), + ); + assert.deepEqual(runtimePortFeatures['agent-api'], ['dep:bitfun-core-types']); + assert.deepEqual(runtimePortFeatures['plugin-runtime'], []); + assert.deepEqual(runtimePortFeatures['script-tool-runtime'], []); + assert.deepEqual(new Set(runtimePortFeatures['workspace-ports']), new Set(['dep:anyhow', 'dep:tokio-util'])); + assert.deepEqual(runtimePortFeatures['terminal-port'], ['dep:tokio']); + assert.deepEqual(runtimePortFeatures['remote-exec-port'], ['dep:tokio']); + assert.deepEqual( + new Set(runtimePortFeatures['tool-runtime-handles']), + new Set([ + 'workspace-ports', + 'terminal-port', + 'remote-exec-port', + ]), + ); + + const agentToolFeatures = parseManifestFeatures(agentToolsManifest); + assert.deepEqual(agentToolFeatures.default, []); + assert.deepEqual(agentToolFeatures['acp-bridge'], []); + assert.deepEqual(agentToolFeatures['computer-use-contract'], []); + assert.deepEqual(agentToolFeatures['element-token'], []); + assert.deepEqual(agentToolFeatures['mcp-bridge'], []); +}); + +test('runtime-port capability source gates protect modules and public exports', async () => { + const { requiredContentRules } = await import( + './core-boundaries/rules/source/required-rules.mjs' + ); + const sourceRule = requiredContentRules.find( + (rule) => rule.path === 'src/crates/contracts/runtime-ports/src/lib.rs' + && rule.reason.includes('capability features'), + ); + const patterns = sourceRule?.patterns.map(({ regex }) => regex.source).join('\n') ?? ''; + + for (const [feature, moduleName] of [ + ['workspace-ports', 'workspace_ports'], + ['terminal-port', 'terminal_port'], + ['remote-exec-port', 'remote_exec_port'], + ['remote-workspace-ports', 'remote_workspace_ports'], + ['runtime-event-port', 'runtime_event_port'], + ['git-port', 'git_port'], + ['tool-runtime-handles', 'tool_runtime_handles'], + ]) { + assert.match(patterns, new RegExp(`${feature}.*mod ${moduleName}`)); + assert.match(patterns, new RegExp(`${feature}.*pub use ${moduleName}`)); + } +}); + +test('runtime-ports async dependencies stay behind their exact port owners', () => { + const ownerRule = optionalDependencyFeatureOwnerRules.find( + (rule) => rule.crateName === 'runtime-ports', + ); + const ownersByDependency = new Map( + ownerRule.dependencies.map((dependency) => [ + dependency.depName, + new Set(dependency.ownerFeatures), + ]), + ); + + assert.deepEqual( + ownersByDependency.get('anyhow'), + new Set(['workspace-ports']), + ); + assert.deepEqual( + ownersByDependency.get('tokio-util'), + new Set(['workspace-ports']), + ); + assert.deepEqual( + ownersByDependency.get('tokio'), + new Set(['remote-exec-port', 'terminal-port']), + ); +}); + +test('Core feature-free dependencies stay attached to their exact runtime owners', () => { + const coreOwnerRule = optionalDependencyFeatureOwnerRules.find( + (rule) => rule.crateName === 'core', + ); + const ownersByDependency = new Map( + coreOwnerRule.dependencies.map((dependency) => [ + dependency.depName, + new Set(dependency.ownerFeatures), + ]), + ); + + assert.deepEqual(ownersByDependency.get('base64'), new Set(['agent-runtime', 'dispatch-store'])); + assert.deepEqual(ownersByDependency.get('futures'), new Set(['agent-runtime'])); + assert.deepEqual(ownersByDependency.get('regex'), new Set(['agent-runtime'])); + assert.deepEqual( + ownersByDependency.get('bitfun-agent-tools'), + new Set(['agent-runtime', 'local-storage', 'mcp-runtime']), + ); + assert.deepEqual(ownersByDependency.get('fluent-bundle'), new Set(['i18n-runtime'])); + assert.deepEqual(ownersByDependency.get('unic-langid'), new Set(['i18n-runtime'])); + assert.deepEqual( + ownersByDependency.get('tokio-util'), + new Set(['agent-runtime', 'debug-log']), + ); +}); + +test('Services Core feature-free dependencies stay behind exact text and async IO owners', () => { + const ownerRule = optionalDependencyFeatureOwnerRules.find( + (rule) => rule.crateName === 'services-core', + ); + const ownersByDependency = new Map( + ownerRule.dependencies.map((dependency) => [ + dependency.depName, + new Set(dependency.ownerFeatures), + ]), + ); + + assert.deepEqual( + ownersByDependency.get('regex'), + new Set(['diagnostics', 'filesystem', 'local-storage', 'markdown', 'workspace-instructions']), + ); + assert.deepEqual(ownersByDependency.get('similar'), new Set(['diff', 'local-storage'])); + assert.deepEqual( + ownersByDependency.get('tokio'), + new Set([ + 'diff', + 'filesystem', + 'json-io', + 'local-storage', + 'lsp', + 'permission', + 'process-runtime', + 'workspace-instructions', + 'workspace-runtime', + 'workspace-text-runtime', + ]), + ); +}); + +test('Services Core text runtime features keep independent exact owner profiles', () => { + const profiles = new Map( + coreClosedFeatureProfileRules + .filter((rule) => rule.manifestPath === 'src/crates/services/services-core/Cargo.toml') + .map((rule) => [rule.featureName, rule.requiredFeatureRefs]), + ); + + assert.deepEqual(profiles.get('diagnostics'), ['dep:regex']); + assert.deepEqual(profiles.get('diff'), [ + 'dep:similar', + 'dep:tokio', + 'tokio/rt', + 'tokio/time', + ]); + assert.deepEqual(profiles.get('workspace-text-runtime'), [ + 'dep:tokio', + 'tokio/rt', + ]); +}); + function parseManifestFeatures(manifest) { const section = manifest.match(/^\[features\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m)?.[1] ?? ''; const features = {}; @@ -91,6 +367,61 @@ function pathDependency(repoCratePath, options = {}) { }; } +const RUNTIME_PORT_FEATURE_PROFILES = { + default: [], + 'agent-api': ['dep:bitfun-core-types'], + 'git-port': [], + permission: ['dep:bitfun-product-domains'], + 'plugin-runtime': [], + 'remote-exec-port': ['dep:tokio'], + 'remote-workspace-ports': [], + 'runtime-event-port': [], + 'script-tool-runtime': [], + 'terminal-port': ['dep:tokio'], + 'tool-runtime-handles': ['workspace-ports', 'terminal-port', 'remote-exec-port'], + ts: [ + 'dep:ts-rs', + 'agent-api', + 'permission', + 'bitfun-core-types/ts', + 'bitfun-product-domains?/ts', + ], + 'workspace-ports': ['dep:anyhow', 'dep:tokio-util'], +}; + +const AGENT_TOOL_FEATURE_PROFILES = { + default: [], + 'acp-bridge': [], + 'computer-use-contract': [], + 'element-token': [], + 'mcp-bridge': [], +}; + +function capabilityPackage(name, repoManifestPath, featureProfiles) { + return { + ...packageAt(name, repoManifestPath), + features: structuredClone(featureProfiles), + }; +} + +function agentToolsCapabilityPackage() { + return { + ...capabilityPackage( + 'bitfun-agent-tools', + 'src/crates/execution/tool-contracts/Cargo.toml', + AGENT_TOOL_FEATURE_PROFILES, + ), + dependencies: [pathDependency('src/crates/contracts/runtime-ports', { + name: 'bitfun-runtime-ports', + usesDefaultFeatures: false, + })], + }; +} + +function findTestCapabilityViolations(finder, packages, rules) { + return finder(packages, rules, { root: TEST_ROOT }); +} + function integrationTarget(name, sourcePath, requiredFeatures = []) { return { kind: ['test'], @@ -242,6 +573,223 @@ test('CLI integration tests keep the reviewed three-target topology', () => { assert.deepEqual(checkCliIntegrationTestTopology(repositoryRoot), []); }); +test('service integration tests keep their reviewed explicit target topology', () => { + const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); + + assert.deepEqual(checkServicesCoreIntegrationTestTopology(repositoryRoot), []); + assert.deepEqual(checkServicesIntegrationsIntegrationTestTopology(repositoryRoot), []); +}); + +test('contract and AI adapter tests keep reviewed feature and failure-domain topology', async () => { + const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); + const topology = await import('./core-boundaries/explicit-test-topology.mjs'); + + assert.deepEqual(topology.coreTypesIntegrationTestTargets, [ + { + name: 'core_type_contracts', + path: 'tests/core_type_contracts.rs', + leaves: [ + 'tests/core_type_contracts/lsp_contracts.rs', + 'tests/core_type_contracts/session_contracts.rs', + 'tests/core_type_contracts/session_usage_contracts.rs', + 'tests/core_type_contracts/surface_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.runtimePortsIntegrationTestTargets, [ + { + name: 'plugin_runtime_contracts', + path: 'tests/runtime_port_contracts.rs', + leaves: [ + 'tests/runtime_port_contracts/plugin_runtime_contracts.rs', + 'tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs', + ], + requiredFeatures: ['plugin-runtime'], + }, + { + name: 'git_port_contracts', + path: 'tests/git_port_contracts.rs', + requiredFeatures: ['git-port'], + }, + { + name: 'script_tool_port_contracts', + path: 'tests/script_tool_port_contracts.rs', + requiredFeatures: ['script-tool-runtime'], + }, + { + name: 'session_store_contracts', + path: 'tests/session_store_contracts.rs', + requiredFeatures: ['workspace-ports'], + }, + ]); + assert.deepEqual(topology.productDomainsIntegrationTestTargets, [ + { + name: 'product_domain_contracts', + path: 'tests/product_domain_contracts.rs', + leaves: [ + 'tests/product_domain_contracts/canvas_contracts.rs', + 'tests/product_domain_contracts/tool_permission_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'external_source_contracts', + path: 'tests/external_source_contracts.rs', + leaves: [ + 'tests/external_source_contracts/external_hook_catalog_contracts.rs', + 'tests/external_source_contracts/external_hook_contribution_contracts.rs', + 'tests/external_source_contracts/external_source_contracts.rs', + 'tests/external_source_contracts/workspace_reference_contracts.rs', + ], + requiredFeatures: ['external-sources'], + }, + { + name: 'function_agent_contracts', + path: 'tests/function_agent_contracts.rs', + requiredFeatures: ['function-agents'], + }, + { + name: 'miniapp_contracts', + path: 'tests/miniapp_contracts.rs', + requiredFeatures: ['miniapp'], + }, + { + name: 'plugin_source_contracts', + path: 'tests/plugin_source_contracts.rs', + requiredFeatures: ['plugin-source'], + }, + ]); + assert.deepEqual(topology.aiAdaptersIntegrationTestTargets, [ + { + name: 'ai_protocol_contracts', + path: 'tests/ai_protocol_contracts.rs', + leaves: [ + 'tests/ai_protocol_contracts/model_selector.rs', + 'tests/ai_protocol_contracts/openai_empty_content_parts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'ai_stream_contracts', + path: 'tests/ai_stream_contracts.rs', + leaves: [ + 'tests/ai_stream_contracts/common.rs', + 'tests/ai_stream_contracts/stream_processor_anthropic.rs', + 'tests/ai_stream_contracts/stream_processor_openai.rs', + 'tests/ai_stream_contracts/stream_processor_tool_arguments.rs', + 'tests/ai_stream_contracts/stream_replay_regressions.rs', + 'tests/ai_stream_contracts/stream_test_harness.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.productCapabilitiesIntegrationTestTargets, [ + { + name: 'product_capability_contracts', + path: 'tests/product_capability_contracts.rs', + leaves: [ + 'tests/product_capability_contracts/plugin_product_shape.rs', + 'tests/product_capability_contracts/product_capabilities.rs', + 'tests/product_capability_contracts/product_sdk_assembly.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.checkBuildGraphContractIntegrationTestTopologies(repositoryRoot), []); + + const widenedOwnerErrors = validateExplicitIntegrationTestTopology({ + manifestText: [ + '[package]', + 'autotests = false', + '[[test]]', + 'name = "external_source_contracts"', + 'path = "tests/external_source_contracts.rs"', + 'required-features = ["product-full"]', + ].join('\n'), + expectedTargets: [{ + name: 'external_source_contracts', + path: 'tests/external_source_contracts.rs', + requiredFeatures: ['external-sources'], + }], + topLevelRustFiles: ['tests/external_source_contracts.rs'], + rootSources: new Map([[ + 'tests/external_source_contracts.rs', + '#![cfg(feature = "product-full")]\n', + ]]), + leafRustFiles: [], + leafSources: new Map(), + }); + assert.match(widenedOwnerErrors.join('\n'), /required-features.*external-sources/); +}); + +test('external source integration tests keep reviewed owner and process boundaries', () => { + const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); + + assert.deepEqual(opencodeAdapterIntegrationTestTargets, [ + { name: 'opencode_mcp_adapter', path: 'tests/opencode_mcp_adapter.rs' }, + { name: 'opencode_source_adapter', path: 'tests/opencode_source_adapter.rs' }, + { + name: 'opencode_static_source_contracts', + path: 'tests/opencode_static_source_contracts.rs', + leaves: [ + 'tests/opencode_static_source_contracts/hook_source.rs', + 'tests/opencode_static_source_contracts/opencode_command_adapter.rs', + 'tests/opencode_static_source_contracts/opencode_skill_roots.rs', + 'tests/opencode_static_source_contracts/opencode_subagent_adapter.rs', + 'tests/opencode_static_source_contracts/opencode_workspace_references.rs', + ], + forbidRequiredFeatures: true, + }, + { name: 'tool_source_contracts', path: 'tests/tool_source_contracts.rs' }, + ]); + assert.deepEqual(claudeCodeAdapterIntegrationTestTargets, [ + { + name: 'claude_code_source_contracts', + path: 'tests/claude_code_source_contracts.rs', + leaves: [ + 'tests/claude_code_source_contracts/command_source.rs', + 'tests/claude_code_source_contracts/hook_source.rs', + 'tests/claude_code_source_contracts/mcp_source.rs', + 'tests/claude_code_source_contracts/subagent_source.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(codexAdapterIntegrationTestTargets, [ + { + name: 'codex_source_contracts', + path: 'tests/codex_source_contracts.rs', + leaves: [ + 'tests/codex_source_contracts/hook_source.rs', + 'tests/codex_source_contracts/mcp_source.rs', + 'tests/codex_source_contracts/subagent_source.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(externalSourcesIntegrationTestTargets, [ + { + name: 'external_source_coordination_contracts', + path: 'tests/external_source_coordination_contracts.rs', + leaves: [ + 'tests/external_source_coordination_contracts/control_plane.rs', + 'tests/external_source_coordination_contracts/coordinator_contracts.rs', + 'tests/external_source_coordination_contracts/hook_coordinator.rs', + 'tests/external_source_coordination_contracts/mcp_coordinator.rs', + 'tests/external_source_coordination_contracts/subagent_coordinator.rs', + 'tests/external_source_coordination_contracts/tool_coordinator_contracts.rs', + 'tests/external_source_coordination_contracts/workspace_reference.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual( + checkExternalSourceIntegrationTestTopologies(repositoryRoot), + [], + ); +}); + test('runtime-services test support is absent from ordinary library builds', async () => { const [manifest, library] = await Promise.all([ readFile( @@ -254,176 +802,782 @@ test('runtime-services test support is absent from ordinary library builds', asy ), ]); - assert.match(manifest, /^test-support\s*=\s*\[\]\s*$/m); - assert.doesNotMatch(manifest, /^required-features\s*=.*test-support.*$/m); + assert.match(manifest, /^test-support\s*=\s*\[\]\s*$/m); + assert.doesNotMatch(manifest, /^required-features\s*=.*test-support.*$/m); + assert.match( + library, + /#\[cfg\(any\(test, feature = "test-support"\)\)\]\s*pub mod test_support;/, + ); + assert.match(library, /#\[cfg\(test\)\]\s*mod runtime_services_contracts;/); + assert.equal((library.match(/^pub mod test_support;\s*$/gm) ?? []).length, 1); +}); + +test('feature-gated integration targets reject extra umbrella requirements', () => { + const sourcePath = join(TEST_ROOT, 'tests', 'focused.rs'); + const pkg = { + ...packageAt('example', 'src/crates/services/example/Cargo.toml'), + targets: [integrationTarget('focused', sourcePath, ['focused', 'product-full'])], + }; + + const violations = findFeatureGatedTestTargetViolations([pkg], { + readSource: () => '#![cfg(feature = "focused")]\n', + }); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /unexpected required-features: product-full/); +}); + +test('target guard ignores module cfg and non-integration targets', () => { + const moduleSourcePath = join(TEST_ROOT, 'tests', 'module.rs'); + const binarySourcePath = join(TEST_ROOT, 'src', 'main.rs'); + const pkg = { + ...packageAt('example', 'src/crates/services/example/Cargo.toml'), + targets: [ + integrationTarget('module', moduleSourcePath), + { + ...integrationTarget('binary', binarySourcePath), + kind: ['bin'], + }, + ], + }; + const sources = new Map([ + [moduleSourcePath, '#[cfg(feature = "serde")]\nmod serde_tests {}\n'], + [binarySourcePath, '#![cfg(feature = "cli")]\nfn main() {}\n'], + ]); + + assert.deepEqual( + findFeatureGatedTestTargetViolations([pkg], { + readSource: (path) => sources.get(path), + }), + [], + ); +}); + +test('target guard ignores crate cfg examples in comments and strings', () => { + const sourcePath = join(TEST_ROOT, 'tests', 'documented.rs'); + const pkg = { + ...packageAt('example', 'src/crates/services/example/Cargo.toml'), + targets: [integrationTarget('documented', sourcePath)], + }; + + assert.deepEqual( + findFeatureGatedTestTargetViolations([pkg], { + readSource: () => [ + '// Example: #![cfg(feature = "commented")]', + 'const EXAMPLE: &str = r#"', + '#![cfg(feature = "string-literal")]', + '"#;', + ].join('\n'), + }), + [], + ); +}); + +test('target guard rejects feature OR gates that Cargo cannot express', () => { + const sourcePath = join(TEST_ROOT, 'tests', 'provider.rs'); + const pkg = { + ...packageAt('example', 'src/crates/services/example/Cargo.toml'), + targets: [integrationTarget('provider', sourcePath, ['provider-a'])], + }; + + const violations = findFeatureGatedTestTargetViolations([pkg], { + readSource: () => '#![cfg(any(feature = "provider-a", feature = "provider-b"))]\n', + }); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /cannot express.*split the target/); +}); + +test('multiple crate feature gates combine as required feature AND conditions', () => { + const sourcePath = join(TEST_ROOT, 'tests', 'combined.rs'); + const pkg = { + ...packageAt('example', 'src/crates/services/example/Cargo.toml'), + targets: [integrationTarget('combined', sourcePath, ['first'])], + }; + + const violations = findFeatureGatedTestTargetViolations([pkg], { + readSource: () => '#![cfg(feature = "first")]\n#![cfg(feature = "second")]\n', + }); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /second/); +}); + +test('product entrypoints may inherit the guarded empty bitfun-core default', () => { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const app = packageAt('entry', 'src/apps/example/Cargo.toml', [ + pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + features: ['plugin-source'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [app, core], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.deepEqual(violations, []); +}); + +test('Core Agent Runtime baseline excludes concrete capability unions', () => { + const agentRuntime = coreClosedFeatureProfileRules.find( + (rule) => rule.featureName === 'agent-runtime', + ); + assert.ok(agentRuntime, 'agent-runtime closed profile must exist'); + + for (const forbidden of [ + 'bitfun-services-integrations/browser-control', + 'bitfun-services-integrations/deep-research', + 'bitfun-services-integrations/mcp', + 'bitfun-services-integrations/models-dev', + 'bitfun-services-integrations/remote-connect', + 'bitfun-services-integrations/script-tool-runtime', + 'bitfun-services-integrations/web-tools', + 'bitfun-services-integrations/workspace-search', + 'dep:cron', + 'dep:semver', + 'dep:tokio-tungstenite', + 'git', + 'review-platform', + ]) { + assert.ok( + !agentRuntime.requiredFeatureRefs.includes(forbidden), + `agent-runtime must not own ${forbidden}`, + ); + } +}); + +test('Core optional document and subscription capabilities have independent modifiers', () => { + const ruleByFeature = new Map( + coreClosedFeatureProfileRules.map((rule) => [rule.featureName, rule]), + ); + assert.deepEqual(ruleByFeature.get('document-read')?.requiredFeatureRefs, [ + 'tool-runtime?/document-read', + ]); + assert.deepEqual(ruleByFeature.get('subscription-auth')?.requiredFeatureRefs, [ + 'bitfun-ai-adapters?/subscription-auth', + ]); + assert.deepEqual(ruleByFeature.get('ai-adapter-runtime')?.requiredFeatureRefs, [ + 'dep:bitfun-ai-adapters', + ]); + assert.ok( + !ruleByFeature.get('tools-basic')?.requiredFeatureRefs.includes('tool-runtime/document-read'), + 'baseline tools must not activate document conversion', + ); +}); + +test('Core product-full explicitly assembles service and tool capability owners', () => { + for (const required of [ + 'document-read', + 'subscription-auth', + 'i18n-runtime', + 'model-catalog', + 'mcp-runtime', + 'remote-connect', + 'workspace-search', + 'browser-control', + 'web-tools', + 'deep-research', + 'scheduled-jobs', + 'tools-basic', + 'tools-git', + 'tools-mcp', + 'tools-browser-web', + 'tools-computer-use', + 'tools-image-analysis', + 'tools-miniapp', + 'tools-canvas', + 'tools-agent-control', + ]) { + assert.ok( + coreProductFullFeatureAssemblyRule.requiredFeatureRefs.includes(required), + `product-full must explicitly assemble ${required}`, + ); + } +}); + +test('product entrypoints must select explicit bitfun-core features', () => { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const interfacePackage = packageAt( + 'interface', + 'src/crates/interfaces/acp/Cargo.toml', + [pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + })], + ); + + const violations = findProductEntrypointCoreFeatureViolations( + [interfacePackage, core], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /at least one explicit feature/); +}); + +test('explicit product entrypoint bitfun-core feature selections pass', () => { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const consumers = [ + packageAt('app', 'src/apps/example/Cargo.toml'), + packageAt('interface', 'src/crates/interfaces/acp/Cargo.toml'), + packageAt('installer', 'BitFun-Installer/src-tauri/Cargo.toml'), + ].map((pkg) => ({ + ...pkg, + dependencies: [pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: ['plugin-source'], + })], + })); + + assert.deepEqual( + findProductEntrypointCoreFeatureViolations( + [...consumers, core, packageAt('no-core', 'src/apps/no-core/Cargo.toml')], + { root: TEST_ROOT, crateLayoutRules }, + ), + [], + ); +}); + +test('Desktop and Server must retain the full product Core capability closure', () => { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + + for (const [name, manifestPath] of [ + ['bitfun-desktop', 'src/apps/desktop/Cargo.toml'], + ['bitfun-server', 'src/apps/server/Cargo.toml'], + ]) { + const product = packageAt(name, manifestPath, [ + pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: ['i18n-runtime'], + }), + ]); + const messages = findProductEntrypointCoreFeatureViolations( + [product, core], + { root: TEST_ROOT, crateLayoutRules }, + ).map((violation) => violation.message); + + assert.deepEqual(messages, [ + `${name} Core capability closure must select exactly product-full`, + ]); + } +}); + +test('Desktop and Server must retain their Core product dependency', () => { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + for (const [name, manifestPath] of [ + ['bitfun-desktop', 'src/apps/desktop/Cargo.toml'], + ['bitfun-server', 'src/apps/server/Cargo.toml'], + ]) { + const product = packageAt(name, manifestPath); + assert.deepEqual( + findProductEntrypointCoreFeatureViolations( + [product, core], + { root: TEST_ROOT, crateLayoutRules }, + ).map((violation) => violation.message), + [`${name} Core capability closure must keep the bitfun-core dependency`], + ); + } +}); + +test('Desktop must select only the ACP client role', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { + default: ['client', 'server'], + client: [], + server: [], + }, + }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: false, + features: ['client', 'server'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /Desktop ACP role selection must not include server/); +}); + +test('ACP consumers must disable compatibility default roles', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, + }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: true, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /must set default-features = false on every dependency/); +}); + +test('CLI must select both ACP roles explicitly', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { + default: ['client', 'server'], + client: [], + server: [], + }, + }; + const cli = packageAt('bitfun-cli', 'src/apps/cli/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: false, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [cli, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /CLI ACP role selection must include server/); +}); + +test('new product entrypoints must register an explicit ACP role selection', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { + default: ['client', 'server'], + client: [], + server: [], + }, + }; + const newHost = packageAt('bitfun-new-host', 'src/apps/new-host/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: false, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [newHost, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /must register an explicit role selection/); +}); + +test('ACP roles must be selected by an unconditional normal dependency', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { + default: ['client', 'server'], + client: [], + server: [], + }, + }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + kind: 'dev', + usesDefaultFeatures: false, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); assert.match( - library, - /#\[cfg\(any\(test, feature = "test-support"\)\)\]\s*pub mod test_support;/, + violations[0].message, + /Desktop ACP role selection must keep an unconditional normal bitfun-acp dependency/, ); - assert.match(library, /#\[cfg\(test\)\]\s*mod runtime_services_contracts;/); - assert.equal((library.match(/^pub mod test_support;\s*$/gm) ?? []).length, 1); }); -test('feature-gated integration targets reject extra umbrella requirements', () => { - const sourcePath = join(TEST_ROOT, 'tests', 'focused.rs'); - const pkg = { - ...packageAt('example', 'src/crates/services/example/Cargo.toml'), - targets: [integrationTarget('focused', sourcePath, ['focused', 'product-full'])], +test('reviewed ACP roles require an unconditional normal dependency', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + target: 'cfg(windows)', + usesDefaultFeatures: false, + features: ['client'], + }), + ]); - const violations = findFeatureGatedTestTargetViolations([pkg], { - readSource: () => '#![cfg(feature = "focused")]\n', - }); + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); assert.equal(violations.length, 1); - assert.match(violations[0].message, /unexpected required-features: product-full/); + assert.match(violations[0].message, /must keep an unconditional normal bitfun-acp dependency/); }); -test('target guard ignores module cfg and non-integration targets', () => { - const moduleSourcePath = join(TEST_ROOT, 'tests', 'module.rs'); - const binarySourcePath = join(TEST_ROOT, 'src', 'main.rs'); - const pkg = { - ...packageAt('example', 'src/crates/services/example/Cargo.toml'), - targets: [ - integrationTarget('module', moduleSourcePath), - { - ...integrationTarget('binary', binarySourcePath), - kind: ['bin'], - }, - ], +test('target-specific ACP edges cannot expand a reviewed product role', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, }; - const sources = new Map([ - [moduleSourcePath, '#[cfg(feature = "serde")]\nmod serde_tests {}\n'], - [binarySourcePath, '#![cfg(feature = "cli")]\nfn main() {}\n'], + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: false, + features: ['client'], + }), + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + target: 'cfg(windows)', + usesDefaultFeatures: false, + features: ['server'], + }), ]); - assert.deepEqual( - findFeatureGatedTestTargetViolations([pkg], { - readSource: (path) => sources.get(path), - }), - [], + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /Desktop ACP role selection must not include server/); }); -test('target guard ignores crate cfg examples in comments and strings', () => { - const sourcePath = join(TEST_ROOT, 'tests', 'documented.rs'); - const pkg = { - ...packageAt('example', 'src/crates/services/example/Cargo.toml'), - targets: [integrationTarget('documented', sourcePath)], +test('dev and build ACP edges cannot expand a reviewed product role', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, }; - assert.deepEqual( - findFeatureGatedTestTargetViolations([pkg], { - readSource: () => [ - '// Example: #![cfg(feature = "commented")]', - 'const EXAMPLE: &str = r#"', - '#![cfg(feature = "string-literal")]', - '"#;', - ].join('\n'), + for (const kind of ['dev', 'build']) { + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: false, + features: ['client'], + }), + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + kind, + usesDefaultFeatures: false, + features: ['server'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1, `${kind} dependency must not widen Desktop ACP roles`); + assert.match(violations[0].message, /Desktop ACP role selection must not include server/); + } +}); + +test('reviewed ACP product dependencies must not become optional', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, + }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + optional: true, + usesDefaultFeatures: false, + features: ['client'], }), - [], + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, ); + + assert.equal(violations.length, 2); + assert.match(violations[0].message, /must keep an unconditional normal bitfun-acp dependency/); + assert.match(violations[1].message, /must not make a bitfun-acp dependency optional/); }); -test('target guard rejects feature OR gates that Cargo cannot express', () => { - const sourcePath = join(TEST_ROOT, 'tests', 'provider.rs'); - const pkg = { - ...packageAt('example', 'src/crates/services/example/Cargo.toml'), - targets: [integrationTarget('provider', sourcePath, ['provider-a'])], +test('target, dev, and build ACP consumers must still register their role selection', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, }; + for (const dependency of [ + { target: 'cfg(windows)' }, + { kind: 'dev' }, + { kind: 'build' }, + ]) { + const newHost = packageAt('bitfun-new-host', 'src/apps/new-host/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + ...dependency, + usesDefaultFeatures: false, + features: ['client'], + }), + ]); - const violations = findFeatureGatedTestTargetViolations([pkg], { - readSource: () => '#![cfg(any(feature = "provider-a", feature = "provider-b"))]\n', - }); + const violations = findProductEntrypointCoreFeatureViolations( + [newHost, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); - assert.equal(violations.length, 1); - assert.match(violations[0].message, /cannot express.*split the target/); + assert.equal(violations.length, 1); + assert.match(violations[0].message, /must register an explicit role selection/); + } }); -test('multiple crate feature gates combine as required feature AND conditions', () => { - const sourcePath = join(TEST_ROOT, 'tests', 'combined.rs'); - const pkg = { - ...packageAt('example', 'src/crates/services/example/Cargo.toml'), - targets: [integrationTarget('combined', sourcePath, ['first'])], +const SDK_HOST_REVIEWED_CORE_FEATURES = [ + 'agent-runtime', + 'document-read', + 'subscription-auth', + 'deep-research', + 'lsp', + 'external-sources', + 'tools-basic', + 'tools-git', + 'tools-mcp', + 'tools-browser-web', + 'tools-computer-use', + 'tools-image-analysis', + 'tools-miniapp', + 'tools-canvas', + 'tools-agent-control', +]; + +const ACP_REVIEWED_CORE_FEATURES = [ + ...SDK_HOST_REVIEWED_CORE_FEATURES, + 'ssh-remote', +]; + +const CLI_REVIEWED_CORE_FEATURES = [ + ...ACP_REVIEWED_CORE_FEATURES, + 'remote-connect', + 'plugin-runtime', +]; + +const APP_SERVER_REVIEWED_CORE_FEATURES = [ + 'external-sources', + 'git', + 'i18n-runtime', + 'remote-connect', +]; + +test('SDK Host Core capability closure keeps every reviewed owner', () => { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const sdkHost = packageAt( + 'bitfun-sdk-host-app', + 'src/apps/sdk-host/Cargo.toml', + [pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: SDK_HOST_REVIEWED_CORE_FEATURES.filter( + (feature) => feature !== 'external-sources', + ), + })], + ); + + const violations = findProductEntrypointCoreFeatureViolations( + [sdkHost, core], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.deepEqual(violations.map((violation) => violation.message), [ + 'bitfun-sdk-host-app Core capability closure must include external-sources', + ]); +}); + +test('SDK Host closure rejects unreviewed capability owners below Core', () => { + const cases = [ + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'remote-connect'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'remote-ssh'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'remote-ssh-concrete'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'function-agents'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'announcement'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'debug-log'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'product-full'], + ['bitfun-product-domains', 'src/crates/contracts/product-domains/Cargo.toml', 'function-agents'], + ['bitfun-product-domains', 'src/crates/contracts/product-domains/Cargo.toml', 'product-full'], + ['bitfun-services-core', 'src/crates/services/services-core/Cargo.toml', 'dispatch-workspace'], + ]; + + for (const [ownerName, ownerManifest, forbiddenFeature] of cases) { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const owner = { + ...packageAt(ownerName, ownerManifest), + features: { [forbiddenFeature]: [] }, + }; + const bridge = packageAt('bridge', 'src/crates/assembly/bridge/Cargo.toml', [ + pathDependency(ownerManifest.replace('/Cargo.toml', ''), { + name: ownerName, + usesDefaultFeatures: false, + features: [forbiddenFeature], + }), + ]); + const sdkHost = packageAt( + 'bitfun-sdk-host-app', + 'src/apps/sdk-host/Cargo.toml', + [ + pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: SDK_HOST_REVIEWED_CORE_FEATURES, + }), + pathDependency('src/crates/assembly/bridge', { name: 'bridge' }), + ], + ); + + const violations = findProductEntrypointCoreFeatureViolations( + [sdkHost, bridge, core, owner], + { root: TEST_ROOT, crateLayoutRules }, + ); + + const forbiddenOwner = `${ownerName}/${forbiddenFeature}`; + assert.equal(violations.length, 1, forbiddenOwner); + assert.match( + violations[0].message, + new RegExp(forbiddenOwner), + ); + } +}); + +test('SDK Host closure inspects lower owners forwarded by reviewed Core features', () => { + const ownerManifest = 'src/crates/services/services-integrations/Cargo.toml'; + const core = { + ...packageAt( + 'bitfun-core', + 'src/crates/assembly/core/Cargo.toml', + [pathDependency('src/crates/services/services-integrations', { + name: 'bitfun-services-integrations', + optional: true, + usesDefaultFeatures: false, + })], + ), + features: { + 'external-sources': ['bitfun-services-integrations/remote-connect'], + }, + }; + const owner = { + ...packageAt('bitfun-services-integrations', ownerManifest), + features: { 'remote-connect': [] }, }; + const sdkHost = packageAt( + 'bitfun-sdk-host-app', + 'src/apps/sdk-host/Cargo.toml', + [pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: SDK_HOST_REVIEWED_CORE_FEATURES, + })], + ); - const violations = findFeatureGatedTestTargetViolations([pkg], { - readSource: () => '#![cfg(feature = "first")]\n#![cfg(feature = "second")]\n', - }); + const violations = findProductEntrypointCoreFeatureViolations( + [sdkHost, core, owner], + { root: TEST_ROOT, crateLayoutRules }, + ); assert.equal(violations.length, 1); - assert.match(violations[0].message, /second/); + assert.match( + violations[0].message, + /bitfun-services-integrations\/remote-connect/, + ); }); -test('product entrypoints must disable bitfun-core default features', () => { +test('App Server Core capability closure keeps its production Git owner', () => { const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); - const app = packageAt('entry', 'src/apps/example/Cargo.toml', [ - pathDependency('src/crates/assembly/core', { + const appServer = packageAt( + 'bitfun-app-server', + 'src/crates/interfaces/app-server/Cargo.toml', + [pathDependency('src/crates/assembly/core', { name: 'bitfun-core', - features: ['plugin-source'], - }), - ]); + usesDefaultFeatures: false, + features: APP_SERVER_REVIEWED_CORE_FEATURES.filter((feature) => feature !== 'git'), + })], + ); const violations = findProductEntrypointCoreFeatureViolations( - [app, core], + [appServer, core], { root: TEST_ROOT, crateLayoutRules }, ); - assert.equal(violations.length, 1); - assert.match(violations[0].message, /default-features = false/); + assert.deepEqual(violations.map((violation) => violation.message), [ + 'bitfun-app-server Core capability closure must include git', + ]); }); -test('product entrypoints must select explicit bitfun-core features', () => { +test('App Server Core capability closure keeps its backend i18n runtime', () => { const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); - const interfacePackage = packageAt( - 'interface', - 'src/crates/interfaces/acp/Cargo.toml', + const appServer = packageAt( + 'bitfun-app-server', + 'src/crates/interfaces/app-server/Cargo.toml', [pathDependency('src/crates/assembly/core', { name: 'bitfun-core', usesDefaultFeatures: false, + features: APP_SERVER_REVIEWED_CORE_FEATURES.filter( + (feature) => feature !== 'i18n-runtime', + ), })], ); const violations = findProductEntrypointCoreFeatureViolations( - [interfacePackage, core], + [appServer, core], { root: TEST_ROOT, crateLayoutRules }, ); - assert.equal(violations.length, 1); - assert.match(violations[0].message, /at least one explicit feature/); + assert.deepEqual(violations.map((violation) => violation.message), [ + 'bitfun-app-server Core capability closure must include i18n-runtime', + ]); }); -test('explicit product entrypoint bitfun-core feature selections pass', () => { +test('App Server reviewed Core capability closure remains independently valid', () => { const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); - const consumers = [ - packageAt('app', 'src/apps/example/Cargo.toml'), - packageAt('interface', 'src/crates/interfaces/acp/Cargo.toml'), - packageAt('installer', 'BitFun-Installer/src-tauri/Cargo.toml'), - ].map((pkg) => ({ - ...pkg, - dependencies: [pathDependency('src/crates/assembly/core', { + const appServer = packageAt( + 'bitfun-app-server', + 'src/crates/interfaces/app-server/Cargo.toml', + [pathDependency('src/crates/assembly/core', { name: 'bitfun-core', usesDefaultFeatures: false, - features: ['plugin-source'], + features: APP_SERVER_REVIEWED_CORE_FEATURES, })], - })); + ); assert.deepEqual( findProductEntrypointCoreFeatureViolations( - [...consumers, core, packageAt('no-core', 'src/apps/no-core/Cargo.toml')], + [appServer, core], { root: TEST_ROOT, crateLayoutRules }, ), [], ); }); -test('ACP Core capability closure must retain its Canvas owner', () => { +test('ACP Core capability closure must retain its Canvas tool owner', () => { const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); const acp = packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml', [ pathDependency('src/crates/assembly/core', { name: 'bitfun-core', usesDefaultFeatures: false, - features: ['agent-runtime', 'external-sources', 'ssh-remote'], + features: ACP_REVIEWED_CORE_FEATURES.filter((feature) => feature !== 'tools-canvas'), }), ]); @@ -433,7 +1587,7 @@ test('ACP Core capability closure must retain its Canvas owner', () => { ); assert.equal(violations.length, 1); - assert.match(violations[0].message, /must include canvas-runtime/); + assert.match(violations[0].message, /must include tools-canvas/); }); test('ACP Core capability closure validation cannot be disabled by removing an owner', () => { @@ -442,7 +1596,7 @@ test('ACP Core capability closure validation cannot be disabled by removing an o pathDependency('src/crates/assembly/core', { name: 'bitfun-core', usesDefaultFeatures: false, - features: ['ssh-remote'], + features: ACP_REVIEWED_CORE_FEATURES.filter((feature) => feature !== 'agent-runtime'), }), ]); @@ -451,14 +1605,9 @@ test('ACP Core capability closure validation cannot be disabled by removing an o { root: TEST_ROOT, crateLayoutRules }, ); - assert.deepEqual( - violations.map((violation) => violation.message).sort(), - [ - 'bitfun-acp Core capability closure must include agent-runtime', - 'bitfun-acp Core capability closure must include canvas-runtime', - 'bitfun-acp Core capability closure must include external-sources', - ], - ); + assert.deepEqual(violations.map((violation) => violation.message), [ + 'bitfun-acp Core capability closure must include agent-runtime', + ]); }); test('CLI Core capability closure requires every reviewed owner', () => { @@ -467,12 +1616,7 @@ test('CLI Core capability closure requires every reviewed owner', () => { pathDependency('src/crates/assembly/core', { name: 'bitfun-core', usesDefaultFeatures: false, - features: [ - 'agent-runtime', - 'canvas-runtime', - 'external-sources', - 'ssh-remote', - ], + features: CLI_REVIEWED_CORE_FEATURES.filter((feature) => feature !== 'plugin-runtime'), }), ]); @@ -868,8 +2012,8 @@ test('CLI dependency architecture closure unions unconditional and target-specif function reviewedCoreFeaturesFor(rootName) { return rootName === 'bitfun-cli' - ? ['agent-runtime', 'canvas-runtime', 'external-sources', 'plugin-runtime', 'ssh-remote'] - : ['agent-runtime', 'canvas-runtime', 'external-sources', 'ssh-remote']; + ? CLI_REVIEWED_CORE_FEATURES + : ACP_REVIEWED_CORE_FEATURES; } function targetedWeakForwardingGraph(rootName, forwardTarget, activateTarget, reverse = false) { @@ -1114,11 +2258,9 @@ test('ACP active closure cannot be expanded by a reviewed owner definition', () const core = { ...packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'), features: { - 'agent-runtime': [], - 'canvas-runtime': ['plugin-runtime'], - 'external-sources': [], + ...Object.fromEntries(reviewedFeatures.map((feature) => [feature, []])), + 'tools-canvas': ['plugin-runtime'], 'plugin-runtime': [], - 'ssh-remote': [], }, }; const acp = packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml', [ @@ -1240,6 +2382,21 @@ test('workspace Tokio capabilities stay crate-owned', async () => { assert.doesNotMatch(workspaceTokio, /(?:^|,\s*)features\s*=/); const packages = collectCargoMetadataPackages({ root: repositoryRoot }); assert.deepEqual(findTokioDependencyFeatureViolations(packages), []); + + const integrations = packages.find((pkg) => pkg.name === 'bitfun-services-integrations'); + const mutatedPackages = packages.map((pkg) => pkg === integrations + ? { + ...pkg, + dependencies: pkg.dependencies.map((dependency) => + dependency.name === 'tokio' && (dependency.kind ?? null) === null + ? { ...dependency, features: ['net'] } + : dependency), + } + : pkg); + assert.ok( + findTokioDependencyFeatureViolations(mutatedPackages).some((violation) => + violation.message === 'bitfun-services-integrations has unexpected base Tokio capabilities: net'), + ); }); test('services integrations Tokio owner contracts reject feature-union masking', async () => { @@ -1271,7 +2428,7 @@ test('services integrations Reqwest policy uses Cargo-decoded feature references reqwest = ["dep:reqwest"] announcement = ["reqwest", "reqwest/rustls"] file-watch = ["reqwest?/__native-tls"] -mcp = ["reqwest"] +mcp = ["reqwest", "reqwest/rustls", "reqwest/json"] models-dev = ["reqwest", "reqwest/rustls", "reqwest/system-proxy"] speech = ["reqwest", "reqwest/rustls", "reqwest/http3"] `); @@ -1279,8 +2436,9 @@ speech = ["reqwest", "reqwest/rustls", "reqwest/http3"] const messages = findServicesIntegrationsReqwestFeatureViolations(pkg) .map((violation) => violation.message) .join('\n'); + assert.match(messages, /announcement.*missing Reqwest feature reference reqwest\/json/); assert.match(messages, /file-watch.*outside its reviewed owner features/); - assert.match(messages, /mcp.*missing reqwest\/rustls/); + assert.match(messages, /mcp.*missing Reqwest feature reference reqwest\/stream/); assert.doesNotMatch(messages, /models-dev.*system-proxy/); assert.match(messages, /speech.*unreviewed Reqwest feature reference reqwest\/http3/); }); @@ -1294,11 +2452,7 @@ test('direct Reqwest clients reject extra decoded dependency and package feature uses_default_features: false, features: [ 'http2', - 'json', 'stream', - 'multipart', - 'query', - 'form', 'rustls', '__native-tls', ], @@ -1311,17 +2465,58 @@ test('direct Reqwest clients reject extra decoded dependency and package feature .join('\n'); assert.match(messages, /bitfun-cli.*unexpected dependency features: __native-tls/); assert.match(messages, /bitfun-cli:default.*unreviewed Reqwest feature reference reqwest\?\/http3/); + + const installerMessages = findReqwestDependencyFeatureViolations([{ + ...pkg, + name: 'bitfun-installer', + manifest_path: join(TEST_ROOT, 'BitFun-Installer', 'src-tauri', 'Cargo.toml'), + }]).map((violation) => violation.message).join('\n'); + assert.match(installerMessages, /bitfun-installer.*missing a reviewed owner profile/); +}); + +test('AI adapters Reqwest profile owns the supported SOCKS transport', () => { + const baseFeatures = ['http2', 'json', 'stream']; + const valid = { + ...packageAt('bitfun-ai-adapters', 'src/crates/adapters/ai-adapters/Cargo.toml', [{ + name: 'reqwest', + kind: null, + optional: false, + uses_default_features: false, + features: [...baseFeatures, 'rustls', 'socks'], + }]), + features: { 'subscription-auth': ['reqwest/form'] }, + }; + const missingSocks = { + ...packageAt( + 'bitfun-ai-adapters', + 'src/crates/adapters/ai-adapters/Cargo.toml', + [{ + name: 'reqwest', + kind: null, + optional: false, + uses_default_features: false, + features: [...baseFeatures, 'rustls'], + }], + ), + features: { 'subscription-auth': ['reqwest/form'] }, + }; + + assert.deepEqual(findReqwestDependencyFeatureViolations([valid]), []); + const messages = findReqwestDependencyFeatureViolations([missingSocks]) + .map((violation) => violation.message) + .join('\n'); + assert.match(messages, /bitfun-ai-adapters.*missing features: socks/); }); test('Reqwest metadata policy covers URL-only and future dependency owners', () => { - const baseFeatures = ['http2', 'json', 'stream', 'multipart', 'query', 'form']; + const coreFeatures = []; const core = { ...packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml', [{ name: 'reqwest', kind: null, optional: true, uses_default_features: false, - features: baseFeatures, + features: coreFeatures, }]), features: { product: ['dep:reqwest', 'reqwest/__native-tls'] }, }; @@ -1330,7 +2525,7 @@ test('Reqwest metadata policy covers URL-only and future dependency owners', () kind: null, optional: false, uses_default_features: false, - features: [...baseFeatures, 'rustls'], + features: ['http2', 'rustls', 'stream'], }]); const duplicate = packageAt( 'bitfun-services-integrations', @@ -1341,7 +2536,7 @@ test('Reqwest metadata policy covers URL-only and future dependency owners', () kind: null, optional: true, uses_default_features: false, - features: baseFeatures, + features: ['http2'], }, { name: 'reqwest', @@ -1350,7 +2545,7 @@ test('Reqwest metadata policy covers URL-only and future dependency owners', () optional: true, target: 'cfg(windows)', uses_default_features: false, - features: [...baseFeatures, '__native-tls'], + features: ['http2', '__native-tls'], }, ], ); @@ -1363,6 +2558,22 @@ test('Reqwest metadata policy covers URL-only and future dependency owners', () assert.match(messages, /bitfun-services-integrations.*exactly one normal Reqwest dependency/); }); +test('Reqwest consumers inherit the workspace version without duplicating feature rules', async () => { + const { requiredContentRules } = await import( + './core-boundaries/rules/source/required-rules.mjs' + ); + const rules = requiredContentRules.filter((rule) => + rule.reason.includes('Reqwest consumers must inherit the workspace-owned compatible version') + ); + + assert.equal(rules.length, 7); + for (const rule of rules) { + const pattern = rule.patterns[0].regex; + assert.match('reqwest = { workspace = true, features = ["rustls"] }', pattern); + assert.doesNotMatch('reqwest = { version = "99", features = ["rustls"] }', pattern); + } +}); + test('resolved Reqwest feature union rejects every native TLS backend alias', () => { const violations = findResolvedReqwestNativeTlsViolations( [ @@ -1398,6 +2609,14 @@ test('Cargo metadata Tokio policy catches table-style and renamed full dependenc assert.equal(violations.length, 1); assert.match(violations[0].message, /table-style must not enable tokio\/full/); + + const installerViolations = findTokioDependencyFeatureViolations([{ + ...pkg, + name: 'bitfun-installer', + manifest_path: join(TEST_ROOT, 'BitFun-Installer', 'src-tauri', 'Cargo.toml'), + }]); + assert.equal(installerViolations.length, 1); + assert.match(installerViolations[0].message, /bitfun-installer must not enable tokio\/full/); }); test('cargo layer checker allows documented downward and peer dependencies', () => { @@ -1809,7 +3028,10 @@ test('split core boundary check keeps self-test and default execution behavior', }); test('optional dependency ownership rejects undeclared direct feature owners', async () => { - const { unexpectedDependencyOwnerFeatures } = await import( + const { + featureReferencesOptionalDependencyOwner, + unexpectedDependencyOwnerFeatures, + } = await import( './core-boundaries/manifest-feature-helpers.mjs' ); const features = new Map([ @@ -1825,7 +3047,31 @@ test('optional dependency ownership rejects undeclared direct feature owners', a depName: 'example', ownerFeatures: ['declared'], }).map(([featureName]) => featureName), - ['missing', 'feature-ref'], + ['missing', 'feature-ref', 'weak-ref'], + ); + assert.equal(featureReferencesOptionalDependencyOwner(features.get('declared'), 'example'), true); + assert.equal(featureReferencesOptionalDependencyOwner(features.get('weak-ref'), 'example'), true); + assert.equal(featureReferencesOptionalDependencyOwner(features.get('unrelated'), 'example'), false); +}); + +test('optional dependency ownership rejects hidden aliases but permits reviewed aggregates', async () => { + const { unexpectedDependencyOwnerFeatures } = await import( + './core-boundaries/manifest-feature-helpers.mjs' + ); + const features = new Map([ + ['owner', { refs: ['dep:example'], line: 1 }], + ['reviewed-aggregate', { refs: ['owner'], line: 2 }], + ['sneaky', { refs: ['owner'], line: 3 }], + ['bad-aggregate', { refs: ['owner', 'dep:example'], line: 4 }], + ]); + + assert.deepEqual( + unexpectedDependencyOwnerFeatures( + features, + { depName: 'example', ownerFeatures: ['owner'] }, + new Set(['reviewed-aggregate', 'bad-aggregate']), + ).map(([featureName]) => featureName), + ['sneaky', 'bad-aggregate'], ); }); @@ -1850,8 +3096,22 @@ test('services-core capability profiles keep heavy owners out of the empty profi 'dep:base64', 'dep:chrono', 'dep:ignore', + 'dep:regex', 'dep:sha2', + 'dep:tokio', + 'tokio/fs', + 'tokio/rt', + ]); + assert.deepEqual(profiles.get('json-io'), [ + 'dep:fs2', + 'dep:tokio', + 'dep:windows', 'tokio/fs', + 'tokio/rt', + 'tokio/sync', + 'tokio/time', + 'windows/Win32_Foundation', + 'windows/Win32_Storage_FileSystem', ]); assert.deepEqual(profiles.get('local-storage'), [ 'dep:bitfun-core-types', @@ -1859,29 +3119,40 @@ test('services-core capability profiles keep heavy owners out of the empty profi 'dep:chrono', 'dep:fs2', 'dep:libc', + 'dep:regex', 'dep:sha2', + 'dep:similar', + 'dep:tokio', 'dep:windows', 'tokio/fs', + 'tokio/rt', 'tokio/sync', + 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_Storage_FileSystem', ]); assert.deepEqual(profiles.get('process-runtime'), [ 'dep:libc', + 'dep:tokio', 'dep:which', 'dep:win32job', 'dep:windows', 'tokio/io-util', 'tokio/process', + 'tokio/rt', + 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_System_Diagnostics_ToolHelp', 'windows/Win32_System_Threading', ]); assert.deepEqual(profiles.get('workspace-instructions'), [ 'dep:globset', + 'dep:regex', 'dep:serde_yaml', + 'dep:tokio', 'tokio/fs', 'tokio/io-util', + 'tokio/rt', ]); assert.deepEqual(profiles.get('lsp'), [ 'dep:anyhow', @@ -1897,6 +3168,8 @@ test('services-core capability profiles keep heavy owners out of the empty profi 'dep:anyhow', 'dep:async-trait', 'dep:bitfun-runtime-ports', + 'bitfun-runtime-ports/runtime-event-port', + 'bitfun-runtime-ports/workspace-ports', 'dep:dunce', 'process-runtime', 'tokio/fs', @@ -1916,7 +3189,9 @@ test('services-core capability profiles keep heavy owners out of the empty profi 'globset', 'ignore', 'libc', + 'regex', 'sha2', + 'similar', 'which', 'win32job', 'windows', @@ -1932,6 +3207,8 @@ test('services-core capability profiles keep heavy owners out of the empty profi ); const sourceContracts = sourceRule?.patterns.map((pattern) => pattern.regex.source).join('\n') ?? ''; for (const moduleName of [ + 'diagnostics', + 'diff', 'filesystem', 'json_store', 'managed_runtime', @@ -1967,6 +3244,7 @@ test('services-core Tokio capabilities stay owner-scoped', () => { ], features: { filesystem: [], + 'json-io': [], 'local-storage': [], 'process-runtime': [], 'workspace-instructions': [], @@ -1992,6 +3270,186 @@ test('services-core Tokio capabilities stay owner-scoped', () => { ); }); +test('Services Core accepts only the reviewed feature-owned Tokio runtime graph', () => { + const validPackage = { + name: 'bitfun-services-core', + manifest_path: 'src/crates/services/services-core/Cargo.toml', + dependencies: [ + { + name: 'tokio', + kind: null, + optional: true, + features: [], + }, + ], + features: { + diff: ['dep:tokio', 'tokio/rt', 'tokio/time'], + filesystem: ['dep:tokio', 'tokio/fs', 'tokio/rt'], + 'json-io': ['dep:tokio', 'tokio/fs', 'tokio/rt', 'tokio/sync', 'tokio/time'], + 'local-storage': [ + 'dep:tokio', + 'tokio/fs', + 'tokio/rt', + 'tokio/sync', + 'tokio/time', + ], + permission: ['dep:tokio', 'tokio/rt'], + 'process-runtime': [ + 'dep:tokio', + 'tokio/io-util', + 'tokio/process', + 'tokio/rt', + 'tokio/time', + ], + 'workspace-instructions': ['dep:tokio', 'tokio/fs', 'tokio/io-util', 'tokio/rt'], + 'workspace-text-runtime': ['dep:tokio', 'tokio/rt'], + lsp: ['process-runtime', 'tokio/fs', 'tokio/io-util', 'tokio/sync'], + 'workspace-runtime': [ + 'process-runtime', + 'tokio/fs', + 'tokio/io-util', + 'tokio/sync', + ], + 'session-git': ['local-storage'], + }, + }; + + assert.deepEqual(findTokioDependencyFeatureViolations([validPackage]), []); +}); + +test('Services Core Tokio owners cannot be hidden behind an unreviewed alias', () => { + const invalidPackage = { + name: 'bitfun-services-core', + manifest_path: 'src/crates/services/services-core/Cargo.toml', + dependencies: [ + { + name: 'tokio', + kind: null, + optional: true, + features: [], + }, + ], + features: { + diff: ['dep:tokio', 'tokio/rt', 'tokio/time'], + filesystem: ['dep:tokio', 'tokio/fs', 'tokio/rt'], + 'json-io': ['dep:tokio', 'tokio/fs', 'tokio/rt', 'tokio/sync', 'tokio/time'], + 'local-storage': [ + 'dep:tokio', + 'tokio/fs', + 'tokio/rt', + 'tokio/sync', + 'tokio/time', + ], + permission: ['dep:tokio', 'tokio/rt'], + 'process-runtime': [ + 'dep:tokio', + 'tokio/io-util', + 'tokio/process', + 'tokio/rt', + 'tokio/time', + ], + 'workspace-instructions': ['dep:tokio', 'tokio/fs', 'tokio/io-util', 'tokio/rt'], + 'workspace-text-runtime': ['dep:tokio', 'tokio/rt'], + lsp: ['process-runtime', 'tokio/fs', 'tokio/io-util', 'tokio/sync'], + 'workspace-runtime': [ + 'process-runtime', + 'tokio/fs', + 'tokio/io-util', + 'tokio/sync', + ], + sneaky: ['filesystem', 'local-storage'], + 'sneaky-weak': ['tokio?/full'], + }, + }; + + const messages = findTokioDependencyFeatureViolations([invalidPackage]).map( + (violation) => violation.message, + ); + assert.ok( + messages.includes('bitfun-services-core:sneaky Tokio capabilities require an explicit owner contract'), + ); + assert.ok( + messages.includes('bitfun-services-core:sneaky-weak Tokio capabilities require an explicit owner contract'), + ); +}); + +test('Core feature-free Tokio capabilities stay limited to baseline path and state IO', () => { + const invalidPackage = { + name: 'bitfun-core', + manifest_path: 'src/crates/assembly/core/Cargo.toml', + dependencies: [ + { + name: 'tokio', + kind: null, + optional: false, + features: ['fs', 'io-util', 'macros', 'net', 'rt', 'sync', 'time'], + }, + ], + features: {}, + }; + + const messages = findTokioDependencyFeatureViolations([invalidPackage]).map( + (violation) => violation.message, + ); + assert.ok( + messages.some((message) => message.includes('unexpected base Tokio capabilities')), + 'Core must reject async runtime, networking, and timing capabilities in its feature-free profile', + ); +}); + +test('Core Tokio capabilities cannot hide behind an unreviewed owner feature', () => { + const invalidPackage = { + name: 'bitfun-core', + manifest_path: 'src/crates/assembly/core/Cargo.toml', + dependencies: [ + { + name: 'tokio', + kind: null, + optional: false, + features: ['fs', 'sync'], + }, + ], + features: { + 'agent-runtime': ['tokio/io-util', 'tokio/macros', 'tokio/rt', 'tokio/time'], + 'mcp-runtime': ['agent-runtime', 'tokio/rt-multi-thread'], + 'browser-control': ['tokio/net', 'tokio/rt', 'tokio/time'], + 'debug-log': ['tokio/macros', 'tokio/net', 'tokio/rt', 'tokio/time'], + lsp: ['tokio/macros'], + sneaky: ['agent-runtime', 'browser-control'], + }, + }; + + const messages = findTokioDependencyFeatureViolations([invalidPackage]).map( + (violation) => violation.message, + ); + assert.deepEqual(messages, [ + 'bitfun-core:sneaky Tokio capabilities require an explicit owner contract', + ]); +}); + +test('reviewed Tokio aggregates cannot declare runtime capabilities directly', () => { + const invalidPackage = { + name: 'bitfun-core', + manifest_path: 'src/crates/assembly/core/Cargo.toml', + dependencies: [{ name: 'tokio', kind: null, optional: false, features: ['fs', 'sync'] }], + features: { + 'agent-runtime': ['tokio/io-util', 'tokio/macros', 'tokio/rt', 'tokio/time'], + 'mcp-runtime': ['agent-runtime', 'tokio/rt-multi-thread'], + 'browser-control': ['tokio/net', 'tokio/rt', 'tokio/time'], + 'debug-log': ['tokio/macros', 'tokio/net', 'tokio/rt', 'tokio/time'], + lsp: ['tokio/macros'], + 'product-full': ['agent-runtime', 'tokio/net'], + }, + }; + + const messages = findTokioDependencyFeatureViolations([invalidPackage]).map( + (violation) => violation.message, + ); + assert.deepEqual(messages, [ + 'bitfun-core:product-full Tokio aggregate must compose reviewed owners instead of declaring Tokio capabilities directly', + ]); +}); + test('services-core Windows API capabilities stay feature-owned', async () => { const { findServicesCorePlatformDependencyFeatureViolations } = await import( './core-boundaries/cargo-dependency-boundaries.mjs' @@ -2065,3 +3523,388 @@ test('closed feature profiles reject product-full hidden behind a child feature' ], ); }); + +test('capability contract consumers may inherit empty defaults but must select reviewed features', async () => { + const cargoBoundaries = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + assert.equal( + typeof cargoBoundaries.findCapabilityContractConsumerViolations, + 'function', + 'Cargo boundary checker must expose the capability contract consumer policy', + ); + + const runtimePorts = capabilityPackage( + 'bitfun-runtime-ports', + 'src/crates/contracts/runtime-ports/Cargo.toml', + RUNTIME_PORT_FEATURE_PROFILES, + ); + const agentTools = agentToolsCapabilityPackage(); + const pluginRuntimeClient = packageAt( + 'bitfun-plugin-runtime-client', + 'src/crates/execution/plugin-runtime-client/Cargo.toml', + [ + pathDependency('src/crates/contracts/runtime-ports', { + name: 'bitfun-runtime-ports', + }), + ], + ); + + const messages = findTestCapabilityViolations( + cargoBoundaries.findCapabilityContractConsumerViolations, + [ + runtimePorts, + agentTools, + pluginRuntimeClient, + ], + ).map((violation) => violation.message); + + assert.doesNotMatch(messages.join('\n'), /default-features = false/); + assert.ok(messages.some((message) => /plugin-runtime/.test(message))); +}); + +test('unreviewed consumers cannot add capability contract dependency edges', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const runtimePorts = capabilityPackage( + 'bitfun-runtime-ports', + 'src/crates/contracts/runtime-ports/Cargo.toml', + RUNTIME_PORT_FEATURE_PROFILES, + ); + const agentTools = agentToolsCapabilityPackage(); + const unreviewed = packageAt( + 'unreviewed-host', + 'src/apps/unreviewed-host/Cargo.toml', + [ + pathDependency('src/crates/contracts/runtime-ports', { + name: 'bitfun-runtime-ports', + target: 'cfg(windows)', + usesDefaultFeatures: false, + features: ['agent-api'], + }), + ], + ); + + const messages = findTestCapabilityViolations(findCapabilityContractConsumerViolations, [ + runtimePorts, + agentTools, + unreviewed, + ]).map( + (violation) => violation.message, + ); + assert.equal(messages.length, 1, messages.join('\n')); + assert.match(messages[0], /unreviewed consumer/); +}); + +test('capability contract edge policy rejects alias, weak, optional, and non-normal widening', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const runtimePorts = capabilityPackage( + 'bitfun-runtime-ports', + 'src/crates/contracts/runtime-ports/Cargo.toml', + RUNTIME_PORT_FEATURE_PROFILES, + ); + const validDependency = pathDependency('src/crates/contracts/runtime-ports', { + name: 'bitfun-runtime-ports', + usesDefaultFeatures: false, + features: ['plugin-runtime'], + }); + const mutations = [ + { label: 'renamed alias forwarding', dependency: { ...validDependency, rename: 'ports' }, features: { sneaky: ['ports/agent-api'] }, expected: /sneaky.*unreviewed.*forwarding/ }, + { label: 'weak alias forwarding', dependency: { ...validDependency, rename: 'ports' }, features: { sneaky: ['ports?/agent-api'] }, expected: /sneaky.*unreviewed.*forwarding/ }, + { label: 'optional edge', dependency: { ...validDependency, optional: true }, features: {}, expected: /unreviewed.*dependency edge/ }, + { label: 'dev edge', dependency: { ...validDependency, kind: 'dev' }, features: {}, expected: /unreviewed.*dependency edge/ }, + { label: 'build edge', dependency: { ...validDependency, kind: 'build' }, features: {}, expected: /unreviewed.*dependency edge/ }, + { label: 'target edge', dependency: { ...validDependency, target: 'cfg(windows)' }, features: {}, expected: /unreviewed.*dependency edge/ }, + ]; + + for (const mutation of mutations) { + const consumer = { + ...packageAt( + 'bitfun-plugin-runtime-client', + 'src/crates/execution/plugin-runtime-client/Cargo.toml', + [mutation.dependency], + ), + features: mutation.features, + }; + const messages = findTestCapabilityViolations( + findCapabilityContractConsumerViolations, + [runtimePorts, consumer], + ).map( + (violation) => violation.message, + ); + assert.ok( + messages.some((message) => mutation.expected.test(message)), + `${mutation.label} must not widen the reviewed capability contract`, + ); + } +}); + +test('capability contract targets require an explicit empty default feature', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const runtimePorts = capabilityPackage( + 'bitfun-runtime-ports', + 'src/crates/contracts/runtime-ports/Cargo.toml', + RUNTIME_PORT_FEATURE_PROFILES, + ); + delete runtimePorts.features.default; + + const messages = findTestCapabilityViolations( + findCapabilityContractConsumerViolations, + [runtimePorts], + ).map( + (violation) => violation.message, + ); + assert.ok(messages.some((message) => /default feature must stay empty/.test(message))); +}); + +test('capability contract optional activators reject unreviewed dep aliases', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const agentTools = agentToolsCapabilityPackage(); + const dependency = pathDependency('src/crates/execution/tool-contracts', { + name: 'bitfun-agent-tools', + rename: 'tools_contract', + optional: true, + usesDefaultFeatures: false, + }); + const consumer = { + ...packageAt( + 'bitfun-acp', + 'src/crates/interfaces/acp/Cargo.toml', + [dependency], + ), + features: { + client: ['tools_contract/acp-bridge'], + server: ['dep:tools_contract'], + sneaky: ['tools_contract'], + }, + }; + + const messages = findTestCapabilityViolations( + findCapabilityContractConsumerViolations, + [agentTools, consumer], + ).map( + (violation) => violation.message, + ); + assert.ok(messages.some((message) => /sneaky.*unreviewed.*activation/.test(message))); + assert.doesNotMatch(messages.join('\n'), /server.*unreviewed.*activation/); +}); + +test('capability contract consumers cannot remove reviewed forwarding or activation', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const runtimePorts = capabilityPackage( + 'bitfun-runtime-ports', + 'src/crates/contracts/runtime-ports/Cargo.toml', + RUNTIME_PORT_FEATURE_PROFILES, + ); + const integrations = { + ...packageAt( + 'bitfun-services-integrations', + 'src/crates/services/services-integrations/Cargo.toml', + [pathDependency('src/crates/contracts/runtime-ports', { + name: 'bitfun-runtime-ports', + optional: true, + usesDefaultFeatures: false, + })], + ), + features: { + git: [], + 'remote-connect': [ + 'bitfun-runtime-ports/agent-api', + 'bitfun-runtime-ports/remote-workspace-ports', + ], + 'remote-ssh': [ + 'bitfun-runtime-ports/remote-exec-port', + 'bitfun-runtime-ports/remote-workspace-ports', + 'bitfun-runtime-ports/workspace-ports', + ], + 'remote-ssh-concrete': ['dep:bitfun-runtime-ports'], + 'script-tool-runtime': ['bitfun-runtime-ports/script-tool-runtime'], + }, + }; + const servicesCore = { + ...packageAt( + 'bitfun-services-core', + 'src/crates/services/services-core/Cargo.toml', + [pathDependency('src/crates/contracts/runtime-ports', { + name: 'bitfun-runtime-ports', + optional: true, + usesDefaultFeatures: false, + })], + ), + features: { + permission: [], + 'workspace-runtime': [ + 'dep:bitfun-runtime-ports', + 'bitfun-runtime-ports/runtime-event-port', + 'bitfun-runtime-ports/workspace-ports', + ], + }, + }; + + const messages = findTestCapabilityViolations(findCapabilityContractConsumerViolations, [ + runtimePorts, + integrations, + servicesCore, + ]).map((violation) => violation.message); + + assert.ok(messages.some((message) => /bitfun-services-integrations:git.*missing reviewed.*git-port forwarding/.test(message))); + assert.ok(messages.some((message) => /bitfun-services-core:permission.*missing reviewed.*activation/.test(message))); +}); + +test('capability contract targets cannot be removed or replaced by a same-name package', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const reviewedConsumer = packageAt( + 'bitfun-plugin-runtime-client', + 'src/crates/execution/plugin-runtime-client/Cargo.toml', + [pathDependency('src/crates/contracts/runtime-ports', { + name: 'bitfun-runtime-ports', + usesDefaultFeatures: false, + features: ['plugin-runtime'], + })], + ); + + const missingTargetMessages = findTestCapabilityViolations( + findCapabilityContractConsumerViolations, + [reviewedConsumer], + ).map((violation) => violation.message); + assert.ok(missingTargetMessages.some((message) => + /bitfun-runtime-ports managed target.*missing/.test(message))); + + const runtimePorts = capabilityPackage( + 'bitfun-runtime-ports', + 'src/crates/contracts/runtime-ports/Cargo.toml', + RUNTIME_PORT_FEATURE_PROFILES, + ); + reviewedConsumer.dependencies[0] = { + ...reviewedConsumer.dependencies[0], + path: null, + source: 'registry+https://github.com/rust-lang/crates.io-index', + }; + const spoofedTargetMessages = findTestCapabilityViolations( + findCapabilityContractConsumerViolations, + [runtimePorts, reviewedConsumer], + ).map((violation) => violation.message); + assert.ok(spoofedTargetMessages.some((message) => /managed internal path/.test(message))); + + const vendorRuntimePorts = { + ...runtimePorts, + manifest_path: join( + TEST_ROOT, + 'vendor', + 'src', + 'crates', + 'contracts', + 'runtime-ports', + 'Cargo.toml', + ), + }; + reviewedConsumer.dependencies[0] = { + ...reviewedConsumer.dependencies[0], + path: join(TEST_ROOT, 'vendor', 'src', 'crates', 'contracts', 'runtime-ports'), + source: null, + }; + const vendorTargetMessages = findCapabilityContractConsumerViolations( + [vendorRuntimePorts, reviewedConsumer], + [capabilityContractDependencyRules[0]], + { root: TEST_ROOT }, + ).map((violation) => violation.message); + assert.ok(vendorTargetMessages.some((message) => /managed target.*missing/.test(message))); +}); + +test('capability contract target feature graphs stay exact', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const runtimePorts = capabilityPackage( + 'bitfun-runtime-ports', + 'src/crates/contracts/runtime-ports/Cargo.toml', + RUNTIME_PORT_FEATURE_PROFILES, + ); + runtimePorts.features['git-port'] = ['plugin-runtime']; + + const messages = findTestCapabilityViolations( + findCapabilityContractConsumerViolations, + [runtimePorts], + ).map( + (violation) => violation.message, + ); + assert.ok(messages.some((message) => /git-port.*feature graph must stay exact/.test(message))); +}); + +test('unreviewed local feature aliases cannot wrap reviewed capability owners', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const agentTools = agentToolsCapabilityPackage(); + const acp = { + ...packageAt( + 'bitfun-acp', + 'src/crates/interfaces/acp/Cargo.toml', + [pathDependency('src/crates/execution/tool-contracts', { + name: 'bitfun-agent-tools', + optional: true, + usesDefaultFeatures: false, + })], + ), + features: { + default: ['client', 'server'], + client: ['bitfun-agent-tools/acp-bridge'], + server: ['dep:bitfun-agent-tools'], + sneakyClient: ['client'], + sneakyServer: ['server'], + }, + }; + + const messages = findTestCapabilityViolations( + findCapabilityContractConsumerViolations, + [agentTools, acp], + ).map( + (violation) => violation.message, + ); + assert.ok(messages.some((message) => /sneakyClient.*unreviewed.*aggregate/.test(message))); + assert.ok(messages.some((message) => /sneakyServer.*unreviewed.*aggregate/.test(message))); + assert.doesNotMatch(messages.join('\n'), /default.*unreviewed.*aggregate/); +}); + +test('capability contract consumers cannot remove reviewed dependency edges', async () => { + const { findCapabilityContractConsumerViolations } = await import( + './core-boundaries/cargo-dependency-boundaries.mjs' + ); + const runtimePorts = capabilityPackage( + 'bitfun-runtime-ports', + 'src/crates/contracts/runtime-ports/Cargo.toml', + RUNTIME_PORT_FEATURE_PROFILES, + ); + const pluginRuntimeClient = packageAt( + 'bitfun-plugin-runtime-client', + 'src/crates/execution/plugin-runtime-client/Cargo.toml', + ); + const opencodeAdapter = packageAt( + 'bitfun-opencode-adapter', + 'src/crates/adapters/opencode-adapter/Cargo.toml', + [pathDependency('src/crates/contracts/runtime-ports', { + name: 'bitfun-runtime-ports', + usesDefaultFeatures: false, + features: ['plugin-runtime'], + })], + ); + + const messages = findTestCapabilityViolations(findCapabilityContractConsumerViolations, [ + runtimePorts, + pluginRuntimeClient, + opencodeAdapter, + ]).map((violation) => violation.message); + assert.ok(messages.some((message) => /bitfun-plugin-runtime-client.*missing reviewed.*normal.*edge/.test(message))); + assert.ok(messages.some((message) => /bitfun-opencode-adapter.*missing reviewed.*dev.*edge/.test(message))); +}); diff --git a/scripts/check-github-config.test.mjs b/scripts/check-github-config.test.mjs index 15fc7cd0d1..7ea018e46a 100644 --- a/scripts/check-github-config.test.mjs +++ b/scripts/check-github-config.test.mjs @@ -251,6 +251,56 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => assert.equal(cache?.with?.['cache-on-failure'], trustedMain); } + const rustCache = rustJob.steps.find((step) => + step.uses?.startsWith('swatinem/rust-cache@'), + ); + assert.equal( + rustCache?.with?.['cache-directories'], + undefined, + 'Rust cache cleanup must not own native libraries stored under target', + ); + + const restoreSherpaCache = rustJob.steps.find( + (step) => step.name === 'Restore Sherpa native libraries', + ); + const repairSherpaState = rustJob.steps.find( + (step) => step.name === 'Repair missing Sherpa native state', + ); + const checkCompilation = rustJob.steps.find( + (step) => step.name === 'Check compilation', + ); + const saveSherpaCache = rustJob.steps.find( + (step) => step.name === 'Save Sherpa native libraries', + ); + const sherpaCacheKey = + 'sherpa-onnx-v1-${{ runner.os }}-${{ runner.arch }}-1.13.4-static'; + + assert.equal(restoreSherpaCache?.uses, 'actions/cache/restore@v5'); + assert.equal(restoreSherpaCache?.with?.path, 'target/sherpa-onnx-prebuilt'); + assert.equal(restoreSherpaCache?.with?.key, sherpaCacheKey); + assert.match( + repairSherpaState?.run ?? '', + /rm -rf target\/sherpa-onnx-prebuilt/, + ); + assert.match(repairSherpaState?.run ?? '', /cargo clean -p sherpa-onnx-sys/); + assert.equal(saveSherpaCache?.uses, 'actions/cache/save@v5'); + assert.equal(saveSherpaCache?.with?.path, 'target/sherpa-onnx-prebuilt'); + assert.equal(saveSherpaCache?.with?.key, sherpaCacheKey); + assert.equal( + saveSherpaCache?.if, + "github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.sherpa-native-cache.outputs.cache-hit != 'true'", + ); + assert.ok( + rustJob.steps.indexOf(restoreSherpaCache) < + rustJob.steps.indexOf(checkCompilation), + 'Sherpa native libraries must be restored before cargo check', + ); + assert.ok( + rustJob.steps.indexOf(checkCompilation) < + rustJob.steps.indexOf(saveSherpaCache), + 'Sherpa native libraries must be saved before rust-cache post cleanup', + ); + const commandByStep = new Map( rustJob.steps.map((step) => [step.name, step.run]), ); @@ -258,6 +308,14 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => commandByStep.get('Run subscription authentication tests'), 'cargo test --locked -p bitfun-ai-adapters --features subscription-auth --lib subscription_auth', ); + const installerCheck = rustJob.steps.find( + (step) => step.name === 'Check installer compilation', + ); + assert.equal(installerCheck?.if, "runner.os == 'Windows'"); + assert.equal( + installerCheck?.run, + 'cargo check --manifest-path BitFun-Installer/src-tauri/Cargo.toml', + ); assert.equal( commandByStep.get('Run file watch contract tests'), 'cargo test --locked -p bitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts', @@ -267,3 +325,221 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => 'cargo test --locked -p tool-runtime --lib search::', ); }); + +test('generates web API bindings before nightly web type-check', () => { + const workflow = yaml.parse( + readFileSync(path.join(repoRoot, '.github/workflows/nightly.yml'), 'utf8'), + ); + const packageJob = workflow.jobs.package; + const steps = packageJob.steps; + const generationIndex = steps.findIndex( + (step) => step.name === 'Generate web API bindings', + ); + const typeCheckIndex = steps.findIndex( + (step) => step.name === 'Type-check web UI', + ); + + assert.notEqual(generationIndex, -1); + assert.notEqual(typeCheckIndex, -1); + assert.equal( + steps[generationIndex].run, + 'pnpm --dir src/web-ui run gen:types', + ); + assert.ok( + generationIndex < typeCheckIndex, + 'nightly must generate web API bindings before type-checking the web UI', + ); +}); + +test('passes the verification key when signing the versioned Windows installer', () => { + const workflow = yaml.parse( + readFileSync( + path.join(repoRoot, '.github/workflows/desktop-package.yml'), + 'utf8', + ), + ); + const signingStep = workflow.jobs['upload-release-assets'].steps.find( + (step) => step.name === 'Sign versioned Windows installer', + ); + + assert.equal( + signingStep?.env?.BITFUN_SIGNING_PUBKEY, + '${{ secrets.TAURI_UPDATER_PUBKEY }}', + 'release signatures must be self-verified with the configured public key', + ); +}); + +test('stages unique release asset names before publishing', () => { + const workflow = yaml.parse( + readFileSync( + path.join(repoRoot, '.github/workflows/desktop-package.yml'), + 'utf8', + ), + ); + const steps = workflow.jobs['upload-release-assets'].steps; + const stagingIndexes = [ + steps.findIndex((step) => step.name === 'Stage stable release assets'), + steps.findIndex((step) => step.name === 'Stage beta release assets'), + ]; + const uploadIndex = steps.findIndex((step) => step.name === 'Upload to release'); + + assert.equal(stagingIndexes.every((index) => index >= 0), true); + assert.notEqual(uploadIndex, -1); + for (const stagingIndex of stagingIndexes) { + assert.ok(stagingIndex < uploadIndex); + assert.match( + steps[stagingIndex].run, + /node scripts\/stage-github-release-assets\.mjs/, + ); + assert.doesNotMatch( + steps[stagingIndex].run, + /release-assets\/\*\*\/\*\.sig(?:\s|\\)/, + 'raw updater signatures have colliding names across macOS architectures', + ); + } + assert.equal(steps[uploadIndex].with.files, 'release-upload-assets/*'); +}); + +test('Desktop packaging keeps beta identity explicit and stable-safe', () => { + const workflow = yaml.parse( + readFileSync( + path.join(repoRoot, '.github/workflows/desktop-package.yml'), + 'utf8', + ), + ); + const inputs = workflow.on.workflow_dispatch.inputs; + assert.deepEqual(inputs.release_channel.options, ['stable', 'beta']); + assert.equal(inputs.release_channel.default, 'stable'); + + const prepareStep = workflow.jobs.prepare.steps.find( + (step) => step.name === 'Resolve version metadata', + ); + assert.match(prepareStep.run, /GITHUB_REPOSITORY.*GCWing\/BitFun/); + assert.match(prepareStep.run, /merge-base --is-ancestor/); + assert.match(prepareStep.run, /rev-parse --verify --quiet/); + + const packageJob = workflow.jobs.package; + assert.equal( + packageJob.env.BITFUN_RELEASE_CHANNEL, + '${{ needs.prepare.outputs.release_channel }}', + ); + assert.match(packageJob.env.TAURI_UPDATER_ENDPOINT, /github\.repository/); + assert.match(packageJob.env.TAURI_UPDATER_ENDPOINT, /channel-beta/); + assert.match(packageJob.env.BITFUN_RELEASE_PUBKEY, /BITFUN_RELEASE_PUBKEY/); + const appleSetupIndex = packageJob.steps.findIndex( + (step) => step.name === 'Configure Apple Developer ID signing and notarization', + ); + const desktopBuildIndex = packageJob.steps.findIndex( + (step) => step.name === 'Build desktop app', + ); + const appleVerifyIndex = packageJob.steps.findIndex( + (step) => step.name === 'Verify Apple signature and notarization', + ); + assert.ok( + appleSetupIndex >= 0 && + appleSetupIndex < desktopBuildIndex && + desktopBuildIndex < appleVerifyIndex, + 'Apple credentials must be configured before packaging and verified afterwards', + ); + assert.equal(packageJob.steps[appleSetupIndex].if, "runner.os == 'macOS'"); + assert.equal( + packageJob.steps[appleSetupIndex].env.BITFUN_REQUIRE_APPLE_SIGNING, + '${{ needs.prepare.outputs.upload_to_release }}', + ); + assert.equal(packageJob.steps[appleVerifyIndex].if, "runner.os == 'macOS'"); + const patchIndex = packageJob.steps.findIndex( + (step) => step.name === 'Project beta build version', + ); + const verifyIndex = packageJob.steps.findIndex( + (step) => step.name === 'Verify release version metadata', + ); + assert.ok(patchIndex >= 0 && patchIndex < verifyIndex); + assert.equal( + packageJob.steps[patchIndex].if, + "needs.prepare.outputs.release_channel == 'beta'", + ); + + const uploadSteps = workflow.jobs['upload-release-assets'].steps; + const release = uploadSteps.find((step) => step.name === 'Upload to release'); + assert.equal( + release.with.prerelease, + "${{ needs.prepare.outputs.release_channel == 'beta' }}", + ); + const verifyIndexPublished = uploadSteps.findIndex( + (step) => step.name === 'Verify published updater manifest', + ); + const promoteIndex = uploadSteps.findIndex( + (step) => step.name === 'Publish beta channel manifest', + ); + assert.ok(verifyIndexPublished >= 0 && verifyIndexPublished < promoteIndex); + assert.match(workflow.jobs['linux-binaries'].if, /release_channel == 'stable'/); + assert.equal( + uploadSteps.find((step) => step.name === 'Stage beta release assets').if, + "needs.prepare.outputs.release_channel == 'beta'", + ); + assert.match( + uploadSteps.find((step) => step.name === 'Generate updater manifest').run, + /github\.repository/, + ); + const signingStep = uploadSteps.find( + (step) => step.name === 'Sign installer packages', + ); + assert.match(signingStep.run, /write-minisign-public-key\.mjs/); + assert.doesNotMatch(signingStep.run, /BITFUN_SIGNING_PUBKEY.*base64 -d/); + const promotionStep = uploadSteps.find( + (step) => step.name === 'Resolve beta channel promotion', + ); + assert.doesNotMatch(promotionStep.run, /current\.beta\.json \|\| true/); + assert.match(promotionStep.run, /case "\$\{channel_status\}" in/); + assert.match(promotionStep.run, /404\)/); + assert.match(promotionStep.run, /GitHub API returned/); + const publishStep = uploadSteps.find( + (step) => step.name === 'Publish beta channel manifest', + ); + assert.equal( + publishStep.env.CHANNEL_EXISTS, + '${{ steps.beta-channel.outputs.channel_exists }}', + ); +}); + +test('beta publishing cannot advance the Relay latest image tag', () => { + const workflow = yaml.parse( + readFileSync( + path.join(repoRoot, '.github/workflows/desktop-package.yml'), + 'utf8', + ), + ); + const imageTags = workflow.jobs['publish-relay-image'].steps.find( + (step) => step.name === 'Resolve image tags', + ); + assert.equal( + imageTags.env.RELEASE_CHANNEL, + '${{ needs.prepare.outputs.release_channel }}', + ); + assert.match(imageTags.run, /RELEASE_CHANNEL.*stable/); + assert.doesNotMatch(imageTags.run, /RELEASE_PRERELEASE/); +}); + +test('nightly and beta use the shared build-version projection', () => { + const nightly = yaml.parse( + readFileSync(path.join(repoRoot, '.github/workflows/nightly.yml'), 'utf8'), + ); + const patch = nightly.jobs.package.steps.find( + (step) => step.name === 'Patch nightly version', + ); + assert.match(patch.run, /node scripts\/set-build-version\.mjs/); + assert.equal(nightly.jobs.package.env.BITFUN_RELEASE_CHANNEL, 'nightly'); + assert.equal( + nightly.jobs.package.env.TAURI_UPDATER_ENDPOINT, + 'https://github.com/GCWing/BitFun/releases/latest/download/latest.json', + ); + assert.equal( + nightly.jobs.package.env.TAURI_UPDATER_FALLBACK_ENDPOINT, + 'https://openbitfun.com/release/latest.json', + ); + assert.equal(nightly.jobs.package.env.BITFUN_ENABLE_UPDATER_ARTIFACTS, undefined); + const signingStep = nightly.jobs['publish-nightly'].steps.find( + (step) => step.name === 'Sign installer packages', + ); + assert.match(signingStep.run, /write-minisign-public-key\.mjs/); +}); diff --git a/scripts/check-harmonyos-architecture.mjs b/scripts/check-harmonyos-architecture.mjs new file mode 100644 index 0000000000..46eed36b8c --- /dev/null +++ b/scripts/check-harmonyos-architecture.mjs @@ -0,0 +1,340 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..'); +const etsRoot = path.join(repoRoot, 'src/apps/mobile/harmonyos/entry/src/main/ets'); +const pagesRoot = path.join(etsRoot, 'pages'); + +function walkEts(root) { + const entries = fs.readdirSync(root, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) { + files.push(...walkEts(entryPath)); + } else if (entry.isFile() && entry.name.endsWith('.ets')) { + files.push(entryPath); + } + } + return files; +} + +function relative(file) { + return path.relative(repoRoot, file).split(path.sep).join('/'); +} + +function imports(file) { + const source = fs.readFileSync(file, 'utf8'); + const specs = [...source.matchAll(/from\s+['"]([^'"]+)['"]/g)].map((match) => match[1]); + return specs.map((spec) => { + if (!spec.startsWith('.')) { + return spec; + } + return path.relative(etsRoot, path.resolve(path.dirname(file), spec)).split(path.sep).join('/'); + }); +} + +function filesUnder(root) { + return walkEts(root).sort(); +} + +const allPages = filesUnder(pagesRoot); +const services = filesUnder(path.join(etsRoot, 'services')); +const components = allPages.filter((file) => file.includes(`${path.sep}pages${path.sep}components${path.sep}`)); +const viewmodels = allPages.filter((file) => file.includes(`${path.sep}pages${path.sep}viewmodel${path.sep}`)); + +const serviceToPages = services + .filter((file) => imports(file).some((spec) => spec === 'pages' || spec.startsWith('pages/'))) + .map(relative); +const componentToViewmodel = components + .filter((file) => imports(file).some((spec) => spec === 'pages/viewmodel' || spec.startsWith('pages/viewmodel/'))) + .map(relative); +const viewmodelToComponents = viewmodels + .filter((file) => imports(file).some((spec) => spec === 'pages/components' || spec.startsWith('pages/components/'))) + .map(relative); +const v1Components = allPages + .filter((file) => /^\s*@Component\s*$/m.test(fs.readFileSync(file, 'utf8'))) + .map(relative); +const positionalActionConstructors = allPages + .filter((file) => /export\s+class\s+\w+(?:Actions|Hooks)\b/.test(fs.readFileSync(file, 'utf8')) && + /\bconstructor\s*\(/.test(fs.readFileSync(file, 'utf8'))) + .map(relative); +const sharedConversationFields = [ + 'sessions', + 'activeSession', + 'persistedMessages', + 'optimisticMessages', + 'activeTurnMessage', + 'hasMoreMessages', + 'timelineItems', + 'timelineRevision', + 'isBusy', + 'modelCatalog', + 'selectedModelId', + 'statusText', + 'chatInput', + 'selectedImages', + 'isVoiceListening' +]; +const conversationPageStateFiles = [ + path.join(pagesRoot, 'state/GeneralChatPageState.ets'), + path.join(pagesRoot, 'state/RemotePageState.ets') +]; +const duplicatedConversationTraceFields = conversationPageStateFiles.flatMap((file) => { + const source = fs.readFileSync(file, 'utf8'); + return sharedConversationFields + .filter((field) => new RegExp(`@Trace\\s+${field}\\s*:`).test(source)) + .map((field) => `${relative(file)}:${field}`); +}); +const appRootRuntimeFile = path.join(pagesRoot, 'runtime/AppRootRuntime.ets'); +const appRootRuntimeSource = fs.readFileSync(appRootRuntimeFile, 'utf8'); +const appRootRuntimeLines = appRootRuntimeSource.split(/\r?\n/).length - 1; +const appRootPresentationFile = path.join(pagesRoot, 'components/AppRootPresentation.ets'); +const appRootPresentationSource = fs.readFileSync(appRootPresentationFile, 'utf8'); +const appRootPresentationLines = appRootPresentationSource.split(/\r?\n/).length - 1; +const requiredPresentationFiles = [ + 'components/AppRootOverlaySurfaces.ets', + 'components/ChatMessageChrome.ets', + 'components/ConnectManualPairingOverlay.ets', + 'components/ConversationRouteSurface.ets', + 'components/ToolInteractionPanels.ets', + 'components/WideConversationHost.ets', + 'components/remote/RemoteSurfaceHost.ets' +]; +const missingPresentationFiles = requiredPresentationFiles + .filter((file) => !fs.existsSync(path.join(pagesRoot, file))); +const componentLineBudgets = [ + ['components/ChatMessageBubble.ets', 1000], + ['components/ConnectView.ets', 700], + ['components/ToolStatusList.ets', 1120] +]; +const extractedFilePreviewMethods = [ + 'openFilePreview', + 'closeFilePreview', + 'refreshFilePreview', + 'openFilePreviewLink', + 'invalidateFilePreviewTarget' +].filter((method) => new RegExp(`^\\s{2}${method}\\s*\\(`, 'm').test(appRootRuntimeSource)); +const extractedSettingsMethods = [ + 'saveGeneralChatConfig', + 'testGeneralChatConfig', + 'validateGeneralChatConfig', + 'probeGeneralChatConfig', + 'effectiveGeneralChatApiKey', + 'applyGeneralChatConfig', + 'refreshGeneralChatModelCatalog' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedCloudAccountMethods = [ + 'persistDelegatedAccountSession', + 'loginCloudAccount', + 'restoreCloudAccountSession', + 'loadGeneralChatAccountModels', + 'applyCloudAccountSession', + 'logoutCloudAccount', + 'listCloudAccountDevices', + 'getRemotePermissionMode', + 'setRemotePermissionMode', + 'restoreCloudTarget', + 'expireCloudAccountSession', + 'handleRemoteConnectionError', + 'selectCloudAccountDevice' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+|protected\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedConversationMethods = [ + 'isGeneralComposerRoute', + 'visibleChatInput', + 'visibleSelectedImages', + 'visibleVoiceListening', + 'setChatInputForRoute', + 'setSelectedImagesForRoute', + 'addSelectedImagesForRoute', + 'removeSelectedImageForRoute', + 'clearComposerForRoute', + 'setVoiceListeningForRoute', + 'setAllVoiceListening', + 'voiceInputSnapshot', + 'visibleChatBusy', + 'visibleStatusText', + 'setVisibleStatusText' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedRemoteConversationMethods = [ + 'sendChatMessage', + 'stopActiveTask', + 'renameActiveSession', + 'copyMessage', + 'downloadFile', + 'retryMessage', + 'approveTool', + 'rejectTool', + 'cancelTool', + 'answerQuestion', + 'resetChatTimeline', + 'syncChatTimelineFromStore', + 'startPolling', + 'currentChatPollingCursor', + 'updateChatPollingCursor', + 'applyChatSessionSnapshot', + 'hasRunningActiveTurn', + 'projectedTimelineItems', + 'syncAfterTurnEnded' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedRemoteCreateMethods = [ + 'createSession', + 'openRemoteCreateSession', + 'closeRemoteCreateSession', + 'loadRemoteCreateChoices', + 'loadRemoteCreateModelCatalog', + 'loadRemoteCreateDevices', + 'loadRemoteCreateWorkspaces', + 'toggleRemoteCreateDevices', + 'toggleRemoteCreateWorkspaces', + 'selectRemoteCreateDevice', + 'selectRemoteCreateWorkspace', + 'submitRemoteCreateSession', + 'createSessionInWorkspace', + 'openSession', + 'applyRemoteActiveSession', + 'deleteSession' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedGeneralConversationMethods = [ + 'openHomeSession', + 'openHomeSessionInPlace', + 'deleteHomeSession', + 'activeGeneralChatAsRemoteSession', + 'activeGeneralUploadedFileCount', + 'archiveHomeSession', + 'exportHomeSession', + 'openGeneralSession', + 'startGeneralChat', + 'sendVisibleChatMessage', + 'stopActiveChatTask', + 'closeActiveChat', + 'renameVisibleSession', + 'retryVisibleMessage', + 'downloadVisibleFile', + 'selectModel', + 'sendGeneralChatMessage', + 'stopGeneralChatStream', + 'startVisibleGeneralChat', + 'generalChatHomeStatusText', + 'prepareNewGeneralChat', + 'onVisibleChatInputChange', + 'visibleGeneralChatDraftId', + 'restoreGeneralChatDraft', + 'latestUserMessageText', + 'showHomeToast', + 'resetGeneralChatTimeline', + 'syncGeneralChatTimelineFromStore' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const extractedRemoteConnectionForwards = [ + 'applyWorkspace', + 'applyRemotePairingProjection', + 'ensureRemoteAvailable', + 'setRemoteConnectionState', + 'setRemoteUrl', + 'setRemoteUserId', + 'setRemoteAuthenticatedUserId', + 'setRemoteStatusText', + 'setRemoteConnectionFailureKind', + 'setRemoteBusy', + 'setRemoteUrlInputVisible' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); +const appRootRuntimeStateGetters = [ + 'remoteUrl', 'userId', 'authenticatedUserId', 'statusText', 'connectionState', + 'connectionFailureKind', 'isBusy', 'showRemoteUrlInput', 'workspaceName', 'workspacePath', + 'workspaceBranch', 'workspaceKind', 'assistantId', 'desktopName', 'desktopId', 'activeSession', + 'messages', 'pendingMessages', 'activeTurnMessage', 'timelineItems', 'hasMoreMessages' +].filter((getter) => new RegExp(`^\\s{2}get\\s+${getter}\\s*\\(`, 'm').test(appRootRuntimeSource)); +const extractedOwnerForwards = [ + 'currentRoute', 'isRoute', 'isGeneralChatVisible', 'pushRoute', 'replaceRoute', 'popRoute', + 'handleConversationIntent', 'pasteRemoteUrl', 'scanRemoteUrl', 'handleDetectedRemoteUrl', + 'showRecentWorkspaces', 'showAssistants', 'refreshSessions', 'loadMoreSessions', 'setSessionFilter', + 'openAddConnection', 'selectRemoteCreateModel', 'loadRecentWorkspacesInBackground', + 'loadOlderMessages', 'removeSelectedImage', 'persistVisibleGeneralChatDraft', 'stopPolling', + 'nudgeChatPolling', 'pollActiveSession', 'startHeartbeat', 'stopHeartbeat', + 'checkConnectionHealth', 'resumeRemoteActivity' +].filter((method) => new RegExp(`^\\s{2}(?:private\\s+)?(?:async\\s+)?${method}\\s*\\(`, 'm') + .test(appRootRuntimeSource)); + +const expected = { + serviceToPages: [], + componentToViewmodel: [], + viewmodelToComponents: [], + v1Components: [], + positionalActionConstructors: [], + duplicatedConversationTraceFields: [], + extractedFilePreviewMethods: [], + extractedSettingsMethods: [], + extractedCloudAccountMethods: [], + extractedConversationMethods: [], + extractedRemoteConversationMethods: [], + extractedRemoteCreateMethods: [], + extractedGeneralConversationMethods: [], + extractedRemoteConnectionForwards: [], + appRootRuntimeStateGetters: [], + extractedOwnerForwards: [], + missingPresentationFiles: [] +}; + +function sameSet(actual, wanted) { + return actual.length === wanted.length && actual.every((item, index) => item === wanted[index]); +} + +const actual = { + serviceToPages, + componentToViewmodel, + viewmodelToComponents, + v1Components, + positionalActionConstructors, + duplicatedConversationTraceFields, + extractedFilePreviewMethods, + extractedSettingsMethods, + extractedCloudAccountMethods, + extractedConversationMethods, + extractedRemoteConversationMethods, + extractedRemoteCreateMethods, + extractedGeneralConversationMethods, + extractedRemoteConnectionForwards, + appRootRuntimeStateGetters, + extractedOwnerForwards, + missingPresentationFiles +}; +let failed = false; +for (const [name, wanted] of Object.entries(expected)) { + if (!sameSet(actual[name], wanted)) { + failed = true; + console.error(`${name} mismatch`); + console.error(`expected: ${JSON.stringify(wanted)}`); + console.error(`actual: ${JSON.stringify(actual[name])}`); + } +} +if (appRootRuntimeLines > 500) { + failed = true; + console.error(`AppRootRuntime line budget exceeded: expected <=500, actual=${appRootRuntimeLines}`); +} +if (appRootPresentationLines > 500) { + failed = true; + console.error(`AppRootPresentation line budget exceeded: expected <=500, actual=${appRootPresentationLines}`); +} +for (const [file, budget] of componentLineBudgets) { + const source = fs.readFileSync(path.join(pagesRoot, file), 'utf8'); + const lineCount = source.split(/\r?\n/).length - 1; + if (lineCount > budget) { + failed = true; + console.error(`${file} line budget exceeded: expected <=${budget}, actual=${lineCount}`); + } +} + +if (failed) { + process.exitCode = 1; +} else { + console.log('HarmonyOS architecture contracts are satisfied.'); +} diff --git a/scripts/ci/setup-macos-signing.sh b/scripts/ci/setup-macos-signing.sh new file mode 100755 index 0000000000..17264e4b36 --- /dev/null +++ b/scripts/ci/setup-macos-signing.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +required=( + APPLE_CERTIFICATE + APPLE_CERTIFICATE_PASSWORD + APPLE_SIGNING_IDENTITY + APPLE_API_ISSUER + APPLE_API_KEY + APPLE_API_PRIVATE_KEY + KEYCHAIN_PASSWORD +) + +missing=() +for name in "${required[@]}"; do + if [[ -z "${!name:-}" ]]; then + missing+=("${name}") + fi +done + +if [[ "${#missing[@]}" -gt 0 ]]; then + if [[ "${BITFUN_REQUIRE_APPLE_SIGNING:-false}" == "true" ]]; then + printf 'Apple signing is required, but these secrets are missing: %s\n' "${missing[*]}" >&2 + exit 1 + fi + printf 'Apple signing is not fully configured; leaving this non-release macOS build unsigned. Missing: %s\n' "${missing[*]}" + exit 0 +fi + +signing_dir="${RUNNER_TEMP}/bitfun-apple-signing" +certificate_path="${signing_dir}/developer-id.p12" +api_key_path="${signing_dir}/AuthKey_${APPLE_API_KEY}.p8" +keychain_path="${RUNNER_TEMP}/bitfun-signing.keychain-db" + +mkdir -p "${signing_dir}" +chmod 700 "${signing_dir}" +printf '%s' "${APPLE_CERTIFICATE}" | base64 --decode >"${certificate_path}" +printf '%s' "${APPLE_API_PRIVATE_KEY}" >"${api_key_path}" +chmod 600 "${certificate_path}" "${api_key_path}" + +security create-keychain -p "${KEYCHAIN_PASSWORD}" "${keychain_path}" +security set-keychain-settings -lut 21600 "${keychain_path}" +security unlock-keychain -p "${KEYCHAIN_PASSWORD}" "${keychain_path}" +security import "${certificate_path}" \ + -k "${keychain_path}" \ + -P "${APPLE_CERTIFICATE_PASSWORD}" \ + -T /usr/bin/codesign \ + -T /usr/bin/security +security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "${KEYCHAIN_PASSWORD}" \ + "${keychain_path}" + +curl --fail --location --silent --show-error \ + 'https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer' \ + --output "${signing_dir}/DeveloperIDG2CA.cer" +security import "${signing_dir}/DeveloperIDG2CA.cer" -k "${keychain_path}" + +security list-keychains -d user -s "${keychain_path}" "${HOME}/Library/Keychains/login.keychain-db" +security default-keychain -d user -s "${keychain_path}" +security find-identity -v -p codesigning "${keychain_path}" + +if ! security find-identity -v -p codesigning "${keychain_path}" | grep -Fq "${APPLE_SIGNING_IDENTITY}"; then + echo "The imported certificate does not provide ${APPLE_SIGNING_IDENTITY}." >&2 + exit 1 +fi + +{ + echo "APPLE_API_ISSUER=${APPLE_API_ISSUER}" + echo "APPLE_API_KEY=${APPLE_API_KEY}" + echo "APPLE_API_KEY_PATH=${api_key_path}" + echo "APPLE_SIGNING_IDENTITY=${APPLE_SIGNING_IDENTITY}" + echo "BITFUN_APPLE_SIGNING_CONFIGURED=true" +} >>"${GITHUB_ENV}" + +echo "Apple Developer ID signing and notarization credentials are ready." diff --git a/scripts/ci/verify-macos-signing.sh b/scripts/ci/verify-macos-signing.sh new file mode 100755 index 0000000000..4513fa57a5 --- /dev/null +++ b/scripts/ci/verify-macos-signing.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +target="${1:?usage: verify-macos-signing.sh }" +if [[ "${BITFUN_APPLE_SIGNING_CONFIGURED:-false}" != "true" ]]; then + echo "Apple signing is not configured; skipping macOS signature verification." + exit 0 +fi + +bundle_dir="target/${target}/release/bundle" + +shopt -s nullglob +apps=("${bundle_dir}/macos/"*.app) +dmgs=("${bundle_dir}/dmg/"*.dmg) + +if [[ "${#apps[@]}" -eq 0 || "${#dmgs[@]}" -eq 0 ]]; then + echo "Expected one or more macOS app and DMG bundles under ${bundle_dir}." >&2 + exit 1 +fi + +for app in "${apps[@]}"; do + codesign --verify --deep --strict --verbose=2 "${app}" + while IFS= read -r -d '' candidate; do + if ! file -b "${candidate}" | grep -q 'Mach-O'; then + continue + fi + signature_details="$(codesign -dv --verbose=4 "${candidate}" 2>&1)" + grep -Fq 'Authority=Developer ID Application:' <<<"${signature_details}" + grep -Eq 'flags=.*\(runtime\)' <<<"${signature_details}" + grep -Fq 'Timestamp=' <<<"${signature_details}" + done < <(find "${app}" -type f -print0) + xcrun stapler validate "${app}" + spctl --assess --type execute --verbose=4 "${app}" +done + +for dmg in "${dmgs[@]}"; do + xcrun notarytool submit "${dmg}" \ + --issuer "${APPLE_API_ISSUER}" \ + --key-id "${APPLE_API_KEY}" \ + --key "${APPLE_API_KEY_PATH}" \ + --wait + xcrun stapler staple "${dmg}" + xcrun stapler validate "${dmg}" + spctl --assess --type open --context context:primary-signature --verbose=4 "${dmg}" +done + +echo "Verified Developer ID signatures, notarization tickets, and Gatekeeper acceptance." diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 02d67bf762..4cc3edeb2c 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -2,7 +2,13 @@ import { readFileSync, readdirSync } from 'node:fs'; import { isAbsolute, join, relative, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; -import { servicesReqwestOwnerFeatures } from './rules/feature-rules.mjs'; +import { + acpClientCoreFeatures, + acpServerCoreFeatures, + capabilityContractDependencyRules, + guardedEmptyInternalDefaultManifestPaths, + servicesReqwestOwnerFeatures, +} from './rules/feature-rules.mjs'; const SKIPPED_DIRECTORIES = new Set([ '.git', @@ -146,18 +152,41 @@ const SERVICES_INTEGRATIONS_TOKIO_FEATURES = new Map([ ]); const SERVICES_CORE_TOKIO_FEATURES = new Map([ - ['filesystem', ['fs']], - ['local-storage', ['fs', 'sync']], - ['process-runtime', ['io-util', 'process']], - ['workspace-instructions', ['fs', 'io-util']], - ['lsp', ['fs', 'io-util', 'process', 'sync']], - ['workspace-runtime', ['fs', 'io-util', 'process', 'sync']], + ['diff', ['rt', 'time']], + ['filesystem', ['fs', 'rt']], + ['json-io', ['fs', 'rt', 'sync', 'time']], + ['local-storage', ['fs', 'rt', 'sync', 'time']], + ['permission', ['rt']], + ['process-runtime', ['io-util', 'process', 'rt', 'time']], + ['workspace-instructions', ['fs', 'io-util', 'rt']], + ['workspace-text-runtime', ['rt']], + ['lsp', ['fs', 'io-util', 'process', 'rt', 'sync', 'time']], + ['workspace-runtime', ['fs', 'io-util', 'process', 'rt', 'sync', 'time']], +]); +const SERVICES_CORE_BASE_TOKIO_FEATURES = []; +const SERVICES_INTEGRATIONS_TOKIO_AGGREGATES = new Set(['product-full']); +const SERVICES_CORE_TOKIO_AGGREGATES = new Set(['session-git']); +const CORE_TOKIO_FEATURES = new Map([ + ['agent-runtime', ['io-util', 'macros', 'rt', 'time']], + ['mcp-runtime', ['io-util', 'macros', 'rt', 'rt-multi-thread', 'time']], + ['browser-control', ['net', 'rt', 'time']], + ['debug-log', ['macros', 'net', 'rt', 'time']], + ['lsp', ['macros']], +]); +const CORE_TOKIO_AGGREGATES = new Set([ + 'external-sources', + 'plugin-runtime', + 'product-full', + 'remote-connect', + 'tools-browser-web', + 'tools-mcp', ]); -const SERVICES_CORE_BASE_TOKIO_FEATURES = ['rt', 'time']; -// The installer is an excluded standalone workspace with its own Rust checks -// and packaging lifecycle; this policy governs the root product workspace. -const TOKIO_DEPENDENCY_POLICY_EXCLUDED_PACKAGES = new Set(['bitfun-installer']); +const TOKIO_DEPENDENCY_POLICY_EXCLUDED_PACKAGES = new Set(); + +function tokioCapabilityReference(value) { + return value.match(/^tokio\??\/(.+)$/)?.[1]; +} function effectiveTokioCapabilities(feature, featureGraph, visiting = new Set()) { if (visiting.has(feature)) { @@ -167,8 +196,9 @@ function effectiveTokioCapabilities(feature, featureGraph, visiting = new Set()) const capabilities = new Set(); for (const value of featureGraph[feature] ?? []) { - if (value.startsWith('tokio/')) { - capabilities.add(value.slice('tokio/'.length)); + const capability = tokioCapabilityReference(value); + if (capability) { + capabilities.add(capability); } else if (Object.hasOwn(featureGraph, value)) { for (const capability of effectiveTokioCapabilities(value, featureGraph, visiting)) { capabilities.add(capability); @@ -180,7 +210,7 @@ function effectiveTokioCapabilities(feature, featureGraph, visiting = new Set()) return capabilities; } -function findOwnedTokioFeatureViolations(pkg, ownerProfiles) { +function findOwnedTokioFeatureViolations(pkg, ownerProfiles, aggregateFeatures = new Set()) { const violations = []; const featureGraph = pkg.features ?? {}; @@ -218,7 +248,17 @@ function findOwnedTokioFeatureViolations(pkg, ownerProfiles) { if (ownerProfiles.has(feature)) { continue; } - if (values.some((value) => value.startsWith('tokio/'))) { + if (aggregateFeatures.has(feature)) { + if (values.some((value) => tokioCapabilityReference(value))) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name}:${feature} Tokio aggregate must compose reviewed owners instead of declaring Tokio capabilities directly`, + }); + } + continue; + } + if (effectiveTokioCapabilities(feature, featureGraph).size > 0) { violations.push({ path: pkg.manifest_path, line: 1, @@ -231,7 +271,11 @@ function findOwnedTokioFeatureViolations(pkg, ownerProfiles) { } export function findServicesIntegrationsTokioFeatureViolations(pkg) { - return findOwnedTokioFeatureViolations(pkg, SERVICES_INTEGRATIONS_TOKIO_FEATURES); + return findOwnedTokioFeatureViolations( + pkg, + SERVICES_INTEGRATIONS_TOKIO_FEATURES, + SERVICES_INTEGRATIONS_TOKIO_AGGREGATES, + ); } function reqwestDependencyFeatureReferences(references) { @@ -244,37 +288,37 @@ function reqwestDependencyFeatureReferences(references) { ); } -const REQWEST_TRANSPORT_FEATURES = [ - 'form', - 'http2', - 'json', - 'multipart', - 'query', - 'stream', -]; const REQWEST_PACKAGE_PROFILES = new Map([ - ['bitfun-installer', { - dependencyFeatures: ['json', 'rustls-tls', 'stream'], - optional: false, - allowedPackageFeatureRefs: new Set(['reqwest/rustls-tls']), - }], - ['bitfun-core', { dependencyFeatures: REQWEST_TRANSPORT_FEATURES, optional: true }], + ['bitfun-core', { dependencyFeatures: [], optional: true }], ['bitfun-services-integrations', { - dependencyFeatures: REQWEST_TRANSPORT_FEATURES, + dependencyFeatures: ['http2'], optional: true, servicesOwners: true, }], - ...[ - 'bitfun-ai-adapters', - 'bitfun-cli', - 'bitfun-desktop', - 'bitfun-miniapp-market-service', - 'bitfun-skin-market-service', - ].map((packageName) => [packageName, { - dependencyFeatures: [...REQWEST_TRANSPORT_FEATURES, 'rustls'], + ['bitfun-ai-adapters', { + dependencyFeatures: ['http2', 'json', 'rustls', 'socks', 'stream'], + optional: false, + allowedPackageFeatureRefs: new Set(['reqwest/form']), + requiredPackageFeatureRefs: new Map([ + ['subscription-auth', new Set(['reqwest/form'])], + ]), + }], + ['bitfun-cli', { + dependencyFeatures: ['http2', 'rustls', 'stream'], + optional: false, + }], + ['bitfun-desktop', { + dependencyFeatures: ['http2', 'json', 'query', 'rustls', 'stream'], + optional: false, + }], + ['bitfun-miniapp-market-service', { + dependencyFeatures: ['form', 'http2', 'json', 'rustls'], optional: false, - allowedPackageFeatureRefs: new Set(['reqwest/rustls']), - }]), + }], + ['bitfun-skin-market-service', { + dependencyFeatures: ['http2', 'json', 'rustls'], + optional: false, + }], ]); function findReqwestPackageProfileViolations(pkg, profile) { @@ -358,6 +402,18 @@ function findReqwestPackageProfileViolations(pkg, profile) { } } } + for (const [featureName, requiredReferences] of profile.requiredPackageFeatureRefs ?? []) { + const actualReferences = new Set(pkg.features?.[featureName] ?? []); + for (const reference of requiredReferences) { + if (!actualReferences.has(reference)) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name}:${featureName} is missing Reqwest feature reference ${reference}`, + }); + } + } + } } return violations; @@ -513,6 +569,20 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { const violations = []; const featureGraph = pkg.features ?? {}; const ownerFeatures = new Set(servicesReqwestOwnerFeatures); + const ownerFeatureReferences = new Map([ + ['announcement', ['reqwest/json']], + ['browser-control', ['reqwest/json']], + ['debug-log', ['reqwest/json']], + ['mcp', ['reqwest/json', 'reqwest/stream']], + ['miniapp-market', ['reqwest/json', 'reqwest/query', 'reqwest/stream']], + ['miniapp-runtime', ['reqwest/stream']], + ['models-dev', ['reqwest/system-proxy']], + ['remote-connect', ['reqwest/json', 'reqwest/multipart', 'reqwest/query']], + ['remote-ssh-concrete', ['reqwest/stream']], + ['review-platform', ['reqwest/json', 'reqwest/query', 'reqwest/stream']], + ['speech', ['reqwest/stream']], + ['web-tools', ['reqwest/json']], + ]); for (const featureName of servicesReqwestOwnerFeatures) { const references = featureGraph[featureName]; @@ -538,6 +608,15 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { message: `${pkg.name}:${featureName} is missing reqwest/rustls`, }); } + for (const reference of ownerFeatureReferences.get(featureName) ?? []) { + if (!references.includes(reference)) { + violations.push({ + path: pkg.manifest_path, + line: 1, + message: `${pkg.name}:${featureName} is missing Reqwest feature reference ${reference}`, + }); + } + } } for (const [featureName, references] of Object.entries(featureGraph)) { @@ -558,12 +637,15 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { }); continue; } + const allowedReferences = new Set([ + 'reqwest', + 'dep:reqwest', + 'reqwest/rustls', + ...(ownerFeatureReferences.get(featureName) ?? []), + ]); for (const reference of reqwestReferences) { if ( - reference !== 'reqwest' - && reference !== 'dep:reqwest' - && reference !== 'reqwest/rustls' - && !(featureName === 'models-dev' && reference === 'reqwest/system-proxy') + !allowedReferences.has(reference) ) { violations.push({ path: pkg.manifest_path, @@ -580,7 +662,11 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { export function findServicesCoreTokioFeatureViolations(pkg) { - return findOwnedTokioFeatureViolations(pkg, SERVICES_CORE_TOKIO_FEATURES); + return findOwnedTokioFeatureViolations( + pkg, + SERVICES_CORE_TOKIO_FEATURES, + SERVICES_CORE_TOKIO_AGGREGATES, + ); } export function findServicesCorePlatformDependencyFeatureViolations(packages) { @@ -631,9 +717,18 @@ export function findTokioDependencyFeatureViolations(packages) { const featureOwnedServicesCoreRuntime = pkg.name === 'bitfun-services-core' && (dependency.kind ?? null) === null; - if (featureOwnedServicesCoreRuntime) { + const featureOwnedCoreRuntime = + pkg.name === 'bitfun-core' + && (dependency.kind ?? null) === null; + if ( + featureOwnedIntegrationRuntime + || featureOwnedServicesCoreRuntime + || featureOwnedCoreRuntime + ) { const actual = [...features].sort(); - const expected = [...SERVICES_CORE_BASE_TOKIO_FEATURES].sort(); + const expected = [...(featureOwnedCoreRuntime + ? ['fs', 'sync'] + : SERVICES_CORE_BASE_TOKIO_FEATURES)].sort(); const missing = expected.filter((feature) => !actual.includes(feature)); const unexpected = actual.filter((feature) => !expected.includes(feature)); if (missing.length > 0) { @@ -665,6 +760,13 @@ export function findTokioDependencyFeatureViolations(packages) { if (pkg.name === 'bitfun-services-core') { violations.push(...findServicesCoreTokioFeatureViolations(pkg)); } + if (pkg.name === 'bitfun-core') { + violations.push(...findOwnedTokioFeatureViolations( + pkg, + CORE_TOKIO_FEATURES, + CORE_TOKIO_AGGREGATES, + )); + } } return violations; @@ -772,53 +874,245 @@ export function findProductEntrypointCoreFeatureViolations( packages, { root, crateLayoutRules }, ) { + const coreCompatibilityReviewedFeatures = [ + 'agent-runtime', + 'document-read', + 'subscription-auth', + 'deep-research', + 'lsp', + 'external-sources', + 'tools-basic', + 'tools-git', + 'tools-mcp', + 'tools-browser-web', + 'tools-computer-use', + 'tools-image-analysis', + 'tools-miniapp', + 'tools-canvas', + 'tools-agent-control', + ]; const reviewedCoreFeatureClosures = new Map([ ['bitfun-cli', [ - 'agent-runtime', - 'canvas-runtime', - 'external-sources', + ...coreCompatibilityReviewedFeatures, + 'remote-connect', 'plugin-runtime', 'ssh-remote', ]], - ['bitfun-acp', [ - 'agent-runtime', - 'canvas-runtime', + ['bitfun-acp', [...new Set([...acpClientCoreFeatures, ...acpServerCoreFeatures])]], + ['bitfun-app-server', [ 'external-sources', - 'ssh-remote', + 'git', + 'i18n-runtime', + 'remote-connect', ]], + ['bitfun-sdk-host-app', coreCompatibilityReviewedFeatures], ]); - const acpActiveCoreFeatures = [ + const fullProductCoreEntrypoints = new Set(['bitfun-desktop', 'bitfun-server']); + const fullProductCoreEntrypointsFound = new Set(); + const coreCompatibilityActiveFeatures = [ 'agent-runtime', 'ai-adapter-runtime', + 'browser-control', 'canvas-runtime', + 'deep-research', + 'document-read', 'external-sources', 'file-watch', 'filesystem', 'git', 'lsp', 'local-storage', + 'mcp-runtime', + 'model-catalog', 'plugin-source', 'process-runtime', 'product-capabilities', - 'product-domains', - 'remote-workspace', 'review-platform', 'runtime-services', - 'ssh-remote', + 'scheduled-jobs', + 'script-tool-runtime', + 'subscription-auth', 'terminal', 'tool-packs', + 'tools-agent-control', + 'tools-basic', + 'tools-browser-web', + 'tools-canvas', + 'tools-computer-use', + 'tools-git', + 'tools-image-analysis', + 'tools-mcp', + 'tools-miniapp', + 'web-tools', + 'workspace-search', 'workspace-runtime', 'workspace-watch', ]; + const acpActiveCoreFeatures = [ + ...coreCompatibilityActiveFeatures, + 'remote-workspace', + 'ssh-remote', + ]; const reviewedActiveCoreFeatureClosures = new Map([ - ['bitfun-cli', [...acpActiveCoreFeatures, 'plugin-runtime']], + ['bitfun-cli', [ + ...acpActiveCoreFeatures, + 'i18n-runtime', + 'plugin-runtime', + 'remote-connect', + ]], ['bitfun-acp', acpActiveCoreFeatures], + ['bitfun-sdk-host-app', coreCompatibilityActiveFeatures], + ['bitfun-app-server', [ + 'agent-runtime', + 'ai-adapter-runtime', + 'external-sources', + 'file-watch', + 'filesystem', + 'git', + 'i18n-runtime', + 'local-storage', + 'mcp-runtime', + 'model-catalog', + 'plugin-source', + 'process-runtime', + 'product-capabilities', + 'remote-connect', + 'runtime-services', + 'scheduled-jobs', + 'script-tool-runtime', + 'terminal', + 'tool-packs', + 'tools-agent-control', + 'tools-basic', + 'ts', + 'workspace-search', + 'workspace-runtime', + 'workspace-watch', + ]], + ]); + const reviewedForbiddenDependencyOwnerFeatures = new Map([ + ['bitfun-sdk-host-app', new Map([ + ['bitfun-services-integrations', [ + 'announcement', + 'debug-log', + 'function-agents', + 'product-full', + 'remote-connect', + 'remote-ssh', + 'remote-ssh-concrete', + ]], + ['bitfun-product-domains', ['function-agents', 'product-full']], + ['bitfun-services-core', ['dispatch-workspace']], + ])], ]); const packageByManifest = new Map( packages.map((pkg) => [normalizedPath(pkg.manifest_path), pkg]), ); const violations = []; + const reviewedAcpRoleSelections = new Map([ + ['bitfun-cli', { + label: 'CLI', + requiredFeatures: ['client', 'server'], + }], + ['bitfun-desktop', { + label: 'Desktop', + requiredFeatures: ['client'], + }], + ]); + const acpPackage = packages.find((pkg) => pkg.name === 'bitfun-acp'); + if (acpPackage) { + const reviewedConsumersFound = new Set(); + for (const sourcePackage of packages) { + const declaredDependencies = (sourcePackage.dependencies ?? []).filter((candidate) => { + if (!candidate.path) { + return false; + } + return packageByManifest.get( + normalizedPath(join(candidate.path, 'Cargo.toml')), + )?.name === 'bitfun-acp'; + }); + const normalDependencies = declaredDependencies.filter( + (dependency) => dependency.kind === null, + ); + const rule = reviewedAcpRoleSelections.get(sourcePackage.name); + if (!rule) { + if (declaredDependencies.length > 0) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `bitfun-acp consumer ${sourcePackage.name} must register an explicit role selection`, + }); + } + continue; + } + if (declaredDependencies.length === 0) { + continue; + } + reviewedConsumersFound.add(sourcePackage.name); + const unconditionalDependencies = normalDependencies.filter( + (dependency) => dependency.target === null && dependency.optional !== true, + ); + if (unconditionalDependencies.length === 0) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must keep an unconditional normal bitfun-acp dependency`, + }); + } + if (declaredDependencies.some((dependency) => dependency.optional === true)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must not make a bitfun-acp dependency optional`, + }); + } + if (declaredDependencies.some((dependency) => dependency.uses_default_features !== false)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must set default-features = false on every dependency`, + }); + } + const unconditionalFeatures = new Set( + unconditionalDependencies.flatMap((dependency) => dependency.features ?? []), + ); + const selectedFeatures = new Set( + declaredDependencies.flatMap((dependency) => dependency.features ?? []), + ); + if (unconditionalDependencies.length > 0) { + for (const requiredFeature of rule.requiredFeatures) { + if (!unconditionalFeatures.has(requiredFeature)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must include ${requiredFeature}`, + }); + } + } + } + for (const selectedFeature of selectedFeatures) { + if (!rule.requiredFeatures.includes(selectedFeature)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must not include ${selectedFeature}`, + }); + } + } + } + for (const [sourceName, rule] of reviewedAcpRoleSelections) { + const sourcePackage = packages.find((pkg) => pkg.name === sourceName); + if (sourcePackage && !reviewedConsumersFound.has(sourceName)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must keep the bitfun-acp dependency`, + }); + } + } + } + for (const sourcePackage of packages) { const sourceLayer = layerForManifest(sourcePackage.manifest_path, { root, @@ -838,21 +1132,32 @@ export function findProductEntrypointCoreFeatureViolations( if (targetPackage?.name !== 'bitfun-core') { continue; } - if (dependency.uses_default_features !== false) { - violations.push({ - path: sourcePackage.manifest_path, - line: 1, - message: `product entrypoint ${sourcePackage.name} must set default-features = false for its bitfun-core ${dependencyDescription(dependency)}`, - }); - } - if (!Array.isArray(dependency.features) || dependency.features.length === 0) { + const roleOwnedAcpDependency = + sourcePackage.name === 'bitfun-acp' && dependency.optional === true; + if ( + !roleOwnedAcpDependency + && (!Array.isArray(dependency.features) || dependency.features.length === 0) + ) { violations.push({ path: sourcePackage.manifest_path, line: 1, message: `product entrypoint ${sourcePackage.name} must select at least one explicit feature for its bitfun-core ${dependencyDescription(dependency)}`, }); } - const reviewedClosure = reviewedCoreFeatureClosures.get(sourcePackage.name); + if (fullProductCoreEntrypoints.has(sourcePackage.name)) { + fullProductCoreEntrypointsFound.add(sourcePackage.name); + const selectedFeatures = [...new Set(dependency.features ?? [])].sort(); + if (selectedFeatures.length !== 1 || selectedFeatures[0] !== 'product-full') { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${sourcePackage.name} Core capability closure must select exactly product-full`, + }); + } + } + const reviewedClosure = roleOwnedAcpDependency + ? undefined + : reviewedCoreFeatureClosures.get(sourcePackage.name); if (reviewedClosure) { const selectedFeatures = new Set(dependency.features ?? []); for (const requiredFeature of reviewedClosure) { @@ -876,6 +1181,18 @@ export function findProductEntrypointCoreFeatureViolations( } } } + for (const sourceName of packages.some((pkg) => pkg.name === 'bitfun-core') + ? fullProductCoreEntrypoints + : []) { + const sourcePackage = packages.find((pkg) => pkg.name === sourceName); + if (sourcePackage && !fullProductCoreEntrypointsFound.has(sourceName)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${sourceName} Core capability closure must keep the bitfun-core dependency`, + }); + } + } const corePackage = packages.find((pkg) => pkg.name === 'bitfun-core'); if (corePackage) { @@ -893,11 +1210,18 @@ export function findProductEntrypointCoreFeatureViolations( continue; } const allowedCoreFeatures = new Set( - reviewedActiveCoreFeatureClosures.get(rootName) ?? [], + ['default', ...(reviewedActiveCoreFeatureClosures.get(rootName) ?? [])], ); const rootSelectedFeatures = Object.keys(rootPackage.features ?? {}) .filter((feature) => feature !== 'default'); - const rootLabel = rootName === 'bitfun-cli' ? 'CLI' : 'ACP'; + const rootLabel = new Map([ + ['bitfun-cli', 'CLI'], + ['bitfun-acp', 'ACP'], + ['bitfun-app-server', 'App Server'], + ['bitfun-sdk-host-app', 'SDK Host'], + ]).get(rootName) ?? rootName; + const forbiddenOwnerFeatures = + reviewedForbiddenDependencyOwnerFeatures.get(rootName); const packageStates = new Map(); const pending = []; @@ -1036,6 +1360,30 @@ export function findProductEntrypointCoreFeatureViolations( } continue; } + + const forbiddenOwnerFeature = ( + forbiddenOwnerFeatures?.get(targetPackage.name) ?? [] + ).find((feature) => targetState.featureState.active.has(feature)); + if (forbiddenOwnerFeature) { + const forbiddenOwner = `${targetPackage.name}/${forbiddenOwnerFeature}`; + const reportKey = [ + rootName, + targetDependencyKindContext, + forbiddenOwner, + ].join('|'); + if (!reportedUnexpectedFeatures.has(reportKey)) { + reportedUnexpectedFeatures.add(reportKey); + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rootLabel} dependency closure must not enable ${forbiddenOwner}: ${[ + ...packagePath, + forbiddenOwner, + ].join(' -> ')}`, + }); + } + continue; + } } } } @@ -1044,6 +1392,363 @@ export function findProductEntrypointCoreFeatureViolations( return violations; } +function normalizedDependencyKind(dependency) { + return dependency.kind ?? 'normal'; +} + +function normalizedDependencyTarget(dependency) { + return dependency.target ?? null; +} + +function sameStringSet(actual, expected) { + const left = [...new Set(actual ?? [])].sort(); + const right = [...new Set(expected ?? [])].sort(); + return left.length === right.length + && left.every((value, index) => value === right[index]); +} + +function featureForwardingReferences(featureGraph, dependency) { + const alias = dependencyAlias(dependency); + const references = []; + for (const [sourceFeature, values] of Object.entries(featureGraph ?? {})) { + for (const value of values) { + const match = value.match(/^([^/?]+)(\?)?\/(.+)$/); + if (match?.[1] === alias) { + references.push({ + sourceFeature, + feature: match[3], + weak: Boolean(match[2]), + }); + } + } + } + return references; +} + +function featureDependencyActivations(featureGraph, dependency) { + const alias = dependencyAlias(dependency); + const activations = []; + for (const [sourceFeature, values] of Object.entries(featureGraph ?? {})) { + if (values.includes(`dep:${alias}`) || values.includes(alias)) { + activations.push(sourceFeature); + } + } + return activations; +} + +function packageMatchesManifest(pkg, manifestPath, root) { + return repositoryPath(root, pkg.manifest_path) === manifestPath; +} + +function dependencyTargetsPackage(dependency, targetPackage) { + return dependency.path !== null + && dependency.path !== undefined + && normalizedPath(join(dependency.path, 'Cargo.toml')) + === normalizedPath(targetPackage.manifest_path); +} + +function dependencyEdgeMatches(dependency, expected) { + return normalizedDependencyKind(dependency) === expected.kind + && dependency.optional === expected.optional + && normalizedDependencyTarget(dependency) === expected.target + && (dependency.rename ?? null) === (expected.rename ?? null) + && sameStringSet(dependency.features, expected.features); +} + +function featureTransitivelyReaches(featureGraph, sourceFeature, destinations, seen = new Set()) { + if (destinations.has(sourceFeature)) { + return true; + } + if (seen.has(sourceFeature)) { + return false; + } + seen.add(sourceFeature); + return (featureGraph[sourceFeature] ?? []).some((reference) => + Object.hasOwn(featureGraph, reference) + && featureTransitivelyReaches(featureGraph, reference, destinations, seen)); +} + +export function findRedundantInternalDefaultFeatureDisables( + packages, + { + root, + guardedManifests = guardedEmptyInternalDefaultManifestPaths, + } = {}, +) { + if (!root) { + throw new Error('redundant internal default-feature check requires the repository root'); + } + + const packageByManifest = new Map( + packages.map((pkg) => [normalizedPath(pkg.manifest_path), pkg]), + ); + const guardedTargets = new Set( + guardedManifests.map((path) => normalizedPath(join(root, path))), + ); + const violations = []; + + for (const consumer of packages) { + for (const dependency of consumer.dependencies ?? []) { + if (dependency.uses_default_features !== false || !dependency.path) { + continue; + } + const targetPackage = packageByManifest.get( + normalizedPath(join(dependency.path, 'Cargo.toml')), + ); + const targetFeatures = targetPackage?.features ?? {}; + if ( + !targetPackage + || !guardedTargets.has(normalizedPath(targetPackage.manifest_path)) + || !Object.hasOwn(targetFeatures, 'default') + || !sameStringSet(targetFeatures.default, []) + ) { + continue; + } + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name} ${targetPackage.name} dependency has redundant default-features = false because the target default is guarded empty`, + }); + } + } + + return violations; +} + +export function findGuardedInternalDefaultFeatureViolations( + packages, + { + root, + guardedManifests = guardedEmptyInternalDefaultManifestPaths, + } = {}, +) { + if (!root) { + throw new Error('guarded internal default-feature check requires the repository root'); + } + + const packageByManifest = new Map( + packages.map((pkg) => [normalizedPath(pkg.manifest_path), pkg]), + ); + const violations = []; + + for (const manifestPath of guardedManifests) { + const targetPackage = packageByManifest.get(normalizedPath(join(root, manifestPath))); + if (!targetPackage) { + violations.push({ + path: join(root, manifestPath), + line: 1, + message: `guarded internal empty-default target is missing: ${manifestPath}`, + }); + continue; + } + const targetFeatures = targetPackage.features ?? {}; + if ( + !Object.hasOwn(targetFeatures, 'default') + || !sameStringSet(targetFeatures.default, []) + ) { + violations.push({ + path: targetPackage.manifest_path, + line: 1, + message: `${targetPackage.name} guarded default feature must stay explicitly empty`, + }); + } + } + + return violations; +} + +export function findCapabilityContractConsumerViolations( + packages, + rules = capabilityContractDependencyRules, + { root } = {}, +) { + const violations = []; + const targetPackages = new Map(); + + if (!root) { + throw new Error('capability contract consumer check requires the repository root'); + } + + for (const rule of rules) { + const targetPackage = packages.find((pkg) => + pkg.name === rule.packageName && packageMatchesManifest(pkg, rule.manifestPath, root)); + if (!targetPackage) { + violations.push({ + path: rule.manifestPath, + line: 1, + message: `${rule.packageName} managed target is missing from Cargo metadata`, + }); + continue; + } + targetPackages.set(rule.packageName, targetPackage); + if ( + !Object.hasOwn(targetPackage.features ?? {}, 'default') + || !sameStringSet(targetPackage.features.default, []) + ) { + violations.push({ + path: targetPackage.manifest_path, + line: 1, + message: `${rule.packageName} capability contract default feature must stay empty`, + }); + } + const actualFeatures = Object.keys(targetPackage.features ?? {}) + .filter((feature) => feature !== 'default'); + const expectedFeatures = Object.keys(rule.featureProfiles) + .filter((feature) => feature !== 'default'); + if (!sameStringSet(actualFeatures, expectedFeatures)) { + violations.push({ + path: targetPackage.manifest_path, + line: 1, + message: `${rule.packageName} capability contract feature surface must stay exact`, + }); + } + for (const [feature, expectedReferences] of Object.entries(rule.featureProfiles)) { + if ( + Object.hasOwn(targetPackage.features ?? {}, feature) + && !sameStringSet(targetPackage.features[feature], expectedReferences) + ) { + violations.push({ + path: targetPackage.manifest_path, + line: 1, + message: `${rule.packageName}:${feature} feature graph must stay exact`, + }); + } + } + } + + for (const rule of rules) { + const targetPackage = targetPackages.get(rule.packageName); + if (!targetPackage) { + continue; + } + for (const consumer of packages) { + const namedDependencies = (consumer.dependencies ?? []).filter( + (dependency) => dependency.name === rule.packageName, + ); + const managedDependencies = namedDependencies.filter((dependency) => + dependencyTargetsPackage(dependency, targetPackage)); + for (const dependency of namedDependencies) { + if (dependencyTargetsPackage(dependency, targetPackage)) { + continue; + } + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name} ${rule.packageName} dependency must use the managed internal path`, + }); + } + + const consumerProfile = rule.consumers.get(consumer.name); + if (!consumerProfile) { + if (managedDependencies.length > 0) { + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name} has an unreviewed consumer edge to ${rule.packageName}`, + }); + } + continue; + } + const unmatchedExpectedEdges = [...consumerProfile.edges]; + for (const dependency of managedDependencies) { + const matchIndex = unmatchedExpectedEdges.findIndex((edge) => + dependencyEdgeMatches(dependency, edge)); + if (matchIndex === -1) { + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name} has an unreviewed ${rule.packageName} dependency edge`, + }); + } else { + unmatchedExpectedEdges.splice(matchIndex, 1); + } + } + for (const edge of unmatchedExpectedEdges) { + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name} is missing reviewed ${edge.kind} ${rule.packageName} dependency edge`, + }); + } + + const actualForwarders = managedDependencies.flatMap((dependency) => + featureForwardingReferences(consumer.features, dependency)); + for (const forwarding of actualForwarders) { + const allowed = (consumerProfile.forwarders ?? []).some((expected) => + expected.sourceFeature === forwarding.sourceFeature + && expected.feature === forwarding.feature + && expected.weak === forwarding.weak); + if (allowed) { + continue; + } + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name}:${forwarding.sourceFeature} has unreviewed ${rule.packageName}/${forwarding.feature} forwarding`, + }); + } + for (const expected of consumerProfile.forwarders ?? []) { + const present = actualForwarders.some((forwarding) => + expected.sourceFeature === forwarding.sourceFeature + && expected.feature === forwarding.feature + && expected.weak === forwarding.weak); + if (present) { + continue; + } + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name}:${expected.sourceFeature} is missing reviewed ${rule.packageName}/${expected.feature} forwarding`, + }); + } + const actualActivators = managedDependencies.flatMap((dependency) => + featureDependencyActivations(consumer.features, dependency)); + for (const sourceFeature of actualActivators) { + if ((consumerProfile.activators ?? []).includes(sourceFeature)) { + continue; + } + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name}:${sourceFeature} has unreviewed ${rule.packageName} activation`, + }); + } + for (const sourceFeature of consumerProfile.activators ?? []) { + if (actualActivators.includes(sourceFeature)) { + continue; + } + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name}:${sourceFeature} is missing reviewed ${rule.packageName} activation`, + }); + } + + const directOwners = new Set([ + ...(consumerProfile.forwarders ?? []).map(({ sourceFeature }) => sourceFeature), + ...(consumerProfile.activators ?? []), + ]); + const allowedAggregates = new Set(consumerProfile.aggregates ?? []); + for (const feature of Object.keys(consumer.features ?? {})) { + if ( + directOwners.has(feature) + || allowedAggregates.has(feature) + || !featureTransitivelyReaches(consumer.features, feature, directOwners) + ) { + continue; + } + violations.push({ + path: consumer.manifest_path, + line: 1, + message: `${consumer.name}:${feature} is an unreviewed aggregate of ${rule.packageName} capability owners`, + }); + } + } + } + + return violations; +} + function matchingClosingDelimiter( source, openingIndex, @@ -1456,6 +2161,9 @@ export function checkCargoDependencyBoundaries({ root, crateLayoutRules }) { packages, { root, crateLayoutRules }, ), + ...findGuardedInternalDefaultFeatureViolations(packages, { root }), + ...findRedundantInternalDefaultFeatureDisables(packages, { root }), + ...findCapabilityContractConsumerViolations(packages, undefined, { root }), ...findFeatureGatedTestTargetViolations(packages), ...findRuntimeServicesTestSupportFeatureViolations(packages), ...findTokioDependencyFeatureViolations(packages), diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs index fbd68a3717..d70a510bc9 100644 --- a/scripts/core-boundaries/checker.mjs +++ b/scripts/core-boundaries/checker.mjs @@ -15,10 +15,12 @@ import { } from './rules/crate-layout.mjs'; import { checkTuiLegacyBackendRatchet } from './tui-boundary-ratchet.mjs'; import { + acpClosedFeatureProfileRules, coreClosedFeatureProfileRules, coreProductFullFeatureAssemblyRule, optionalDependencyFeatureOwnerRules, ownerCrateFeatureAssemblyRules, + reviewedOptionalDependencyAggregateFeatures, } from './rules/feature-rules.mjs'; import { facadeOnlyFiles, @@ -32,14 +34,18 @@ import { runManifestParserSelfTest } from './self-test.mjs'; import { featureReferencesDependency, featureReferencesFeature, + featureReferencesOptionalDependencyOwner, unexpectedDependencyOwnerFeatures, unexpectedReachableLocalFeatures, } from './manifest-feature-helpers.mjs'; import { checkCargoDependencyBoundariesSafely } from './cargo-dependency-boundaries.mjs'; +import { checkPeerCommandPolicySync } from './peer-command-policy.mjs'; import { agentRuntimeIntegrationTestTargets, checkAgentRuntimeIntegrationTestTopology, checkCliIntegrationTestTopology, + checkExternalSourceIntegrationTestTopologies, + checkReviewedIntegrationTestTopologies, cliIntegrationTestTargets, validateExplicitIntegrationTestTopology, } from './explicit-test-topology.mjs'; @@ -510,11 +516,16 @@ function checkForbiddenManifestDependencyRule(rule) { function checkOptionalDependencyFeatureOwners(crateDir, rule) { const manifestPath = join(crateDir, 'Cargo.toml'); + const repoManifestPath = toRepoPath(manifestPath); const lines = readText(manifestPath).split(/\r?\n/); const deps = parseManifestDependencies(lines); const normalDeps = deps.filter((dep) => dep.kind === 'normal'); const depsByName = new Map(normalDeps.map((dep) => [dep.name, dep])); const features = parseManifestFeatures(lines); + const reviewedAggregateFeatures = new Set([ + ...reviewedOptionalDependencyAggregateFeatures(repoManifestPath), + ...(rule.reviewedAggregateFeatures ?? []), + ]); const declaredOwnerDeps = new Set(rule.dependencies.map((dependency) => dependency.depName)); for (const dependency of rule.dependencies) { @@ -544,7 +555,7 @@ function checkOptionalDependencyFeatureOwners(crateDir, rule) { }); continue; } - if (!featureReferencesDependency(feature, dependency.depName)) { + if (!featureReferencesOptionalDependencyOwner(feature, dependency.depName)) { failures.push({ path: manifestPath, line: feature.line, @@ -552,7 +563,11 @@ function checkOptionalDependencyFeatureOwners(crateDir, rule) { }); } } - for (const [featureName, feature] of unexpectedDependencyOwnerFeatures(features, dependency)) { + for (const [featureName, feature] of unexpectedDependencyOwnerFeatures( + features, + dependency, + reviewedAggregateFeatures, + )) { failures.push({ path: manifestPath, line: feature.line, @@ -581,19 +596,6 @@ function checkOptionalDependencyFeatureOwners(crateDir, rule) { } } -function checkCoreDefaultProductFullFeature() { - const manifestPath = join(crateDirForName('core'), 'Cargo.toml'); - const features = parseManifestFeatures(readText(manifestPath).split(/\r?\n/)); - if (!featureReferencesFeature(features.get('default'), 'product-full')) { - failures.push({ - path: manifestPath, - line: features.get('default')?.line ?? 1, - message: - 'bitfun-core default feature must remain product-full until a separate product matrix review changes it', - }); - } -} - function checkCoreProductFullFeatureAssembly(rule) { const manifestPath = repoPathToFsPath(rule.manifestPath); const features = parseManifestFeatures(readText(manifestPath).split(/\r?\n/)); @@ -873,14 +875,14 @@ function checkForbiddenContent(repoPath, patterns) { } }); } - function checkRequiredContent(repoPath, patterns, reason) { const path = repoPathToFsPath(repoPath); - const text = readText(path); for (const pattern of patterns) { + const patternPath = pattern.path ? repoPathToFsPath(pattern.path) : path; + const text = readText(patternPath); if (!pattern.regex.test(text)) { failures.push({ - path, + path: patternPath, line: 1, message: `${reason}; ${pattern.message}`, }); @@ -1088,6 +1090,7 @@ export function runCoreBoundaryCheck() { manifestDependencyMatches, matchingForbiddenDependency, coreClosedFeatureProfileRules, + acpClosedFeatureProfileRules, coreProductFullFeatureAssemblyRule, ownerCrateFeatureAssemblyRules, parseManifestFeatures, @@ -1122,6 +1125,8 @@ export function runCoreBoundaryCheck() { failures.push(...checkCargoDependencyBoundariesSafely({ root: ROOT, crateLayoutRules })); failures.push(...checkAgentRuntimeIntegrationTestTopology(ROOT)); failures.push(...checkCliIntegrationTestTopology(ROOT)); + failures.push(...checkExternalSourceIntegrationTestTopologies(ROOT), ...checkReviewedIntegrationTestTopologies(ROOT)); + failures.push(...checkPeerCommandPolicySync(ROOT)); for (const rule of forbiddenManifestDependencyRules) { checkForbiddenManifestDependencyRule(rule); @@ -1152,11 +1157,13 @@ export function runCoreBoundaryCheck() { checkOptionalDependencyFeatureOwners(crateDir, rule); } - checkCoreDefaultProductFullFeature(); checkCoreProductFullFeatureAssembly(coreProductFullFeatureAssemblyRule); for (const rule of coreClosedFeatureProfileRules) { checkClosedFeatureProfile(rule); } + for (const rule of acpClosedFeatureProfileRules) { + checkClosedFeatureProfile(rule); + } for (const rule of ownerCrateFeatureAssemblyRules) { checkOwnerCrateFeatureAssembly(rule); } @@ -1176,7 +1183,6 @@ export function runCoreBoundaryCheck() { for (const rule of requiredContentRules) { checkRequiredContent(rule.path, rule.patterns, rule.reason); } - for (const rule of publicApiAllowlistRules) { checkPublicApiAllowlist(rule); } diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs index e43a0659c3..1afa6dd097 100644 --- a/scripts/core-boundaries/explicit-test-topology.mjs +++ b/scripts/core-boundaries/explicit-test-topology.mjs @@ -15,6 +15,291 @@ export const cliIntegrationTestTargets = [ { name: 'terminal_process_contracts', path: 'tests/terminal_process_contracts.rs' }, ]; +export const servicesCoreIntegrationTestTargets = [ + { name: 'markdown_owner_contracts', path: 'tests/markdown_owner_contracts.rs' }, + { name: 'declarative_workspace_instruction_contracts', path: 'tests/declarative_workspace_instruction_contracts.rs' }, + { name: 'lsp_plugin_registry_contracts', path: 'tests/lsp_plugin_registry_contracts.rs' }, + { name: 'runtime_ownership_contracts', path: 'tests/runtime_ownership_contracts.rs' }, + { name: 'local_runtime_ports', path: 'tests/local_runtime_ports.rs' }, + { name: 'permission_store_contracts', path: 'tests/permission_store_contracts.rs' }, + { name: 'workspace_instruction_contracts', path: 'tests/workspace_instruction_contracts.rs' }, + { name: 'session_write_lock_contracts', path: 'tests/session_write_lock_contracts.rs' }, + { name: 'process_runtime_contracts', path: 'tests/process_runtime_contracts.rs' }, + { name: 'service_contracts', path: 'tests/service_contracts.rs' }, + { name: 'storage_owner_contracts', path: 'tests/storage_owner_contracts.rs' }, + { name: 'session_contracts', path: 'tests/session_contracts.rs' }, + { name: 'session_usage_contracts', path: 'tests/session_usage_contracts.rs' }, +]; + +export const servicesIntegrationsIntegrationTestTargets = [ + { name: 'debug_log_owner_contracts', path: 'tests/debug_log_owner_contracts.rs' }, + { name: 'script_tool_runtime', path: 'tests/script_tool_runtime.rs' }, + { name: 'announcement_contracts', path: 'tests/announcement_contracts.rs' }, + { name: 'file_watch_contracts', path: 'tests/file_watch_contracts.rs' }, + { name: 'function_agent_contracts', path: 'tests/function_agent_contracts.rs' }, + { name: 'git_contracts', path: 'tests/git_contracts.rs' }, + { name: 'mcp_contracts', path: 'tests/mcp_contracts.rs' }, + { name: 'mcp_streamable_http_contracts', path: 'tests/mcp_streamable_http_contracts.rs' }, + { name: 'remote_connect_contracts', path: 'tests/remote_connect_contracts.rs' }, + { name: 'remote_ssh_contracts', path: 'tests/remote_ssh_contracts.rs' }, + { name: 'remote_workspace_search_disabled_contracts', path: 'tests/remote_workspace_search_disabled_contracts.rs' }, + { name: 'workspace_search_contracts', path: 'tests/workspace_search_contracts.rs' }, +]; + +export const opencodeAdapterIntegrationTestTargets = [ + { name: 'opencode_mcp_adapter', path: 'tests/opencode_mcp_adapter.rs' }, + { name: 'opencode_source_adapter', path: 'tests/opencode_source_adapter.rs' }, + { + name: 'opencode_static_source_contracts', + path: 'tests/opencode_static_source_contracts.rs', + leaves: [ + 'tests/opencode_static_source_contracts/hook_source.rs', + 'tests/opencode_static_source_contracts/opencode_command_adapter.rs', + 'tests/opencode_static_source_contracts/opencode_skill_roots.rs', + 'tests/opencode_static_source_contracts/opencode_subagent_adapter.rs', + 'tests/opencode_static_source_contracts/opencode_workspace_references.rs', + ], + forbidRequiredFeatures: true, + }, + { name: 'tool_source_contracts', path: 'tests/tool_source_contracts.rs' }, +]; + +export const claudeCodeAdapterIntegrationTestTargets = [ + { + name: 'claude_code_source_contracts', + path: 'tests/claude_code_source_contracts.rs', + leaves: [ + 'tests/claude_code_source_contracts/command_source.rs', + 'tests/claude_code_source_contracts/hook_source.rs', + 'tests/claude_code_source_contracts/mcp_source.rs', + 'tests/claude_code_source_contracts/subagent_source.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const codexAdapterIntegrationTestTargets = [ + { + name: 'codex_source_contracts', + path: 'tests/codex_source_contracts.rs', + leaves: [ + 'tests/codex_source_contracts/hook_source.rs', + 'tests/codex_source_contracts/mcp_source.rs', + 'tests/codex_source_contracts/subagent_source.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const externalSourcesIntegrationTestTargets = [ + { + name: 'external_source_coordination_contracts', + path: 'tests/external_source_coordination_contracts.rs', + leaves: [ + 'tests/external_source_coordination_contracts/control_plane.rs', + 'tests/external_source_coordination_contracts/coordinator_contracts.rs', + 'tests/external_source_coordination_contracts/hook_coordinator.rs', + 'tests/external_source_coordination_contracts/mcp_coordinator.rs', + 'tests/external_source_coordination_contracts/subagent_coordinator.rs', + 'tests/external_source_coordination_contracts/tool_coordinator_contracts.rs', + 'tests/external_source_coordination_contracts/workspace_reference.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const coreTypesIntegrationTestTargets = [ + { + name: 'core_type_contracts', + path: 'tests/core_type_contracts.rs', + leaves: [ + 'tests/core_type_contracts/lsp_contracts.rs', + 'tests/core_type_contracts/session_contracts.rs', + 'tests/core_type_contracts/session_usage_contracts.rs', + 'tests/core_type_contracts/surface_contracts.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const runtimePortsIntegrationTestTargets = [ + { + name: 'plugin_runtime_contracts', + path: 'tests/runtime_port_contracts.rs', + leaves: [ + 'tests/runtime_port_contracts/plugin_runtime_contracts.rs', + 'tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs', + ], + requiredFeatures: ['plugin-runtime'], + }, + { + name: 'git_port_contracts', + path: 'tests/git_port_contracts.rs', + requiredFeatures: ['git-port'], + }, + { + name: 'script_tool_port_contracts', + path: 'tests/script_tool_port_contracts.rs', + requiredFeatures: ['script-tool-runtime'], + }, + { + name: 'session_store_contracts', + path: 'tests/session_store_contracts.rs', + requiredFeatures: ['workspace-ports'], + }, +]; + +export const productDomainsIntegrationTestTargets = [ + { + name: 'product_domain_contracts', + path: 'tests/product_domain_contracts.rs', + leaves: [ + 'tests/product_domain_contracts/canvas_contracts.rs', + 'tests/product_domain_contracts/tool_permission_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'external_source_contracts', + path: 'tests/external_source_contracts.rs', + leaves: [ + 'tests/external_source_contracts/external_hook_catalog_contracts.rs', + 'tests/external_source_contracts/external_hook_contribution_contracts.rs', + 'tests/external_source_contracts/external_source_contracts.rs', + 'tests/external_source_contracts/workspace_reference_contracts.rs', + ], + requiredFeatures: ['external-sources'], + }, + { + name: 'function_agent_contracts', + path: 'tests/function_agent_contracts.rs', + requiredFeatures: ['function-agents'], + }, + { + name: 'miniapp_contracts', + path: 'tests/miniapp_contracts.rs', + requiredFeatures: ['miniapp'], + }, + { + name: 'plugin_source_contracts', + path: 'tests/plugin_source_contracts.rs', + requiredFeatures: ['plugin-source'], + }, +]; + +export const aiAdaptersIntegrationTestTargets = [ + { + name: 'ai_protocol_contracts', + path: 'tests/ai_protocol_contracts.rs', + leaves: [ + 'tests/ai_protocol_contracts/model_selector.rs', + 'tests/ai_protocol_contracts/openai_empty_content_parts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'ai_stream_contracts', + path: 'tests/ai_stream_contracts.rs', + leaves: [ + 'tests/ai_stream_contracts/common.rs', + 'tests/ai_stream_contracts/stream_processor_anthropic.rs', + 'tests/ai_stream_contracts/stream_processor_openai.rs', + 'tests/ai_stream_contracts/stream_processor_tool_arguments.rs', + 'tests/ai_stream_contracts/stream_replay_regressions.rs', + 'tests/ai_stream_contracts/stream_test_harness.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const productCapabilitiesIntegrationTestTargets = [ + { + name: 'product_capability_contracts', + path: 'tests/product_capability_contracts.rs', + leaves: [ + 'tests/product_capability_contracts/plugin_product_shape.rs', + 'tests/product_capability_contracts/product_capabilities.rs', + 'tests/product_capability_contracts/product_sdk_assembly.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +function decodeBasicTomlKey(token) { + let decoded = ''; + const simpleEscapes = new Map([ + ['b', '\b'], ['t', '\t'], ['n', '\n'], ['f', '\f'], ['r', '\r'], + ['"', '"'], ['\\', '\\'], + ]); + for (let index = 1; index < token.length - 1; index += 1) { + if (token[index] !== '\\') { + decoded += token[index]; + continue; + } + index += 1; + const escape = token[index]; + if (simpleEscapes.has(escape)) { + decoded += simpleEscapes.get(escape); + continue; + } + if (escape !== 'u' && escape !== 'U') { + return null; + } + const digitCount = escape === 'u' ? 4 : 8; + const hex = token.slice(index + 1, index + 1 + digitCount); + if (!new RegExp(`^[0-9a-fA-F]{${digitCount}}$`).test(hex)) { + return null; + } + const codePoint = Number.parseInt(hex, 16); + if (codePoint > 0x10FFFF || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) { + return null; + } + decoded += String.fromCodePoint(codePoint); + index += digitCount; + } + return decoded; +} + +function tomlFieldName(line) { + const match = line.match(/^([A-Za-z0-9_-]+|'[^']*'|"(?:[^"\\]|\\.)*")\s*=/); + if (!match) { + return null; + } + const token = match[1]; + if (token.startsWith("'")) { + return token.slice(1, -1); + } + return token.startsWith('"') ? decodeBasicTomlKey(token) : token; +} + +function parseTomlStringArrayValue(line) { + const equalsIndex = line.indexOf('='); + const value = equalsIndex === -1 ? '' : line.slice(equalsIndex + 1).trim(); + const array = value.match(/^\[(.*)\]\s*(?:#.*)?$/); + if (!array) { + return null; + } + const inner = array[1]; + const values = []; + const stringPattern = /'[^']*'|"(?:[^"\\]|\\.)*"/g; + let cursor = 0; + for (const match of inner.matchAll(stringPattern)) { + if (!/^[\s,]*$/.test(inner.slice(cursor, match.index))) { + return null; + } + const token = match[0]; + const decoded = token.startsWith("'") + ? token.slice(1, -1) + : decodeBasicTomlKey(token); + if (decoded === null) { + return null; + } + values.push(decoded); + cursor = match.index + token.length; + } + return /^[\s,]*$/.test(inner.slice(cursor)) ? values : null; +} + function parseExplicitTestTargets(manifestText) { const targets = []; let current = null; @@ -36,6 +321,10 @@ function parseExplicitTestTargets(manifestText) { finishCurrent(); continue; } + if (current && tomlFieldName(trimmed) === 'required-features') { + current.hasRequiredFeatures = true; + current.requiredFeatures = parseTomlStringArrayValue(trimmed); + } const field = current && trimmed.match(/^(name|path)\s*=\s*"([^"]+)"\s*$/); if (field) { current[field[1]] = field[2]; @@ -66,7 +355,11 @@ function parseFlatRootModules(root, source, errors) { let valid = true; for (let index = 0; index < lines.length; index += 1) { const line = lines[index].trim(); - if (line === '' || line.startsWith('//!')) { + if ( + line === '' + || line.startsWith('//!') + || /^#!\[cfg\(feature = "[A-Za-z0-9_-]+"\)\]$/.test(line) + ) { continue; } const pathAttribute = line.match(/^#\[path\s*=\s*"([^"]+)"\]$/); @@ -82,12 +375,268 @@ function parseFlatRootModules(root, source, errors) { return valid ? references : []; } +function skipRustTrivia(source, start) { + let index = start; + while (index < source.length) { + if (/\s/.test(source[index])) { + index += 1; + continue; + } + if (source.startsWith('//', index)) { + const lineEnd = source.indexOf('\n', index + 2); + index = lineEnd === -1 ? source.length : lineEnd + 1; + continue; + } + if (source.startsWith('/*', index)) { + let depth = 1; + index += 2; + while (index < source.length && depth > 0) { + if (source.startsWith('/*', index)) { + depth += 1; + index += 2; + } else if (source.startsWith('*/', index)) { + depth -= 1; + index += 2; + } else { + index += 1; + } + } + if (depth > 0) { + return { index: source.length, error: 'unterminated block comment' }; + } + continue; + } + break; + } + return { index }; +} + +function rustRawStringEnd(source, start) { + let quoteIndex = start; + if (source.startsWith('br', start) || source.startsWith('cr', start)) { + quoteIndex += 2; + } else if (source[start] === 'r') { + quoteIndex += 1; + } else { + return null; + } + let hashCount = 0; + while (source[quoteIndex] === '#') { + hashCount += 1; + quoteIndex += 1; + } + if (source[quoteIndex] !== '"') { + return null; + } + const terminator = `"${'#'.repeat(hashCount)}`; + const closingIndex = source.indexOf(terminator, quoteIndex + 1); + return closingIndex === -1 ? -1 : closingIndex + terminator.length; +} + +function rustCharLiteralEnd(source, start) { + if (source[start] !== "'") { + return null; + } + let index = start + 1; + if (source[index] === '\\') { + index += 1; + if (source[index] === 'x') { + if (!/^[0-9A-Fa-f]{2}$/.test(source.slice(index + 1, index + 3))) { + return null; + } + index += 3; + } else if (source[index] === 'u' && source[index + 1] === '{') { + const closingBrace = source.indexOf('}', index + 2); + if ( + closingBrace === -1 + || !/^[0-9A-Fa-f_]+$/.test(source.slice(index + 2, closingBrace)) + ) { + return null; + } + index = closingBrace + 1; + } else if (source[index] !== undefined && !/[\r\n]/.test(source[index])) { + index += 1; + } else { + return null; + } + } else { + const codePoint = source.codePointAt(index); + if (codePoint === undefined || source[index] === "'" || /[\r\n]/.test(source[index])) { + return null; + } + index += codePoint > 0xFFFF ? 2 : 1; + } + return source[index] === "'" ? index + 1 : null; +} + +function rustQuotedLiteralEnd(source, start) { + let quoteIndex = start; + if ((source[start] === 'b' || source[start] === 'c') && source[start + 1] === '"') { + quoteIndex += 1; + } + const quote = source[quoteIndex]; + if (quote !== '"') { + return null; + } + let escaped = false; + for (let index = quoteIndex + 1; index < source.length; index += 1) { + const character = source[index]; + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === quote) { + return index + 1; + } + } + return -1; +} + +function matchingRustAttributeBracket(source, openingIndex) { + const closingForOpening = new Map([['[', ']'], ['(', ')'], ['{', '}']]); + const stack = [']']; + let index = openingIndex + 1; + while (index < source.length) { + if (source.startsWith('//', index) || source.startsWith('/*', index)) { + const trivia = skipRustTrivia(source, index); + if (trivia.error) { + return { error: trivia.error }; + } + index = trivia.index; + continue; + } + const rawStringEnd = rustRawStringEnd(source, index); + if (rawStringEnd !== null) { + if (rawStringEnd === -1) { + return { error: 'unterminated raw string in inner attribute' }; + } + index = rawStringEnd; + continue; + } + const charLiteralEnd = rustCharLiteralEnd(source, index); + if (charLiteralEnd !== null) { + index = charLiteralEnd; + continue; + } + const quotedLiteralEnd = rustQuotedLiteralEnd(source, index); + if (quotedLiteralEnd !== null) { + if (quotedLiteralEnd === -1) { + return { error: 'unterminated quoted literal in inner attribute' }; + } + index = quotedLiteralEnd; + continue; + } + const character = source[index]; + const closing = closingForOpening.get(character); + if (closing) { + stack.push(closing); + } else if (character === ']' || character === ')' || character === '}') { + if (stack.at(-1) !== character) { + return { error: 'mismatched delimiter in inner attribute' }; + } + stack.pop(); + if (stack.length === 0) { + return { closingIndex: index }; + } + } + index += 1; + } + return { error: 'unterminated inner attribute' }; +} + +function leadingRustInnerAttributes(source) { + const attributes = []; + let index = source.charCodeAt(0) === 0xFEFF ? 1 : 0; + if (source.startsWith('#!', index)) { + const afterShebangBang = skipRustTrivia(source, index + 2); + if (!afterShebangBang.error && source[afterShebangBang.index] !== '[') { + const lineEnd = source.indexOf('\n', index + 2); + index = lineEnd === -1 ? source.length : lineEnd + 1; + } + } + while (index < source.length) { + const leadingTrivia = skipRustTrivia(source, index); + if (leadingTrivia.error) { + return { attributes, error: leadingTrivia.error }; + } + index = leadingTrivia.index; + const attributeStart = index; + if (source[index] !== '#') { + break; + } + const afterHash = skipRustTrivia(source, index + 1); + if (afterHash.error) { + return { attributes, error: afterHash.error }; + } + if (source[afterHash.index] !== '!') { + break; + } + const afterBang = skipRustTrivia(source, afterHash.index + 1); + if (afterBang.error) { + return { attributes, error: afterBang.error }; + } + if (source[afterBang.index] !== '[') { + break; + } + const matched = matchingRustAttributeBracket(source, afterBang.index); + if (matched.error) { + return { attributes, error: matched.error }; + } + const nameStart = skipRustTrivia(source, afterBang.index + 1); + if (nameStart.error) { + return { attributes, error: nameStart.error }; + } + const nameSource = source.slice(nameStart.index, matched.closingIndex); + const nameMatch = /^(?:r#)?([A-Za-z_][A-Za-z0-9_]*)/.exec(nameSource); + if (!nameMatch) { + return { attributes, error: 'inner attribute has no supported name' }; + } + attributes.push({ + name: nameMatch[1], + raw: source.slice(attributeStart, matched.closingIndex + 1).trim(), + }); + index = matched.closingIndex + 1; + } + return { attributes }; +} + +function validateGroupedLeafCfg( + leaf, + leafSource, + allowedLeafCfgLines, + errors, +) { + const scanned = leadingRustInnerAttributes(leafSource); + if (scanned.error) { + errors.push(`grouped test leaf ${leaf} has an unsupported crate preamble: ${scanned.error}`); + return; + } + const cfgAttributes = scanned.attributes.filter( + (attribute) => attribute.name === 'cfg' || attribute.name === 'cfg_attr', + ); + const allowedLine = allowedLeafCfgLines.get(leaf); + if ( + allowedLine !== undefined + && cfgAttributes.length === 1 + && cfgAttributes[0].raw === allowedLine + ) { + return; + } + if (cfgAttributes.length > 0 || allowedLine !== undefined) { + errors.push( + `grouped test leaf ${leaf} has a crate cfg that belongs in its explicit target root`, + ); + } +} + export function validateExplicitIntegrationTestTopology({ manifestText, expectedTargets, topLevelRustFiles, rootSources, leafRustFiles, + leafSources, + allowedLeafCfgLines = new Map(), }) { const errors = []; if (!packageDisablesAutotests(manifestText)) { @@ -95,12 +644,41 @@ export function validateExplicitIntegrationTestTopology({ } const expectedTargetEntries = expectedTargets.map(({ name, path }) => `${name}=${path}`).sort(); - const actualTargetEntries = parseExplicitTestTargets(manifestText) + const actualTargets = parseExplicitTestTargets(manifestText); + const actualTargetEntries = actualTargets .map(({ name, path }) => `${name ?? ''}=${path ?? ''}`) .sort(); if (actualTargetEntries.join('\n') !== expectedTargetEntries.join('\n')) { errors.push(`explicit test targets must be exactly: ${expectedTargetEntries.join(', ')}`); } + const targetsWithoutRequiredFeatures = new Set( + expectedTargets + .filter(({ forbidRequiredFeatures }) => forbidRequiredFeatures) + .map(({ name, path }) => `${name}=${path}`), + ); + for (const { name, path, hasRequiredFeatures } of actualTargets) { + if (hasRequiredFeatures && targetsWithoutRequiredFeatures.has(`${name}=${path}`)) { + errors.push(`explicit test target ${name} must not declare required-features`); + } + } + for (const { name, path, requiredFeatures } of expectedTargets) { + if (requiredFeatures === undefined) { + continue; + } + const actual = actualTargets.find( + (target) => target.name === name && target.path === path, + ); + const actualRequiredFeatures = actual?.requiredFeatures; + if ( + actualRequiredFeatures === null + || actualRequiredFeatures === undefined + || [...actualRequiredFeatures].sort().join('\n') !== [...requiredFeatures].sort().join('\n') + ) { + errors.push( + `explicit test target ${name} required-features must be exactly: ${requiredFeatures.join(', ')}`, + ); + } + } const expectedRoots = expectedTargets.map(({ path }) => path).sort(); if ([...topLevelRustFiles].sort().join('\n') !== expectedRoots.join('\n')) { @@ -108,6 +686,13 @@ export function validateExplicitIntegrationTestTopology({ } const leaves = new Set(leafRustFiles); + const expectedLeaves = expectedTargets.flatMap(({ leaves: targetLeaves = [] }) => targetLeaves).sort(); + if ( + expectedLeaves.length > 0 + && [...leaves].sort().join('\n') !== expectedLeaves.join('\n') + ) { + errors.push(`grouped test leaves must be exactly: ${expectedLeaves.join(', ')}`); + } const referenceCounts = new Map(); for (const root of expectedRoots) { const source = rootSources.get(root); @@ -130,6 +715,17 @@ export function validateExplicitIntegrationTestTopology({ errors.push(`test root ${root} references missing leaf: ${leaf}`); continue; } + const leafSource = leafSources.get(leaf); + if (leafSource === undefined) { + errors.push(`missing grouped test leaf source: ${leaf}`); + continue; + } + validateGroupedLeafCfg( + leaf, + leafSource, + allowedLeafCfgLines, + errors, + ); const expectedModuleName = posix.basename(leaf, '.rs'); if (reference.moduleName !== expectedModuleName) { errors.push(`test leaf ${leaf} must use module name ${expectedModuleName}`); @@ -147,17 +743,18 @@ export function validateExplicitIntegrationTestTopology({ return errors; } -function collectRustFiles(dir, testsDir, files, ignoredDirectories) { +function collectRustFiles(dir, testsDir, files, sources, ignoredDirectories) { for (const entry of readdirSync(dir, { withFileTypes: true })) { const path = join(dir, entry.name); if (entry.isDirectory()) { const repoPath = `tests/${relative(testsDir, path).replaceAll('\\', '/')}`; if (!ignoredDirectories.has(repoPath)) { - collectRustFiles(path, testsDir, files, ignoredDirectories); + collectRustFiles(path, testsDir, files, sources, ignoredDirectories); } } else if (entry.isFile() && entry.name.endsWith('.rs')) { const repoPath = `tests/${relative(testsDir, path).replaceAll('\\', '/')}`; files.push(repoPath); + sources.set(repoPath, readFileSync(path, 'utf8')); } } } @@ -166,6 +763,7 @@ function checkExplicitIntegrationTestTopology(root, { cratePath, expectedTargets, ignoredDirectories = [], + allowedLeafCfgLines = new Map(), }) { const crateDir = join(root, ...cratePath.split('/')); const testsDir = join(crateDir, 'tests'); @@ -173,6 +771,7 @@ function checkExplicitIntegrationTestTopology(root, { const topLevelRustFiles = []; const leafRustFiles = []; const rootSources = new Map(); + const leafSources = new Map(); const ignoredDirectorySet = new Set(ignoredDirectories); for (const entry of readdirSync(testsDir, { withFileTypes: true })) { @@ -187,6 +786,7 @@ function checkExplicitIntegrationTestTopology(root, { join(testsDir, entry.name), testsDir, leafRustFiles, + leafSources, ignoredDirectorySet, ); } @@ -199,6 +799,8 @@ function checkExplicitIntegrationTestTopology(root, { topLevelRustFiles, rootSources, leafRustFiles, + leafSources, + allowedLeafCfgLines, }).map((message) => ({ path: manifestPath, line: 1, message })); } @@ -216,3 +818,94 @@ export function checkCliIntegrationTestTopology(root) { ignoredDirectories: ['tests/support'], }); } + +export function checkServicesCoreIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/services/services-core', + expectedTargets: servicesCoreIntegrationTestTargets, + }); +} + +export function checkServicesIntegrationsIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/services/services-integrations', + expectedTargets: servicesIntegrationsIntegrationTestTargets, + allowedLeafCfgLines: new Map([[ + 'tests/remote_ssh_contracts/remote_ssh_disabled_contracts.rs', + '#![cfg(not(feature = "remote-ssh-concrete"))]', + ]]), + }); +} + +export function checkOpencodeAdapterIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/adapters/opencode-adapter', + expectedTargets: opencodeAdapterIntegrationTestTargets, + ignoredDirectories: ['tests/fixtures'], + }); +} + +export function checkClaudeCodeAdapterIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/adapters/claude-code-adapter', + expectedTargets: claudeCodeAdapterIntegrationTestTargets, + }); +} + +export function checkCodexAdapterIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/adapters/codex-adapter', + expectedTargets: codexAdapterIntegrationTestTargets, + }); +} + +export function checkExternalSourcesIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/assembly/external-sources', + expectedTargets: externalSourcesIntegrationTestTargets, + }); +} + +export function checkExternalSourceIntegrationTestTopologies(root) { + return [ + ...checkOpencodeAdapterIntegrationTestTopology(root), + ...checkClaudeCodeAdapterIntegrationTestTopology(root), + ...checkCodexAdapterIntegrationTestTopology(root), + ...checkExternalSourcesIntegrationTestTopology(root), + ]; +} + +export function checkServiceIntegrationTestTopologies(root) { + return [ + ...checkServicesCoreIntegrationTestTopology(root), + ...checkServicesIntegrationsIntegrationTestTopology(root), + ]; +} + +export function checkBuildGraphContractIntegrationTestTopologies(root) { + const topologies = [ + ['src/crates/contracts/core-types', coreTypesIntegrationTestTargets], + ['src/crates/contracts/runtime-ports', runtimePortsIntegrationTestTargets], + ['src/crates/contracts/product-domains', productDomainsIntegrationTestTargets], + [ + 'src/crates/adapters/ai-adapters', + aiAdaptersIntegrationTestTargets, + ['tests/common', 'tests/fixtures'], + ], + ['src/crates/assembly/product-capabilities', productCapabilitiesIntegrationTestTargets], + ]; + return topologies.flatMap(([cratePath, expectedTargets, ignoredDirectories]) => ( + checkExplicitIntegrationTestTopology(root, { + cratePath, + expectedTargets, + ignoredDirectories, + }) + )); +} + +export function checkReviewedIntegrationTestTopologies(root) { + return [ + ...checkServiceIntegrationTestTopologies(root), + ...checkBuildGraphContractIntegrationTestTopologies(root), + ]; +} diff --git a/scripts/core-boundaries/manifest-feature-helpers.mjs b/scripts/core-boundaries/manifest-feature-helpers.mjs index 4445e20707..618f04ca59 100644 --- a/scripts/core-boundaries/manifest-feature-helpers.mjs +++ b/scripts/core-boundaries/manifest-feature-helpers.mjs @@ -10,15 +10,64 @@ export function featureReferencesDependency(feature, depName) { ); } +export function featureReferencesOptionalDependencyOwner(feature, depName) { + return Boolean( + featureReferencesDependency(feature, depName) + || feature?.refs.some((reference) => reference.startsWith(`${depName}?/`)), + ); +} + export function featureReferencesFeature(feature, featureName) { return Boolean(feature && feature.refs.includes(featureName)); } -export function unexpectedDependencyOwnerFeatures(features, dependency) { +function featureTransitivelyReferencesOptionalDependencyOwner( + features, + featureName, + depName, + visiting = new Set(), +) { + if (visiting.has(featureName)) { + return false; + } + const feature = features.get(featureName); + if (featureReferencesOptionalDependencyOwner(feature, depName)) { + return true; + } + const nextVisiting = new Set(visiting).add(featureName); + return Boolean(feature?.refs.some((reference) => + features.has(reference) + && featureTransitivelyReferencesOptionalDependencyOwner( + features, + reference, + depName, + nextVisiting, + ))); +} + +export function unexpectedDependencyOwnerFeatures( + features, + dependency, + reviewedAggregateFeatures = new Set(), +) { return [...features.entries()].filter( - ([featureName, feature]) => - featureReferencesDependency(feature, dependency.depName) - && !dependency.ownerFeatures.includes(featureName), + ([featureName, feature]) => { + if (dependency.ownerFeatures.includes(featureName)) { + return false; + } + const directlyReferencesOwner = featureReferencesOptionalDependencyOwner( + feature, + dependency.depName, + ); + if (reviewedAggregateFeatures.has(featureName)) { + return directlyReferencesOwner; + } + return featureTransitivelyReferencesOptionalDependencyOwner( + features, + featureName, + dependency.depName, + ); + }, ); } diff --git a/scripts/core-boundaries/peer-command-policy.mjs b/scripts/core-boundaries/peer-command-policy.mjs new file mode 100644 index 0000000000..7be725188d --- /dev/null +++ b/scripts/core-boundaries/peer-command-policy.mjs @@ -0,0 +1,137 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +/** + * Peer Device Mode controller/peer command ownership boundary. + * + * The controller-side deny list in the Web UI transport adapter is an + * optimization: it keeps a controller-owned command on the controller without + * a round trip. It is not the boundary. A controller running an older build, + * or any non-Web-UI controller, still reaches a peer host over HostInvoke, so + * each peer host must independently refuse every controller-owned command. + * + * The enforced direction is therefore one-way: whatever the controller refuses + * to send, a peer host must also refuse to run. A host denying more than the + * controller is safe and stays allowed. + */ + +const FE_ADAPTER = 'src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts'; +const DESKTOP_HOST = 'src/apps/desktop/src/api/peer_host_invoke.rs'; +const CLI_HOST = 'src/apps/cli/src/peer_host/deny.rs'; + +/** + * Commands the CLI peer host answers before the deny-list check, so they are + * intentionally absent from its list. See `src/apps/cli/src/peer_host/dispatch.rs`. + */ +const CLI_PRE_HANDLED_COMMANDS = new Set([ + 'peer_control_attach', + 'peer_control_detach', + 'peer_mode_ping', + 'account_cancel_pending_login', +]); + +function stripLineComments(text) { + return text.replace(/\/\/[^\n]*/g, ''); +} + +function parseTypeScriptSet(source, name) { + const match = new RegExp(`const ${name}\\s*=\\s*new Set\\(\\[(.*?)\\n\\]\\);`, 's').exec(source); + if (!match) { + return null; + } + return new Set(Array.from(stripLineComments(match[1]).matchAll(/'([^']+)'/g), m => m[1])); +} + +function parseRustSlice(source, name) { + const match = new RegExp(`static ${name}[^=]*=\\s*&\\[(.*?)\\n\\];`, 's').exec(source); + if (!match) { + return null; + } + return new Set(Array.from(stripLineComments(match[1]).matchAll(/"([^"]+)"/g), m => m[1])); +} + +export function checkPeerCommandPolicySync(root) { + const failures = []; + + const read = (relativePath) => { + try { + return readFileSync(join(root, relativePath), 'utf8'); + } catch { + failures.push({ + path: relativePath, + line: 1, + message: + 'Peer command policy check could not read this file; update scripts/core-boundaries/peer-command-policy.mjs if it moved', + }); + return null; + } + }; + + const feSource = read(FE_ADAPTER); + const desktopSource = read(DESKTOP_HOST); + const cliSource = read(CLI_HOST); + if (!feSource || !desktopSource || !cliSource) { + return failures; + } + + const controllerDenied = parseTypeScriptSet(feSource, 'LOCAL_ONLY_COMMANDS'); + const desktopDenied = parseRustSlice(desktopSource, 'LOCAL_ONLY_COMMANDS'); + const cliDenied = parseRustSlice(cliSource, 'LOCAL_ONLY_COMMANDS'); + + for (const [path, parsed] of [ + [FE_ADAPTER, controllerDenied], + [DESKTOP_HOST, desktopDenied], + [CLI_HOST, cliDenied], + ]) { + if (!parsed) { + failures.push({ + path, + line: 1, + message: + 'Could not parse LOCAL_ONLY_COMMANDS; keep the declaration shape the peer command policy check expects', + }); + } + } + if (!controllerDenied || !desktopDenied || !cliDenied) { + return failures; + } + + const missingOnDesktop = [...controllerDenied].filter(command => !desktopDenied.has(command)); + if (missingOnDesktop.length > 0) { + failures.push({ + path: DESKTOP_HOST, + line: 1, + message: + `Desktop peer host must refuse every controller-owned command. Missing from LOCAL_ONLY_COMMANDS: ${missingOnDesktop.sort().join(', ')}. ` + + 'An older or non-Web-UI controller can still HostInvoke these onto this peer', + }); + } + + const missingOnCli = [...controllerDenied].filter( + command => !cliDenied.has(command) && !CLI_PRE_HANDLED_COMMANDS.has(command), + ); + if (missingOnCli.length > 0) { + failures.push({ + path: CLI_HOST, + line: 1, + message: + `CLI peer host must refuse every controller-owned command. Missing from LOCAL_ONLY_COMMANDS: ${missingOnCli.sort().join(', ')}. ` + + 'An older or non-Web-UI controller can still HostInvoke these onto this peer', + }); + } + + const staleCliExceptions = [...CLI_PRE_HANDLED_COMMANDS].filter( + command => !controllerDenied.has(command), + ); + if (staleCliExceptions.length > 0) { + failures.push({ + path: CLI_HOST, + line: 1, + message: + `Stale CLI pre-handled exception(s) in scripts/core-boundaries/peer-command-policy.mjs: ${staleCliExceptions.sort().join(', ')}. ` + + 'Remove the exception once the controller no longer treats the command as controller-owned', + }); + } + + return failures; +} diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index ab5288042e..15e0032a77 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -476,6 +476,7 @@ export const dependencyProfileRules = [ 'bitfun-services-integrations', 'bitfun-product-capabilities', 'bitfun-product-domains', + 'bitfun-agent-tools', 'bitfun-relay-service', 'bitfun-tool-packs', 'chrono-tz', @@ -484,6 +485,7 @@ export const dependencyProfileRules = [ 'eventsource-stream', 'filetime', 'flate2', + 'fluent-bundle', 'fs2', 'git2', 'glob', @@ -513,6 +515,8 @@ export const dependencyProfileRules = [ 'terminal-core', 'tool-runtime', 'tokio-tungstenite', + 'bitfun-transport', + 'unic-langid', 'win32job', 'x25519-dalek', ], @@ -537,9 +541,11 @@ export const dependencyProfileRules = [ 'ignore', 'libc', 'notify', + 'regex', 'rusqlite', 'serde_yaml', 'sha2', + 'similar', 'which', 'win32job', 'windows', diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index c6ba62c96a..2bb2832f70 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -15,6 +15,18 @@ export const servicesReqwestOwnerFeatures = [ 'web-tools', ]; +export const guardedEmptyInternalDefaultManifestPaths = [ + 'src/crates/assembly/core/Cargo.toml', + 'src/crates/assembly/product-capabilities/Cargo.toml', + 'src/crates/contracts/product-domains/Cargo.toml', + 'src/crates/contracts/runtime-ports/Cargo.toml', + 'src/crates/execution/tool-contracts/Cargo.toml', + 'src/crates/execution/tool-execution/Cargo.toml', + 'src/crates/execution/tool-provider-groups/Cargo.toml', + 'src/crates/services/services-core/Cargo.toml', + 'src/crates/services/services-integrations/Cargo.toml', +]; + export const optionalDependencyFeatureOwnerRules = [ { crateName: 'services-core', @@ -29,14 +41,25 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'bitfun-runtime-ports', ownerFeatures: ['permission', 'workspace-runtime'] }, { depName: 'chrono', ownerFeatures: ['filesystem', 'local-storage'] }, { depName: 'dunce', ownerFeatures: ['runtime-ownership', 'workspace-identity', 'workspace-runtime'] }, - { depName: 'fs2', ownerFeatures: ['local-storage', 'runtime-ownership'] }, + { depName: 'fs2', ownerFeatures: ['json-io', 'local-storage', 'runtime-ownership'] }, { depName: 'git2', ownerFeatures: ['session-git'] }, { depName: 'globset', ownerFeatures: ['workspace-instructions'] }, { depName: 'ignore', ownerFeatures: ['filesystem'] }, { depName: 'libc', ownerFeatures: ['local-storage', 'process-runtime'] }, { depName: 'notify', ownerFeatures: ['lsp'] }, + { + depName: 'regex', + ownerFeatures: [ + 'diagnostics', + 'filesystem', + 'local-storage', + 'markdown', + 'workspace-instructions', + ], + }, { depName: 'rusqlite', ownerFeatures: ['permission'] }, { depName: 'serde_yaml', ownerFeatures: ['markdown', 'workspace-instructions'] }, + { depName: 'similar', ownerFeatures: ['diff', 'local-storage'] }, { depName: 'sha2', ownerFeatures: [ @@ -49,16 +72,37 @@ export const optionalDependencyFeatureOwnerRules = [ }, { depName: 'which', ownerFeatures: ['process-runtime'] }, { depName: 'win32job', ownerFeatures: ['process-runtime'] }, - { depName: 'windows', ownerFeatures: ['local-storage', 'process-runtime'] }, + { depName: 'windows', ownerFeatures: ['json-io', 'local-storage', 'process-runtime'] }, { depName: 'zip', ownerFeatures: ['lsp'] }, + { + depName: 'tokio', + ownerFeatures: [ + 'diff', + 'filesystem', + 'json-io', + 'local-storage', + 'lsp', + 'permission', + 'process-runtime', + 'workspace-instructions', + 'workspace-runtime', + 'workspace-text-runtime', + ], + }, ], }, { crateName: 'runtime-ports', + reviewedAggregateFeatures: ['tool-runtime-handles'], reason: - 'runtime-ports may expose product-domain permission ports only through the explicit permission contract slice', + 'runtime-ports optional capabilities must stay behind their exact contract slice', dependencies: [ - { depName: 'bitfun-product-domains', ownerFeatures: ['permission'] }, + { depName: 'anyhow', ownerFeatures: ['workspace-ports'] }, + { depName: 'bitfun-core-types', ownerFeatures: ['agent-api', 'ts'] }, + { depName: 'bitfun-product-domains', ownerFeatures: ['permission', 'ts'] }, + { depName: 'tokio', ownerFeatures: ['remote-exec-port', 'terminal-port'] }, + { depName: 'tokio-util', ownerFeatures: ['workspace-ports'] }, + { depName: 'ts-rs', ownerFeatures: ['ts'] }, ], }, { @@ -66,10 +110,15 @@ export const optionalDependencyFeatureOwnerRules = [ reason: 'bitfun-core product/runtime optional dependencies must stay owned by explicit feature gates', dependencies: [ - { depName: 'axum', ownerFeatures: ['agent-runtime', 'debug-log'] }, - { depName: 'bitfun-ai-adapters', ownerFeatures: ['ai-adapter-runtime'] }, + { depName: 'axum', ownerFeatures: ['debug-log', 'mcp-runtime'] }, + { depName: 'base64', ownerFeatures: ['agent-runtime', 'dispatch-store'] }, + { + depName: 'bitfun-ai-adapters', + ownerFeatures: ['ai-adapter-runtime', 'subscription-auth'], + }, { depName: 'bitfun-agent-runtime', ownerFeatures: ['agent-runtime'] }, { depName: 'bitfun-agent-stream', ownerFeatures: ['agent-runtime'] }, + { depName: 'bitfun-agent-tools', ownerFeatures: ['agent-runtime', 'local-storage', 'mcp-runtime'] }, { depName: 'bitfun-claude-code-adapter', ownerFeatures: ['external-sources'] }, { depName: 'bitfun-codex-adapter', ownerFeatures: ['external-sources'] }, { depName: 'bitfun-external-sources', ownerFeatures: ['external-sources'] }, @@ -77,46 +126,87 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'bitfun-opencode-adapter', ownerFeatures: ['external-sources'] }, { depName: 'bitfun-plugin-runtime-client', ownerFeatures: ['plugin-runtime'] }, { depName: 'bitfun-product-capabilities', ownerFeatures: ['product-capabilities'] }, - { depName: 'bitfun-product-domains', ownerFeatures: ['product-domains'] }, + { + depName: 'bitfun-product-domains', + ownerFeatures: [ + 'agent-runtime', + 'canvas-runtime', + 'function-agents', + 'plugin-source', + 'tools-miniapp', + 'ts', + ], + }, { depName: 'bitfun-runtime-services', ownerFeatures: ['runtime-services'] }, { depName: 'bitfun-services-integrations', ownerFeatures: [ 'announcement', - 'agent-runtime', 'canvas-runtime', + 'browser-control', + 'deep-research', 'debug-log', 'external-sources', 'file-watch', + 'function-agents', 'git', + 'mcp-runtime', + 'model-catalog', 'plugin-source', - 'product-domains', + 'remote-connect', 'remote-workspace', 'review-platform', + 'script-tool-runtime', 'ssh-remote', + 'tools-miniapp', + 'ts', + 'web-tools', + 'workspace-search', + ], + }, + { + depName: 'bitfun-tool-packs', + ownerFeatures: [ + 'tool-packs', + 'tools-agent-control', + 'tools-basic', + 'tools-browser-web', + 'tools-canvas', + 'tools-computer-use', + 'tools-git', + 'tools-image-analysis', + 'tools-mcp', + 'tools-miniapp', ], }, - { depName: 'bitfun-tool-packs', ownerFeatures: ['tool-packs'] }, - { depName: 'chrono-tz', ownerFeatures: ['agent-runtime'] }, - { depName: 'cron', ownerFeatures: ['agent-runtime'] }, + { depName: 'chrono-tz', ownerFeatures: ['scheduled-jobs'] }, + { depName: 'cron', ownerFeatures: ['scheduled-jobs'] }, { depName: 'dashmap', ownerFeatures: ['agent-runtime'] }, { depName: 'filetime', ownerFeatures: ['agent-runtime'] }, { depName: 'flate2', ownerFeatures: ['agent-runtime'] }, + { depName: 'fluent-bundle', ownerFeatures: ['i18n-runtime'] }, { depName: 'fs2', ownerFeatures: ['agent-runtime'] }, - { depName: 'image', ownerFeatures: ['agent-runtime', 'tool-packs'] }, + { depName: 'futures', ownerFeatures: ['agent-runtime'] }, + { depName: 'image', ownerFeatures: ['agent-runtime'] }, { depName: 'include_dir', ownerFeatures: ['agent-runtime'] }, { depName: 'indexmap', ownerFeatures: ['agent-runtime'] }, { depName: 'md5', ownerFeatures: ['agent-runtime'] }, - { depName: 'reqwest', ownerFeatures: ['ai-adapter-runtime', 'agent-runtime'] }, + { depName: 'reqwest', ownerFeatures: ['mcp-runtime', 'tools-miniapp'] }, + { depName: 'regex', ownerFeatures: ['agent-runtime'] }, { depName: 'rusqlite', ownerFeatures: ['agent-runtime'] }, - { depName: 'semver', ownerFeatures: ['agent-runtime'] }, + { depName: 'semver', ownerFeatures: ['tools-miniapp'] }, { depName: 'serde_yaml', ownerFeatures: ['workspace-runtime'] }, { depName: 'similar', ownerFeatures: ['agent-runtime'] }, { depName: 'terminal-core', ownerFeatures: ['terminal'] }, { depName: 'notify', ownerFeatures: ['lsp', 'workspace-watch'] }, - { depName: 'tokio-tungstenite', ownerFeatures: ['agent-runtime'] }, + { depName: 'tokio-tungstenite', ownerFeatures: ['browser-control'] }, + { depName: 'tokio-util', ownerFeatures: ['agent-runtime', 'debug-log'] }, { depName: 'tower-http', ownerFeatures: ['debug-log'] }, - { depName: 'tool-runtime', ownerFeatures: ['agent-runtime'] }, + { depName: 'unic-langid', ownerFeatures: ['i18n-runtime'] }, + { + depName: 'tool-runtime', + ownerFeatures: ['agent-runtime', 'document-read', 'web-tools'], + }, ], }, { @@ -204,11 +294,237 @@ export const optionalDependencyFeatureOwnerRules = [ }, ]; +function capabilityEdge(features = [], overrides = {}) { + return { + kind: 'normal', + optional: false, + rename: null, + target: null, + features, + ...overrides, + }; +} + +function capabilityForwarder(sourceFeature, feature, weak = false) { + return { sourceFeature, feature, weak }; +} + +function capabilityConsumer(edges, forwarders = [], activators = [], aggregates = []) { + return { edges, forwarders, activators, aggregates }; +} + +export const capabilityContractDependencyRules = [ + { + packageName: 'bitfun-runtime-ports', + manifestPath: 'src/crates/contracts/runtime-ports/Cargo.toml', + featureProfiles: { + default: [], + 'agent-api': ['dep:bitfun-core-types'], + 'git-port': [], + permission: ['dep:bitfun-product-domains'], + 'plugin-runtime': [], + 'remote-exec-port': ['dep:tokio'], + 'remote-workspace-ports': [], + 'runtime-event-port': [], + 'script-tool-runtime': [], + 'terminal-port': ['dep:tokio'], + 'tool-runtime-handles': ['workspace-ports', 'terminal-port', 'remote-exec-port'], + ts: [ + 'dep:ts-rs', + 'agent-api', + 'permission', + 'bitfun-core-types/ts', + 'bitfun-product-domains?/ts', + ], + 'workspace-ports': ['dep:anyhow', 'dep:tokio-util'], + }, + consumers: new Map([ + ['bitfun-agent-runtime', capabilityConsumer([ + capabilityEdge([ + 'agent-api', + 'git-port', + 'permission', + 'plugin-runtime', + 'remote-workspace-ports', + 'runtime-event-port', + 'terminal-port', + 'workspace-ports', + ]), + ])], + ['bitfun-agent-runtime-ipc', capabilityConsumer([ + capabilityEdge(['agent-api', 'git-port']), + ])], + ['bitfun-agent-tools', capabilityConsumer([capabilityEdge()])], + ['bitfun-app-server', capabilityConsumer( + [capabilityEdge(['agent-api'])], + [capabilityForwarder('ts', 'ts')], + )], + ['bitfun-app-server-protocol', capabilityConsumer([ + capabilityEdge(['agent-api', 'git-port']), + ])], + ['bitfun-cli', capabilityConsumer([ + capabilityEdge(['agent-api', 'git-port', 'permission', 'plugin-runtime', 'workspace-ports']), + ])], + ['bitfun-core', capabilityConsumer( + [capabilityEdge(['permission', 'workspace-ports'])], + [ + capabilityForwarder('agent-runtime', 'agent-api'), + capabilityForwarder('agent-runtime', 'git-port'), + capabilityForwarder('agent-runtime', 'remote-exec-port'), + capabilityForwarder('agent-runtime', 'remote-workspace-ports'), + capabilityForwarder('agent-runtime', 'runtime-event-port'), + capabilityForwarder('agent-runtime', 'terminal-port'), + capabilityForwarder('agent-runtime', 'tool-runtime-handles'), + capabilityForwarder('agent-runtime', 'workspace-ports'), + capabilityForwarder('plugin-runtime', 'plugin-runtime'), + capabilityForwarder('script-tool-runtime', 'script-tool-runtime'), + capabilityForwarder('ts', 'ts'), + ], + [], + ['external-sources', 'mcp-runtime', 'product-full', 'remote-connect', 'tools-mcp'], + )], + ['bitfun-desktop', capabilityConsumer([ + capabilityEdge(['agent-api', 'permission', 'workspace-ports']), + ])], + ['bitfun-opencode-adapter', capabilityConsumer([ + capabilityEdge(['plugin-runtime']), + capabilityEdge(['script-tool-runtime'], { kind: 'dev' }), + ])], + ['bitfun-plugin-runtime-client', capabilityConsumer([ + capabilityEdge(['plugin-runtime']), + ])], + ['bitfun-product-capabilities', capabilityConsumer([ + capabilityEdge(['plugin-runtime']), + capabilityEdge(['agent-api'], { kind: 'dev' }), + ])], + ['bitfun-runtime-services', capabilityConsumer([ + capabilityEdge([ + 'git-port', + 'remote-exec-port', + 'remote-workspace-ports', + 'runtime-event-port', + 'terminal-port', + 'workspace-ports', + ]), + ])], + ['bitfun-sdk-host', capabilityConsumer([ + capabilityEdge(['agent-api']), + capabilityEdge(['permission'], { kind: 'dev' }), + ])], + ['bitfun-services-core', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [ + capabilityForwarder('permission', 'permission'), + capabilityForwarder('workspace-runtime', 'runtime-event-port'), + capabilityForwarder('workspace-runtime', 'workspace-ports'), + ], + ['permission', 'workspace-runtime'], + )], + ['bitfun-services-integrations', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [ + capabilityForwarder('git', 'git-port'), + capabilityForwarder('remote-connect', 'agent-api'), + capabilityForwarder('remote-connect', 'remote-workspace-ports'), + capabilityForwarder('remote-ssh', 'remote-exec-port'), + capabilityForwarder('remote-ssh', 'remote-workspace-ports'), + capabilityForwarder('remote-ssh', 'workspace-ports'), + capabilityForwarder('script-tool-runtime', 'script-tool-runtime'), + ], + ['remote-ssh-concrete'], + ['function-agents', 'product-full'], + )], + ['terminal-core', capabilityConsumer([ + capabilityEdge(['terminal-port']), + ])], + ['tool-runtime', capabilityConsumer([capabilityEdge()])], + ]), + }, + { + packageName: 'bitfun-agent-tools', + manifestPath: 'src/crates/execution/tool-contracts/Cargo.toml', + featureProfiles: { + default: [], + 'acp-bridge': [], + 'computer-use-contract': [], + 'element-token': [], + 'mcp-bridge': [], + }, + consumers: new Map([ + ['bitfun-acp', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [capabilityForwarder('client', 'acp-bridge')], + ['server'], + ['default'], + )], + ['bitfun-agent-runtime', capabilityConsumer([capabilityEdge()])], + ['bitfun-agent-stream', capabilityConsumer([ + capabilityEdge([], { kind: 'dev' }), + ])], + ['bitfun-cli', capabilityConsumer([capabilityEdge()])], + ['bitfun-core', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [ + capabilityForwarder('agent-runtime', 'computer-use-contract'), + capabilityForwarder('mcp-runtime', 'mcp-bridge'), + ], + ['agent-runtime', 'local-storage'], + [ + 'dispatch-store', + 'external-sources', + 'plugin-runtime', + 'product-full', + 'remote-connect', + 'remote-workspace', + 'ssh-remote', + 'tools-basic', + 'tools-mcp', + 'workspace-runtime', + 'workspace-search', + 'workspace-watch', + ], + )], + ['bitfun-desktop', capabilityConsumer([ + capabilityEdge(['element-token']), + ])], + ['bitfun-services-integrations', capabilityConsumer( + [capabilityEdge([], { optional: true })], + [capabilityForwarder('mcp', 'mcp-bridge')], + ['remote-connect'], + ['product-full'], + )], + ['tool-runtime', capabilityConsumer([capabilityEdge()])], + ]), + }, +]; + export const coreProductFullFeatureAssemblyRule = { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'product-full', requiredFeatureRefs: [ 'agent-runtime', + 'diagnostics', + 'diff', + 'document-read', + 'subscription-auth', + 'i18n-runtime', + 'browser-control', + 'deep-research', + 'mcp-runtime', + 'model-catalog', + 'remote-connect', + 'scheduled-jobs', + 'tools-agent-control', + 'tools-basic', + 'tools-browser-web', + 'tools-canvas', + 'tools-computer-use', + 'tools-git', + 'tools-image-analysis', + 'tools-mcp', + 'tools-miniapp', + 'web-tools', + 'workspace-search', 'announcement', 'canvas-runtime', 'debug-log', @@ -228,13 +544,80 @@ export const coreProductFullFeatureAssemblyRule = { 'workspace-runtime', 'workspace-watch', 'product-capabilities', - 'product-domains', + 'function-agents', 'tool-packs', ], reason: 'bitfun-core product-full must explicitly assemble current owner feature groups', }; +export const acpClientCoreFeatures = [ + 'agent-runtime', + 'ssh-remote', +]; + +export const acpServerCoreFeatures = [ + 'agent-runtime', + 'document-read', + 'subscription-auth', + 'deep-research', + 'lsp', + 'external-sources', + 'tools-basic', + 'tools-git', + 'tools-mcp', + 'tools-browser-web', + 'tools-computer-use', + 'tools-image-analysis', + 'tools-miniapp', + 'tools-canvas', + 'tools-agent-control', +]; + +export const acpClosedFeatureProfileRules = [ + { + manifestPath: 'src/crates/interfaces/acp/Cargo.toml', + featureName: 'default', + requiredFeatureRefs: ['client', 'server'], + exact: true, + reason: 'bitfun-acp default must preserve its complete client and server compatibility surface', + }, + { + manifestPath: 'src/crates/interfaces/acp/Cargo.toml', + featureName: 'client', + requiredFeatureRefs: [ + 'bitfun-agent-tools/acp-bridge', + 'dep:futures', + 'dep:serde', + 'dep:bitfun-core', + ...acpClientCoreFeatures.map((feature) => `bitfun-core/${feature}`), + ], + exact: true, + reason: 'bitfun-acp client must own only external ACP agent and SSH transport capabilities', + }, + { + manifestPath: 'src/crates/interfaces/acp/Cargo.toml', + featureName: 'server', + requiredFeatureRefs: [ + 'dep:bitfun-agent-tools', + 'dep:bitfun-agent-runtime', + 'dep:bitfun-core-types', + 'dep:bitfun-core', + 'dep:sha2', + ...acpServerCoreFeatures.map((feature) => `bitfun-core/${feature}`), + ], + exact: true, + reason: 'bitfun-acp server must preserve the reviewed Agent Runtime capability surface without SSH transport', + }, +]; + export const coreClosedFeatureProfileRules = [ + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'default', + requiredFeatureRefs: [], + exact: true, + reason: 'bitfun-core default must stay empty so product entrypoints select capabilities explicitly', + }, { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'agent-runtime', @@ -243,66 +626,97 @@ export const coreClosedFeatureProfileRules = [ 'dep:bitfun-agent-runtime', 'dep:bitfun-agent-content', 'dep:bitfun-agent-stream', + 'dep:bitfun-agent-tools', + 'bitfun-agent-tools/computer-use-contract', + 'bitfun-runtime-ports/agent-api', + 'bitfun-runtime-ports/git-port', + 'bitfun-runtime-ports/remote-exec-port', + 'bitfun-runtime-ports/remote-workspace-ports', + 'bitfun-runtime-ports/runtime-event-port', + 'bitfun-runtime-ports/terminal-port', + 'bitfun-runtime-ports/tool-runtime-handles', + 'bitfun-runtime-ports/workspace-ports', + 'dep:base64', 'dep:bitfun-harness', - 'dep:chrono-tz', - 'dep:cron', 'dep:dashmap', 'dep:filetime', 'dep:flate2', 'dep:fs2', + 'dep:futures', 'dep:include_dir', 'dep:indexmap', 'dep:image', 'dep:md5', - 'dep:reqwest', - 'dep:semver', 'dep:rusqlite', + 'dep:regex', 'dep:similar', - 'dep:tokio-tungstenite', + 'dep:tokio-util', 'dep:tool-runtime', - 'dep:axum', - 'bitfun-services-integrations/browser-control', - 'bitfun-services-integrations/deep-research', - 'bitfun-services-integrations/mcp', - 'bitfun-services-integrations/models-dev', - 'bitfun-services-integrations/remote-connect', - 'bitfun-services-integrations/script-tool-runtime', - 'bitfun-services-integrations/web-tools', - 'bitfun-services-integrations/workspace-search', - 'tokio/rt-multi-thread', - 'bitfun-services-core/dispatch-workspace', 'bitfun-services-core/permission', 'bitfun-services-core/runtime-ownership', 'bitfun-services-core/session-git', + 'bitfun-services-core/workspace-text-runtime', 'filesystem', - 'lsp', 'local-storage', 'process-runtime', - 'remote-workspace', 'terminal', 'workspace-runtime', 'product-capabilities', - 'product-domains', + 'dep:bitfun-product-domains', + 'bitfun-product-domains/external-sources', 'runtime-services', - 'git', - 'review-platform', 'tool-packs', + 'tools-basic', + 'tools-agent-control', + 'tokio/io-util', + 'tokio/macros', + 'tokio/rt', + 'tokio/time', + ], + allowedTransitiveFeatureRefs: [ + 'workspace-search', + 'scheduled-jobs', ], - allowedTransitiveFeatureRefs: ['plugin-source'], exact: true, reason: 'bitfun-core agent-runtime is the reviewed Core Agent Runtime owner closure, not a product-full alias', }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'i18n-runtime', + requiredFeatureRefs: ['dep:fluent-bundle', 'dep:unic-langid'], + exact: true, + reason: + 'i18n-runtime must own only the backend Fluent bundle and language identifier implementation', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'diagnostics', + requiredFeatureRefs: ['bitfun-services-core/diagnostics'], + exact: true, + reason: 'bitfun-core diagnostics must preserve only the reusable diagnostic redaction facade', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'diff', + requiredFeatureRefs: ['bitfun-services-core/diff'], + exact: true, + reason: 'bitfun-core diff must preserve only the reusable local diff facade', + }, { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'external-sources', requiredFeatureRefs: [ 'agent-runtime', + 'model-catalog', + 'mcp-runtime', + 'script-tool-runtime', 'dep:bitfun-opencode-adapter', 'dep:bitfun-claude-code-adapter', 'dep:bitfun-codex-adapter', 'dep:bitfun-external-sources', 'bitfun-services-integrations/hook-import', + 'plugin-source', 'file-watch', 'workspace-watch', ], @@ -316,11 +730,14 @@ export const coreClosedFeatureProfileRules = [ 'terminal', 'workspace-runtime', 'product-capabilities', - 'product-domains', 'runtime-services', 'git', 'review-platform', 'tool-packs', + 'tools-basic', + 'tools-agent-control', + 'workspace-search', + 'scheduled-jobs', 'plugin-source', ], exact: true, @@ -330,9 +747,16 @@ export const coreClosedFeatureProfileRules = [ { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'plugin-runtime', - requiredFeatureRefs: ['external-sources', 'dep:bitfun-plugin-runtime-client'], + requiredFeatureRefs: [ + 'external-sources', + 'dep:bitfun-plugin-runtime-client', + 'bitfun-runtime-ports/plugin-runtime', + ], allowedTransitiveFeatureRefs: [ 'agent-runtime', + 'model-catalog', + 'mcp-runtime', + 'script-tool-runtime', 'file-watch', 'workspace-watch', 'ai-adapter-runtime', @@ -344,11 +768,14 @@ export const coreClosedFeatureProfileRules = [ 'terminal', 'workspace-runtime', 'product-capabilities', - 'product-domains', 'runtime-services', 'git', 'review-platform', 'tool-packs', + 'tools-basic', + 'tools-agent-control', + 'workspace-search', + 'scheduled-jobs', 'plugin-source', ], exact: true, @@ -357,17 +784,291 @@ export const coreClosedFeatureProfileRules = [ }, { manifestPath: 'src/crates/assembly/core/Cargo.toml', - featureName: 'canvas-runtime', + featureName: 'model-catalog', requiredFeatureRefs: [ - 'product-domains', - 'bitfun-services-integrations/canvas-runtime', + 'ai-adapter-runtime', + 'bitfun-services-integrations/models-dev', + 'runtime-services', + ], + exact: true, + reason: 'model-catalog must own built-in AI projection, models.dev refresh, and catalog update events', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'mcp-runtime', + requiredFeatureRefs: [ + 'agent-runtime', + 'bitfun-agent-tools/mcp-bridge', + 'dep:axum', + 'dep:reqwest', + 'bitfun-services-integrations/mcp', + 'tokio/rt-multi-thread', ], allowedTransitiveFeatureRefs: [ 'ai-adapter-runtime', - 'plugin-source', + 'filesystem', + 'local-storage', + 'process-runtime', + 'terminal', + 'workspace-runtime', + 'product-capabilities', + 'runtime-services', + 'tool-packs', + 'tools-basic', + 'tools-agent-control', + 'workspace-search', + 'scheduled-jobs', + ], + exact: true, + reason: 'mcp-runtime must layer the Core MCP tool bridge and service on the Agent Runtime', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'remote-connect', + requiredFeatureRefs: [ + 'agent-runtime', + 'git', + 'model-catalog', + 'bitfun-services-integrations/remote-connect', + ], + allowedTransitiveFeatureRefs: [ + 'ai-adapter-runtime', + 'filesystem', + 'local-storage', + 'process-runtime', + 'terminal', + 'workspace-runtime', + 'product-capabilities', + 'runtime-services', + 'tool-packs', + 'tools-basic', + 'tools-agent-control', + 'workspace-search', + 'scheduled-jobs', + ], + exact: true, + reason: 'remote-connect must layer phone relay integration on the Agent Runtime and model catalog', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'workspace-search', + requiredFeatureRefs: [ + 'workspace-runtime', + 'bitfun-services-integrations/workspace-search', + ], + allowedTransitiveFeatureRefs: ['filesystem', 'local-storage', 'process-runtime'], + exact: true, + reason: 'workspace-search must layer indexed search on the local workspace runtime', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'browser-control', + requiredFeatureRefs: [ + 'dep:tokio-tungstenite', + 'bitfun-services-integrations/browser-control', + 'tokio/net', + 'tokio/rt', + 'tokio/time', + ], + exact: true, + reason: 'browser-control must own only the CDP browser adapter', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'web-tools', + requiredFeatureRefs: ['bitfun-services-integrations/web-tools', 'tool-runtime/web-readable'], + exact: true, + reason: 'web-tools must own only web network and readable-content support', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'deep-research', + requiredFeatureRefs: ['bitfun-services-integrations/deep-research'], + exact: true, + reason: 'deep-research must own only research report post-processing', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'script-tool-runtime', + requiredFeatureRefs: [ + 'bitfun-runtime-ports/script-tool-runtime', + 'bitfun-services-integrations/script-tool-runtime', + ], + exact: true, + reason: 'script-tool-runtime must own only external script tool execution support', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'scheduled-jobs', + requiredFeatureRefs: ['dep:chrono-tz', 'dep:cron'], + exact: true, + reason: 'scheduled-jobs is an additive Agent Runtime modifier for cron parsing and timezone scheduling', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'document-read', + requiredFeatureRefs: ['tool-runtime?/document-read'], + exact: true, + reason: + 'document-read must add conversion only when the Agent tool runtime owner is selected', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'subscription-auth', + requiredFeatureRefs: ['bitfun-ai-adapters?/subscription-auth'], + exact: true, + reason: + 'subscription-auth must add local credential resolution only when the AI adapter runtime owner is selected', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'ai-adapter-runtime', + requiredFeatureRefs: ['dep:bitfun-ai-adapters'], + exact: true, + reason: + 'ai-adapter-runtime must own provider protocol clients without implicitly enabling local subscription credentials', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'tools-basic', + requiredFeatureRefs: [ + 'bitfun-tool-packs/basic', + 'workspace-search', + ], + allowedTransitiveFeatureRefs: [ + 'filesystem', + 'local-storage', + 'process-runtime', + 'workspace-runtime', + ], + exact: true, + reason: 'tools-basic must compose only the baseline code-agent tool dependencies', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'tools-git', + requiredFeatureRefs: [ + 'bitfun-tool-packs/git', + 'git', + 'review-platform', + ], + exact: true, + reason: 'tools-git must compose only Git, worktree, and review platform tools', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'tools-mcp', + requiredFeatureRefs: [ + 'bitfun-tool-packs/mcp', + 'mcp-runtime', + ], + allowedTransitiveFeatureRefs: [ + 'agent-runtime', + 'ai-adapter-runtime', + 'filesystem', + 'local-storage', + 'process-runtime', + 'terminal', + 'workspace-runtime', + 'product-capabilities', + 'runtime-services', + 'tool-packs', + 'tools-basic', + 'tools-agent-control', + 'workspace-search', + 'scheduled-jobs', + ], + exact: true, + reason: 'tools-mcp must compose only MCP catalog tools and their service owner', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'tools-browser-web', + requiredFeatureRefs: [ + 'bitfun-tool-packs/browser-web', + 'browser-control', + 'web-tools', + ], + exact: true, + reason: 'tools-browser-web must compose only browser control and web research tools', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'tools-computer-use', + requiredFeatureRefs: [ + 'bitfun-tool-packs/computer-use', + ], + exact: true, + reason: 'tools-computer-use must own only the injected desktop automation tool', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'tools-image-analysis', + requiredFeatureRefs: [ + 'bitfun-tool-packs/image-analysis', + ], + exact: true, + reason: 'tools-image-analysis must own only explicit image inspection tools', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'tools-miniapp', + requiredFeatureRefs: [ + 'bitfun-tool-packs/miniapp', + 'dep:bitfun-product-domains', + 'bitfun-product-domains/appearance-market', + 'bitfun-product-domains/miniapp', + 'bitfun-services-integrations/miniapp-runtime', + 'bitfun-services-integrations/miniapp-market', + 'runtime-services', + 'dep:reqwest', + 'dep:semver', + ], + exact: true, + reason: 'tools-miniapp must compose only MiniApp publication and runtime tool dependencies', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'tools-canvas', + requiredFeatureRefs: [ + 'bitfun-tool-packs/canvas', + 'canvas-runtime', + ], + exact: true, + reason: 'tools-canvas must compose only Canvas tools and runtime IO', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'tools-agent-control', + requiredFeatureRefs: [ + 'bitfun-tool-packs/agent-control', + 'scheduled-jobs', + ], + exact: true, + reason: 'tools-agent-control must compose only session, subagent, planning, and scheduled-job tools', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'function-agents', + requiredFeatureRefs: [ + 'ai-adapter-runtime', + 'dep:bitfun-product-domains', + 'bitfun-product-domains/function-agents', + 'bitfun-services-integrations/function-agents', 'runtime-services', ], exact: true, + reason: + 'bitfun-core function-agents must own only function-agent contracts and concrete Git/AI adapters', + }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'canvas-runtime', + requiredFeatureRefs: [ + 'dep:bitfun-product-domains', + 'bitfun-services-integrations/canvas-runtime', + ], + exact: true, reason: 'bitfun-core canvas-runtime must extend only the product domain surface with Canvas runtime IO', }, @@ -376,8 +1077,13 @@ export const coreClosedFeatureProfileRules = [ featureName: 'debug-log', requiredFeatureRefs: [ 'dep:axum', + 'dep:tokio-util', 'dep:tower-http', 'bitfun-services-integrations/debug-log', + 'tokio/macros', + 'tokio/net', + 'tokio/rt', + 'tokio/time', ], exact: true, reason: 'bitfun-core debug-log must own only the debug ingest HTTP capability', @@ -389,13 +1095,53 @@ export const coreClosedFeatureProfileRules = [ exact: true, reason: 'services-core default profile must stay empty so consumers select capabilities explicitly', }, + { + manifestPath: 'src/crates/services/services-core/Cargo.toml', + featureName: 'diagnostics', + requiredFeatureRefs: ['dep:regex'], + exact: true, + reason: 'services-core diagnostics must own only deterministic diagnostic-log redaction', + }, + { + manifestPath: 'src/crates/services/services-core/Cargo.toml', + featureName: 'diff', + requiredFeatureRefs: ['dep:similar', 'dep:tokio', 'tokio/rt', 'tokio/time'], + exact: true, + reason: 'services-core diff must own only local text diff calculation and its bounded async runtime', + }, { manifestPath: 'src/crates/services/services-core/Cargo.toml', featureName: 'filesystem', - requiredFeatureRefs: ['dep:base64', 'dep:chrono', 'dep:ignore', 'dep:sha2', 'tokio/fs'], + requiredFeatureRefs: [ + 'dep:base64', + 'dep:chrono', + 'dep:ignore', + 'dep:regex', + 'dep:sha2', + 'dep:tokio', + 'tokio/fs', + 'tokio/rt', + ], exact: true, reason: 'services-core filesystem must own only local file operations and recursive search dependencies', }, + { + manifestPath: 'src/crates/services/services-core/Cargo.toml', + featureName: 'json-io', + requiredFeatureRefs: [ + 'dep:fs2', + 'dep:tokio', + 'dep:windows', + 'tokio/fs', + 'tokio/rt', + 'tokio/sync', + 'tokio/time', + 'windows/Win32_Foundation', + 'windows/Win32_Storage_FileSystem', + ], + exact: true, + reason: 'services-core json-io must own only generic locked and atomic JSON file IO', + }, { manifestPath: 'src/crates/services/services-core/Cargo.toml', featureName: 'local-storage', @@ -405,10 +1151,15 @@ export const coreClosedFeatureProfileRules = [ 'dep:chrono', 'dep:fs2', 'dep:libc', + 'dep:regex', 'dep:sha2', + 'dep:similar', + 'dep:tokio', 'dep:windows', 'tokio/fs', + 'tokio/rt', 'tokio/sync', + 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_Storage_FileSystem', ], @@ -420,11 +1171,14 @@ export const coreClosedFeatureProfileRules = [ featureName: 'process-runtime', requiredFeatureRefs: [ 'dep:libc', + 'dep:tokio', 'dep:which', 'dep:win32job', 'dep:windows', 'tokio/io-util', 'tokio/process', + 'tokio/rt', + 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_System_Diagnostics_ToolHelp', 'windows/Win32_System_Threading', @@ -435,10 +1189,25 @@ export const coreClosedFeatureProfileRules = [ { manifestPath: 'src/crates/services/services-core/Cargo.toml', featureName: 'workspace-instructions', - requiredFeatureRefs: ['dep:globset', 'dep:serde_yaml', 'tokio/fs', 'tokio/io-util'], + requiredFeatureRefs: [ + 'dep:globset', + 'dep:regex', + 'dep:serde_yaml', + 'dep:tokio', + 'tokio/fs', + 'tokio/io-util', + 'tokio/rt', + ], exact: true, reason: 'services-core workspace-instructions must own declarative instruction discovery, scope parsing, and glob expansion only', }, + { + manifestPath: 'src/crates/services/services-core/Cargo.toml', + featureName: 'workspace-text-runtime', + requiredFeatureRefs: ['dep:tokio', 'tokio/rt'], + exact: true, + reason: 'services-core workspace-text-runtime must own only bounded asynchronous local workspace reads', + }, { manifestPath: 'src/crates/services/services-core/Cargo.toml', featureName: 'lsp', @@ -462,6 +1231,8 @@ export const coreClosedFeatureProfileRules = [ 'dep:anyhow', 'dep:async-trait', 'dep:bitfun-runtime-ports', + 'bitfun-runtime-ports/runtime-event-port', + 'bitfun-runtime-ports/workspace-ports', 'dep:dunce', 'process-runtime', 'tokio/fs', @@ -488,7 +1259,11 @@ export const coreClosedFeatureProfileRules = [ { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'dispatch-store', - requiredFeatureRefs: ['local-storage'], + requiredFeatureRefs: [ + 'dep:base64', + 'local-storage', + 'bitfun-services-core/dispatch-workspace', + ], exact: true, reason: 'bitfun-core dispatch-store must expose only the durable dispatch index facade', }, @@ -502,7 +1277,7 @@ export const coreClosedFeatureProfileRules = [ { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'local-storage', - requiredFeatureRefs: ['bitfun-services-core/local-storage'], + requiredFeatureRefs: ['dep:bitfun-agent-tools', 'bitfun-services-core/local-storage'], exact: true, reason: 'bitfun-core local-storage must select only reusable local persistence owners', }, @@ -516,7 +1291,7 @@ export const coreClosedFeatureProfileRules = [ { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'lsp', - requiredFeatureRefs: ['dep:notify', 'bitfun-services-core/lsp'], + requiredFeatureRefs: ['dep:notify', 'bitfun-services-core/lsp', 'tokio/macros'], exact: true, reason: 'bitfun-core lsp must select only the LSP owner and its workspace watcher dependency', }, @@ -638,6 +1413,13 @@ export const ownerCrateFeatureAssemblyRules = [ { manifestPath: 'src/crates/services/services-integrations/Cargo.toml', reason: 'services-integrations must keep integration feature groups explicit and default-light', + optionalDependencyAggregateFeatures: [ + 'function-agents', + 'mcp', + 'miniapp-market', + 'remote-ssh-concrete', + 'script-tool-runtime', + ], requiredProductFullFeatures: [ 'announcement', 'browser-control', @@ -667,3 +1449,23 @@ export const ownerCrateFeatureAssemblyRules = [ requiredProductFullFeatures: ['appearance-market', 'plugin-source', 'miniapp', 'function-agents', 'external-sources'], }, ]; + +export function reviewedOptionalDependencyAggregateFeatures(manifestPath) { + const features = new Set( + coreClosedFeatureProfileRules + .filter((profile) => profile.manifestPath === manifestPath) + .map((profile) => profile.featureName), + ); + if (coreProductFullFeatureAssemblyRule.manifestPath === manifestPath) { + features.add(coreProductFullFeatureAssemblyRule.featureName); + } + const ownerAssembly = ownerCrateFeatureAssemblyRules.find((profile) => + profile.manifestPath === manifestPath); + if (ownerAssembly) { + features.add('product-full'); + for (const feature of ownerAssembly.optionalDependencyAggregateFeatures ?? []) { + features.add(feature); + } + } + return features; +} diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index c716b558b3..adfdd80cd3 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -216,7 +216,7 @@ export const forbiddenContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs', patterns: [ { regex: /\bbitfun_core\b/, @@ -1482,11 +1482,6 @@ export const forbiddenContentRules = [ { path: 'src/crates/assembly/core/src/service/search/mod.rs', patterns: [ - { - regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*mod remote_disabled\b/s, - message: - 'core workspace search facade must not own disabled remote search stubs; re-export services-integrations remote_ssh workspace_search disabled surface', - }, { regex: /\bbitfun_services_integrations::workspace_search::flashgrep\b/, message: @@ -4140,13 +4135,13 @@ export const forbiddenContentUnderRules = [ /\b(?:use\s+bitfun_opencode_adapter\b|extern\s+crate\s+bitfun_opencode_adapter\b|bitfun_opencode_adapter::)/, allowPaths: [ 'src/crates/adapters/opencode-adapter/tests/opencode_source_adapter.rs', - 'src/crates/adapters/opencode-adapter/tests/opencode_command_adapter.rs', - 'src/crates/adapters/opencode-adapter/tests/opencode_skill_roots.rs', - 'src/crates/adapters/opencode-adapter/tests/opencode_workspace_references.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_command_adapter.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_skill_roots.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_workspace_references.rs', 'src/crates/adapters/opencode-adapter/tests/tool_source_contracts.rs', - 'src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_subagent_adapter.rs', 'src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs', - 'src/crates/adapters/opencode-adapter/tests/hook_source.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/hook_source.rs', 'src/crates/assembly/core/src/plugin_runtime.rs', 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', @@ -4176,10 +4171,10 @@ export const forbiddenContentUnderRules = [ patterns: [{ regex: /\b(?:use\s+bitfun_claude_code_adapter\b|extern\s+crate\s+bitfun_claude_code_adapter\b|bitfun_claude_code_adapter::)/, allowPaths: [ - 'src/crates/adapters/claude-code-adapter/tests/hook_source.rs', - 'src/crates/adapters/claude-code-adapter/tests/command_source.rs', - 'src/crates/adapters/claude-code-adapter/tests/subagent_source.rs', - 'src/crates/adapters/claude-code-adapter/tests/mcp_source.rs', + 'src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/hook_source.rs', + 'src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/command_source.rs', + 'src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/subagent_source.rs', + 'src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/mcp_source.rs', 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', 'src/crates/assembly/core/src/instruction_sources.rs', @@ -4193,9 +4188,9 @@ export const forbiddenContentUnderRules = [ patterns: [{ regex: /\b(?:use\s+bitfun_codex_adapter\b|extern\s+crate\s+bitfun_codex_adapter\b|bitfun_codex_adapter::)/, allowPaths: [ - 'src/crates/adapters/codex-adapter/tests/hook_source.rs', - 'src/crates/adapters/codex-adapter/tests/subagent_source.rs', - 'src/crates/adapters/codex-adapter/tests/mcp_source.rs', + 'src/crates/adapters/codex-adapter/tests/codex_source_contracts/hook_source.rs', + 'src/crates/adapters/codex-adapter/tests/codex_source_contracts/subagent_source.rs', + 'src/crates/adapters/codex-adapter/tests/codex_source_contracts/mcp_source.rs', 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', 'src/crates/assembly/core/src/instruction_sources.rs', @@ -4212,6 +4207,7 @@ export const forbiddenContentUnderRules = [ 'src/crates/adapters/static-hook-support/tests/parser.rs', 'src/crates/adapters/opencode-adapter/src/hook_source.rs', 'src/crates/adapters/opencode-adapter/src/command_source.rs', + 'src/crates/adapters/opencode-adapter/src/agent_source.rs', 'src/crates/adapters/opencode-adapter/src/mcp_source.rs', 'src/crates/adapters/claude-code-adapter/src/hook_source.rs', 'src/crates/adapters/claude-code-adapter/src/command_source.rs', diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 5df1dad4a0..56e7ba8eed 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -319,6 +319,23 @@ function staticSourceSupportEntry(symbol) { }; } +function commonExternalSubagentToolMappingEntry(symbol) { + return { + symbol, + owner: 'static-hook-support shared declarative source adapter utility owner', + consumer: 'reviewed OpenCode and Claude Code declarative subagent adapters', + verification: + 'shared mapping unit tests, ecosystem subagent adapter fixtures, and core-boundary public API budget checks', + p0: 'runtime-free common external Agent tool capability normalization', + contractSlice: contractSlices.externalSourceControlContract, + wireImpact: false, + rationale: + 'sibling declarative adapters need one static mapping while provider-specific aliases remain adapter-owned', + exit: + 'remove only if every reviewed consumer moves to an equivalent adapter-layer mapping owner', + }; +} + function declarativeSourceAdapterEntry( symbol, owner, @@ -477,7 +494,9 @@ export const staticHookSupportPublicApiEntries = [ 'BoundedDirectoryWalkLimit', 'BoundedDirectoryWalkError', 'collect_bounded_regular_files', -].map(staticSourceSupportEntry)); +].map(staticSourceSupportEntry)).concat([ + 'common_external_subagent_tool_capability', +].map(commonExternalSubagentToolMappingEntry)); function externalHookContractEntry(symbol, owner, consumer, wireImpact = false) { return { @@ -868,6 +887,7 @@ export const externalSubagentContractPublicApiEntries = [ 'ExternalSubagentModelBindingMethod', 'ExternalSubagentModelBindingOption', 'ExternalSubagentModelBindingGroup', + 'ExternalSubagentToolCapability', 'ExternalSubagentToolSelector', 'ExternalSubagentToolRequest', 'ExternalSubagentCompatibilityState', @@ -1076,6 +1096,23 @@ export const externalSourceCorePublicApiEntries = [ 'Desktop external-source configuration host adapter', true, ), + ...[ + 'unacknowledged_external_ecosystems', + 'acknowledge_external_ecosystems', + ].map((symbol) => ({ + symbol, + owner: 'bitfun-core external source composition facade', + consumer: 'Desktop external-source host adapter and Web settings navigation', + verification: + 'core acknowledgement persistence and execution-domain scoping tests, Desktop command contract tests, and Web settings awareness tests', + p0: 'first-discovery notification for external-source settings', + contractSlice: contractSlices.externalSourceCommandContract, + wireImpact: true, + rationale: + 'the Web settings notification must remain stable across refreshes and workspace changes without treating acknowledgement as permission or policy', + exit: + 'remove only if Web settings no longer persists first-discovery awareness or a reviewed owner-scoped replacement preserves the same workspace isolation', + })), ...[ 'ExternalToolActivationState', 'ExternalToolApprovalRequest', @@ -1085,6 +1122,7 @@ export const externalSourceCorePublicApiEntries = [ 'ExternalToolConflictCandidateKind', 'ExternalToolRuntimeKind', 'set_external_tool_target_decision', + 'set_external_tool_targets_enabled', 'set_external_tool_conflict_choice', ].map((symbol) => externalToolEntry( @@ -1106,6 +1144,7 @@ export const externalSourceCorePublicApiEntries = [ 'ExternalSubagentModelRequest', 'ExternalSubagentSummary', 'set_external_subagent_activation', + 'set_external_subagents_enabled', 'set_external_subagent_model_binding', 'choose_external_subagent_conflict', ].map((symbol) => @@ -1123,6 +1162,7 @@ export const externalSourceCorePublicApiEntries = [ 'ExternalMcpTransportKind', 'native_mcp_candidate_id', 'set_external_mcp_server_decision', + 'set_external_mcp_servers_enabled', 'choose_external_mcp_conflict', ].map((symbol) => externalMcpEntry( diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 73447c3e3c..94ee305d00 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -1,31 +1,21 @@ // Boundary rules for source ownership, facades, and required owner content. export const requiredContentRules = [ - { - path: 'Cargo.toml', - reason: - 'workspace Reqwest defaults must stay transport-only so client owners select one TLS backend explicitly', - patterns: [ - { - regex: /^reqwest[ \t]*=[ \t]*\{[ \t]*version[ \t]*=[ \t]*"[^"]+",[ \t]*default-features[ \t]*=[ \t]*false,[ \t]*features[ \t]*=[ \t]*\[[ \t]*"http2",[ \t]*"json",[ \t]*"stream",[ \t]*"multipart",[ \t]*"query",[ \t]*"form"[ \t]*\][ \t]*\}[ \t]*$/m, - message: - 'workspace Reqwest dependency must use the reviewed transport/data feature allowlist', - }, - ], - }, ...[ 'src/apps/cli/Cargo.toml', 'src/apps/desktop/Cargo.toml', 'src/crates/adapters/ai-adapters/Cargo.toml', + 'src/crates/assembly/core/Cargo.toml', 'src/crates/services/miniapp-market-service/Cargo.toml', + 'src/crates/services/services-integrations/Cargo.toml', 'src/crates/services/skin-market-service/Cargo.toml', ].map((path) => ({ path, - reason: 'first-party Reqwest client owners must select the repository TLS backend explicitly', + reason: 'first-party Reqwest consumers must inherit the workspace-owned compatible version', patterns: [ { - regex: /^reqwest\s*=\s*\{\s*workspace\s*=\s*true,\s*features\s*=\s*\[\s*"rustls"\s*\]\s*\}/m, - message: 'Reqwest client dependency must explicitly enable rustls', + regex: /^reqwest\s*=\s*\{\s*workspace\s*=\s*true(?:\s*,|\s*\})/m, + message: 'Reqwest dependency must use workspace = true', }, ], })), @@ -34,6 +24,14 @@ export const requiredContentRules = [ reason: 'services-core must compile concrete service owners only through their declared capability features', patterns: [ + { + regex: /#\[cfg\(feature = "diagnostics"\)\]\s*pub mod diagnostics;/, + message: 'missing diagnostic redaction capability source gate', + }, + { + regex: /#\[cfg\(feature = "diff"\)\]\s*pub mod diff;/, + message: 'missing local diff capability source gate', + }, { regex: /#\[cfg\(any\(feature = "local-storage", feature = "runtime-ownership"\)\)\]\s*mod file_lock;/, message: 'missing shared file lock owner source gate', @@ -43,8 +41,8 @@ export const requiredContentRules = [ message: 'missing filesystem capability source gate', }, { - regex: /#\[cfg\(feature = "local-storage"\)\]\s*pub mod json_store;/, - message: 'missing local-storage JSON owner source gate', + regex: /#\[cfg\(any\(feature = "json-io", feature = "local-storage"\)\)\]\s*pub mod json_store;/, + message: 'missing json-io/local-storage JSON owner source gate', }, { regex: /#\[cfg\(feature = "local-storage"\)\]\s*pub mod persistence;/, @@ -88,6 +86,25 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/services/services-core/src/workspace_text.rs', + reason: + 'workspace text contracts stay synchronous while bounded local IO requires its runtime owner', + patterns: [ + { + regex: /pub fn normalize_workspace_relative_path\b/, + message: 'missing feature-free workspace path normalization contract', + }, + { + regex: /#\[cfg\(feature = "workspace-text-runtime"\)\]\s*pub async fn read_workspace_relative_text_bounded\b/s, + message: 'bounded workspace text reads must stay behind workspace-text-runtime', + }, + { + regex: /#\[cfg\(feature = "workspace-text-runtime"\)\]\s*pub async fn resolve_workspace_relative_entry\b/s, + message: 'workspace entry resolution must stay behind workspace-text-runtime', + }, + ], + }, { path: 'src/crates/services/services-core/src/persistence.rs', reason: @@ -197,7 +214,7 @@ export const requiredContentRules = [ message: 'serde_yaml must remain optional in services-core', }, { - regex: /markdown = \["dep:serde_yaml"\]/, + regex: /markdown = \[[^\]]*"dep:serde_yaml"[^\]]*\]/, message: 'missing explicit markdown feature for services-core', }, ], @@ -230,7 +247,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/services/services-core/tests/storage_owner_contracts.rs', + path: 'src/crates/services/services-core/tests/storage_owner_contracts/storage_owner_contracts.rs', reason: 'services-core local storage owner must keep persistence, cleanup, and token usage behavior contracts', patterns: [ @@ -442,7 +459,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/core-types/tests/lsp_contracts.rs', + path: 'src/crates/contracts/core-types/tests/core_type_contracts/lsp_contracts.rs', reason: 'core-types must keep LSP manifest serialization, default-value, and placeholder regressions', patterns: [ @@ -1523,7 +1540,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_capabilities.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs', reason: 'product-capabilities tests must protect product shape facts, runtime service gap reporting, and legacy harness routing', patterns: [ @@ -1554,7 +1571,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/plugin_product_shape.rs', reason: 'product-capabilities plugin shape tests must protect P0 plugin-capable profiles, non-P0 rejection, default availability reasons, and runtime handoff', patterns: [ @@ -1581,7 +1598,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs', reason: 'product-capabilities must prove product runtime parts can feed the SDK runtime without bitfun-core', patterns: [ @@ -3907,12 +3924,12 @@ export const requiredContentRules = [ patterns: [ { regex: - /bitfun-tool-packs = \{ path = "\.\.\/\.\.\/execution\/tool-provider-groups", default-features = false, optional = true \}/, + /bitfun-tool-packs = \{ path = "\.\.\/\.\.\/execution\/tool-provider-groups", optional = true \}/, message: 'bitfun-tool-packs dependency must stay optional and not force product-full outside the core feature graph', }, { regex: - /bitfun-services-integrations = \{ path = "\.\.\/\.\.\/services\/services-integrations", default-features = false, optional = true \}/, + /bitfun-services-integrations = \{ path = "\.\.\/\.\.\/services\/services-integrations", optional = true \}/, message: 'bitfun-services-integrations dependency must stay optional so local workspace profiles do not compile remote integrations', }, @@ -3925,21 +3942,37 @@ export const requiredContentRules = [ regex: /"dep:bitfun-ai-adapters"/, message: 'core ai-adapter-runtime feature must explicitly enable the optional dependency', }, + { + regex: /subscription-auth = \["bitfun-ai-adapters\?\/subscription-auth"\]/, + message: 'core subscription-auth modifier must not activate the optional AI adapter runtime by itself', + }, + { + regex: /document-read = \["tool-runtime\?\/document-read"\]/, + message: 'core document-read modifier must not activate the optional tool runtime by itself', + }, { regex: /agent-runtime = \[[^\]]*"ai-adapter-runtime"[^\]]*\]/, message: 'core agent-runtime assembly must explicitly opt into AI adapter runtime', }, { - regex: /product-domains = \[[^\]]*"ai-adapter-runtime"[^\]]*\]/, - message: 'core product-domain facade must explicitly opt into AI adapter runtime while concrete AI adapters remain optional', + regex: /agent-runtime = \[[^\]]*"bitfun-product-domains\/external-sources"[^\]]*\]/, + message: 'core agent-runtime must select only the external-subagent contract slice it uses', }, { - regex: /product-domains = \[[^\]]*"bitfun-services-integrations\/function-agents"[^\]]*\]/, - message: 'core product-domain facade must enable the function-agent service owner feature it imports', + regex: /function-agents = \[[^\]]*"ai-adapter-runtime"[^\]]*\]/, + message: 'core function-agent facade must explicitly opt into AI adapter runtime while concrete AI adapters remain optional', }, { - regex: /product-domains = \[[^\]]*"bitfun-services-integrations\/miniapp-runtime"[^\]]*\]/, - message: 'core product-domain facade must enable the MiniApp service owner feature it imports', + regex: /function-agents = \[[^\]]*"bitfun-services-integrations\/function-agents"[^\]]*\]/, + message: 'core function-agent facade must enable the function-agent service owner feature it imports', + }, + { + regex: /tools-miniapp = \[[^\]]*"bitfun-services-integrations\/miniapp-runtime"[^\]]*\]/, + message: 'core MiniApp tool owner must enable the MiniApp runtime service feature it imports', + }, + { + regex: /tools-miniapp = \[[^\]]*"bitfun-product-domains\/miniapp"[^\]]*\]/, + message: 'core MiniApp tool owner must select its product-domain slice explicitly', }, { regex: /canvas-runtime = \[[^\]]*"bitfun-services-integrations\/canvas-runtime"[^\]]*\]/, @@ -3948,19 +3981,19 @@ export const requiredContentRules = [ }, { regex: - /canvas-runtime = \[[\s\S]*"product-domains"[\s\S]*"bitfun-services-integrations\/canvas-runtime"[\s\S]*\]/, + /canvas-runtime = \[[\s\S]*"dep:bitfun-product-domains"[\s\S]*"bitfun-services-integrations\/canvas-runtime"[\s\S]*\]/, message: - 'core canvas-runtime feature must explicitly aggregate product domains and the canvas service owner feature', + 'core canvas-runtime feature must explicitly aggregate the domain contract and canvas service owner', }, { regex: - /bitfun-product-domains = \{ path = "\.\.\/\.\.\/contracts\/product-domains", default-features = false, optional = true \}/, + /bitfun-product-domains = \{ path = "\.\.\/\.\.\/contracts\/product-domains", optional = true \}/, message: 'bitfun-product-domains dependency must stay optional and not force product-full outside the core feature graph', }, { regex: - /bitfun-product-capabilities = \{ path = "\.\.\/product-capabilities", default-features = false, optional = true \}/, + /bitfun-product-capabilities = \{ path = "\.\.\/product-capabilities", optional = true \}/, message: 'bitfun-product-capabilities dependency must stay optional and not force product-full outside the core feature graph', }, @@ -3969,18 +4002,12 @@ export const requiredContentRules = [ message: 'core tool-packs feature must explicitly enable the optional dependency', }, { - regex: /"bitfun-tool-packs\/product-full"/, - message: 'core product-full must explicitly enable tool pack product features', - }, - { - regex: - /agent-runtime = \[[\s\S]*"bitfun-services-integrations\/mcp"[\s\S]*"bitfun-services-integrations\/remote-connect"[\s\S]*"bitfun-services-integrations\/workspace-search"[\s\S]*\]/, - message: - 'core agent-runtime must directly assemble the MCP, Remote Connect, and workspace-search services it exposes', + regex: /tools-basic = \[[^\]]*"bitfun-tool-packs\/basic"[^\]]*\]/, + message: 'core basic tools owner must explicitly enable the matching tool pack feature', }, { regex: /"dep:bitfun-product-domains"/, - message: 'core product-domains feature must explicitly enable the optional dependency', + message: 'core capability owners must explicitly enable the optional product-domain dependency', }, { regex: /"dep:bitfun-product-capabilities"/, @@ -3988,8 +4015,8 @@ export const requiredContentRules = [ 'core product-capabilities feature must explicitly enable the optional dependency', }, { - regex: /"bitfun-product-domains\/product-full"/, - message: 'core product-full must explicitly enable product-domain features', + regex: /"bitfun-product-domains\/function-agents"/, + message: 'core function-agent owner must explicitly select its product-domain slice', }, ], }, @@ -4007,12 +4034,12 @@ export const requiredContentRules = [ message: 'external subagent product assembly must stay behind external-sources', }, { - regex: /#\[cfg\(feature = "product-domains"\)\]\s*pub mod function_agents\b/s, - message: 'function-agent product domain facade must stay behind product-domains', + regex: /#\[cfg\(feature = "function-agents"\)\]\s*pub mod function_agents\b/s, + message: 'function-agent product domain facade must stay behind function-agents', }, { - regex: /#\[cfg\(feature = "product-domains"\)\]\s*pub mod miniapp\b/s, - message: 'MiniApp product domain facade must stay behind product-domains', + regex: /#\[cfg\(feature = "tools-miniapp"\)\]\s*pub mod miniapp\b/s, + message: 'MiniApp product facade must stay behind its tool capability owner', }, { regex: /#\[cfg\(feature = "agent-runtime"\)\]\s*pub\(crate\) mod service_agent_runtime\b/s, @@ -4059,8 +4086,8 @@ export const requiredContentRules = [ message: 'AI client runtime must stay behind ai-adapter-runtime', }, { - regex: /#\[cfg\(feature = "ai-adapter-runtime"\)\]\s*pub mod subscription_auth\b/s, - message: 'AI subscription auth runtime must stay behind ai-adapter-runtime', + regex: /#\[cfg\(all\(feature = "ai-adapter-runtime", feature = "subscription-auth"\)\)\]\s*pub mod subscription_auth\b/s, + message: 'AI subscription auth runtime must require both the adapter and credential owners', }, { regex: /#\[cfg\(feature = "debug-log"\)\]\s*pub mod debug_log\b/s, @@ -4095,29 +4122,57 @@ export const requiredContentRules = [ regex: /#\[cfg\(feature = "file-watch"\)\]\s*pub use bitfun_services_integrations::file_watch\b/s, message: 'file-watch facade must stay behind its exact feature', }, + { + regex: /#\[cfg\(feature = "diagnostics"\)\]\s*pub use bitfun_services_core::diagnostics\b/s, + message: 'diagnostics compatibility facade must stay behind diagnostics', + }, + { + regex: /#\[cfg\(feature = "diff"\)\]\s*pub use bitfun_services_core::diff\b/s, + message: 'diff compatibility facade must stay behind diff', + }, { regex: /#\[cfg\(feature = "git"\)\]\s*pub mod git\b/s, message: 'git service facade must stay behind its exact feature', }, { - regex: /#\[cfg\(feature = "agent-runtime"\)\]\s*pub mod mcp\b/s, - message: 'Core MCP product bridge must stay behind agent-runtime', + regex: /#\[cfg\(all\(feature = "agent-runtime", feature = "git"\)\)\]\s*pub mod worktree\b/s, + message: 'managed worktree service must require both Agent lifecycle and Git owners', + }, + { + regex: /#\[cfg\(all\(feature = "agent-runtime", feature = "git"\)\)\]\s*pub use worktree::WorktreeService\b/s, + message: 'managed worktree export must require both Agent lifecycle and Git owners', + }, + { + regex: /#\[cfg\(all\(feature = "agent-runtime", feature = "scheduled-jobs"\)\)\]\s*pub mod cron\b/s, + message: 'scheduled job service must require both the Agent lifecycle and scheduled-jobs modifier', + }, + { + regex: /#\[cfg\(all\(feature = "agent-runtime", feature = "scheduled-jobs"\)\)\]\s*pub use cron::/s, + message: 'scheduled job exports must require both the Agent lifecycle and scheduled-jobs modifier', + }, + { + regex: /#\[cfg\(all\(not\(feature = "remote-workspace"\), feature = "agent-runtime"\)\)\]\s*#\[path = "remote_ssh_compat.rs"\]\s*pub mod remote_ssh\b/s, + message: 'local Agent workspace identity compatibility must stay behind agent-runtime without enabling remote transport', }, { - regex: /#\[cfg\(feature = "agent-runtime"\)\]\s*pub mod remote_connect\b/s, - message: 'Core Remote Connect product bridge must stay behind agent-runtime', + regex: /#\[cfg\(feature = "mcp-runtime"\)\]\s*pub mod mcp\b/s, + message: 'Core MCP product bridge must stay behind mcp-runtime', + }, + { + regex: /#\[cfg\(feature = "remote-connect"\)\]\s*pub mod remote_connect\b/s, + message: 'Core Remote Connect product bridge must stay behind remote-connect', }, { regex: /#\[cfg\(feature = "review-platform"\)\]\s*pub mod review_platform\b/s, message: 'review platform facade must stay behind its exact feature', }, { - regex: /#\[cfg\(feature = "agent-runtime"\)\]\s*pub mod search\b/s, - message: 'workspace search facade must stay behind agent-runtime', + regex: /#\[cfg\(feature = "workspace-search"\)\]\s*pub mod search\b/s, + message: 'workspace search facade must stay behind workspace-search', }, { - regex: /#\[cfg\(feature = "agent-runtime"\)\]\s*pub use search::/s, - message: 'workspace search exports must stay behind agent-runtime', + regex: /#\[cfg\(feature = "workspace-search"\)\]\s*pub use search::/s, + message: 'workspace search exports must stay behind workspace-search', }, { regex: /#\[cfg\(feature = "agent-runtime"\)\]\s*pub mod snapshot\b/s, @@ -4823,7 +4878,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/runtime-ports/tests/plugin_runtime_contracts.rs', + path: 'src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_contracts.rs', reason: 'runtime-ports plugin contract tests must cover typed envelopes, candidate effects, and disabled/projection-only behavior', patterns: [ @@ -4880,7 +4935,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/runtime-ports/tests/plugin_runtime_diagnostics_contracts.rs', + path: 'src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs', reason: 'runtime-ports plugin diagnostics contract tests must cover permission prompts, diagnostics, and quarantine facts', patterns: [ @@ -5003,7 +5058,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/runtime-ports/src/lib.rs', + path: 'src/crates/contracts/runtime-ports/src/agent_api.rs', reason: 'runtime-ports must keep remote and subagent runtime boundary contracts DTO/trait-only', patterns: [ @@ -5016,6 +5071,7 @@ export const requiredContentRules = [ message: 'missing remote control state port contract', }, { + path: 'src/crates/contracts/runtime-ports/src/runtime_event_port.rs', regex: /\bpub trait RuntimeEventSink\b/, message: 'missing runtime event sink contract', }, @@ -5024,62 +5080,77 @@ export const requiredContentRules = [ message: 'agent session create result must return the persisted session name', }, { + path: 'src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs', regex: /\bpub struct RemoteWorkspaceFacts\b/, message: 'missing remote workspace facts contract', }, { + path: 'src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs', regex: /\bpub trait RemoteWorkspaceRuntimeHost\b/, message: 'missing remote workspace runtime host contract', }, { + path: 'src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs', regex: /\bpub trait RemoteWorkspacePort\b/, message: 'missing remote workspace service port contract', }, { + path: 'src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs', regex: /\bpub trait RemoteWorkspaceFileRuntimeHost\b/, message: 'missing remote workspace file runtime host contract', }, { + path: 'src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs', regex: /\bpub trait RemoteProjectionPort\b/, message: 'missing remote projection service port contract', }, { + path: 'src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs', regex: /\bpub trait RemoteInitialSyncRuntimeHost\b/, message: 'missing remote initial sync runtime host contract', }, { + path: 'src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs', regex: /\bremote_workspace_contracts_preserve_workspace_and_session_facts\b/, message: 'missing remote workspace contract regression', }, { + path: 'src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs', regex: /\bremote_projection_contract_preserves_file_chunk_identity\b/, message: 'missing remote projection contract regression', }, { + path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', regex: /\bpub trait WorkspaceFileSystem\b/, message: 'missing workspace file-system port contract', }, { + path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', regex: /\bpub trait WorkspaceShell\b/, message: 'missing workspace shell port contract', }, { + path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', regex: /\bpub struct WorkspaceServices\b/, message: 'missing workspace services bundle contract', }, { + path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', regex: /\bpub struct WorkspaceCommandOptions\b/, message: 'missing workspace command options contract', }, { + path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', regex: /\bpub struct WorkspaceCommandResult\b/, message: 'missing workspace command result contract', }, { + path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', regex: /\bpub struct WorkspaceDirEntry\b/, message: 'missing workspace dir-entry contract', }, { + path: 'src/crates/contracts/runtime-ports/src/workspace_ports.rs', regex: /\bworkspace_services_contract_is_runtime_port_owned\b/, message: 'missing workspace service ownership regression', }, @@ -5156,10 +5227,12 @@ export const requiredContentRules = [ message: 'missing thread-goal lifecycle request regression', }, { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', regex: /\bpub type DialogTriggerSource = AgentSubmissionSource\b/, message: 'missing dialog trigger source compatibility contract', }, { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', regex: /\bdialog_trigger_source_reuses_agent_submission_source_contract\b/, message: 'missing dialog trigger source alias regression', }, @@ -5300,31 +5373,75 @@ export const requiredContentRules = [ message: 'missing compression contract rendering regression', }, { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', regex: /\bpub struct RelatedPath\b/, message: 'missing related path request-context contract', }, { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', regex: /\brelated_path_serializes_as_request_context_fact\b/, message: 'missing related path serialization regression', }, { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', regex: /\bpub struct DelegationPolicy\b/, message: 'missing delegation policy contract', }, { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', regex: /\bpub enum SubagentContextMode\b/, message: 'missing subagent context mode contract', }, { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', regex: /\bdelegation_policy_child_blocks_recursive_spawn_without_losing_depth\b/, message: 'missing delegation policy contract regression', }, { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', regex: /\bsubagent_context_mode_preserves_fork_wire_value\b/, message: 'missing subagent context mode contract regression', }, ], }, + { + path: 'src/crates/contracts/runtime-ports/src/lib.rs', + reason: 'runtime-ports capability features must gate their owned source modules and exports', + patterns: [ + { regex: /#\[cfg\(feature = "agent-api"\)\]\r?\nmod agent_api;/, message: 'agent-api must gate its source module' }, + { regex: /#\[cfg\(feature = "agent-api"\)\]\r?\npub use agent_api::\*;/, message: 'agent-api must gate its public exports' }, + { regex: /#\[cfg\(feature = "plugin-runtime"\)\]\r?\nmod plugin;/, message: 'plugin-runtime must gate its source module' }, + { regex: /#\[cfg\(feature = "plugin-runtime"\)\]\r?\npub use plugin::\{/, message: 'plugin-runtime must gate its public exports' }, + { regex: /#\[cfg\(feature = "script-tool-runtime"\)\]\r?\nmod script_tool;/, message: 'script-tool-runtime must gate its source module' }, + { regex: /#\[cfg\(feature = "script-tool-runtime"\)\]\r?\npub use script_tool::\{/, message: 'script-tool-runtime must gate its public exports' }, + { regex: /#\[cfg\(feature = "workspace-ports"\)\]\r?\nmod workspace_ports;/, message: 'workspace-ports must gate its source module' }, + { regex: /#\[cfg\(feature = "workspace-ports"\)\]\r?\npub use workspace_ports::\*;/, message: 'workspace-ports must gate its public exports' }, + { regex: /#\[cfg\(feature = "terminal-port"\)\]\r?\nmod terminal_port;/, message: 'terminal-port must gate its source module' }, + { regex: /#\[cfg\(feature = "terminal-port"\)\]\r?\npub use terminal_port::\*;/, message: 'terminal-port must gate its public exports' }, + { regex: /#\[cfg\(feature = "remote-exec-port"\)\]\r?\nmod remote_exec_port;/, message: 'remote-exec-port must gate its source module' }, + { regex: /#\[cfg\(feature = "remote-exec-port"\)\]\r?\npub use remote_exec_port::\*;/, message: 'remote-exec-port must gate its public exports' }, + { regex: /#\[cfg\(feature = "remote-workspace-ports"\)\]\r?\nmod remote_workspace_ports;/, message: 'remote-workspace-ports must gate its source module' }, + { regex: /#\[cfg\(feature = "remote-workspace-ports"\)\]\r?\npub use remote_workspace_ports::\*;/, message: 'remote-workspace-ports must gate its public exports' }, + { regex: /#\[cfg\(feature = "runtime-event-port"\)\]\r?\nmod runtime_event_port;/, message: 'runtime-event-port must gate its source module' }, + { regex: /#\[cfg\(feature = "runtime-event-port"\)\]\r?\npub use runtime_event_port::\*;/, message: 'runtime-event-port must gate its public exports' }, + { regex: /#\[cfg\(feature = "git-port"\)\]\r?\nmod git_port;/, message: 'git-port must gate its source module' }, + { regex: /#\[cfg\(feature = "git-port"\)\]\r?\npub use git_port::\*;/, message: 'git-port must gate its public exports' }, + { regex: /#\[cfg\(feature = "tool-runtime-handles"\)\]\r?\nmod tool_runtime_handles;/, message: 'tool-runtime-handles must gate its source module' }, + { regex: /#\[cfg\(feature = "tool-runtime-handles"\)\]\r?\npub use tool_runtime_handles::\*;/, message: 'tool-runtime-handles must gate its public exports' }, + ], + }, + { + path: 'src/crates/execution/tool-contracts/src/lib.rs', + reason: 'tool-contract capability features must gate their owned source modules and exports', + patterns: [ + { regex: /#\[cfg\(feature = "acp-bridge"\)\]\r?\npub mod acp_tool_bridge;/, message: 'acp-bridge must gate its source module' }, + { regex: /#\[cfg\(feature = "acp-bridge"\)\]\r?\npub use acp_tool_bridge::\{/, message: 'acp-bridge must gate its public exports' }, + { regex: /#\[cfg\(feature = "mcp-bridge"\)\]\r?\npub mod mcp_tool_bridge;/, message: 'mcp-bridge must gate its source module' }, + { regex: /#\[cfg\(feature = "mcp-bridge"\)\]\r?\npub use mcp_tool_bridge::\{/, message: 'mcp-bridge must gate its public exports' }, + { regex: /#\[cfg\(feature = "computer-use-contract"\)\]\r?\npub mod computer_use;/, message: 'computer-use-contract must gate its source module' }, + { regex: /#\[cfg\(feature = "element-token"\)\]\r?\npub mod element_token;/, message: 'element-token must gate its source module' }, + ], + }, { path: 'src/crates/assembly/core/src/agentic/subagent_runtime/mod.rs', reason: @@ -7208,6 +7325,10 @@ export const requiredContentRules = [ regex: /\bcreate_product_tool_registry_from_plan\b/, message: 'missing product registry creation adapter', }, + { + regex: /\bunavailable_feature_groups\b/, + message: 'product registry materialization must fail closed when a planned group was not compiled', + }, { regex: /\bmaterialize_tool\b/, message: 'missing concrete tool materialization boundary', @@ -7497,6 +7618,10 @@ export const requiredContentRules = [ regex: /\bpub fn enabled_feature_groups\b/, message: 'missing tool-pack compile-time feature metadata helper', }, + { + regex: /\bpub fn unavailable_feature_groups\b/, + message: 'missing tool-pack planned-versus-compiled validation helper', + }, { regex: /\bpub struct ToolProviderGroupPlan\b/, message: 'missing tool-pack provider group plan contract', @@ -8041,10 +8166,72 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/interfaces/app-server/src/management/service.rs', + reason: + 'App Server owns the concrete management adapter while Hosts retain explicit service injection and capability scope', + patterns: [ + { + regex: /\bpub struct AppManagementService\b/, + message: 'missing concrete App Server management service', + }, + { + regex: /\bimpl AppManagementService\b/, + message: 'missing concrete App Server management implementation', + }, + { + regex: /\bAppManagementCapabilities::available\(\)/, + message: 'missing App Server management capability projection', + }, + ], + }, + { + path: 'src/apps/cli/src/shared_tui_backend.rs', + reason: + 'Shared TUI must retain local Model, Skill, Subagent, and MCP compatibility management without expanding Runtime IPC or leaking owners into controllers', + patterns: [ + { + regex: /management: Arc/, + message: 'missing injected Shared TUI App Server management service', + }, + { + regex: /\bfn management_service\b/, + message: 'missing Shared TUI management capability gate', + }, + { + regex: /\bfn set_management_scope_from_binding\b/, + message: 'missing Shared TUI Remote workspace compatibility guard', + }, + { + regex: /\.list_models\(ListModelsRequest \{\}\)/, + message: 'missing Shared TUI model compatibility delegation', + }, + { + regex: /\.list_skills\(request\)/, + message: 'missing Shared TUI skill compatibility delegation', + }, + { + regex: /\.list_subagents\(request\)/, + message: 'missing Shared TUI subagent compatibility delegation', + }, + { + regex: /\.list_mcp_servers\(request\)/, + message: 'missing Shared TUI MCP compatibility delegation', + }, + { + regex: /\bshared_management_capabilities_follow_the_local_management_service\b/, + message: 'missing Shared TUI management capability regression', + }, + { + regex: /\bremote_workspace_cannot_use_the_local_management_service\b/, + message: 'missing Shared TUI Remote management scope regression', + }, + ], + }, { path: 'src/apps/cli/src/ui/startup.rs', reason: - 'CLI mode-aware subagent management remains an app-layer product surface until agent registry migration has CLI equivalence coverage', + 'CLI subagent presentation remains app-local while mode-aware reads and mutations cross the typed TUI backend boundary', patterns: [ { regex: /\bfn show_available_subagent_list\b/, @@ -8055,16 +8242,16 @@ export const requiredContentRules = [ message: 'missing CLI subagent config surface', }, { - regex: /\bget_subagents_for_query\b/, - message: 'missing CLI mode-scoped subagent query', + regex: /\bagent\.list_subagents\b/, + message: 'missing typed CLI mode-scoped subagent query', }, { - regex: /\bSubagentQueryContext\b/, - message: 'missing CLI subagent query context', + regex: /\bSubagentSummary\b/, + message: 'missing secret-safe CLI subagent read projection', }, { - regex: /\bupdate_subagent_override\b/, - message: 'missing CLI subagent availability update path', + regex: /\bagent\s*\.set_subagent_enabled\b/, + message: 'missing typed CLI subagent availability update path', }, ], }, @@ -8332,8 +8519,23 @@ export const requiredContentRules = [ message: 'missing ssh-remote gate for real remote search implementation', }, { - regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*pub use bitfun_services_integrations::remote_ssh::workspace_search::disabled/s, - message: 'missing service-owned disabled remote search export', + regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*pub use remote_disabled::/s, + message: 'missing dependency-light disabled remote search export', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/search/remote_disabled.rs', + reason: + 'Core local-search builds must retain an explicit remote-search unsupported contract without compiling SSH services', + patterns: [ + { + regex: /Remote SSH search is disabled; enable the `ssh-remote` feature/, + message: 'missing explicit disabled remote search diagnostic', + }, + { + regex: /\bremote_workspace_search_service_for_path\b/, + message: 'missing disabled remote workspace search resolver', }, ], }, diff --git a/scripts/core-boundaries/rules/tui-boundary-rules.mjs b/scripts/core-boundaries/rules/tui-boundary-rules.mjs index fa511d1db0..4487bb85c0 100644 --- a/scripts/core-boundaries/rules/tui-boundary-rules.mjs +++ b/scripts/core-boundaries/rules/tui-boundary-rules.mjs @@ -35,18 +35,13 @@ export const tuiLegacyBackendBudgets = { 'std::process::': 1, }, 'src/apps/cli/src/modes/chat/account.rs': { - 'crate::account::': 11, - 'crate::account_sync::': 5, - }, - 'src/apps/cli/src/modes/chat/external_hooks.rs': { 'bitfun_core::': 8 }, - 'src/apps/cli/src/modes/chat/external_review.rs': { 'bitfun_core::': 5 }, - 'src/apps/cli/src/modes/chat/mcp.rs': { - 'bitfun_core::': 38, - get_mcp_service: 4, + 'crate::account::': 0, + 'crate::account_sync::': 0, }, + 'src/apps/cli/src/modes/chat/external_hooks.rs': { 'bitfun_core::': 0 }, + 'src/apps/cli/src/modes/chat/external_review.rs': { 'bitfun_core::': 0 }, 'src/apps/cli/src/modes/chat/provider_models.rs': { - 'bitfun_core::': 4, - 'crate::account_sync::': 2, + 'crate::account_sync::': 0, }, 'src/apps/cli/src/modes/chat/run.rs': { 'bitfun_core::': 2, @@ -56,14 +51,14 @@ export const tuiLegacyBackendBudgets = { }, 'src/apps/cli/src/modes/chat/session_lineage.rs': { 'bitfun_agent_runtime::': 1 }, 'src/apps/cli/src/modes/chat/selection.rs': { - 'bitfun_core::': 7, + 'bitfun_core::': 1, 'crate::account::': 2, }, 'src/apps/cli/src/modes/chat/tests.rs': { 'bitfun_core::': 8, 'bitfun_agent_runtime::': 3, }, - 'src/apps/cli/src/modes/chat/worktree.rs': { 'bitfun_core::': 2 }, + 'src/apps/cli/src/modes/chat/worktree.rs': { 'bitfun_core::': 0 }, 'src/apps/cli/src/ui/chat/popups.rs': { 'bitfun_core::': 1, 'bitfun_agent_runtime::': 2, @@ -82,10 +77,10 @@ export const tuiLegacyBackendBudgets = { 'src/apps/cli/src/ui/permission.rs': { 'bitfun_agent_runtime::': 2 }, 'src/apps/cli/src/ui/session_lineage_selector.rs': { 'bitfun_agent_runtime::': 1 }, 'src/apps/cli/src/ui/startup.rs': { - 'bitfun_core::': 14, - CoreAgentRuntimeCompatibility: 3, - 'crate::account::': 13, - 'crate::account_sync::': 8, + 'bitfun_core::': 0, + CoreAgentRuntimeCompatibility: 0, + 'crate::account::': 0, + 'crate::account_sync::': 0, }, 'src/apps/cli/src/ui/workspace_diff.rs': { 'bitfun_agent_runtime::': 2 }, 'src/apps/cli/src/ui/workspace_reference.rs': { 'bitfun_agent_runtime::': 1 }, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 782ac5d1f7..5f0253e82d 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -7,6 +7,7 @@ export function runManifestParserSelfTest({ parseManifestDependencies, manifestDependencyMatches, matchingForbiddenDependency, + acpClosedFeatureProfileRules, coreClosedFeatureProfileRules, coreProductFullFeatureAssemblyRule, ownerCrateFeatureAssemblyRules, @@ -80,11 +81,68 @@ export function runManifestParserSelfTest({ topLevelRustFiles: agentRuntimeIntegrationTestTargets.map(({ path }) => path), rootSources: explicitTestRoots, leafRustFiles: ['tests/agent_definition_contracts/prompt_contracts.rs'], + leafSources: new Map([['tests/agent_definition_contracts/prompt_contracts.rs', '']]), }; const topologyErrors = validateExplicitIntegrationTestTopology(explicitTestFixture); if (topologyErrors.length > 0) { throw new Error(`valid explicit integration-test topology failed: ${topologyErrors.join('; ')}`); } + const repeatedOwnerGateErrors = validateExplicitIntegrationTestTopology({ + ...explicitTestFixture, + rootSources: new Map([ + ...explicitTestRoots, + [ + 'tests/agent_definition_contracts.rs', + '#![cfg(feature = "agent-definitions")]\n#[path = "agent_definition_contracts/prompt_contracts.rs"]\nmod prompt_contracts;', + ], + ]), + leafSources: new Map([[ + 'tests/agent_definition_contracts/prompt_contracts.rs', + '#![cfg(feature = "agent-definitions")]\n', + ]]), + }); + if (!repeatedOwnerGateErrors.some((error) => error.includes('belongs in its explicit target root'))) { + throw new Error('grouped test topology must keep positive owner cfg only in the target root'); + } + const hiddenLeafCfgSources = [ + '# ! [ cfg(not(any())) ]\nfn contract() {}\n', + '#![\n cfg(not(any()))\n]\nfn contract() {}\n', + '#/**/!/**/[/**/cfg(not(any()))]\nfn contract() {}\n', + '#![doc = r#"a"]b"#]\n#![/* ] */ cfg(not(any()))]\nfn contract() {}\n', + '#![r#cfg_attr(feature = "unrelated", cfg(windows))]\nfn contract() {}\n', + '#!/usr/bin/env rustx\n#![cfg(not(any()))]\nfn contract() {}\n', + '\uFEFF#!/usr/bin/env rustx\n/* preamble */\n# ! [ cfg(not(any())) ]\nfn contract() {}\n', + "#![doc = stringify!('a)]\n#![cfg(not(any()))]\n#![doc = stringify!('b)]\nfn contract() {}\n", + ]; + for (const leafSource of hiddenLeafCfgSources) { + const hiddenLeafCfgErrors = validateExplicitIntegrationTestTopology({ + ...explicitTestFixture, + leafSources: new Map([[ + 'tests/agent_definition_contracts/prompt_contracts.rs', + leafSource, + ]]), + }); + if (!hiddenLeafCfgErrors.some((error) => error.includes('belongs in its explicit target root'))) { + throw new Error(`grouped test topology must reject obfuscated leaf cfg: ${leafSource}`); + } + } + for (const leafSource of [ + '/* # ! [ cfg(not(any())) ] */\nfn contract() {}\n', + 'const TEXT: &str = r#"\n#![cfg(not(any()))]\n"#;\n', + '#![doc = r#"a"]b"#]\nfn contract() {}\n', + "#![doc = stringify!('a)]\nfn contract() {}\n", + ]) { + const literalLeafCfgErrors = validateExplicitIntegrationTestTopology({ + ...explicitTestFixture, + leafSources: new Map([[ + 'tests/agent_definition_contracts/prompt_contracts.rs', + leafSource, + ]]), + }); + if (literalLeafCfgErrors.length > 0) { + throw new Error(`grouped test topology misread cfg text in trivia or a literal: ${literalLeafCfgErrors.join('; ')}`); + } + } const orphanErrors = validateExplicitIntegrationTestTopology({ ...explicitTestFixture, leafRustFiles: [ @@ -95,6 +153,82 @@ export function runManifestParserSelfTest({ if (!orphanErrors.some((error) => error.includes('orphan_contracts.rs'))) { throw new Error('explicit integration-test topology must reject an orphan leaf test'); } + const reviewedLeafTargets = agentRuntimeIntegrationTestTargets.map((target) => ( + target.path === 'tests/agent_definition_contracts.rs' + ? { + ...target, + leaves: ['tests/agent_definition_contracts/prompt_contracts.rs'], + forbidRequiredFeatures: true, + } + : target + )); + const missingReviewedLeafErrors = validateExplicitIntegrationTestTopology({ + ...explicitTestFixture, + expectedTargets: reviewedLeafTargets, + leafRustFiles: [], + leafSources: new Map(), + }); + if (!missingReviewedLeafErrors.some((error) => error.includes('grouped test leaves'))) { + throw new Error('explicit integration-test topology must reject a removed reviewed leaf'); + } + for (const requiredFeaturesDeclaration of [ + 'required-features = [\n "opt-in",\n]', + '"required\\u002dfeatures" = ["opt-in"]', + ]) { + const unexpectedRequiredFeaturesErrors = validateExplicitIntegrationTestTopology({ + ...explicitTestFixture, + expectedTargets: reviewedLeafTargets, + manifestText: explicitTestManifest.replace( + 'path = "tests/agent_definition_contracts.rs"', + `path = "tests/agent_definition_contracts.rs"\n${requiredFeaturesDeclaration}`, + ), + }); + if (!unexpectedRequiredFeaturesErrors.some((error) => error.includes('required-features'))) { + throw new Error(`ungated explicit test topology accepted: ${requiredFeaturesDeclaration}`); + } + } + const independentRequiredFeaturesErrors = validateExplicitIntegrationTestTopology({ + ...explicitTestFixture, + expectedTargets: reviewedLeafTargets, + manifestText: explicitTestManifest.replace( + 'path = "tests/native_hook_execution_contracts.rs"', + 'path = "tests/native_hook_execution_contracts.rs"\nrequired-features = ["native-hooks"]', + ), + }); + if (independentRequiredFeaturesErrors.length > 0) { + throw new Error( + `target-scoped required-features contract rejected an independent target: ${independentRequiredFeaturesErrors.join('; ')}`, + ); + } + const reviewedLeafCfgFixture = { + ...explicitTestFixture, + leafSources: new Map([[ + 'tests/agent_definition_contracts/prompt_contracts.rs', + '#![cfg(not(feature = "reviewed-negative"))]\n', + ]]), + allowedLeafCfgLines: new Map([[ + 'tests/agent_definition_contracts/prompt_contracts.rs', + '#![cfg(not(feature = "reviewed-negative"))]', + ]]), + }; + const reviewedLeafCfgErrors = validateExplicitIntegrationTestTopology( + reviewedLeafCfgFixture, + ); + if (reviewedLeafCfgErrors.length > 0) { + throw new Error( + `grouped test topology rejected an exact reviewed leaf cfg: ${reviewedLeafCfgErrors.join('; ')}`, + ); + } + const extraLeafCfgErrors = validateExplicitIntegrationTestTopology({ + ...reviewedLeafCfgFixture, + leafSources: new Map([[ + 'tests/agent_definition_contracts/prompt_contracts.rs', + '#![cfg(not(feature = "reviewed-negative"))]\n#![cfg(not(feature = "unrelated-feature"))]\n', + ]]), + }); + if (!extraLeafCfgErrors.some((error) => error.includes('belongs in its explicit target root'))) { + throw new Error('grouped test topology must reject extra cfg lines on a reviewed leaf'); + } const wrongSectionErrors = validateExplicitIntegrationTestTopology({ ...explicitTestFixture, manifestText: explicitTestManifest.replace( @@ -230,6 +364,8 @@ export function runManifestParserSelfTest({ for (const featureName of [ 'agent-runtime', + 'diagnostics', + 'diff', 'announcement', 'canvas-runtime', 'debug-log', @@ -237,6 +373,7 @@ export function runManifestParserSelfTest({ 'file-watch', 'filesystem', 'git', + 'i18n-runtime', 'lsp', 'local-storage', 'process-runtime', @@ -249,7 +386,7 @@ export function runManifestParserSelfTest({ 'workspace-runtime', 'workspace-watch', 'product-capabilities', - 'product-domains', + 'function-agents', 'tool-packs', ]) { if (!coreProductFullFeatureAssemblyRule.requiredFeatureRefs.includes(featureName)) { @@ -266,11 +403,30 @@ export function runManifestParserSelfTest({ const coreManifest = 'src/crates/assembly/core/Cargo.toml'; const servicesCoreManifest = 'src/crates/services/services-core/Cargo.toml'; const expectedClosedCoreProfiles = [ + [coreManifest, 'default', []], + [coreManifest, 'i18n-runtime', ['dep:fluent-bundle', 'dep:unic-langid']], + [coreManifest, 'diagnostics', ['bitfun-services-core/diagnostics']], + [coreManifest, 'diff', ['bitfun-services-core/diff']], [servicesCoreManifest, 'default', []], + [servicesCoreManifest, 'diagnostics', ['dep:regex']], + [ + servicesCoreManifest, + 'diff', + ['dep:similar', 'dep:tokio', 'tokio/rt', 'tokio/time'], + ], [ servicesCoreManifest, 'filesystem', - ['dep:base64', 'dep:chrono', 'dep:ignore', 'dep:sha2', 'tokio/fs'], + [ + 'dep:base64', + 'dep:chrono', + 'dep:ignore', + 'dep:regex', + 'dep:sha2', + 'dep:tokio', + 'tokio/fs', + 'tokio/rt', + ], ], [ servicesCoreManifest, @@ -281,10 +437,15 @@ export function runManifestParserSelfTest({ 'dep:chrono', 'dep:fs2', 'dep:libc', + 'dep:regex', 'dep:sha2', + 'dep:similar', + 'dep:tokio', 'dep:windows', 'tokio/fs', + 'tokio/rt', 'tokio/sync', + 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_Storage_FileSystem', ], @@ -294,11 +455,14 @@ export function runManifestParserSelfTest({ 'process-runtime', [ 'dep:libc', + 'dep:tokio', 'dep:which', 'dep:win32job', 'dep:windows', 'tokio/io-util', 'tokio/process', + 'tokio/rt', + 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_System_Diagnostics_ToolHelp', 'windows/Win32_System_Threading', @@ -307,7 +471,20 @@ export function runManifestParserSelfTest({ [ servicesCoreManifest, 'workspace-instructions', - ['dep:globset', 'dep:serde_yaml', 'tokio/fs', 'tokio/io-util'], + [ + 'dep:globset', + 'dep:regex', + 'dep:serde_yaml', + 'dep:tokio', + 'tokio/fs', + 'tokio/io-util', + 'tokio/rt', + ], + ], + [ + servicesCoreManifest, + 'workspace-text-runtime', + ['dep:tokio', 'tokio/rt'], ], [ servicesCoreManifest, @@ -330,6 +507,8 @@ export function runManifestParserSelfTest({ 'dep:anyhow', 'dep:async-trait', 'dep:bitfun-runtime-ports', + 'bitfun-runtime-ports/runtime-event-port', + 'bitfun-runtime-ports/workspace-ports', 'dep:dunce', 'process-runtime', 'tokio/fs', @@ -339,11 +518,19 @@ export function runManifestParserSelfTest({ ], [servicesCoreManifest, 'session-git', ['local-storage', 'dep:git2']], [servicesCoreManifest, 'workspace-identity', ['dep:dunce', 'dep:sha2']], - [coreManifest, 'dispatch-store', ['local-storage']], + [ + coreManifest, + 'dispatch-store', + ['dep:base64', 'local-storage', 'bitfun-services-core/dispatch-workspace'], + ], [coreManifest, 'filesystem', ['bitfun-services-core/filesystem']], - [coreManifest, 'local-storage', ['bitfun-services-core/local-storage']], + [ + coreManifest, + 'local-storage', + ['dep:bitfun-agent-tools', 'bitfun-services-core/local-storage'], + ], [coreManifest, 'process-runtime', ['bitfun-services-core/process-runtime']], - [coreManifest, 'lsp', ['dep:notify', 'bitfun-services-core/lsp']], + [coreManifest, 'lsp', ['dep:notify', 'bitfun-services-core/lsp', 'tokio/macros']], [coreManifest, 'terminal', ['dep:terminal-core']], [ coreManifest, @@ -372,7 +559,7 @@ export function runManifestParserSelfTest({ [ coreManifest, 'canvas-runtime', - ['product-domains', 'bitfun-services-integrations/canvas-runtime'], + ['dep:bitfun-product-domains', 'bitfun-services-integrations/canvas-runtime'], ], [coreManifest, 'announcement', ['bitfun-services-integrations/announcement']], [coreManifest, 'file-watch', ['bitfun-services-integrations/file-watch']], @@ -406,6 +593,63 @@ export function runManifestParserSelfTest({ throw new Error(`core closed feature profile must not reach product-full in ${featureName}`); } } + const acpProfiles = new Map( + acpClosedFeatureProfileRules.map((rule) => [rule.featureName, rule]), + ); + const expectedAcpProfiles = new Map([ + ['default', ['client', 'server']], + [ + 'client', + [ + 'bitfun-agent-tools/acp-bridge', + 'dep:futures', + 'dep:serde', + 'dep:bitfun-core', + 'bitfun-core/agent-runtime', + 'bitfun-core/ssh-remote', + ], + ], + [ + 'server', + [ + 'dep:bitfun-agent-tools', + 'dep:bitfun-agent-runtime', + 'dep:bitfun-core-types', + 'dep:bitfun-core', + 'dep:sha2', + 'bitfun-core/agent-runtime', + 'bitfun-core/document-read', + 'bitfun-core/subscription-auth', + 'bitfun-core/deep-research', + 'bitfun-core/lsp', + 'bitfun-core/external-sources', + 'bitfun-core/tools-basic', + 'bitfun-core/tools-git', + 'bitfun-core/tools-mcp', + 'bitfun-core/tools-browser-web', + 'bitfun-core/tools-computer-use', + 'bitfun-core/tools-image-analysis', + 'bitfun-core/tools-miniapp', + 'bitfun-core/tools-canvas', + 'bitfun-core/tools-agent-control', + ], + ], + ]); + for (const [featureName, expectedReferences] of expectedAcpProfiles) { + const rule = acpProfiles.get(featureName); + if (!rule?.exact) { + throw new Error(`ACP closed feature profile must cover ${featureName} exactly`); + } + if ( + rule.requiredFeatureRefs.length !== expectedReferences.length + || expectedReferences.some((reference) => !rule.requiredFeatureRefs.includes(reference)) + ) { + throw new Error(`ACP closed feature profile has stale references for ${featureName}`); + } + if (rule.requiredFeatureRefs.some((reference) => reference.includes('product-full'))) { + throw new Error(`ACP closed feature profile must not reach product-full in ${featureName}`); + } + } const ownerFeatureRulePaths = new Set( ownerCrateFeatureAssemblyRules.map((rule) => rule.manifestPath), ); @@ -827,6 +1071,7 @@ export function runManifestParserSelfTest({ 'schannel', 'win32job', 'bitfun-relay-service', + 'bitfun-transport', 'htmd', 'legible', 'readability-js', @@ -845,6 +1090,7 @@ export function runManifestParserSelfTest({ 'aes', 'aes-gcm', 'bitfun-relay-service', + 'bitfun-transport', 'eventsource-stream', 'git2', 'glob', @@ -882,45 +1128,6 @@ export function runManifestParserSelfTest({ const servicesOptionalOwnerRule = optionalDependencyFeatureOwnerRules.find( (rule) => rule.crateName === 'services-integrations', ); - const workspaceReqwestRule = requiredContentRules.find((rule) => rule.path === 'Cargo.toml'); - const workspaceReqwestRuleText = workspaceReqwestRule?.patterns - .map((pattern) => pattern.regex.source) - .join('\n') ?? ''; - for (const featureName of ['http2', 'json', 'stream', 'multipart', 'query', 'form']) { - if (!workspaceReqwestRuleText.includes(featureName)) { - throw new Error(`workspace Reqwest boundary must allow only reviewed feature ${featureName}`); - } - } - const workspaceReqwestPattern = workspaceReqwestRule?.patterns[0]?.regex; - const reviewedReqwestDeclaration = - 'reqwest = { version = "0.13.4", default-features = false, features = ["http2", "json", "stream", "multipart", "query", "form"] }'; - if (!workspaceReqwestPattern?.test(reviewedReqwestDeclaration)) { - throw new Error('workspace Reqwest boundary must accept the reviewed transport/data profile'); - } - for (const featureName of ['default-tls', 'http3', '__native-tls']) { - const expandedDeclaration = reviewedReqwestDeclaration.replace( - '"form"]', - `"form", "${featureName}"]`, - ); - if (workspaceReqwestPattern.test(expandedDeclaration)) { - throw new Error(`workspace Reqwest boundary must reject TLS-enabling feature ${featureName}`); - } - } - for (const path of [ - 'src/apps/cli/Cargo.toml', - 'src/apps/desktop/Cargo.toml', - 'src/crates/adapters/ai-adapters/Cargo.toml', - 'src/crates/services/miniapp-market-service/Cargo.toml', - 'src/crates/services/skin-market-service/Cargo.toml', - ]) { - const reqwestRule = requiredContentRules.find((rule) => rule.path === path); - const reqwestRuleText = reqwestRule?.patterns - .map((pattern) => pattern.regex.source) - .join('\n') ?? ''; - if (!reqwestRuleText.includes('rustls')) { - throw new Error(`${path} must guard the explicit Reqwest Rustls client dependency`); - } - } const servicesCoreOptionalOwnerRule = optionalDependencyFeatureOwnerRules.find( (rule) => rule.crateName === 'services-core', ); @@ -937,14 +1144,19 @@ export function runManifestParserSelfTest({ ['bitfun-core-types', ['local-storage', 'lsp']], ['bitfun-events', ['local-storage']], ['chrono', ['filesystem', 'local-storage']], - ['fs2', ['local-storage', 'runtime-ownership']], + ['fs2', ['json-io', 'local-storage', 'runtime-ownership']], ['git2', ['session-git']], ['globset', ['workspace-instructions']], ['ignore', ['filesystem']], ['libc', ['local-storage', 'process-runtime']], ['notify', ['lsp']], + [ + 'regex', + ['diagnostics', 'filesystem', 'local-storage', 'markdown', 'workspace-instructions'], + ], ['rusqlite', ['permission']], ['serde_yaml', ['markdown', 'workspace-instructions']], + ['similar', ['diff', 'local-storage']], [ 'sha2', [ @@ -957,8 +1169,23 @@ export function runManifestParserSelfTest({ ], ['which', ['process-runtime']], ['win32job', ['process-runtime']], - ['windows', ['local-storage', 'process-runtime']], + ['windows', ['json-io', 'local-storage', 'process-runtime']], ['zip', ['lsp']], + [ + 'tokio', + [ + 'diff', + 'filesystem', + 'json-io', + 'local-storage', + 'lsp', + 'permission', + 'process-runtime', + 'workspace-instructions', + 'workspace-runtime', + 'workspace-text-runtime', + ], + ], ]); for (const [dependencyName, ownerFeatures] of expectedServicesCoreOwners) { const dependency = servicesCoreOptionalOwnerRule?.dependencies.find( @@ -2242,7 +2469,7 @@ export function runManifestParserSelfTest({ const requiredContentContracts = [ { - path: 'src/crates/contracts/runtime-ports/src/lib.rs', + path: 'src/crates/contracts/runtime-ports/src/agent_api.rs', contracts: [ 'AgentDialogTurnRequest', 'AgentDialogPrependedReminder', @@ -3144,7 +3371,7 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_capabilities.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs', contracts: [ 'product_assembly_plan_exposes_build_feature_groups_explicitly', 'product_runtime_assembly_reports_runtime_service_capability_gaps', @@ -3152,7 +3379,7 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/plugin_product_shape.rs', contracts: [ 'executable_plugin_runtime_is_limited_to_product_full_desktop_and_cli', 'executable_plugin_runtime_client_builds_agent_runtime_parts', @@ -3191,7 +3418,7 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs', contracts: [ 'product_runtime_parts_can_build_agent_runtime_sdk_without_core', 'sdk_delivery_profile_builds_shared_runtime_owner_ceiling_without_bitfun_core', @@ -3748,6 +3975,7 @@ export function runManifestParserSelfTest({ 'StaticToolProviderFactory', 'create_registry_from_static_provider_entries', 'create_product_tool_registry_from_plan', + 'unavailable_feature_groups', 'materialize_tool', 'GetToolSpecTool', ], @@ -3836,6 +4064,7 @@ export function runManifestParserSelfTest({ 'ToolProviderGroupPlan', 'all_feature_groups', 'enabled_feature_groups', + 'unavailable_feature_groups', 'product_tool_provider_group_plan', 'ToolProviderGroupPlanSelectionError', 'try_product_tool_provider_group_plan_for_ids', @@ -4008,14 +4237,36 @@ export function runManifestParserSelfTest({ 'AgentSession', ], }, + { + path: 'src/crates/interfaces/app-server/src/management/service.rs', + contracts: [ + 'pub struct AppManagementService', + 'impl AppManagementService', + 'AppManagementCapabilities::available\\(\\)', + ], + }, + { + path: 'src/apps/cli/src/shared_tui_backend.rs', + contracts: [ + 'management: Arc', + 'fn management_service', + 'fn set_management_scope_from_binding', + '\\.list_models\\(ListModelsRequest \\{\\}\\)', + '\\.list_skills\\(request\\)', + '\\.list_subagents\\(request\\)', + '\\.list_mcp_servers\\(request\\)', + 'shared_management_capabilities_follow_the_local_management_service', + 'remote_workspace_cannot_use_the_local_management_service', + ], + }, { path: 'src/apps/cli/src/ui/startup.rs', contracts: [ 'show_available_subagent_list', 'show_subagent_config_selector', - 'get_subagents_for_query', - 'SubagentQueryContext', - 'update_subagent_override', + 'agent.list_subagents', + 'SubagentSummary', + 'agent\\s*\\.set_subagent_enabled', ], }, { @@ -4148,9 +4399,13 @@ export function runManifestParserSelfTest({ path: 'src/crates/assembly/core/src/service/search/mod.rs', contracts: [ 'feature = "ssh-remote"', - 'bitfun_services_integrations::remote_ssh::workspace_search::disabled', + 'remote_disabled', ], }, + { + path: 'src/crates/assembly/core/src/service/search/remote_disabled.rs', + contracts: ['Remote SSH search is disabled', 'remote_workspace_search_service_for_path'], + }, { path: 'src/crates/services/services-integrations/src/remote_ssh/workspace_search/disabled.rs', contracts: ['Remote SSH search is disabled', 'RemoteWorkspaceSearchService', 'remote_workspace_search_service_for_path'], @@ -4158,11 +4413,11 @@ export function runManifestParserSelfTest({ { path: 'src/crates/assembly/core/Cargo.toml', contracts: [ - 'bitfun-product-capabilities = \\{ path = "\\.\\.\\/product-capabilities", default-features = false, optional = true \\}', + 'bitfun-product-capabilities = \\{ path = "\\.\\.\\/product-capabilities", optional = true \\}', 'bitfun-ai-adapters = \\{ path = "\\.\\.\\/\\.\\.\\/adapters\\/ai-adapters", optional = true \\}', - 'bitfun-tool-packs = \\{ path = "\\.\\.\\/\\.\\.\\/execution\\/tool-provider-groups", default-features = false, optional = true \\}', - 'bitfun-services-integrations = \\{ path = "\\.\\.\\/\\.\\.\\/services\\/services-integrations", default-features = false, optional = true \\}', - 'bitfun-product-domains = \\{ path = "\\.\\.\\/\\.\\.\\/contracts\\/product-domains", default-features = false, optional = true \\}', + 'bitfun-tool-packs = \\{ path = "\\.\\.\\/\\.\\.\\/execution\\/tool-provider-groups", optional = true \\}', + 'bitfun-services-integrations = \\{ path = "\\.\\.\\/\\.\\.\\/services\\/services-integrations", optional = true \\}', + 'bitfun-product-domains = \\{ path = "\\.\\.\\/\\.\\.\\/contracts\\/product-domains", optional = true \\}', 'dep:bitfun-ai-adapters', 'ai-adapter-runtime', 'canvas-runtime', @@ -4171,13 +4426,13 @@ export function runManifestParserSelfTest({ 'bitfun-services-integrations\\/miniapp-runtime', 'dep:bitfun-product-capabilities', 'dep:bitfun-tool-packs', - 'bitfun-tool-packs\\/product-full', + 'tools-basic', + 'bitfun-tool-packs\\/basic', 'agent-runtime', - 'bitfun-services-integrations\\/mcp', - 'bitfun-services-integrations\\/remote-connect', - 'bitfun-services-integrations\\/workspace-search', 'dep:bitfun-product-domains', - 'bitfun-product-domains\\/product-full', + 'bitfun-product-domains\\/external-sources', + 'bitfun-product-domains\\/function-agents', + 'bitfun-product-domains\\/miniapp', ], }, { @@ -4187,8 +4442,9 @@ export function runManifestParserSelfTest({ 'pub mod agentic', 'feature = "external-sources"', 'mod external_subagents', - 'feature = "product-domains"', + 'feature = "function-agents"', 'pub mod function_agents', + 'feature = "tools-miniapp"', 'pub mod miniapp', 'feature = "agent-runtime"', 'service_agent_runtime', diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index 3e7518258a..78f5108176 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -4,6 +4,8 @@ import { spawnSync } from 'child_process'; import { fileURLToPath } from 'url'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path'; import { + chmodSync, + copyFileSync, existsSync, mkdirSync, readFileSync, @@ -15,6 +17,7 @@ import { ensureFlashgrepBinary } from './prepare-flashgrep-resource.mjs'; import { extractProductConfigArg } from './product-customization/cli.mjs'; import { productBuildEnvironment } from './product-customization/projections.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; +import { resolveReleaseChannel } from './release-channel.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); @@ -40,18 +43,28 @@ async function main() { const resolution = resolveProductDefinition({ rootDir: ROOT, productConfig, member: 'desktop' }); Object.assign(process.env, productBuildEnvironment(resolution)); console.log(`[product] ${resolution.assembly.member} ${resolution.assembly.assemblyDigest}`); - - const flashgrepBinary = ensureFlashgrepBinary(); - process.env.FLASHGREP_DAEMON_BIN = flashgrepBinary; + const releaseChannel = resolveReleaseChannel(process.env.BITFUN_RELEASE_CHANNEL); + console.log(`[release] channel=${releaseChannel.channel}`); const desktopDir = join(ROOT, 'src', 'apps', 'desktop'); + const flashgrepBinary = prepareMacOSFlashgrepForSigning( + ensureFlashgrepBinary(), + desktopDir, + ); + process.env.FLASHGREP_DAEMON_BIN = flashgrepBinary; // Tauri CLI reads CI and rejects numeric "1" (common in CI providers). process.env.CI = 'true'; + if (process.platform === 'darwin' && requestsDmgBundle(forward)) { + // Tauri otherwise passes --skip-jenkins under CI, which drops the branded + // Finder background and icon positions from the generated DMG. + process.env.TAURI_BUNDLER_DMG_IGNORE_CI = 'true'; + } const tauriConfig = prepareTauriConfig(join(desktopDir, 'tauri.conf.json'), { desktopDir, flashgrepBinary, resolution, + releaseChannel, }); const tauriBin = join(ROOT, 'node_modules', '.bin', 'tauri'); const tauriArgs = ['build', '--config', tauriConfig, ...forward]; @@ -71,10 +84,6 @@ async function main() { process.exit(1); } - if (r.status === 0 && process.platform === 'darwin') { - patchDmgExtras(ROOT); - } - // Keep only the latest useful Cargo caches for this build profile after tauri build ends. try { const { profileFromTauriBuildArgs, runGcBestEffort, targetFromTauriBuildArgs } = await import( @@ -166,9 +175,49 @@ function optionValue(args, option) { return undefined; } +export function prepareMacOSFlashgrepForSigning( + flashgrepBinary, + desktopDir, + runtime = {}, +) { + const platform = runtime.platform ?? process.platform; + const signingIdentity = runtime.signingIdentity ?? process.env.APPLE_SIGNING_IDENTITY; + if (platform !== 'darwin' || !signingIdentity) { + return flashgrepBinary; + } + + const signedDir = join(desktopDir, 'gen', 'signed-resources', 'flashgrep'); + const signedBinary = join(signedDir, basename(flashgrepBinary)); + mkdirSync(signedDir, { recursive: true }); + copyFileSync(flashgrepBinary, signedBinary); + chmodSync(signedBinary, statSync(signedBinary).mode | 0o111); + + const run = runtime.spawnSync ?? spawnSync; + const result = run( + 'codesign', + [ + '--force', + '--sign', + signingIdentity, + '--options', + 'runtime', + '--timestamp', + signedBinary, + ], + { encoding: 'utf8', shell: false }, + ); + if (result.error || result.status !== 0) { + const detail = result.error?.message || result.stderr || `exit status ${result.status}`; + throw new Error(`Failed to sign bundled flashgrep binary: ${detail}`); + } + + console.log(`[tauri-build] Signed bundled flashgrep binary: ${signedBinary}`); + return signedBinary; +} + export function prepareTauriConfig( baseConfigPath, - { desktopDir, flashgrepBinary, resolution } + { desktopDir, flashgrepBinary, resolution, releaseChannel } ) { const config = JSON.parse(readFileSync(baseConfigPath, 'utf8')); if (resolution) { @@ -181,6 +230,16 @@ export function prepareTauriConfig( } injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary); + const release = releaseChannel + ?? resolveReleaseChannel(process.env.BITFUN_RELEASE_CHANNEL); + const primaryEndpoint = + process.env.TAURI_UPDATER_ENDPOINT || release.primaryUpdaterEndpoint; + const fallbackEndpoint = + process.env.TAURI_UPDATER_FALLBACK_ENDPOINT || release.fallbackUpdaterEndpoint; + process.env.BITFUN_RELEASE_CHANNEL = release.channel; + process.env.BITFUN_UPDATER_PRIMARY_ENDPOINT = primaryEndpoint; + process.env.BITFUN_UPDATER_FALLBACK_ENDPOINT = fallbackEndpoint; + const enabled = ['1', 'true', 'yes'].includes( String(process.env.BITFUN_ENABLE_UPDATER_ARTIFACTS || '').toLowerCase() ); @@ -196,16 +255,9 @@ export function prepareTauriConfig( process.exit(1); } - const primaryEndpoint = - process.env.TAURI_UPDATER_ENDPOINT || - 'https://github.com/GCWing/BitFun/releases/latest/download/latest.json'; // Fallback endpoint used when GitHub is unreachable (not when no update is found). // Tauri updater iterates endpoints and only falls through on network/HTTP errors; // a 204 (no update) or a successfully parsed manifest stops the loop. - const fallbackEndpoint = - process.env.TAURI_UPDATER_FALLBACK_ENDPOINT || - 'https://openbitfun.com/release/latest.json'; - config.bundle = { ...(config.bundle || {}), createUpdaterArtifacts: true, @@ -216,12 +268,12 @@ export function prepareTauriConfig( endpoints: [primaryEndpoint, fallbackEndpoint], pubkey, windows: { - installMode: 'passive', + installMode: 'quiet', }, }, }; console.log( - `[tauri-build] Updater artifacts enabled: ${primaryEndpoint} (fallback: ${fallbackEndpoint})` + `[tauri-build] Updater artifacts enabled for ${release.channel}: ${primaryEndpoint} (fallback: ${fallbackEndpoint})` ); } @@ -270,48 +322,6 @@ function toTauriPath(value) { return value.split(sep).join('/'); } -// Find all .dmg files under target/ and inject the helper TXT files -// (quarantine removal instructions) into each one. -function patchDmgExtras(root) { - const patchScript = join(root, 'scripts', 'patch-dmg-extras.sh'); - const targetDir = join(root, 'target'); - - const dmgFiles = findDmgFiles(targetDir); - if (dmgFiles.length === 0) { - console.log('[patch-dmg] No .dmg files found — skipping.'); - return; - } - - for (const dmg of dmgFiles) { - console.log(`[patch-dmg] Patching ${dmg}`); - const p = spawnSync('bash', [patchScript, dmg], { - stdio: 'inherit', - shell: false, - }); - if (p.status !== 0) { - console.error(`[patch-dmg] Failed to patch ${dmg}`); - process.exit(1); - } - } -} - -function findDmgFiles(dir) { - const results = []; - try { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...findDmgFiles(full)); - } else if (entry.name.endsWith('.dmg')) { - results.push(full); - } - } - } catch { - // directory may not exist for some targets - } - return results; -} - if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { main().catch((e) => { console.error(e); diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 777b0eb079..60c5604f35 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -3,13 +3,109 @@ import { mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import { prepareTauriConfig, shouldRetryMacDmgBuild } from './desktop-tauri-build.mjs'; +import { + prepareMacOSFlashgrepForSigning, + prepareTauriConfig, + shouldRetryMacDmgBuild, +} from './desktop-tauri-build.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; const FAILED_BUILD = { status: 1 }; const DMG_ARGS = ['--target', 'x86_64-apple-darwin', '--bundles', 'app,dmg']; const ROOT = join(import.meta.dirname, '..'); +test('release builds do not mutate DMGs after Tauri signs and notarizes them', () => { + const source = readFileSync(join(ROOT, 'scripts', 'desktop-tauri-build.mjs'), 'utf8'); + assert.doesNotMatch(source, /patchDmgExtras/); + assert.doesNotMatch(source, /patch-dmg-extras\.sh/); + assert.match(source, /TAURI_BUNDLER_DMG_IGNORE_CI = 'true'/); +}); + +test('Desktop DMG uses the branded installer layout', () => { + const config = JSON.parse( + readFileSync(join(ROOT, 'src', 'apps', 'desktop', 'tauri.conf.json'), 'utf8') + ); + assert.deepEqual(config.bundle.macOS.dmg, { + background: 'dmg/background.png', + windowSize: { width: 800, height: 563 }, + appPosition: { x: 235, y: 240 }, + applicationFolderPosition: { x: 565, y: 240 }, + }); +}); + +test('macOS release signing covers the bundled flashgrep executable', () => { + const fixture = join(tmpdir(), `bitfun-flashgrep-signing-${process.pid}-${Date.now()}`); + const desktopDir = join(fixture, 'src', 'apps', 'desktop'); + const source = join(fixture, 'flashgrep-aarch64-apple-darwin'); + const calls = []; + mkdirSync(desktopDir, { recursive: true }); + writeFileSync(source, 'test-binary'); + + try { + const signed = prepareMacOSFlashgrepForSigning(source, desktopDir, { + platform: 'darwin', + signingIdentity: 'Developer ID Application: Test (TEAMID)', + spawnSync: (...args) => { + calls.push(args); + return { status: 0 }; + }, + }); + + assert.notEqual(signed, source); + assert.equal(readFileSync(signed, 'utf8'), 'test-binary'); + assert.deepEqual(calls[0][0], 'codesign'); + assert.deepEqual(calls[0][1], [ + '--force', + '--sign', + 'Developer ID Application: Test (TEAMID)', + '--options', + 'runtime', + '--timestamp', + signed, + ]); + } finally { + rmSync(fixture, { force: true, recursive: true }); + } +}); + +test('unsigned and non-macOS builds keep the original flashgrep executable', () => { + assert.equal( + prepareMacOSFlashgrepForSigning('/tmp/flashgrep', '/tmp/desktop', { + platform: 'darwin', + signingIdentity: '', + }), + '/tmp/flashgrep', + ); + assert.equal( + prepareMacOSFlashgrepForSigning('/tmp/flashgrep', '/tmp/desktop', { + platform: 'linux', + signingIdentity: 'unused', + }), + '/tmp/flashgrep', + ); +}); + +test('macOS packaging fails when bundled flashgrep signing fails', () => { + const fixture = join(tmpdir(), `bitfun-flashgrep-signing-failure-${process.pid}-${Date.now()}`); + const desktopDir = join(fixture, 'src', 'apps', 'desktop'); + const source = join(fixture, 'flashgrep-x86_64-apple-darwin'); + mkdirSync(desktopDir, { recursive: true }); + writeFileSync(source, 'test-binary'); + + try { + assert.throws( + () => prepareMacOSFlashgrepForSigning(source, desktopDir, { + platform: 'darwin', + signingIdentity: 'Developer ID Application: Test (TEAMID)', + spawnSync: () => ({ status: 1, stderr: 'identity unavailable' }), + }), + /Failed to sign bundled flashgrep binary: identity unavailable/, + ); + } finally { + rmSync(fixture, { force: true, recursive: true }); + } +}); + function retryFixture() { const root = join(tmpdir(), `bitfun-dmg-retry-${process.pid}-${Date.now()}`); const desktopDir = join(root, 'src', 'apps', 'desktop'); @@ -125,6 +221,99 @@ test('Desktop Tauri projection consumes only the resolved member identity', () = } }); +test('Windows updater installs NSIS packages without showing its progress window', () => { + const fixture = join(tmpdir(), `bitfun-tauri-updater-${process.pid}-${Date.now()}`); + const baseConfig = join(fixture, 'tauri.conf.json'); + const updaterEnv = { + BITFUN_ENABLE_UPDATER_ARTIFACTS: process.env.BITFUN_ENABLE_UPDATER_ARTIFACTS, + BITFUN_RELEASE_CHANNEL: process.env.BITFUN_RELEASE_CHANNEL, + BITFUN_UPDATER_FALLBACK_ENDPOINT: process.env.BITFUN_UPDATER_FALLBACK_ENDPOINT, + BITFUN_UPDATER_PRIMARY_ENDPOINT: process.env.BITFUN_UPDATER_PRIMARY_ENDPOINT, + TAURI_SIGNING_PRIVATE_KEY: process.env.TAURI_SIGNING_PRIVATE_KEY, + TAURI_UPDATER_ENDPOINT: process.env.TAURI_UPDATER_ENDPOINT, + TAURI_UPDATER_FALLBACK_ENDPOINT: process.env.TAURI_UPDATER_FALLBACK_ENDPOINT, + TAURI_UPDATER_PUBKEY: process.env.TAURI_UPDATER_PUBKEY, + }; + mkdirSync(fixture, { recursive: true }); + writeFileSync(baseConfig, JSON.stringify({ bundle: { resources: {} } })); + process.env.BITFUN_ENABLE_UPDATER_ARTIFACTS = 'true'; + process.env.TAURI_SIGNING_PRIVATE_KEY = 'test-private-key'; + process.env.TAURI_UPDATER_PUBKEY = 'test-public-key'; + + try { + const generated = prepareTauriConfig(baseConfig, { + desktopDir: fixture, + flashgrepBinary: join(fixture, 'flashgrep'), + }); + const config = JSON.parse(readFileSync(generated, 'utf8')); + assert.equal(config.plugins.updater.windows.installMode, 'quiet'); + assert.match(config.plugins.updater.endpoints[0], /releases\/latest\/download/); + } finally { + for (const [name, value] of Object.entries(updaterEnv)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + rmSync(fixture, { force: true, recursive: true }); + } +}); + +test('beta Desktop artifacts compile and bundle only beta updater endpoints', () => { + const fixture = join(tmpdir(), `bitfun-tauri-beta-${process.pid}-${Date.now()}`); + const baseConfig = join(fixture, 'tauri.conf.json'); + const names = [ + 'BITFUN_ENABLE_UPDATER_ARTIFACTS', + 'BITFUN_RELEASE_CHANNEL', + 'BITFUN_UPDATER_FALLBACK_ENDPOINT', + 'BITFUN_UPDATER_PRIMARY_ENDPOINT', + 'TAURI_SIGNING_PRIVATE_KEY', + 'TAURI_UPDATER_ENDPOINT', + 'TAURI_UPDATER_FALLBACK_ENDPOINT', + 'TAURI_UPDATER_PUBKEY', + ]; + const previous = Object.fromEntries(names.map((name) => [name, process.env[name]])); + mkdirSync(fixture, { recursive: true }); + writeFileSync(baseConfig, JSON.stringify({ bundle: { resources: {} } })); + process.env.BITFUN_ENABLE_UPDATER_ARTIFACTS = 'true'; + process.env.BITFUN_RELEASE_CHANNEL = 'beta'; + process.env.TAURI_SIGNING_PRIVATE_KEY = 'test-private-key'; + process.env.TAURI_UPDATER_PUBKEY = 'test-public-key'; + delete process.env.TAURI_UPDATER_ENDPOINT; + delete process.env.TAURI_UPDATER_FALLBACK_ENDPOINT; + + try { + const generated = prepareTauriConfig(baseConfig, { + desktopDir: fixture, + flashgrepBinary: join(fixture, 'flashgrep'), + }); + const config = JSON.parse(readFileSync(generated, 'utf8')); + assert.equal( + config.plugins.updater.endpoints[0], + 'https://github.com/GCWing/BitFun/releases/download/channel-beta/latest.json', + ); + assert.equal( + config.plugins.updater.endpoints[1], + 'https://openbitfun.com/release/beta/latest.json', + ); + assert.equal( + process.env.BITFUN_UPDATER_PRIMARY_ENDPOINT, + config.plugins.updater.endpoints[0], + ); + assert.equal( + process.env.BITFUN_UPDATER_FALLBACK_ENDPOINT, + config.plugins.updater.endpoints[1], + ); + } finally { + for (const [name, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + rmSync(fixture, { force: true, recursive: true }); + } +}); + test('Desktop release config bundles models.dev notices and provenance', () => { const config = JSON.parse( readFileSync(join(ROOT, 'src', 'apps', 'desktop', 'tauri.conf.json'), 'utf8') diff --git a/scripts/dev.cjs b/scripts/dev.cjs index 39918b36aa..d218cb06b4 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -666,7 +666,11 @@ async function main() { }, { name: 'Generate version info', - promise: runCommandPrefixed('version', 'node', ['scripts/generate-version.cjs']), + promise: runCommandPrefixed('version', 'node', [ + 'scripts/generate-version.cjs', + '--build-env', + 'development', + ]), }, ]; diff --git a/scripts/diagnostics/analyze-flowchat-log.mjs b/scripts/diagnostics/analyze-flowchat-log.mjs index 5cc894753c..081250c44b 100644 --- a/scripts/diagnostics/analyze-flowchat-log.mjs +++ b/scripts/diagnostics/analyze-flowchat-log.mjs @@ -1,16 +1,75 @@ +/** + * Read `flowchat.log` and answer the questions the viewport trail exists for. + * + * The log is JSONL, one object per entry, written by + * `src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.ts` behind + * `app.logging.flow_chat_diagnostics`. Viewport entries carry the tag + * `viewport`; history paging carries `history-paging`. + * + * The reports here are not a frequency count with extra steps. Each one + * corresponds to a fault that has actually shipped: + * + * - **Placements that did not stick.** A Turn that lands and is dragged away + * and a Turn that never landed leave the same final position, so every + * placement is recorded with what became of it and the drift is what + * separates them. + * - **Fights.** Travel far exceeding net displacement is two writers undoing + * each other — the shape of a snap back reissued 958 times without arriving. + * - **Refusals.** Who was outranked, by whom. A write that never happened is + * invisible in the DOM and in every other log. + * - **Silent declines.** Each writer's reason for not moving. "Nothing + * happened" has been the report more often than "it moved wrongly". + * + * Entries carry a `repeated` summary when the frontend coalesced a run of + * identical events, so every count here weighs an entry by what it stands for + * rather than by one. Ignoring that would under-report exactly the runaway + * loops the log is for. + */ + import { createReadStream } from 'node:fs'; import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; + +/** Joins the parts of a composite map key; cannot occur inside one. */ +const KEY_SEPARATOR = String.fromCharCode(31); + +const VIEWPORT_TAG = 'viewport'; +const WRITE_LOCATION = 'viewportOwner.write'; +const OUTCOME_SUFFIX = '.outcome'; +const DROPPED_ENTRY_LOCATION = 'FlowChatDiagnosticsRecorder.flush'; + +/** Locations whose whole purpose is to record a movement that did not happen. */ +const DECLINE_LOCATIONS = new Set([ + 'anchor.dropped', + 'anchor.stoodDown', + 'followOutput.deferNewTurn', + 'historyPaging.refused', + 'snapBack.declined', + 'snapBack.notNeeded', + 'turnNavigation.rejected', +]); + +export const DEFAULT_OPTIONS = { + top: 20, + minDrift: 8, + gapMs: 750, + around: null, + radius: 12, + tags: [], +}; function printUsage() { console.log(`Usage: node scripts/diagnostics/analyze-flowchat-log.mjs [options] Options: - --top Maximum rows per summary table (default: 20) - --min-delta Minimum positive reservation jump to show (default: 100) - --around Show a compact event window around a sequence - --radius Sequence radius for --around (default: 8) - --help Show this help`); + --top Maximum rows per table (default: ${DEFAULT_OPTIONS.top}) + --min-drift Report a placement as unstuck past this drift (default: ${DEFAULT_OPTIONS.minDrift}) + --gap Quiet period that ends an episode (default: ${DEFAULT_OPTIONS.gapMs}) + --around Print the raw entries around a sequence number + --radius Sequence radius for --around (default: ${DEFAULT_OPTIONS.radius}) + --tag Only entries with this hypothesis tag; repeatable + --help Show this help`); } function parseNumberOption(args, index, optionName) { @@ -22,7 +81,11 @@ function parseNumberOption(args, index, optionName) { return value; } -function parseArgs(argv) { +export function parseArgs(rawArgv) { + // `npm run … -- ` forwards the separator itself; pnpm forwards it too + // when it is typed. Either way it is not an argument. + const argv = rawArgv[0] === '--' ? rawArgv.slice(1) : rawArgv; + if (argv.includes('--help')) { printUsage(); process.exit(0); @@ -34,21 +97,18 @@ function parseArgs(argv) { throw new Error('A FlowChat JSONL log path is required'); } - const options = { - logPath, - top: 20, - minDelta: 100, - around: null, - radius: 8, - }; + const options = { ...DEFAULT_OPTIONS, tags: [], logPath }; for (let index = 1; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--top') { options.top = Math.max(1, Math.floor(parseNumberOption(argv, index, arg))); index += 1; - } else if (arg === '--min-delta') { - options.minDelta = Math.max(0, parseNumberOption(argv, index, arg)); + } else if (arg === '--min-drift') { + options.minDrift = Math.max(0, parseNumberOption(argv, index, arg)); + index += 1; + } else if (arg === '--gap') { + options.gapMs = Math.max(0, parseNumberOption(argv, index, arg)); index += 1; } else if (arg === '--around') { options.around = Math.floor(parseNumberOption(argv, index, arg)); @@ -56,6 +116,11 @@ function parseArgs(argv) { } else if (arg === '--radius') { options.radius = Math.max(0, Math.floor(parseNumberOption(argv, index, arg))); index += 1; + } else if (arg === '--tag') { + const tag = argv[index + 1]; + if (!tag || tag.startsWith('--')) throw new Error('--tag requires a name'); + options.tags.push(tag); + index += 1; } else { throw new Error(`Unknown option: ${arg}`); } @@ -70,11 +135,26 @@ function finiteNumber(value) { } function round(value) { - return Math.round(finiteNumber(value) * 100) / 100; + return Math.round(finiteNumber(value) * 10) / 10; } -function reservationTotal(reservation) { - return finiteNumber(reservation?.collapse?.px) + finiteNumber(reservation?.pin?.px); +/** + * What one entry stands for. + * + * A coalesced entry is one line describing a run, so counting it as one event + * would report a 300-write fight as a handful of writes. Travel is the same + * question for distance. + */ +export function entryWeight(entry) { + return 1 + Math.max(0, Math.floor(finiteNumber(entry?.data?.repeated?.suppressedCount))); +} + +export function entryTravelPx(entry) { + const data = entry?.data ?? {}; + const own = data.toPx === undefined || data.fromPx === undefined + ? 0 + : Math.abs(finiteNumber(data.toPx) - finiteNumber(data.fromPx)); + return own + Math.abs(finiteNumber(data.repeated?.suppressedTravelPx)); } function compactData(data) { @@ -83,131 +163,330 @@ function compactData(data) { return serialized.length <= 240 ? serialized : `${serialized.slice(0, 237)}...`; } -async function analyze(options) { - const eventCounts = new Map(); - const reservationJumps = []; - const collapseIntents = []; - const sequenceWindow = []; - let lineCount = 0; - let eventCount = 0; - let parseErrorCount = 0; - - const input = createReadStream(options.logPath, { encoding: 'utf8' }); - const lines = createInterface({ input, crlfDelay: Infinity }); +function increment(map, key, by = 1) { + map.set(key, (map.get(key) ?? 0) + by); +} - for await (const line of lines) { - lineCount += 1; - if (!line.trim()) continue; +/** + * Group viewport writes into stretches of activity. + * + * A fault is a period, not an entry: "it flickered for a second when I opened + * the session" is one episode with several owners in it. The gap that ends one + * is wall-clock, because the interesting silence is the reader looking at a + * still transcript. + */ +export function collectEpisodes(writes, gapMs) { + const episodes = []; + let current = null; - let event; - try { - event = JSON.parse(line); - } catch { - parseErrorCount += 1; - continue; + for (const write of writes) { + const atMs = finiteNumber(write.performanceTimeMs); + if (current === null || atMs - current.endMs > gapMs) { + current = { + firstSequence: write.sequence, + lastSequence: write.sequence, + startMs: atMs, + endMs: atMs, + writes: 0, + refusals: 0, + travelPx: 0, + fromPx: null, + toPx: null, + owners: new Map(), + }; + episodes.push(current); } - eventCount += 1; - const countKey = `${event.location ?? ''}\u0000${event.message ?? ''}`; - const existingCount = eventCounts.get(countKey); - if (existingCount) { - existingCount.count += 1; + const weight = entryWeight(write); + const granted = write.data?.granted !== false; + current.lastSequence = write.sequence; + current.endMs = atMs; + current.travelPx += entryTravelPx(write); + if (granted) { + current.writes += weight; + if (current.fromPx === null) current.fromPx = finiteNumber(write.data?.fromPx); + current.toPx = finiteNumber(write.data?.toPx); } else { - eventCounts.set(countKey, { - count: 1, - location: event.location ?? '', - message: event.message ?? '', - }); + current.refusals += weight; } + increment(current.owners, String(write.data?.owner ?? 'unknown'), weight); + } + + return episodes.map(episode => { + const netPx = episode.fromPx === null ? 0 : episode.toPx - episode.fromPx; + return { + sequences: `${episode.firstSequence}-${episode.lastSequence}`, + durationMs: Math.round(episode.endMs - episode.startMs), + writes: episode.writes, + refusals: episode.refusals, + travelPx: round(episode.travelPx), + netPx: round(netPx), + /* + * Distance travelled per pixel of progress. One means a clean move; + * anything large is writers undoing each other, and it is the number to + * sort by when the report is "it shook". + */ + churn: Math.abs(netPx) < 1 + ? round(episode.travelPx) + : round(episode.travelPx / Math.abs(netPx)), + owners: [...episode.owners.entries()] + .sort((left, right) => right[1] - left[1]) + .map(([owner, count]) => `${owner}x${count}`) + .join(' '), + }; + }); +} - if ( - event.location === 'VirtualMessageList.updateBottomReservationState' && - event.data?.before && - event.data?.after - ) { - const before = reservationTotal(event.data.before); - const after = reservationTotal(event.data.after); - const delta = after - before; - if (delta >= options.minDelta) { - reservationJumps.push({ - sequence: event.sequence, - deltaPx: round(delta), - beforePx: round(before), - afterPx: round(after), - collapsePx: round(event.data.after.collapse?.px), - pinPx: round(event.data.after.pin?.px), - coordinatorMode: event.data.coordinatorMode ?? '', - following: event.data.isFollowingOutput === true, - streaming: event.data.isStreamingOutput === true, - }); +/** + * Pair every placement with the sample taken after it settled. + * + * Matched first-in-first-out per location: an outcome lands hundreds of + * milliseconds after its placement, so another placement of the same kind can + * begin in between, and the queue keeps them in order. An outcome with nothing + * pending is reported rather than dropped — it means the log starts mid-flight. + */ +export function joinPlacements(entries) { + const pending = new Map(); + const placements = []; + const orphanedOutcomes = []; + + for (const entry of entries) { + const location = String(entry.location ?? ''); + if (location.endsWith(OUTCOME_SUFFIX)) { + const placedLocation = location.slice(0, -OUTCOME_SUFFIX.length); + const queue = pending.get(placedLocation); + const placement = queue?.shift(); + if (!placement) { + orphanedOutcomes.push({ sequence: entry.sequence, location: placedLocation }); + continue; } + placement.outcome = entry; + continue; } + if (entry.data?.placedPx === undefined) continue; + + const placement = { placed: entry, outcome: null }; + placements.push(placement); + const queue = pending.get(location); + if (queue) queue.push(placement); + else pending.set(location, [placement]); + } + + return { placements, orphanedOutcomes }; +} + +export function summarizePlacements(placements) { + return placements.map(({ placed, outcome }) => { + const data = placed.data ?? {}; + const outcomeData = outcome?.data ?? {}; + return { + sequence: placed.sequence, + location: String(placed.location ?? ''), + branch: String(data.branch ?? data.reason ?? ''), + beforePx: round(data.beforePx), + placedPx: round(data.placedPx), + targetPx: data.targetPx === undefined ? round(data.placedPx) : round(data.targetPx), + settledPx: outcome ? round(outcomeData.settledPx) : null, + driftPx: outcome ? round(outcomeData.driftPx) : null, + /* + * No outcome is not "no drift". The sample is scheduled on a timer, so a + * placement at the end of the log, or one whose view unmounted first, + * never reports — and reading that as a clean landing is how a report + * turns into a false negative. + */ + settled: outcome ? 'yes' : 'unknown', + }; + }); +} - if ( - event.location === 'VirtualMessageList.handleToolCardCollapseIntent' && - event.message === 'Tool card collapse reservation calculated' - ) { - const current = finiteNumber(event.data?.currentTotalCompensationPx); - const provisional = finiteNumber(event.data?.provisionalTotalCompensationPx); - collapseIntents.push({ - sequence: event.sequence, - tool: event.data?.nextIntent?.toolName ?? '', - cardHeightPx: round(event.data?.estimatedShrink), - distancePx: round(event.data?.effectiveDistanceFromBottom), - addedPx: round(provisional - current), - totalPx: round(provisional), - coordinatorMode: event.data?.coordinatorMode ?? '', - }); +export function analyzeEntries(entries, options) { + const settings = { ...DEFAULT_OPTIONS, ...options }; + const tags = new Set(settings.tags ?? []); + const kept = tags.size === 0 + ? entries + : entries.filter(entry => tags.has(String(entry.hypothesis ?? ''))); + + const viewportEntries = kept.filter(entry => entry.hypothesis === VIEWPORT_TAG); + const writes = viewportEntries.filter(entry => entry.location === WRITE_LOCATION); + + const refusals = new Map(); + const ownerActivity = new Map(); + for (const write of writes) { + const weight = entryWeight(write); + const owner = String(write.data?.owner ?? 'unknown'); + const activity = ownerActivity.get(owner) ?? { owner, writes: 0, refusals: 0, travelPx: 0 }; + activity.travelPx += entryTravelPx(write); + if (write.data?.granted === false) { + activity.refusals += weight; + increment(refusals, `${owner}${KEY_SEPARATOR}${String(write.data?.heldBy ?? 'nobody')}`, weight); + } else { + activity.writes += weight; } + ownerActivity.set(owner, activity); + } + + const declines = new Map(); + for (const entry of viewportEntries) { + const location = String(entry.location ?? ''); + if (!DECLINE_LOCATIONS.has(location)) continue; + const reason = String(entry.data?.reason ?? entry.data?.branch ?? ''); + increment(declines, `${location}${KEY_SEPARATOR}${reason}`, entryWeight(entry)); + } + + const frequency = new Map(); + for (const entry of kept) { + const key = `${String(entry.hypothesis ?? '')}${KEY_SEPARATOR}${String(entry.location ?? '')}`; + increment(frequency, key, entryWeight(entry)); + } + + const { placements, orphanedOutcomes } = joinPlacements(viewportEntries); + const summarized = summarizePlacements(placements); - if ( - options.around !== null && - finiteNumber(event.sequence) >= options.around - options.radius && - finiteNumber(event.sequence) <= options.around + options.radius - ) { - sequenceWindow.push({ - sequence: event.sequence, - location: event.location ?? '', - message: event.message ?? '', - data: compactData(event.data), - }); + const droppedEntries = kept + .filter(entry => entry.location === DROPPED_ENTRY_LOCATION) + .reduce((total, entry) => total + finiteNumber(entry.data?.droppedEntries), 0); + + return { + entryCount: kept.length, + viewportEntryCount: viewportEntries.length, + droppedEntries, + sequenceRange: kept.length === 0 + ? null + : { first: kept[0].sequence, last: kept[kept.length - 1].sequence }, + episodes: collectEpisodes(writes, settings.gapMs) + .sort((left, right) => right.churn - left.churn), + unstuckPlacements: summarized + .filter(placement => placement.driftPx !== null + && Math.abs(placement.driftPx) >= settings.minDrift) + .sort((left, right) => Math.abs(right.driftPx) - Math.abs(left.driftPx)), + unsampledPlacements: summarized.filter(placement => placement.settled === 'unknown'), + placementCount: summarized.length, + orphanedOutcomes, + refusals: [...refusals.entries()] + .map(([key, count]) => { + const [owner, heldBy] = key.split(KEY_SEPARATOR); + return { owner, refusedBy: heldBy, count }; + }) + .sort((left, right) => right.count - left.count), + ownerActivity: [...ownerActivity.values()] + .map(activity => ({ ...activity, travelPx: round(activity.travelPx) })) + .sort((left, right) => right.writes + right.refusals - (left.writes + left.refusals)), + declines: [...declines.entries()] + .map(([key, count]) => { + const [location, reason] = key.split(KEY_SEPARATOR); + return { location, reason, count }; + }) + .sort((left, right) => right.count - left.count), + frequency: [...frequency.entries()] + .map(([key, count]) => { + const [tag, location] = key.split(KEY_SEPARATOR); + return { tag, location, count }; + }) + .sort((left, right) => right.count - left.count), + window: settings.around === null ? [] : kept + .filter(entry => finiteNumber(entry.sequence) >= settings.around - settings.radius + && finiteNumber(entry.sequence) <= settings.around + settings.radius) + .map(entry => ({ + sequence: entry.sequence, + tag: String(entry.hypothesis ?? ''), + location: String(entry.location ?? ''), + message: String(entry.message ?? ''), + data: compactData(entry.data), + })), + }; +} + +export async function readEntries(logPath) { + const entries = []; + let lineCount = 0; + let parseErrorCount = 0; + + const input = createReadStream(logPath, { encoding: 'utf8' }); + const lines = createInterface({ input, crlfDelay: Infinity }); + for await (const line of lines) { + lineCount += 1; + if (!line.trim()) continue; + try { + entries.push(JSON.parse(line)); + } catch { + parseErrorCount += 1; } } - console.log(`FlowChat log: ${options.logPath}`); - console.log(`Lines: ${lineCount}, events: ${eventCount}, parse errors: ${parseErrorCount}`); + return { entries, lineCount, parseErrorCount }; +} - console.log('\nMost frequent events'); - console.table( - [...eventCounts.values()] - .sort((left, right) => right.count - left.count) - .slice(0, options.top), +function reportTable(title, rows, top) { + console.log(`\n${title}`); + if (rows.length === 0) { + console.log(' (none)'); + return; + } + console.table(rows.slice(0, top)); + if (rows.length > top) { + console.log(` ... ${rows.length - top} more`); + } +} + +export function printReport(report, options, source) { + console.log(`FlowChat log: ${source.logPath}`); + console.log( + `Lines: ${source.lineCount}, entries: ${report.entryCount}` + + ` (viewport: ${report.viewportEntryCount}), parse errors: ${source.parseErrorCount}`, ); + if (report.sequenceRange) { + console.log(`Sequences: ${report.sequenceRange.first}-${report.sequenceRange.last}`); + } + if (report.droppedEntries > 0) { + // Said loudly: every count below is a lower bound once entries were lost. + console.log( + `WARNING: ${report.droppedEntries} entries were dropped before reaching the log.` + + ' Counts below are lower bounds.', + ); + } - console.log(`\nLargest reservation increases (>= ${options.minDelta}px)`); - console.table( - reservationJumps - .sort((left, right) => right.deltaPx - left.deltaPx) - .slice(0, options.top), + reportTable( + 'Episodes of viewport activity, worst churn first (travel per pixel of progress)', + report.episodes, + options.top, ); + reportTable( + `Placements that did not stick (drift >= ${options.minDrift}px)`, + report.unstuckPlacements, + options.top, + ); + reportTable('Refusals: who was outranked, by whom', report.refusals, options.top); + reportTable('Declines: a writer choosing not to move, and why', report.declines, options.top); + reportTable('Per owner', report.ownerActivity, options.top); + reportTable('Most frequent locations', report.frequency, options.top); - console.log('\nLargest collapse-intent estimates'); - console.table( - collapseIntents - .sort((left, right) => right.addedPx - left.addedPx) - .slice(0, options.top), + console.log( + `\nPlacements: ${report.placementCount},` + + ` never sampled: ${report.unsampledPlacements.length},` + + ` outcomes with no placement in this log: ${report.orphanedOutcomes.length}`, ); if (options.around !== null) { - console.log(`\nEvents around sequence ${options.around} (+/- ${options.radius})`); - console.table(sequenceWindow); + reportTable( + `Entries around sequence ${options.around} (+/- ${options.radius})`, + report.window, + report.window.length, + ); } } -try { +async function main() { const options = parseArgs(process.argv.slice(2)); - await analyze(options); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; + const source = await readEntries(options.logPath); + const report = analyzeEntries(source.entries, options); + printReport(report, options, { ...source, logPath: options.logPath }); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } } diff --git a/scripts/diagnostics/analyze-flowchat-log.test.mjs b/scripts/diagnostics/analyze-flowchat-log.test.mjs new file mode 100644 index 0000000000..152f583596 --- /dev/null +++ b/scripts/diagnostics/analyze-flowchat-log.test.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + analyzeEntries, + collectEpisodes, + entryTravelPx, + entryWeight, + joinPlacements, + parseArgs, +} from './analyze-flowchat-log.mjs'; + +let nextSequence = 0; + +function entry(location, data, overrides = {}) { + nextSequence += 1; + return { + sequence: nextSequence, + timestamp: '2026-08-11T00:00:00.000Z', + performanceTimeMs: nextSequence * 16, + hypothesis: 'viewport', + location, + message: location, + data, + ...overrides, + }; +} + +function write(owner, fromPx, toPx, overrides = {}) { + return entry('viewportOwner.write', { + owner, + granted: true, + heldBy: null, + fromPx, + toPx, + ...overrides.data, + }, overrides.entry); +} + +test('parseArgs requires a log path and rejects unknown options', () => { + assert.throws(() => parseArgs([]), /log path is required/); + assert.throws(() => parseArgs(['flowchat.log', '--nope']), /Unknown option/); + + const options = parseArgs(['flowchat.log', '--min-drift', '20', '--tag', 'viewport']); + assert.equal(options.logPath, 'flowchat.log'); + assert.equal(options.minDrift, 20); + assert.deepEqual(options.tags, ['viewport']); + + // `npm run … -- ` hands the separator through as an argument. + assert.equal(parseArgs(['--', 'flowchat.log']).logPath, 'flowchat.log'); +}); + +test('a coalesced entry counts as the run it stands for', () => { + const coalesced = write('follow-output', 100, 102, { + data: { repeated: { suppressedCount: 59, suppressedTravelPx: 118, suppressedForMs: 480 } }, + }); + + assert.equal(entryWeight(coalesced), 60); + assert.equal(entryTravelPx(coalesced), 120); + // An entry with no run behind it still stands for itself. + assert.equal(entryWeight(write('follow-output', 0, 10)), 1); +}); + +test('episodes split on a quiet gap and score a fight by its churn', () => { + nextSequence = 0; + const fight = [ + write('snap-back', 1000, 1000.7), + write('anchor-correction', 1000.7, 1000), + write('snap-back', 1000, 1000.7), + write('anchor-correction', 1000.7, 1000), + ]; + const later = write('follow-output', 1000, 2000, { + entry: { performanceTimeMs: 20_000 }, + }); + + const episodes = collectEpisodes([...fight, later], 750); + + assert.equal(episodes.length, 2); + // Four writes that went nowhere: travel without progress is the whole signal. + assert.equal(episodes[0].writes, 4); + assert.equal(episodes[0].netPx, 0); + assert.ok(episodes[0].churn >= 2, `expected churn, got ${episodes[0].churn}`); + assert.equal(episodes[1].netPx, 1000); + assert.equal(episodes[1].churn, 1); +}); + +test('refused writes are counted apart from the ones that moved', () => { + nextSequence = 0; + const episodes = collectEpisodes([ + write('follow-output', 500, 600), + write('anchor-correction', 600, 500, { data: { granted: false, heldBy: 'user-gesture' } }), + ], 750); + + assert.equal(episodes[0].writes, 1); + assert.equal(episodes[0].refusals, 1); + // A refusal moved nothing, so it cannot contribute to net displacement. + assert.equal(episodes[0].netPx, 100); +}); + +test('placements pair with their outcome in order, and a stray outcome is reported', () => { + nextSequence = 0; + const first = entry('turnNavigation.placed', { beforePx: 0, placedPx: 800, targetPx: 800 }); + const second = entry('turnNavigation.placed', { beforePx: 800, placedPx: 1600, targetPx: 1600 }); + const firstOutcome = entry('turnNavigation.placed.outcome', { settledPx: 40, driftPx: -760 }); + const secondOutcome = entry('turnNavigation.placed.outcome', { settledPx: 1600, driftPx: 0 }); + const stray = entry('visibleTask.scrollToTask.outcome', { settledPx: 10, driftPx: -5 }); + + const { placements, orphanedOutcomes } = joinPlacements([ + first, + second, + firstOutcome, + secondOutcome, + stray, + ]); + + assert.equal(placements.length, 2); + assert.equal(placements[0].outcome, firstOutcome); + assert.equal(placements[1].outcome, secondOutcome); + assert.deepEqual(orphanedOutcomes, [ + { sequence: stray.sequence, location: 'visibleTask.scrollToTask' }, + ]); +}); + +test('analyzeEntries reports what did not stick, who was refused, and who declined', () => { + nextSequence = 0; + const report = analyzeEntries([ + entry('visibleTask.scrollToTask', { beforePx: 0, placedPx: 900, targetPx: 900 }), + write('anchor-correction', 900, 120), + entry('visibleTask.scrollToTask.outcome', { settledPx: 120, driftPx: -780 }), + write('follow-output', 120, 200, { data: { granted: false, heldBy: 'user-gesture' } }), + entry('snapBack.declined', { reason: 'follow-correcting' }), + entry('snapBack.declined', { reason: 'follow-correcting' }), + entry('followOutput.deferNewTurn', { turnId: 't-42' }), + { ...entry('history_paging_requested', { direction: 'before' }), hypothesis: 'history-paging' }, + ], { minDrift: 8 }); + + assert.equal(report.unstuckPlacements.length, 1); + assert.equal(report.unstuckPlacements[0].driftPx, -780); + assert.equal(report.unstuckPlacements[0].location, 'visibleTask.scrollToTask'); + + assert.deepEqual(report.refusals, [ + { owner: 'follow-output', refusedBy: 'user-gesture', count: 1 }, + ]); + + const declined = report.declines.find(row => row.location === 'snapBack.declined'); + assert.deepEqual(declined, { + location: 'snapBack.declined', + reason: 'follow-correcting', + count: 2, + }); + assert.ok(report.declines.some(row => row.location === 'followOutput.deferNewTurn')); + + // The paging tag is counted but never mistaken for viewport activity. + assert.equal(report.viewportEntryCount, 7); + assert.ok(report.frequency.some(row => row.tag === 'history-paging')); +}); + +test('a placement whose outcome never arrived is not reported as having stuck', () => { + nextSequence = 0; + const report = analyzeEntries([ + entry('navigation.scrollIntoView', { beforePx: 0, placedPx: 300 }), + ], {}); + + assert.equal(report.placementCount, 1); + assert.equal(report.unstuckPlacements.length, 0); + assert.equal(report.unsampledPlacements.length, 1); + assert.equal(report.unsampledPlacements[0].settled, 'unknown'); +}); + +test('dropped entries are surfaced, because every count becomes a lower bound', () => { + nextSequence = 0; + const report = analyzeEntries([ + { ...entry('FlowChatDiagnosticsRecorder.flush', { droppedEntries: 128 }), hypothesis: 'I' }, + write('follow-output', 0, 100), + ], {}); + + assert.equal(report.droppedEntries, 128); +}); + +test('--tag keeps only the requested stream', () => { + nextSequence = 0; + const report = analyzeEntries([ + write('follow-output', 0, 100), + { ...entry('history_paging_requested', { direction: 'before' }), hypothesis: 'history-paging' }, + ], { tags: ['history-paging'] }); + + assert.equal(report.entryCount, 1); + assert.equal(report.viewportEntryCount, 0); + assert.equal(report.episodes.length, 0); +}); diff --git a/scripts/generate-tauri-latest-json.mjs b/scripts/generate-tauri-latest-json.mjs index b0f6ee57d0..f8f47f6c1d 100644 --- a/scripts/generate-tauri-latest-json.mjs +++ b/scripts/generate-tauri-latest-json.mjs @@ -9,6 +9,7 @@ const tag = requireArg(args, 'tag'); const repo = requireArg(args, 'repo'); const out = requireArg(args, 'out'); const requiredPlatforms = parseListArg(args['required-platforms'] || ''); +const manualAssetsDir = args['manual-assets-dir']; if (!existsSync(assetsDir)) { fail(`Assets directory does not exist: ${assetsDir}`); @@ -55,6 +56,22 @@ const manifest = { platforms, }; +if (manualAssetsDir) { + const installerName = `BitFun_${version}_windows-x86_64-installer.exe`; + const installerPath = join(manualAssetsDir, installerName); + const signaturePath = `${installerPath}.sig`; + if (!existsSync(installerPath) || !existsSync(signaturePath)) { + fail(`Missing signed manual installer pair: ${installerPath} and ${signaturePath}`); + } + const assetUrl = `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(installerName)}`; + manifest.manual_installers = { + 'windows-x86_64': { + url: assetUrl, + signature_url: `${assetUrl}.sig`, + }, + }; +} + mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); console.log(`[latest-json] Wrote ${out}`); diff --git a/scripts/generate-version.cjs b/scripts/generate-version.cjs index b6c2acf273..d84d8d50b5 100644 --- a/scripts/generate-version.cjs +++ b/scripts/generate-version.cjs @@ -19,6 +19,20 @@ const { const packageJsonPath = path.resolve(__dirname, '../package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); +function parseBuildEnv(args) { + const index = args.indexOf('--build-env'); + const buildEnv = index >= 0 ? args[index + 1] : undefined; + if (!['development', 'production', 'preview'].includes(buildEnv)) { + throw new Error('Expected --build-env development|production|preview'); + } + return buildEnv; +} + +function readArg(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + function getGitInfo() { try { const gitCommitFull = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim(); @@ -40,12 +54,15 @@ function getGitInfo() { } } -function generateVersionInfo() { +function generateVersionInfo(buildEnv) { const gitInfo = getGitInfo(); const buildDate = new Date().toISOString(); const buildTimestamp = Date.now(); - const buildEnv = process.env.NODE_ENV || 'development'; const isDev = buildEnv === 'development'; + const releaseChannel = process.env.BITFUN_RELEASE_CHANNEL || 'stable'; + if (!['stable', 'beta', 'nightly'].includes(releaseChannel)) { + throw new Error(`Unsupported BITFUN_RELEASE_CHANNEL: ${releaseChannel}`); + } const versionInfo = { name: packageJson.name === 'BitFun' ? 'BitFun' : packageJson.name, @@ -53,6 +70,7 @@ function generateVersionInfo() { buildDate, buildTimestamp, buildEnv, + releaseChannel, isDev, ...gitInfo }; @@ -60,8 +78,8 @@ function generateVersionInfo() { return versionInfo; } -function saveVersionInfoToJson(versionInfo) { - const outputPath = path.resolve(__dirname, '../src/web-ui/public/version.json'); +function saveVersionInfoToJson(versionInfo, outputRoot) { + const outputPath = path.resolve(outputRoot, 'src/web-ui/public/version.json'); const dir = path.dirname(outputPath); if (!fs.existsSync(dir)) { @@ -75,8 +93,8 @@ function saveVersionInfoToJson(versionInfo) { ); } -function saveVersionInfoToTS(versionInfo) { - const outputPath = path.resolve(__dirname, '../src/web-ui/src/generated/version.ts'); +function saveVersionInfoToTS(versionInfo, outputRoot) { + const outputPath = path.resolve(outputRoot, 'src/web-ui/src/generated/version.ts'); const dir = path.dirname(outputPath); if (!fs.existsSync(dir)) { @@ -104,13 +122,16 @@ function generateHtmlInjectionScript(versionInfo) { } function main() { - const versionInfo = generateVersionInfo(); + const args = process.argv.slice(2); + const buildEnv = parseBuildEnv(args); + const outputRoot = path.resolve(readArg(args, '--output-root') || path.resolve(__dirname, '..')); + const versionInfo = generateVersionInfo(buildEnv); - saveVersionInfoToJson(versionInfo); - saveVersionInfoToTS(versionInfo); + saveVersionInfoToJson(versionInfo, outputRoot); + saveVersionInfoToTS(versionInfo, outputRoot); const htmlScript = generateHtmlInjectionScript(versionInfo); - const htmlScriptPath = path.resolve(__dirname, '../src/web-ui/src/generated/version-injection.html'); + const htmlScriptPath = path.resolve(outputRoot, 'src/web-ui/src/generated/version-injection.html'); const htmlDir = path.dirname(htmlScriptPath); if (!fs.existsSync(htmlDir)) { @@ -123,12 +144,10 @@ function main() { printSuccess(`${versionInfo.name} v${versionInfo.version}${gitStr}`); } -// On failure: warn and exit 0 so build is not interrupted try { main(); } catch (err) { - printWarning('Version info generation failed, skipped: ' + (err.message || err)); - process.exit(0); + printWarning('Version info generation failed: ' + (err.message || err)); + process.exit(1); } - diff --git a/scripts/linux-binaries-manifest.test.mjs b/scripts/linux-binaries-manifest.test.mjs index 50862f1d8b..0eb3bbe2be 100644 --- a/scripts/linux-binaries-manifest.test.mjs +++ b/scripts/linux-binaries-manifest.test.mjs @@ -181,7 +181,7 @@ test('openbitfun sync mirrors the website installer from the exact updater relea source "$SYNC_SCRIPT" VERSION_DIR="$TEST_VERSION_DIR" RELEASE_ASSET_BASE_URL="https://github.com/GCWing/BitFun/releases/download/v1.2.3" - WINDOWS_INSTALLER_FILENAME="bitfun-installer.exe" + LATEST_JSON="$TEST_LATEST_JSON" download_asset() { printf '%s\\t%s\\n' "$1" "$2" >> "$DOWNLOAD_CALLS" } @@ -194,6 +194,14 @@ test('openbitfun sync mirrors the website installer from the exact updater relea DOWNLOAD_CALLS: calls, SYNC_SCRIPT: path.join(repoRoot, 'scripts/openbitfun-release-sync.sh'), TEST_VERSION_DIR: versionDir, + TEST_LATEST_JSON: JSON.stringify({ + manual_installers: { + 'windows-x86_64': { + url: 'https://github.com/GCWing/BitFun/releases/download/v1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe', + signature_url: 'https://github.com/GCWing/BitFun/releases/download/v1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe.sig', + }, + }, + }), }, } ); @@ -201,8 +209,42 @@ test('openbitfun sync mirrors the website installer from the exact updater relea const downloads = fs.readFileSync(calls, 'utf8').trim().split('\n'); assert.deepEqual(downloads, [ - `https://github.com/GCWing/BitFun/releases/download/v1.2.3/bitfun-installer.exe\t${versionDir}/bitfun-installer.exe`, - `https://github.com/GCWing/BitFun/releases/download/v1.2.3/bitfun-installer.exe.sig\t${versionDir}/bitfun-installer.exe.sig`, + `https://github.com/GCWing/BitFun/releases/download/v1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe\t${versionDir}/BitFun_1.2.3_windows-x86_64-installer.exe`, + `https://github.com/GCWing/BitFun/releases/download/v1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe.sig\t${versionDir}/BitFun_1.2.3_windows-x86_64-installer.exe.sig`, + ]); +}); + +test('openbitfun sync retains the legacy fixed installer fallback', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-legacy-installer-mirror-')); + const versionDir = path.join(temp, 'release', '1.2.2'); + const calls = path.join(temp, 'download-calls.tsv'); + fs.mkdirSync(versionDir, { recursive: true }); + + const result = spawnSync( + 'bash', + ['-c', ` + source "$SYNC_SCRIPT" + VERSION_DIR="$TEST_VERSION_DIR" + RELEASE_ASSET_BASE_URL="https://github.com/GCWing/BitFun/releases/download/v1.2.2" + download_asset() { + printf '%s\\t%s\\n' "$1" "$2" >> "$DOWNLOAD_CALLS" + } + mirror_windows_installer + `], + { + encoding: 'utf8', + env: { + ...process.env, + DOWNLOAD_CALLS: calls, + SYNC_SCRIPT: path.join(repoRoot, 'scripts/openbitfun-release-sync.sh'), + TEST_VERSION_DIR: versionDir, + }, + } + ); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(fs.readFileSync(calls, 'utf8').trim().split('\n'), [ + `https://github.com/GCWing/BitFun/releases/download/v1.2.2/bitfun-installer.exe\t${versionDir}/bitfun-installer.exe`, + `https://github.com/GCWing/BitFun/releases/download/v1.2.2/bitfun-installer.exe.sig\t${versionDir}/bitfun-installer.exe.sig`, ]); }); @@ -278,6 +320,12 @@ test('website download manifest uses installer while updater manifest keeps setu url: 'https://openbitfun.test/release/1.2.3/BitFun_1.2.3_darwin-aarch64.app.tar.gz', }, }, + manual_installers: { + 'windows-x86_64': { + url: 'https://openbitfun.test/release/1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe', + signature_url: 'https://openbitfun.test/release/1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe.sig', + }, + }, }; fs.writeFileSync(updaterPath, `${JSON.stringify(updater, null, 2)}\n`); @@ -314,11 +362,11 @@ test('website download manifest uses installer while updater manifest keeps setu assert.equal(website.version, '1.2.3'); assert.equal( website.platforms['windows-x86_64'].url, - 'https://openbitfun.test/release/1.2.3/bitfun-installer.exe' + 'https://openbitfun.test/release/1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe' ); assert.equal( website.platforms['windows-x86_64'].signatureUrl, - 'https://openbitfun.test/release/1.2.3/bitfun-installer.exe.sig' + 'https://openbitfun.test/release/1.2.3/BitFun_1.2.3_windows-x86_64-installer.exe.sig' ); assert.equal( website.platforms['darwin-aarch64'].url, @@ -326,15 +374,17 @@ test('website download manifest uses installer while updater manifest keeps setu ); }); -test('Linux archives are mirrored before the much larger Desktop packages', () => { +test('stable Linux archives are mirrored before the much larger Desktop packages', () => { const syncScript = fs.readFileSync( path.join(repoRoot, 'scripts/openbitfun-release-sync.sh'), 'utf8' ); - const linuxCall = syncScript.indexOf('\n mirror_linux_binaries\n'); + const stableBranch = syncScript.indexOf('if [ "$RELEASE_CHANNEL" = "stable" ]; then'); + const linuxCall = syncScript.indexOf('\n mirror_linux_binaries\n', stableBranch); const desktopLoop = syncScript.indexOf('Mirroring Desktop asset'); - assert.ok(linuxCall > 0, 'mirror_linux_binaries must be called from main'); + assert.ok(stableBranch > 0, 'stable channel branch must exist'); + assert.ok(linuxCall > stableBranch, 'stable main path must call mirror_linux_binaries'); assert.ok(desktopLoop > 0, 'Desktop asset mirroring must still exist'); assert.ok( linuxCall < desktopLoop, diff --git a/scripts/openbitfun-release-sync.sh b/scripts/openbitfun-release-sync.sh index b880a26f36..1093d4746a 100755 --- a/scripts/openbitfun-release-sync.sh +++ b/scripts/openbitfun-release-sync.sh @@ -3,7 +3,7 @@ # sync-release.sh — Mirror BitFun release assets from GitHub to openbitfun.com. # # Flow: -# 1. Fetch latest.json from GitHub (follows /releases/latest/download/ redirect) +# 1. Fetch the selected channel's latest.json from GitHub # 2. Mirror the signed Relay image descriptor and Linux binary manifest FIRST # (small trust metadata must not queue behind ~700 MB of Desktop packages) # 3. Download every Desktop updater package plus the standalone Windows @@ -12,12 +12,13 @@ # 5. Atomically publish versioned and root manifests # 6. Remove old version dirs, keeping only the most recent KEEP_VERSIONS # -# The published release/latest.json is the Tauri updater fallback endpoint. +# The published release/latest.json and release/beta/latest.json files are the +# stable and beta Tauri updater fallback endpoints. # When GitHub is unreachable, the desktop client automatically falls through # to https://openbitfun.com/release/latest.json and downloads from this mirror. -# The published release/downloads.json is for the website. Its Windows URL -# points at bitfun-installer.exe while latest.json deliberately keeps the Tauri -# updater's versioned setup.exe URL. +# The published release/downloads.json is for the website. Its Windows URL uses +# latest.json's manual_installers entry while the updater keeps the versioned +# Tauri setup.exe URL. # # Cron (every 10 minutes): # */10 * * * * /root/repos/BitFun-AutoUpdate/openbitfun-release-sync.sh \ @@ -31,7 +32,8 @@ # # while true; do # nc -l -p 8787 -q 1 >/dev/null \ -# && /root/repos/BitFun-AutoUpdate/openbitfun-release-sync.sh \ +# && BITFUN_RELEASE_CHANNEL=stable \ +# /root/repos/BitFun-AutoUpdate/openbitfun-release-sync.sh \ # >> /root/repos/BitFun-AutoUpdate/sync.log 2>&1 # done # @@ -42,13 +44,37 @@ set -euo pipefail # ── Configuration ────────────────────────────────────────────── -GITHUB_LATEST_JSON_URL="https://github.com/GCWing/BitFun/releases/latest/download/latest.json" -GITHUB_LINUX_BINARIES_URL="https://github.com/GCWing/BitFun/releases/latest/download/linux-binaries.json" -GITHUB_RELAY_IMAGE_URL="https://github.com/GCWing/BitFun/releases/latest/download/relay-image.json" -OPENBITFUN_BASE_URL="https://openbitfun.com/release" -WEBSITE_RELEASE_DIR="/root/repos/BitFun-Website/dist/release" +RELEASE_CHANNEL="${BITFUN_RELEASE_CHANNEL:-stable}" +case "$RELEASE_CHANNEL" in + stable) + CHANNEL_PATH="" + GITHUB_RELEASE_ROOT="https://github.com/GCWing/BitFun/releases/latest/download" + ;; + beta) + CHANNEL_PATH="/beta" + GITHUB_RELEASE_ROOT="https://github.com/GCWing/BitFun/releases/download/channel-beta" + ;; + *) + echo "Unsupported BITFUN_RELEASE_CHANNEL: $RELEASE_CHANNEL" >&2 + exit 1 + ;; +esac +GITHUB_LATEST_JSON_URL="${GITHUB_RELEASE_ROOT}/latest.json" +GITHUB_LINUX_BINARIES_URL="${GITHUB_RELEASE_ROOT}/linux-binaries.json" +GITHUB_RELAY_IMAGE_URL="${GITHUB_RELEASE_ROOT}/relay-image.json" +OPENBITFUN_BASE_URL="https://openbitfun.com/release${CHANNEL_PATH}" +# The mirror deliberately lives outside the website checkout. It used to be +# BitFun-Website/dist/release, but `npm run build` empties dist/, so every +# website deploy silently deleted the mirrored installers and manifests — +# breaking downloads and the updater fallback until someone noticed. nginx +# serves this directory through a `location ^~ /release/` alias instead. +WEBSITE_RELEASE_ROOT="${WEBSITE_RELEASE_DIR:-/srv/bitfun-release}" +WEBSITE_RELEASE_DIR="${WEBSITE_RELEASE_ROOT}${CHANNEL_PATH}" LOCK_FILE="/root/repos/BitFun-AutoUpdate/sync.lock" -WINDOWS_INSTALLER_FILENAME="bitfun-installer.exe" +LEGACY_WINDOWS_INSTALLER_FILENAME="bitfun-installer.exe" +WINDOWS_INSTALLER_FILENAME="$LEGACY_WINDOWS_INSTALLER_FILENAME" +WINDOWS_INSTALLER_URL="" +WINDOWS_INSTALLER_SIGNATURE_URL="" WEBSITE_DOWNLOADS_MANIFEST="downloads.json" # Keep enough releases that the mirror still serves a Desktop build a few # versions behind and SSH Dispatch can finish an already-confirmed install even @@ -110,22 +136,47 @@ publish_file_atomically() { # /releases/latest/download so the installer and setup package cannot come from # different releases while GitHub is advancing the latest-release pointer. mirror_windows_installer() { - local installer_url - installer_url="${RELEASE_ASSET_BASE_URL}/${WINDOWS_INSTALLER_FILENAME}" + local installer_url signature_url metadata + installer_url="${WINDOWS_INSTALLER_URL:-${RELEASE_ASSET_BASE_URL}/${WINDOWS_INSTALLER_FILENAME}}" + signature_url="${WINDOWS_INSTALLER_SIGNATURE_URL:-${installer_url}.sig}" + + if [ -n "${LATEST_JSON:-}" ]; then + metadata=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c " +import json, sys +data = json.load(sys.stdin) +entry = data.get('manual_installers', {}).get('windows-x86_64') +if entry: + print(entry['url']) + print(entry.get('signature_url', entry['url'] + '.sig')) +") + if [ -n "$metadata" ]; then + installer_url=$(printf '%s\n' "$metadata" | sed -n '1p') + signature_url=$(printf '%s\n' "$metadata" | sed -n '2p') + if [ "${installer_url%/*}" != "$RELEASE_ASSET_BASE_URL" ]; then + log "ERROR: Manual installer URL does not belong to the updater release: $installer_url" + return 1 + fi + if [ "$signature_url" != "${installer_url}.sig" ]; then + log "ERROR: Manual installer signature URL does not match the installer URL" + return 1 + fi + WINDOWS_INSTALLER_FILENAME="${installer_url##*/}" + fi + fi log " Mirroring website Windows installer: ${WINDOWS_INSTALLER_FILENAME}" download_asset \ "$installer_url" \ "${VERSION_DIR}/${WINDOWS_INSTALLER_FILENAME}" || exit 1 download_asset \ - "${installer_url}.sig" \ + "$signature_url" \ "${VERSION_DIR}/${WINDOWS_INSTALLER_FILENAME}.sig" || exit 1 } # Build a website-only manifest from the already rewritten updater manifest. # All non-Windows targets continue to use their mirrored updater packages. The -# Windows target alone is replaced with the custom installer URL; latest.json -# is never modified and remains a valid Tauri updater contract. +# Windows target alone is replaced with the custom installer URL. The updater +# URL remains untouched; manual_installers is a mirror/website extension only. write_website_download_manifest() { local output="${VERSION_DIR}/${WEBSITE_DOWNLOADS_MANIFEST}" local output_tmp="${output}.part" @@ -152,9 +203,14 @@ windows = platforms.get("windows-x86_64") if windows is None: raise SystemExit("latest.json is missing windows-x86_64") -version_base = f"{base}/{version}" -windows["url"] = f"{version_base}/{windows_installer}" -windows["signatureUrl"] = f"{version_base}/{windows_installer}.sig" +manual = updater.get("manual_installers", {}).get("windows-x86_64") +if manual: + windows["url"] = manual["url"] + windows["signatureUrl"] = manual.get("signature_url", manual["url"] + ".sig") +else: + version_base = f"{base}/{version}" + windows["url"] = f"{version_base}/{windows_installer}" + windows["signatureUrl"] = f"{version_base}/{windows_installer}.sig" website = { "schemaVersion": 1, @@ -457,7 +513,7 @@ main() { exit 0 fi - log "=== BitFun release sync started ===" + log "=== BitFun ${RELEASE_CHANNEL} release sync started ===" mkdir -p "$WEBSITE_RELEASE_DIR" @@ -480,8 +536,8 @@ main() { log "Latest version: $VERSION" # Resolve the exact tagged release directory from the updater URLs. Using - # this base for the standalone installer avoids a latest-release race where - # latest.json and bitfun-installer.exe could otherwise resolve to different +# this base for the standalone installer avoids a latest-release race where +# latest.json and the manual installer could otherwise resolve to different # versions during publication. RELEASE_ASSET_BASE_URL=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c " import json, sys @@ -495,14 +551,48 @@ print(bases.pop()) exit 1 } + INSTALLER_METADATA=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c " +import json, sys +data = json.load(sys.stdin) +entry = data.get('manual_installers', {}).get('windows-x86_64') +if entry: + print(entry['url']) + print(entry.get('signature_url', entry['url'] + '.sig')) +") || { + log "ERROR: Failed to resolve the manual Windows installer from latest.json" + exit 1 + } + if [ -n "$INSTALLER_METADATA" ]; then + WINDOWS_INSTALLER_URL=$(printf '%s\n' "$INSTALLER_METADATA" | sed -n '1p') + WINDOWS_INSTALLER_SIGNATURE_URL=$(printf '%s\n' "$INSTALLER_METADATA" | sed -n '2p') + if [ "${WINDOWS_INSTALLER_URL%/*}" != "$RELEASE_ASSET_BASE_URL" ]; then + log "ERROR: Manual installer URL does not belong to release $VERSION" + exit 1 + fi + if [ "$WINDOWS_INSTALLER_SIGNATURE_URL" != "${WINDOWS_INSTALLER_URL}.sig" ]; then + log "ERROR: Manual installer signature URL does not match the installer URL" + exit 1 + fi + WINDOWS_INSTALLER_FILENAME="${WINDOWS_INSTALLER_URL##*/}" + else + WINDOWS_INSTALLER_URL="${RELEASE_ASSET_BASE_URL}/${LEGACY_WINDOWS_INSTALLER_FILENAME}" + WINDOWS_INSTALLER_SIGNATURE_URL="${WINDOWS_INSTALLER_URL}.sig" + WINDOWS_INSTALLER_FILENAME="$LEGACY_WINDOWS_INSTALLER_FILENAME" + fi + # 3. Create version directory VERSION_DIR="${WEBSITE_RELEASE_DIR}/${VERSION}" mkdir -p "$VERSION_DIR" - # 4. Mirror small trust metadata and Linux archives first. - mirror_relay_image_descriptor - mirror_linux_binaries - mirror_dispatch_macos_cli_archives + # 4. Stable owns the CLI/Relay floating manifests. The first beta slice only + # mirrors Desktop updater and installer assets under /release/beta. + if [ "$RELEASE_CHANNEL" = "stable" ]; then + mirror_relay_image_descriptor + mirror_linux_binaries + mirror_dispatch_macos_cli_archives + else + log "Skipping stable-only CLI and Relay metadata for the beta channel" + fi # 5. Download all platform installer packages # Extract "\t" pairs, then curl each one. @@ -524,8 +614,7 @@ for p, info in data.get('platforms', {}).items(): download_asset "$url" "${VERSION_DIR}/${filename}" || exit 1 done <<< "$ASSET_LIST" - # latest.json only lists the Tauri setup.exe. Mirror the custom installer - # separately for website users while preserving the updater contract. + # Mirror the manual installer separately while preserving the updater URL. mirror_windows_installer # 6. Rewrite URLs in latest.json to point at openbitfun.com @@ -538,6 +627,10 @@ base = '${OPENBITFUN_BASE_URL}/' + version for p, info in data.get('platforms', {}).items(): fname = info['url'].split('/')[-1] info['url'] = base + '/' + fname +for p, info in data.get('manual_installers', {}).items(): + for key in ('url', 'signature_url'): + if info.get(key): + info[key] = base + '/' + info[key].split('/')[-1] print(json.dumps(data, indent=2)) " > "$LATEST_MANIFEST_TMP" mv "$LATEST_MANIFEST_TMP" "${VERSION_DIR}/latest.json" @@ -568,7 +661,7 @@ print(json.dumps(data, indent=2)) done fi - log "=== Sync complete: version $VERSION ===" + log "=== ${RELEASE_CHANNEL} sync complete: version $VERSION ===" } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then diff --git a/scripts/plan-channel-promotion.mjs b/scripts/plan-channel-promotion.mjs new file mode 100644 index 0000000000..6e03a27efe --- /dev/null +++ b/scripts/plan-channel-promotion.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import { appendFileSync, existsSync, readFileSync } from 'node:fs'; +import { compareReleaseVersions } from './release-channel.mjs'; + +const args = parseArgs(process.argv.slice(2)); +const candidatePath = requireArg(args, 'candidate'); +const currentPath = args.current; +const candidate = readVersion(candidatePath); +const current = currentPath && existsSync(currentPath) ? readVersion(currentPath) : null; +const promote = current === null || compareReleaseVersions(candidate, current) >= 0; + +console.log( + current === null + ? `[channel-promotion] Initial channel version: ${candidate}` + : `[channel-promotion] current=${current} candidate=${candidate} promote=${promote}`, +); +if (args['github-output']) { + appendFileSync(args['github-output'], `promote=${promote}\n`, 'utf8'); + appendFileSync(args['github-output'], `candidate_version=${candidate}\n`, 'utf8'); +} + +function readVersion(file) { + const data = JSON.parse(readFileSync(file, 'utf8')); + if (typeof data.version !== 'string') { + throw new Error(`Manifest has no string version: ${file}`); + } + return data.version; +} + +function parseArgs(rawArgs) { + const parsed = {}; + for (let index = 0; index < rawArgs.length; index += 2) { + const name = rawArgs[index]; + const value = rawArgs[index + 1]; + if (!name?.startsWith('--') || !value) { + throw new Error(`Invalid argument near ${name || ''}`); + } + parsed[name.slice(2)] = value; + } + return parsed; +} + +function requireArg(parsed, name) { + if (!parsed[name]) throw new Error(`Missing required --${name} argument`); + return parsed[name]; +} diff --git a/scripts/prepare-windows-installer-asset.mjs b/scripts/prepare-windows-installer-asset.mjs new file mode 100644 index 0000000000..3761d58142 --- /dev/null +++ b/scripts/prepare-windows-installer-asset.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'fs'; +import { basename, join } from 'path'; + +const args = parseArgs(process.argv.slice(2)); +const assetsDir = requireArg(args, 'assets-dir'); +const version = requireArg(args, 'version'); +const outDir = requireArg(args, 'out-dir'); + +if (!existsSync(assetsDir)) { + fail(`Assets directory does not exist: ${assetsDir}`); +} +if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + fail(`Version is not safe for a release asset name: ${version}`); +} + +const candidates = walkFiles(assetsDir).filter( + (file) => basename(file).toLowerCase() === 'bitfun-installer.exe' +); +if (candidates.length !== 1) { + fail(`Expected exactly one bitfun-installer.exe, found ${candidates.length}`); +} + +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); + +const outputName = `BitFun_${version}_windows-x86_64-installer.exe`; +const outputPath = join(outDir, outputName); +copyFileSync(candidates[0], outputPath); +console.log(`[manual-installer] ${candidates[0]} -> ${outputPath}`); + +function parseArgs(rawArgs) { + const parsed = {}; + for (let i = 0; i < rawArgs.length; i += 1) { + const arg = rawArgs[i]; + if (!arg.startsWith('--')) continue; + const key = arg.slice(2); + const value = rawArgs[i + 1]; + if (!value || value.startsWith('--')) fail(`Missing value for --${key}`); + parsed[key] = value; + i += 1; + } + return parsed; +} + +function requireArg(parsed, key) { + const value = parsed[key]; + if (!value) fail(`Missing required argument --${key}`); + return value; +} + +function walkFiles(dir) { + const files = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) files.push(...walkFiles(fullPath)); + else if (entry.isFile()) files.push(fullPath); + } + return files; +} + +function fail(message) { + console.error(`[manual-installer] ${message}`); + process.exit(1); +} diff --git a/scripts/release-channel.mjs b/scripts/release-channel.mjs new file mode 100644 index 0000000000..358cd24db6 --- /dev/null +++ b/scripts/release-channel.mjs @@ -0,0 +1,82 @@ +const CHANNELS = { + stable: { + primaryUpdaterEndpoint: + 'https://github.com/GCWing/BitFun/releases/latest/download/latest.json', + fallbackUpdaterEndpoint: 'https://openbitfun.com/release/latest.json', + githubChannelTag: null, + }, + beta: { + primaryUpdaterEndpoint: + 'https://github.com/GCWing/BitFun/releases/download/channel-beta/latest.json', + fallbackUpdaterEndpoint: 'https://openbitfun.com/release/beta/latest.json', + githubChannelTag: 'channel-beta', + }, + nightly: { + primaryUpdaterEndpoint: + 'https://github.com/GCWing/BitFun/releases/download/nightly/latest.json', + fallbackUpdaterEndpoint: 'https://openbitfun.com/release/nightly/latest.json', + githubChannelTag: 'nightly', + }, +}; + +export function resolveReleaseChannel(value = 'stable') { + const channel = String(value || 'stable').trim().toLowerCase(); + const config = CHANNELS[channel]; + if (!config) { + throw new Error( + `Unsupported release channel "${value}". Expected one of: ${Object.keys(CHANNELS).join(', ')}`, + ); + } + return { channel, ...config }; +} + +export function validateReleaseVersion(channelValue, versionValue) { + const { channel } = resolveReleaseChannel(channelValue); + const version = String(versionValue || '').trim(); + const stablePattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + const betaPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-beta\.([1-9]\d*)$/; + const nightlyPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-nightly\.\d{8}$/; + const valid = channel === 'stable' + ? stablePattern.test(version) + : channel === 'beta' + ? betaPattern.test(version) + : nightlyPattern.test(version); + if (!valid) { + const example = channel === 'stable' + ? '0.2.18' + : channel === 'beta' + ? '0.2.18-beta.1' + : '0.2.18-nightly.20260811'; + throw new Error( + `Version "${versionValue}" is invalid for the ${channel} channel. Expected a version like ${example}`, + ); + } + return version; +} + +export function compareReleaseVersions(leftValue, rightValue) { + const left = parseComparableVersion(leftValue); + const right = parseComparableVersion(rightValue); + for (let index = 0; index < 3; index += 1) { + if (left.core[index] !== right.core[index]) { + return left.core[index] < right.core[index] ? -1 : 1; + } + } + if (left.beta === right.beta) return 0; + if (left.beta === null) return 1; + if (right.beta === null) return -1; + return left.beta < right.beta ? -1 : 1; +} + +function parseComparableVersion(value) { + const match = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.([1-9]\d*))?$/.exec(String(value)); + if (!match) { + throw new Error(`Unsupported channel version: ${value}`); + } + return { + core: match.slice(1, 4).map(Number), + beta: match[4] === undefined ? null : Number(match[4]), + }; +} diff --git a/scripts/release-channel.test.mjs b/scripts/release-channel.test.mjs new file mode 100644 index 0000000000..02bed3d72e --- /dev/null +++ b/scripts/release-channel.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + compareReleaseVersions, + resolveReleaseChannel, + validateReleaseVersion, +} from './release-channel.mjs'; +import { setBuildVersion } from './set-build-version.mjs'; +import { decodeMinisignPublicKey } from './write-minisign-public-key.mjs'; + +const RAW_PUBLIC_KEY = `untrusted comment: minisign public key E3E0874CEC1C22C3 +RWTDIhzsTIfg41w2Gwiei0zNDKaLYm9dQVpEWNQ/Ulpyt2mbS2JE1U2M`; + +test('stable and beta channels resolve to isolated updater feeds', () => { + const stable = resolveReleaseChannel('stable'); + const beta = resolveReleaseChannel('beta'); + assert.match(stable.primaryUpdaterEndpoint, /releases\/latest\/download/); + assert.match(beta.primaryUpdaterEndpoint, /releases\/download\/channel-beta/); + assert.equal(beta.fallbackUpdaterEndpoint, 'https://openbitfun.com/release/beta/latest.json'); + assert.notEqual(beta.primaryUpdaterEndpoint, stable.primaryUpdaterEndpoint); +}); + +test('channel promotion follows SemVer including beta precedence', () => { + assert.equal(compareReleaseVersions('0.2.18-beta.2', '0.2.18-beta.1'), 1); + assert.equal(compareReleaseVersions('0.2.18', '0.2.18-beta.9'), 1); + assert.equal(compareReleaseVersions('0.2.19-beta.1', '0.2.18'), 1); + assert.equal(compareReleaseVersions('0.2.18', '0.2.19-beta.1'), -1); + assert.equal(compareReleaseVersions('0.2.18-beta.2', '0.2.18-beta.2'), 0); +}); + +test('release versions must match their channel', () => { + assert.equal(validateReleaseVersion('stable', '0.2.18'), '0.2.18'); + assert.equal(validateReleaseVersion('beta', '0.2.18-beta.1'), '0.2.18-beta.1'); + assert.throws(() => validateReleaseVersion('stable', '0.2.18-beta.1')); + assert.throws(() => validateReleaseVersion('beta', '0.2.18')); + assert.throws(() => validateReleaseVersion('beta', '0.2.18-beta.0')); + assert.equal( + validateReleaseVersion('nightly', '0.2.18-nightly.20260811'), + '0.2.18-nightly.20260811', + ); +}); + +test('release public key export accepts raw and legacy base64 values', () => { + const expected = `${RAW_PUBLIC_KEY}\n`; + assert.equal(decodeMinisignPublicKey(RAW_PUBLIC_KEY), expected); + assert.equal( + decodeMinisignPublicKey(Buffer.from(expected).toString('base64')), + expected, + ); + assert.throws(() => decodeMinisignPublicKey('not-a-key')); +}); + +test('build version projection updates every release-owned version file', () => { + const root = mkdtempSync(path.join(tmpdir(), 'bitfun-build-version-')); + const jsonFiles = [ + 'package.json', + 'package-lock.json', + 'BitFun-Installer/package.json', + 'BitFun-Installer/package-lock.json', + ]; + for (const relative of jsonFiles) { + const file = path.join(root, relative); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify({ version: '1.0.0', packages: { '': { version: '1.0.0' } } })); + } + writeFixture(root, 'Cargo.toml', 'version = "1.0.0" # x-release-please-version\n'); + writeFixture( + root, + 'src/apps/relay-server/Cargo.toml', + 'version = "1.0.0" # x-release-please-version\n', + ); + writeFixture(root, 'BitFun-Installer/src-tauri/Cargo.toml', 'version = "1.0.0"\n'); + + setBuildVersion(root, '1.1.0-beta.2'); + + for (const relative of jsonFiles) { + const data = JSON.parse(readFileSync(path.join(root, relative), 'utf8')); + assert.equal(data.version, '1.1.0-beta.2'); + assert.equal(data.packages[''].version, '1.1.0-beta.2'); + } + assert.match(readFileSync(path.join(root, 'Cargo.toml'), 'utf8'), /1\.1\.0-beta\.2/); + assert.match( + readFileSync(path.join(root, 'src/apps/relay-server/Cargo.toml'), 'utf8'), + /1\.1\.0-beta\.2/, + ); +}); + +function writeFixture(root, relative, content) { + const file = path.join(root, relative); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, content); +} diff --git a/scripts/set-build-version.mjs b/scripts/set-build-version.mjs new file mode 100644 index 0000000000..deaaa91ddb --- /dev/null +++ b/scripts/set-build-version.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +export function setBuildVersion(root, version) { + if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Invalid build version: ${version}`); + } + + for (const relative of [ + 'package.json', + 'package-lock.json', + 'BitFun-Installer/package.json', + 'BitFun-Installer/package-lock.json', + ]) { + const file = path.join(root, relative); + const data = JSON.parse(readFileSync(file, 'utf8')); + data.version = version; + if (data.packages?.['']) { + data.packages[''].version = version; + } + writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); + } + + replaceVersion( + path.join(root, 'Cargo.toml'), + /^version = "[^"]+" # x-release-please-version$/m, + `version = "${version}" # x-release-please-version`, + ); + replaceVersion( + path.join(root, 'src/apps/relay-server/Cargo.toml'), + /^version = "[^"]+" # x-release-please-version$/m, + `version = "${version}" # x-release-please-version`, + ); + replaceVersion( + path.join(root, 'BitFun-Installer/src-tauri/Cargo.toml'), + /^version = "[^"]+"$/m, + `version = "${version}"`, + ); +} + +function replaceVersion(file, pattern, replacement) { + const source = readFileSync(file, 'utf8'); + if (!pattern.test(source)) { + throw new Error(`Version marker was not found in ${file}`); + } + writeFileSync(file, source.replace(pattern, replacement), 'utf8'); +} + +function readArg(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const version = readArg(process.argv.slice(2), '--version'); + if (!version) { + throw new Error('Missing required --version argument'); + } + setBuildVersion(ROOT, version); + console.log(`[build-version] Updated release metadata to ${version}`); + } catch (error) { + console.error(`[build-version] ${error.message || error}`); + process.exit(1); + } +} diff --git a/scripts/sign-release-assets.sh b/scripts/sign-release-assets.sh index f094711654..84370c5517 100755 --- a/scripts/sign-release-assets.sh +++ b/scripts/sign-release-assets.sh @@ -6,9 +6,11 @@ # Usage: sign-release-assets.sh [...] # # Environment: -# BITFUN_SIGNING_KEY minisign secret key, base64 (Tauri's wrapper format) +# BITFUN_SIGNING_KEY minisign secret key, either the raw key file or +# base64 of that file (legacy wrapper format) # BITFUN_SIGNING_PASSWORD password for that key -# BITFUN_SIGNING_PUBKEY minisign public key, base64; used to self-verify +# BITFUN_SIGNING_PUBKEY minisign public key, either the raw key file or +# base64 of that file; used to self-verify # # With no signing key configured this is a no-op, so forks keep building. # @@ -88,10 +90,25 @@ WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT umask 077 -# Tauri stores the minisign secret key base64-wrapped; unwrap it to the on-disk -# format minisign expects. -printf '%s' "$BITFUN_SIGNING_KEY" | base64 -d >"$WORK/release.key" -printf '%s' "${BITFUN_SIGNING_PUBKEY:-}" | base64 -d >"$WORK/release.pub" 2>/dev/null || true +# New Tauri CLIs accept the raw minisign key file as their environment value; +# older repository secrets wrap that whole file in base64. Support both so the +# Tauri bundler and direct minisign asset signing can share one secret. +case "$BITFUN_SIGNING_KEY" in + "untrusted comment:"*) + printf '%s\n' "$BITFUN_SIGNING_KEY" >"$WORK/release.key" + ;; + *) + printf '%s' "$BITFUN_SIGNING_KEY" | base64 -d >"$WORK/release.key" + ;; +esac +case "${BITFUN_SIGNING_PUBKEY:-}" in + "untrusted comment:"*) + printf '%s\n' "$BITFUN_SIGNING_PUBKEY" >"$WORK/release.pub" + ;; + *) + printf '%s' "${BITFUN_SIGNING_PUBKEY:-}" | base64 -d >"$WORK/release.pub" 2>/dev/null || true + ;; +esac signed=0 skipped=0 diff --git a/scripts/stage-github-release-assets.mjs b/scripts/stage-github-release-assets.mjs new file mode 100644 index 0000000000..307744a13c --- /dev/null +++ b/scripts/stage-github-release-assets.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node + +import { + copyFileSync, + mkdirSync, + rmSync, + statSync, +} from 'node:fs'; +import path from 'node:path'; + +const args = process.argv.slice(2); +const outDirIndex = args.indexOf('--out-dir'); +if (outDirIndex === -1 || !args[outDirIndex + 1]) { + fail('Missing required --out-dir argument'); +} + +const outDir = path.resolve(args[outDirIndex + 1]); +const inputs = args.filter( + (_, index) => index !== outDirIndex && index !== outDirIndex + 1, +); + +if (inputs.length === 0) { + fail('No release assets were provided'); +} + +const byName = new Map(); +for (const input of inputs) { + const source = path.resolve(input); + let stats; + try { + stats = statSync(source); + } catch { + fail(`Release asset was not found: ${input}`); + } + if (!stats.isFile()) { + fail(`Release asset is not a file: ${input}`); + } + + const name = path.basename(source); + const previous = byName.get(name); + if (previous) { + fail(`Duplicate release asset name ${name}: ${previous} conflicts with ${source}`); + } + byName.set(name, source); +} + +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); + +for (const [name, source] of byName) { + copyFileSync(source, path.join(outDir, name)); +} + +console.log(`Staged ${byName.size} uniquely named GitHub release assets in ${outDir}`); + +function fail(message) { + console.error(`[stage-release-assets] ${message}`); + process.exit(1); +} diff --git a/scripts/tauri-release-manifest.test.mjs b/scripts/tauri-release-manifest.test.mjs new file mode 100644 index 0000000000..9eaaa1512b --- /dev/null +++ b/scripts/tauri-release-manifest.test.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const root = path.resolve(import.meta.dirname, '..'); + +test('release version metadata is synchronized', () => { + const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version; + const result = run('scripts/verify-release-version-sync.mjs', ['--version', version]); + assert.equal(result.status, 0, result.stderr); +}); + +test('prepares a versioned custom Windows installer asset', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-manual-installer-')); + const assets = path.join(temp, 'assets', 'nested'); + const out = path.join(temp, 'manual'); + fs.mkdirSync(assets, { recursive: true }); + fs.writeFileSync(path.join(assets, 'bitfun-installer.exe'), 'installer'); + + const result = run('scripts/prepare-windows-installer-asset.mjs', [ + '--assets-dir', path.join(temp, 'assets'), + '--version', '1.2.3', + '--out-dir', out, + ]); + assert.equal(result.status, 0, result.stderr); + assert.equal( + fs.readFileSync(path.join(out, 'BitFun_1.2.3_windows-x86_64-installer.exe'), 'utf8'), + 'installer' + ); +}); + +test('latest.json keeps the updater URL separate from the manual installer URL', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-latest-manual-')); + const updater = path.join(temp, 'updater'); + const manual = path.join(temp, 'manual'); + const out = path.join(temp, 'latest.json'); + fs.mkdirSync(updater, { recursive: true }); + fs.mkdirSync(manual, { recursive: true }); + + const updaterName = 'BitFun_1.2.3_windows-x86_64-setup.exe'; + fs.writeFileSync(path.join(updater, updaterName), 'setup'); + fs.writeFileSync(path.join(updater, `${updaterName}.sig`), 'inline-updater-signature'); + const installerName = 'BitFun_1.2.3_windows-x86_64-installer.exe'; + fs.writeFileSync(path.join(manual, installerName), 'installer'); + fs.writeFileSync(path.join(manual, `${installerName}.sig`), 'detached-signature'); + + const generated = run('scripts/generate-tauri-latest-json.mjs', [ + '--assets-dir', updater, + '--manual-assets-dir', manual, + '--version', '1.2.3', + '--tag', 'v1.2.3', + '--repo', 'GCWing/BitFun', + '--out', out, + '--required-platforms', 'windows-x86_64', + ]); + assert.equal(generated.status, 0, generated.stderr); + + const manifest = JSON.parse(fs.readFileSync(out, 'utf8')); + assert.match(manifest.platforms['windows-x86_64'].url, /-setup\.exe$/); + assert.match(manifest.manual_installers['windows-x86_64'].url, /-installer\.exe$/); + assert.equal( + manifest.manual_installers['windows-x86_64'].signature_url, + `${manifest.manual_installers['windows-x86_64'].url}.sig` + ); + + const verified = run('scripts/verify-tauri-latest-json.mjs', [ + '--manifest', out, + '--version', '1.2.3', + '--required-platforms', 'windows-x86_64', + '--required-manual-platforms', 'windows-x86_64', + ]); + assert.equal(verified.status, 0, verified.stderr); +}); + +test('stages GitHub release assets in a flat directory', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-release-assets-')); + const first = path.join(temp, 'updater', 'latest.json'); + const second = path.join(temp, 'manual', 'installer.exe'); + const out = path.join(temp, 'staged'); + fs.mkdirSync(path.dirname(first), { recursive: true }); + fs.mkdirSync(path.dirname(second), { recursive: true }); + fs.writeFileSync(first, 'manifest'); + fs.writeFileSync(second, 'installer'); + + const result = run('scripts/stage-github-release-assets.mjs', [ + '--out-dir', out, + first, + second, + ]); + + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.readFileSync(path.join(out, 'latest.json'), 'utf8'), 'manifest'); + assert.equal(fs.readFileSync(path.join(out, 'installer.exe'), 'utf8'), 'installer'); +}); + +test('rejects duplicate GitHub release asset names before upload', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-release-duplicates-')); + const first = path.join(temp, 'macos-x64', 'BitFun.app.tar.gz.sig'); + const second = path.join(temp, 'macos-arm64', 'BitFun.app.tar.gz.sig'); + const out = path.join(temp, 'staged'); + fs.mkdirSync(path.dirname(first), { recursive: true }); + fs.mkdirSync(path.dirname(second), { recursive: true }); + fs.writeFileSync(first, 'x64-signature'); + fs.writeFileSync(second, 'arm64-signature'); + + const result = run('scripts/stage-github-release-assets.mjs', [ + '--out-dir', out, + first, + second, + ]); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Duplicate release asset name BitFun\.app\.tar\.gz\.sig/); + assert.match(result.stderr, /macos-x64/); + assert.match(result.stderr, /macos-arm64/); +}); + +function run(script, args) { + return spawnSync(process.execPath, [script, ...args], { + cwd: root, + encoding: 'utf8', + }); +} diff --git a/scripts/verify-release-version-sync.mjs b/scripts/verify-release-version-sync.mjs new file mode 100644 index 0000000000..9f9ddd49ee --- /dev/null +++ b/scripts/verify-release-version-sync.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import { readFileSync } from 'fs'; + +const args = parseArgs(process.argv.slice(2)); +const expected = requireArg(args, 'version'); +const versions = new Map([ + ['package.json', readJsonVersion('package.json')], + ['package-lock.json', readJsonVersion('package-lock.json')], + ['Cargo.toml', readTomlVersion('Cargo.toml', /version = "([^"]+)" # x-release-please-version/)], + ['BitFun-Installer/package.json', readJsonVersion('BitFun-Installer/package.json')], + ['BitFun-Installer/package-lock.json', readJsonVersion('BitFun-Installer/package-lock.json')], + ['BitFun-Installer/src-tauri/Cargo.toml', readTomlVersion('BitFun-Installer/src-tauri/Cargo.toml', /^version = "([^"]+)"/m)], +]); + +const mismatches = [...versions].filter(([, version]) => version !== expected); +if (mismatches.length > 0) { + for (const [file, version] of mismatches) { + console.error(`[release-version] ${file}: expected ${expected}, found ${version}`); + } + process.exit(1); +} +console.log(`[release-version] OK: ${expected}`); + +function readJsonVersion(file) { + return JSON.parse(readFileSync(file, 'utf8')).version; +} + +function readTomlVersion(file, pattern) { + const match = pattern.exec(readFileSync(file, 'utf8')); + if (!match) throw new Error(`Version was not found in ${file}`); + return match[1]; +} + +function parseArgs(rawArgs) { + const parsed = {}; + for (let i = 0; i < rawArgs.length; i += 1) { + const arg = rawArgs[i]; + if (!arg.startsWith('--')) continue; + parsed[arg.slice(2)] = rawArgs[i + 1]; + i += 1; + } + return parsed; +} + +function requireArg(parsed, key) { + if (!parsed[key]) throw new Error(`Missing required argument --${key}`); + return parsed[key]; +} diff --git a/scripts/verify-tauri-latest-json.mjs b/scripts/verify-tauri-latest-json.mjs index 81d7c00afc..a4386d12b3 100644 --- a/scripts/verify-tauri-latest-json.mjs +++ b/scripts/verify-tauri-latest-json.mjs @@ -5,6 +5,7 @@ const args = parseArgs(process.argv.slice(2)); const manifestPath = requireArg(args, 'manifest'); const version = args.version; const requiredPlatforms = parseListArg(args['required-platforms'] || ''); +const requiredManualPlatforms = parseListArg(args['required-manual-platforms'] || ''); const checkUrls = ['1', 'true', 'yes'].includes(String(args['check-urls'] || '').toLowerCase()); const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); @@ -33,10 +34,33 @@ for (const [platform, entry] of Object.entries(manifest.platforms)) { } } +const manualInstallers = manifest.manual_installers || {}; +const missingManual = requiredManualPlatforms.filter((platform) => !manualInstallers[platform]); +if (missingManual.length > 0) { + fail(`Missing required manual installers: ${missingManual.join(', ')}`); +} +for (const [platform, entry] of Object.entries(manualInstallers)) { + if (!entry || typeof entry !== 'object') fail(`Invalid manual installer entry for ${platform}`); + if (!entry.url || typeof entry.url !== 'string') fail(`Missing manual installer URL for ${platform}`); + if (!entry.signature_url || typeof entry.signature_url !== 'string') { + fail(`Missing manual installer signature URL for ${platform}`); + } + if (entry.signature_url !== `${entry.url}.sig`) { + fail(`Manual installer signature URL for ${platform} must equal url + .sig`); + } + if (manifest.platforms[platform]?.url === entry.url) { + fail(`Manual installer URL for ${platform} must not replace the updater URL`); + } +} + if (checkUrls) { for (const [platform, entry] of Object.entries(manifest.platforms)) { await assertUrlAvailable(platform, entry.url); } + for (const [platform, entry] of Object.entries(manualInstallers)) { + await assertUrlAvailable(`manual installer ${platform}`, entry.url); + await assertUrlAvailable(`manual installer signature ${platform}`, entry.signature_url); + } } console.log(`[verify-latest-json] OK: ${Object.keys(manifest.platforms).sort().join(', ')}`); diff --git a/scripts/verify-webkit-compatibility.cjs b/scripts/verify-webkit-compatibility.cjs new file mode 100644 index 0000000000..cce0df65d0 --- /dev/null +++ b/scripts/verify-webkit-compatibility.cjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const ROOT_DIR = path.resolve(__dirname, '..'); +const DEFAULT_DIST_DIR = path.join(ROOT_DIR, 'dist'); +const FORBIDDEN_PATTERNS = [ + { + label: 'remark-gfm variable-length email lookbehind', + source: '(?<=^|\\s|\\p{P}|\\p{S})', + }, +]; + +function collectJavaScriptFiles(directory) { + if (!fs.existsSync(directory)) { + return []; + } + + const files = []; + const entries = fs.readdirSync(directory, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...collectJavaScriptFiles(entryPath)); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + files.push(entryPath); + } + } + return files; +} + +function findWebKitCompatibilityViolations(directory = DEFAULT_DIST_DIR) { + const violations = []; + for (const filePath of collectJavaScriptFiles(directory)) { + const content = fs.readFileSync(filePath, 'utf8'); + for (const pattern of FORBIDDEN_PATTERNS) { + if (content.includes(pattern.source)) { + violations.push({ filePath, label: pattern.label }); + } + } + } + return violations; +} + +function main() { + if (!fs.existsSync(DEFAULT_DIST_DIR)) { + console.error('[verify-webkit-compatibility] Production output directory is missing.'); + process.exitCode = 1; + return; + } + + const violations = findWebKitCompatibilityViolations(); + if (violations.length === 0) { + console.log( + '[verify-webkit-compatibility] Known Markdown WebKit incompatibilities were not found.' + ); + return; + } + + console.error('[verify-webkit-compatibility] Unsupported JavaScript found:'); + for (const violation of violations) { + console.error( + ` - ${path.relative(ROOT_DIR, violation.filePath)}: ${violation.label}` + ); + } + process.exitCode = 1; +} + +if (require.main === module) { + main(); +} + +module.exports = { findWebKitCompatibilityViolations }; diff --git a/scripts/verify-webkit-compatibility.test.mjs b/scripts/verify-webkit-compatibility.test.mjs new file mode 100644 index 0000000000..a5868ba0f5 --- /dev/null +++ b/scripts/verify-webkit-compatibility.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { findWebKitCompatibilityViolations } = require('./verify-webkit-compatibility.cjs'); + +function withTempDist(callback) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-webkit-compat-')); + try { + return callback(directory); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +test('accepts production JavaScript without the incompatible lookbehind', () => { + withTempDist((directory) => { + fs.writeFileSync(path.join(directory, 'app.js'), 'const email = /[-.\\w+]+@example\\.com/gu;'); + assert.deepEqual(findWebKitCompatibilityViolations(directory), []); + }); +}); + +test('rejects the remark-gfm variable-length email lookbehind', () => { + withTempDist((directory) => { + const filePath = path.join(directory, 'app.js'); + fs.writeFileSync( + filePath, + String.raw`const email = /(?<=^|\s|\p{P}|\p{S})([-.\w+]+)@example/gu;` + ); + + assert.deepEqual(findWebKitCompatibilityViolations(directory), [ + { + filePath, + label: 'remark-gfm variable-length email lookbehind', + }, + ]); + }); +}); diff --git a/scripts/version-generation.test.mjs b/scripts/version-generation.test.mjs new file mode 100644 index 0000000000..6750c23073 --- /dev/null +++ b/scripts/version-generation.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const root = path.resolve(import.meta.dirname, '..'); +const expectedVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version; + +for (const [buildEnv, isDev] of [['production', false], ['development', true]]) { + test(`generates ${buildEnv} version metadata explicitly`, () => { + const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), `bitfun-version-${buildEnv}-`)); + const result = run(['--build-env', buildEnv, '--output-root', outputRoot]); + assert.equal(result.status, 0, result.stderr); + const generated = JSON.parse( + fs.readFileSync(path.join(outputRoot, 'src/web-ui/public/version.json'), 'utf8') + ); + assert.equal(generated.version, expectedVersion); + assert.equal(generated.buildEnv, buildEnv); + assert.equal(generated.releaseChannel, 'stable'); + assert.equal(generated.isDev, isDev); + }); +} + +test('records the immutable release channel in generated metadata', () => { + const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-version-beta-')); + const result = run( + ['--build-env', 'production', '--output-root', outputRoot], + { BITFUN_RELEASE_CHANNEL: 'beta' }, + ); + assert.equal(result.status, 0, result.stderr); + const generated = JSON.parse( + fs.readFileSync(path.join(outputRoot, 'src/web-ui/public/version.json'), 'utf8') + ); + assert.equal(generated.releaseChannel, 'beta'); +}); + +test('fails instead of reusing stale metadata when build environment is missing', () => { + const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-version-missing-')); + const result = run(['--output-root', outputRoot]); + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}\n${result.stderr}`, /Expected --build-env/); +}); + +function run(args, extraEnv = {}) { + return spawnSync(process.execPath, ['scripts/generate-version.cjs', ...args], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, ...extraEnv }, + }); +} diff --git a/scripts/write-minisign-public-key.mjs b/scripts/write-minisign-public-key.mjs new file mode 100644 index 0000000000..900fab03cb --- /dev/null +++ b/scripts/write-minisign-public-key.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export function decodeMinisignPublicKey(value) { + const input = String(value || '').trim(); + if (!input) { + throw new Error('BITFUN_SIGNING_PUBKEY is required'); + } + + const raw = input.startsWith('untrusted comment:') + ? input + : decodeBase64(input); + const lines = raw.trim().split(/\r?\n/); + if (!lines[0]?.startsWith('untrusted comment:') || lines.length < 2 || !lines[1]) { + throw new Error('Public key is not a minisign public key file'); + } + return `${raw.trim()}\n`; +} + +function decodeBase64(value) { + const compact = value.replace(/\s/g, ''); + if ( + compact.length === 0 + || compact.length % 4 !== 0 + || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(compact) + ) { + throw new Error('Public key is neither raw minisign text nor valid base64'); + } + return Buffer.from(compact, 'base64').toString('utf8'); +} + +function readArg(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const output = readArg(process.argv.slice(2), '--out'); + if (!output) throw new Error('Missing required --out argument'); + writeFileSync(output, decodeMinisignPublicKey(process.env.BITFUN_SIGNING_PUBKEY), 'utf8'); + } catch (error) { + console.error(`[release-key] ${error.message || error}`); + process.exit(1); + } +} diff --git a/src/apps/cli/AGENTS.md b/src/apps/cli/AGENTS.md index 9d7c98b672..7098a99fbc 100644 --- a/src/apps/cli/AGENTS.md +++ b/src/apps/cli/AGENTS.md @@ -42,8 +42,9 @@ ChatView -> TuiAgentClient -> TuiBackend -> App Server client Embedded TUI uses an in-memory App Server connection. During the migration, Shared TUI uses a CLI Host compatibility adapter that implements `TuiBackend` over the private versioned Runtime IPC; the TUI client and controllers must not -reference that IPC. Replacing the physical Shared transport with App Server -Pipe/UDS framing belongs to Phase 5. Side-effecting operations need stable +reference that IPC. Embedded direct-runtime migration belongs to Phase 5; +replacing the physical Shared transport with App Server Pipe/UDS framing belongs +to the separately reviewed Phase 6. Side-effecting operations need stable identities, controller/idle rules, bounded frames, and outcome-unknown handling before a connection can retry. diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 0fdf26e358..068903cead 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -30,23 +30,36 @@ path = "tests/terminal_process_contracts.rs" [dependencies] # Internal crates -bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = [ +bitfun-core = { path = "../../crates/assembly/core", features = [ "agent-runtime", - "canvas-runtime", + "document-read", + "subscription-auth", + "remote-connect", + "deep-research", + "lsp", "external-sources", "plugin-runtime", "ssh-remote", + "tools-basic", + "tools-git", + "tools-mcp", + "tools-browser-web", + "tools-computer-use", + "tools-image-analysis", + "tools-miniapp", + "tools-canvas", + "tools-agent-control", ] } bitfun-events = { path = "../../crates/contracts/events" } bitfun-core-types = { path = "../../crates/contracts/core-types" } -bitfun-acp = { path = "../../crates/interfaces/acp" } +bitfun-acp = { path = "../../crates/interfaces/acp", default-features = false, features = ["client", "server"] } bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } bitfun-agent-runtime-ipc = { path = "../../crates/adapters/agent-runtime-ipc" } -bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" } +bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports", features = ["agent-api", "git-port", "permission", "plugin-runtime", "workspace-ports"] } bitfun-runtime-services = { path = "../../crates/execution/runtime-services" } -bitfun-services-core = { path = "../../crates/services/services-core", default-features = false, features = ["dispatch-workspace", "local-storage", "process-runtime", "runtime-ownership"] } +bitfun-services-core = { path = "../../crates/services/services-core", features = ["dispatch-workspace", "local-storage", "process-runtime", "runtime-ownership"] } bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" } -bitfun-product-domains = { path = "../../crates/contracts/product-domains", default-features = false, features = ["external-sources"] } +bitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["external-sources"] } bitfun-app-server = { path = "../../crates/interfaces/app-server" } bitfun-app-server-client = { path = "../../crates/interfaces/app-server-client" } bitfun-app-server-protocol = { path = "../../crates/interfaces/app-server-protocol" } @@ -65,7 +78,6 @@ toml = { workspace = true } # Session management uuid = { workspace = true } chrono = { workspace = true } -dashmap = { workspace = true } # Async trait async-trait = { workspace = true } @@ -84,7 +96,6 @@ similar = { workspace = true } # Syntax highlighting for code blocks and tool cards syntect = { workspace = true } -syntect-tui = { workspace = true } # Lazy initialization for syntax highlighter singleton once_cell = { workspace = true } @@ -108,7 +119,7 @@ fs2 = { workspace = true } base64 = { workspace = true } image = { workspace = true } minisign-verify = "0.2" -reqwest = { workspace = true, features = ["rustls"] } +reqwest = { workspace = true, features = ["http2", "rustls", "stream"] } sha2 = { workspace = true } tar = { workspace = true } tempfile = "3" diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index b80edfdba0..97d7f7dce5 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -5,6 +5,8 @@ session management, and machine-owned background tasks. Use `bitfun` for all new scripts and integrations; `bitfun-cli` is a deprecated compatibility entrypoint. +![BitFun interactive TUI](../../../png/bitfun_cli_tui.png) + ## Install From the repository root: diff --git a/src/apps/cli/src/account.rs b/src/apps/cli/src/account.rs index b70e68e694..28ffd1dd37 100644 --- a/src/apps/cli/src/account.rs +++ b/src/apps/cli/src/account.rs @@ -1,1106 +1,563 @@ -//! CLI account login and device-routing (RPC control) support. +//! CLI adapter for account-backed device routing. //! -//! This module lets the CLI log in to a BitFun relay account and then become -//! RPC-controllable by other devices on the same account. -//! -//! Incoming `HostInvoke` / `DeviceEvent` messages are handled by -//! `crate::peer_host` (Peer Device Mode host). Other remote-connect commands -//! still go through `RemoteServer`. -//! -//! The master key lives in memory only and is lost when the CLI exits. +//! Shared account identity, persistence, synchronization, and transitions are +//! owned by [`AccountRuntime`]. This module contains only CLI Host effects: +//! daemon retirement, Relay routing, and Peer Device Mode fan-out fencing. -use std::future::Future; -use std::sync::{ - atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, - Arc, OnceLock, -}; +use std::sync::{Arc, OnceLock, Weak}; use std::time::Duration; use anyhow::{anyhow, Result}; -use tokio::sync::{Notify, RwLock}; +use async_trait::async_trait; +use tokio::sync::RwLock; +use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; +use bitfun_core::service::remote_connect::account::{ + ensure_relay_session_history_exportable, AccountSession, +}; +use bitfun_core::service::remote_connect::account_runtime::{ + build_session_backup, AccountRoutingStartRequest, AccountRuntime, AccountRuntimeHost, + AccountSessionBackup, AccountSessionBackupPort, BackgroundRoutingOwnerRetirementError, +}; use bitfun_core::service::remote_connect::{ self, encryption, relay_client::RelayClient, relay_client::RelayEvent, session_store, - validate_relay_base_url, AccountClient, AccountSession, DeviceIdentity, RemoteServer, + DeviceIdentity, RemoteServer, }; -#[derive(Clone)] -struct AccountContextState { - session: AccountSession, - relay_url: String, -} - -/// Session and relay URL are one atomic account context so concurrent login, -/// logout, routing and sync cannot observe a torn pair. -static ACCOUNT_CONTEXT: OnceLock>>> = OnceLock::new(); -static ACCOUNT_CONTEXT_GENERATION: AtomicU64 = AtomicU64::new(1); -static ACCOUNT_CONTEXT_TRANSITIONS: AtomicUsize = AtomicUsize::new(0); -static ACCOUNT_SYNC_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -/// Serializes candidate credential verification without hiding or stopping the -/// currently active account. Only a fully authenticated candidate may enter -/// the account transition that replaces it. -static ACCOUNT_LOGIN_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -static ACCOUNT_CONTEXT_TRANSITION_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -static ACCOUNT_SYNC_CANCEL: OnceLock = OnceLock::new(); -/// At most one delayed daemon-exit recovery poller may own a generation. -/// A newer generation supersedes an older poller without accumulating tasks. -static ROUTING_RECOVERY_GENERATION: AtomicU64 = AtomicU64::new(0); -/// Read leases cover one routing event through its side effects and response. -/// Account transitions and routing-client ownership changes take the write -/// lease, so a new owner cannot be published while an old handler is active. -static DEVICE_ROUTING_LIFECYCLE: RwLock<()> = RwLock::const_new(()); - -pub(crate) fn account_context_generation() -> u64 { - ACCOUNT_CONTEXT_GENERATION.load(Ordering::Acquire) -} - -pub(crate) fn account_context_is_current(generation: u64) -> bool { - ACCOUNT_CONTEXT_TRANSITIONS.load(Ordering::Acquire) == 0 - && account_context_generation() == generation -} - -struct AccountContextTransitionPermit; - -impl AccountContextTransitionPermit { - fn begin() -> Self { - ACCOUNT_CONTEXT_TRANSITIONS.fetch_add(1, Ordering::AcqRel); - ACCOUNT_CONTEXT_GENERATION.fetch_add(1, Ordering::AcqRel); - account_sync_cancel().notify_waiters(); - Self - } +pub(crate) struct CliAccountRuntimeParts { + pub(crate) runtime: Arc, + pub(crate) routing: Arc, } -impl Drop for AccountContextTransitionPermit { - fn drop(&mut self) { - // Reject work queued during the transition before exposing the newly - // installed (or cleared) account context. - ACCOUNT_CONTEXT_GENERATION.fetch_add(1, Ordering::AcqRel); - ACCOUNT_CONTEXT_TRANSITIONS.fetch_sub(1, Ordering::AcqRel); - } +pub(crate) fn build_account_runtime( + compatibility: CoreAgentRuntimeCompatibility, +) -> CliAccountRuntimeParts { + build_account_runtime_with_backup(Arc::new(CliAccountSessionBackupPort { compatibility })) } -struct AccountContextTransitionGuard { - sync_guard: Option>, - transition: Option, - routing_guard: Option>, - transition_guard: Option>, +pub(crate) fn build_management_account_runtime() -> Arc { + build_account_runtime_with_backup(Arc::new(UnavailableSessionBackup)).runtime } -impl AccountContextTransitionGuard { - fn finish(mut self) -> u64 { - drop(self.sync_guard.take()); - drop(self.transition.take()); - let generation = account_context_generation(); - drop(self.routing_guard.take()); - drop(self.transition_guard.take()); - generation - } +fn build_account_runtime_with_backup( + backup: Arc, +) -> CliAccountRuntimeParts { + let routing = CliAccountRoutingHost::new(); + let runtime = AccountRuntime::new(routing.clone(), backup); + routing.bind_runtime(Arc::downgrade(&runtime)); + CliAccountRuntimeParts { runtime, routing } } -impl Drop for AccountContextTransitionGuard { - fn drop(&mut self) { - drop(self.sync_guard.take()); - drop(self.transition.take()); - drop(self.routing_guard.take()); - drop(self.transition_guard.take()); - } -} +struct UnavailableSessionBackup; -pub(crate) async fn lock_account_sync( - generation: u64, -) -> Result> { - let guard = ACCOUNT_SYNC_LOCK.lock().await; - if !account_context_is_current(generation) { - return Err(anyhow!("account sync cancelled")); +#[async_trait] +impl AccountSessionBackupPort for UnavailableSessionBackup { + async fn list_session_backups( + &self, + _workspace_path: &std::path::Path, + ) -> Result> { + Err(anyhow!( + "Session backup is unavailable in a short-lived management command" + )) } - Ok(guard) } -fn account_sync_cancel() -> &'static Notify { - ACCOUNT_SYNC_CANCEL.get_or_init(Notify::new) +struct CliAccountSessionBackupPort { + compatibility: CoreAgentRuntimeCompatibility, } -pub(crate) async fn await_account_sync_current(generation: u64, future: F) -> Result -where - F: Future, -{ - let mut cancelled = Box::pin(account_sync_cancel().notified()); - cancelled.as_mut().enable(); - if !account_context_is_current(generation) { - return Err(anyhow!("account sync cancelled")); - } - tokio::select! { - _ = &mut cancelled => Err(anyhow!("account sync cancelled")), - result = future => { - if !account_context_is_current(generation) { - Err(anyhow!("account sync cancelled")) - } else { - Ok(result) +#[async_trait] +impl AccountSessionBackupPort for CliAccountSessionBackupPort { + async fn list_session_backups( + &self, + workspace_path: &std::path::Path, + ) -> Result> { + let metadata = self + .compatibility + .list_persisted_sessions(workspace_path) + .await + .map_err(|error| anyhow!("list sessions: {error}"))?; + let mut backups = Vec::new(); + for item in &metadata { + if let Err(error) = ensure_relay_session_history_exportable(item) { + tracing::debug!("Skipping CLI account session export: {error}"); + continue; } + let turns = self + .compatibility + .load_persisted_session_turns(workspace_path, &item.session_id, None) + .await + .map_err(|error| anyhow!("load turns: {error}"))?; + backups.push(build_session_backup(item, &turns)?); } + Ok(backups) } } -async fn invalidate_and_wait_for_account_sync() -> AccountContextTransitionGuard { - let transition_guard = ACCOUNT_CONTEXT_TRANSITION_LOCK.lock().await; - let transition = AccountContextTransitionPermit::begin(); - let sync_guard = ACCOUNT_SYNC_LOCK.lock().await; - bitfun_core::service::remote_connect::settings_sync::wait_for_sync_operations_idle().await; - let routing_guard = DEVICE_ROUTING_LIFECYCLE.write().await; - AccountContextTransitionGuard { - sync_guard: Some(sync_guard), - transition: Some(transition), - routing_guard: Some(routing_guard), - transition_guard: Some(transition_guard), - } -} - -async fn invalidate_and_wait_if_account_current( - expected_generation: u64, -) -> Option { - let transition_guard = ACCOUNT_CONTEXT_TRANSITION_LOCK.lock().await; - if !account_context_is_current(expected_generation) { - return None; - } - let transition = AccountContextTransitionPermit::begin(); - let sync_guard = ACCOUNT_SYNC_LOCK.lock().await; - bitfun_core::service::remote_connect::settings_sync::wait_for_sync_operations_idle().await; - let routing_guard = DEVICE_ROUTING_LIFECYCLE.write().await; - Some(AccountContextTransitionGuard { - sync_guard: Some(sync_guard), - transition: Some(transition), - routing_guard: Some(routing_guard), - transition_guard: Some(transition_guard), - }) -} - -/// The background device-routing relay client. Holding this keeps the WS -/// connection alive (the internal read/write tasks own the socket). Dropping it -/// tears the connection down. -static DEVICE_RELAY_CLIENT: OnceLock>>> = OnceLock::new(); - -/// Set when the relay returns an auth error (token expired or invalid). -/// The chat loop checks this via `is_token_expired()` and prompts the user. -static TOKEN_EXPIRED: AtomicBool = AtomicBool::new(false); - -/// True while credentials succeeded but the user has not yet chosen -/// cloud-vs-local settings. Session is held in memory only; a process kill -/// must not restore a logged-in state (same contract as desktop -/// `account_login` / `account_finalize_login`). -static PENDING_SYNC_CHOICE: AtomicBool = AtomicBool::new(false); - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct AutomaticAccountSyncPolicy { - pub(crate) background_engine: bool, - pub(crate) management_push: bool, +/// CLI-owned routing effects injected into the shared Account Runtime. +pub(crate) struct CliAccountRoutingHost { + self_ref: Weak, + runtime: OnceLock>, + relay_client: RwLock>>, + /// Read leases cover one routing event through its response. Routing owner + /// changes take the write lease, so old events cannot escape through a new + /// account's Relay client. + lifecycle: Arc>, } -fn automatic_account_sync_policy_for_pending( - pending_sync_choice: bool, -) -> AutomaticAccountSyncPolicy { - let allowed = !pending_sync_choice; - AutomaticAccountSyncPolicy { - background_engine: allowed, - management_push: allowed, +impl CliAccountRoutingHost { + fn new() -> Arc { + Arc::new_cyclic(|self_ref| Self { + self_ref: self_ref.clone(), + runtime: OnceLock::new(), + relay_client: RwLock::new(None), + lifecycle: Arc::new(RwLock::new(())), + }) } -} - -/// Automatic sync must remain idle while an authenticated account is waiting -/// for the user to choose whether cloud or local settings should win. Explicit -/// first-login sync is intentionally not governed by this policy. -pub(crate) fn automatic_account_sync_policy() -> AutomaticAccountSyncPolicy { - automatic_account_sync_policy_for_pending(PENDING_SYNC_CHOICE.load(Ordering::Acquire)) -} - -fn account_context() -> &'static Arc>> { - ACCOUNT_CONTEXT.get_or_init(|| Arc::new(RwLock::new(None))) -} - -fn device_relay_client() -> &'static RwLock>> { - DEVICE_RELAY_CLIENT.get_or_init(|| RwLock::new(None)) -} - -/// Read both the session and relay URL, returning owned clones to avoid holding -/// locks across awaits. -pub(crate) async fn read_account_context() -> Result<(AccountSession, String)> { - let generation = account_context_generation(); - read_account_context_for_generation(generation).await -} - -async fn read_account_context_raw() -> Result<(AccountSession, String)> { - account_context() - .read() - .await - .clone() - .map(|context| (context.session, context.relay_url)) - .ok_or_else(|| anyhow!("not logged in")) -} -pub(crate) async fn read_account_context_for_generation( - generation: u64, -) -> Result<(AccountSession, String)> { - if !account_context_is_current(generation) { - return Err(anyhow!("account context changed")); - } - let context = read_account_context_raw().await?; - if !account_context_is_current(generation) { - return Err(anyhow!("account context changed")); + fn bind_runtime(&self, runtime: Weak) { + self.runtime + .set(runtime) + .unwrap_or_else(|_| panic!("CLI account routing runtime was bound twice")); } - Ok(context) -} -/// Whether an account session is currently held and login is finalized. -/// Matches desktop `account_status`: pending cloud/local sync choice is not -/// treated as logged in. -pub(crate) async fn is_logged_in() -> bool { - if PENDING_SYNC_CHOICE.load(Ordering::Acquire) { - return false; + fn runtime(&self) -> Result> { + self.runtime + .get() + .and_then(Weak::upgrade) + .ok_or_else(|| anyhow!("account runtime is unavailable")) } - read_account_context().await.is_ok() -} - -fn normalize_relay_url(relay_url: &str) -> Result { - let parsed = validate_relay_base_url(relay_url.trim())?; - Ok(parsed.as_str().trim_end_matches('/').to_string()) -} -/// Attempt to restore a persisted session from disk. Called at startup. -/// Returns `Some(user_id)` if a session was restored. -pub(crate) async fn try_restore_session() -> Option { - let _sync_guard = invalidate_and_wait_for_account_sync().await; - match session_store::load_session_detailed() { - Ok(Some(loaded)) => { - let relay_url = match normalize_relay_url(&loaded.relay_url) { - Ok(url) => url, - Err(error) => { - tracing::warn!("Ignoring invalid persisted relay URL: {error}"); - session_store::clear_session(); - return None; - } - }; - let user_id = loaded.user_id.clone(); - if let Some(device_id) = loaded.device_id.as_deref() { - if let Err(e) = DeviceIdentity::adopt_account_device_id(device_id) { - tracing::warn!("Failed to adopt restored session device_id: {e}"); - } - } - let session = AccountSession { - token: loaded.token, - user_id: user_id.clone(), - master_key: loaded.master_key, - }; - *account_context().write().await = Some(AccountContextState { session, relay_url }); - tracing::info!("Restored account session for user {user_id}"); - Some(user_id) - } - Ok(None) => None, - Err(e) => { - tracing::warn!("Failed to load persisted session: {e}"); - None + async fn start_routing(&self, request: AccountRoutingStartRequest) -> Result<()> { + let runtime = self.runtime()?; + if !runtime.account_context_is_current(request.account_generation) { + return Err(anyhow!("account context changed")); } - } -} - -/// Whether the relay has reported the account token as expired/invalid. -/// The TUI prompts re-login via this; the daemon exits on it. -pub(crate) fn is_token_expired() -> bool { - TOKEN_EXPIRED.load(Ordering::Relaxed) -} - -/// Mark the account token as rejected by the relay (expired / revoked). -/// Called by the settings sync engine when a sync request gets a 401. -pub(crate) fn mark_token_expired() { - TOKEN_EXPIRED.store(true, Ordering::Relaxed); -} - -/// Resolve the current device identity (machine-based). -fn current_device_identity() -> Result { - DeviceIdentity::from_current_machine().map_err(|e| anyhow!("detect device: {e}")) -} - -/// Structured result of a successful credential login. -#[derive(Debug, Clone)] -pub(crate) struct LoginResult { - pub user_id: String, - pub relay_url: String, - /// True when the relay already has a settings blob (Desktop overwrite prompt). - pub has_cloud_settings: bool, - pub status_message: String, -} - -/// Log in with credentials collected by the Login TUI form. -/// -/// Same fields as Desktop Account Login: Auth Server (relay URL), Username, -/// Password. Persists encrypted session + non-secret hint, then starts device -/// routing so this CLI becomes a Peer Device Mode host. -pub(crate) async fn login_with_credentials( - relay_url: &str, - username: &str, - password: &str, -) -> Result { - let _login_guard = ACCOUNT_LOGIN_LOCK.lock().await; - let relay_url_input = relay_url.trim(); - let username = username.trim(); - if relay_url_input.is_empty() { - return Err(anyhow!("Auth Server is required")); - } - if username.is_empty() { - return Err(anyhow!("Username is required")); - } - if password.is_empty() { - return Err(anyhow!("Password is required")); - } - let relay_url = normalize_relay_url(relay_url_input)?; - let expected_generation = account_context_generation(); - if !account_context_is_current(expected_generation) { - return Err(anyhow!("account context changed")); - } - - let device = current_device_identity()?; - let client = AccountClient::new(); - let session = client - .login(&relay_url, username, password, &device) - .await - .map_err(|e| anyhow!("login failed: {e}"))?; - - let has_cloud_settings = - match resolve_cloud_settings_probe(client.fetch_settings(&relay_url, &session).await) { - Ok(has_cloud_settings) => has_cloud_settings, - Err(error) => { - revoke_rejected_login_candidate(&client, &relay_url, &session).await; - return Err(error); + self.stop_routing().await; + + let ws_url = format!( + "{}/ws", + request + .relay_url + .replace("https://", "wss://") + .replace("http://", "ws://") + ); + let (client, mut event_rx) = RelayClient::new(); + client.connect(&ws_url).await?; + client + .connect_authenticated(&request.session.token, &request.device_name) + .await?; + let client = Arc::new(client); + { + let _routing_guard = self.lifecycle.write().await; + if !runtime.account_context_is_current(request.account_generation) { + client.disconnect().await; + return Err(anyhow!("account context changed")); } - }; - - // A daemon is a separate process with its own in-memory session and WebSocket. - // Retire it after candidate authentication but before beginning the local - // generation transition. If retirement fails, the old local owner keeps - // its original generation and remains usable. A clean daemon exit is not - // auto-restarted by the generated launchd/systemd service definitions. - // Snapshot the old owner before the guarded replacement. A generation race - // rejects the transition below, in which case this snapshot is never used. - let previous_account_context = account_context().read().await.clone(); - let (retired_daemon, transition_guard) = match begin_candidate_account_transition( - expected_generation, - retire_running_daemon_for_account_switch().await, - ) - .await - { - Ok(transition) => transition, - Err(CandidateAccountTransitionError::DaemonRetirement(failure)) => { - let recovery_message = if failure.daemon_may_exit { - schedule_routing_recovery_after_daemon_exit( - expected_generation, - device.device_name.clone(), - ); - "; this CLI will restore local routing if the daemon exits" - } else { - "" - }; - revoke_rejected_login_candidate(&client, &relay_url, &session).await; - return Err(anyhow!( - "{}; the old account context and generation were preserved{}", - failure.error, - recovery_message - )); + *self.relay_client.write().await = Some(client.clone()); } - Err(CandidateAccountTransitionError::AccountContextChanged) => { - revoke_rejected_login_candidate(&client, &relay_url, &session).await; + if !runtime.account_context_is_current(request.account_generation) { + self.retire_routing_client_if_same(&client).await; + client.disconnect().await; return Err(anyhow!("account context changed")); } - }; - - // The transition owns the routing lifecycle write lease. Retire any - // in-process owner before making the candidate context observable. - clear_replaced_persisted_session(); - stop_device_routing_locked().await; - let user_id = session.user_id.clone(); - let device_name = device.device_name.clone(); - let token = session.token.clone(); - let master_key = session.master_key; - *account_context().write().await = Some(AccountContextState { - session, - relay_url: relay_url.clone(), - }); - session_store::save_credential_hint(username, &relay_url); - TOKEN_EXPIRED.store(false, Ordering::Relaxed); - - if has_cloud_settings { - // Defer disk persist until the sync choice is accepted. Killing the - // process during the choice panel must not restore a logged-in session. - PENDING_SYNC_CHOICE.store(true, Ordering::Release); - transition_guard.finish(); - revoke_replaced_account_context(&client, previous_account_context, &relay_url, &token) - .await; - return Ok(LoginResult { - user_id: user_id.clone(), - relay_url: relay_url.clone(), - has_cloud_settings, - status_message: format!( - "Authenticated as user {} on {}. Choose cloud or local settings to finish login.{}", - user_id, - relay_url, - if retired_daemon { - " The previous CLI daemon was stopped; routing will resume after the sync choice." - } else { - "" + let routing = self + .self_ref + .upgrade() + .ok_or_else(|| anyhow!("account routing is unavailable"))?; + let expected_token = request.session.token; + let generation = request.account_generation; + tokio::spawn(async move { + loop { + if !routing.routing_loop_is_current(generation, &client).await { + tracing::debug!("Stopping stale device routing event loop"); + break; + } + let Some(event) = event_rx.recv().await else { + break; + }; + if !routing.routing_loop_is_current(generation, &client).await { + tracing::debug!("Stopping stale device routing event loop"); + break; } - ), + routing + .handle_relay_event(event, &client, generation, &expected_token) + .await; + } + routing.retire_routing_client_if_same(&client).await; + tracing::info!("Device routing event loop exited"); }); + Ok(()) } - PENDING_SYNC_CHOICE.store(false, Ordering::Release); - if let Err(e) = session_store::save_session_with_device( - &token, - &user_id, - &master_key, - &relay_url, - Some(device.device_id.as_str()), - ) { - tracing::warn!("Failed to persist session: {e}"); + async fn stop_routing(&self) { + let _routing_guard = self.lifecycle.write().await; + self.stop_routing_locked().await; } - let generation = transition_guard.finish(); - let routing_msg = match spawn_device_routing(&relay_url, &device_name, generation).await { - Ok(()) if retired_daemon => " The previous CLI daemon was stopped and routing is connected in this CLI process. Restart `bitfun daemon run` to restore always-on routing.".to_string(), - Ok(()) => " Device routing connected (Peer Host ready). Tip: `bitfun daemon install` keeps this device reachable after exit or reboot.".to_string(), - Err(e) if retired_daemon => format!(" (Warning: the previous CLI daemon was stopped, but replacement routing failed: {e})"), - Err(e) => format!(" (Warning: device routing failed: {e})"), - }; - revoke_replaced_account_context(&client, previous_account_context, &relay_url, &token).await; - - Ok(LoginResult { - user_id: user_id.clone(), - relay_url: relay_url.clone(), - has_cloud_settings, - status_message: format!( - "Logged in as user {} on {}.{}", - user_id, relay_url, routing_msg - ), - }) -} - -async fn revoke_rejected_login_candidate( - client: &AccountClient, - relay_url: &str, - session: &AccountSession, -) { - if let Err(error) = client.revoke_token(relay_url, session).await { - tracing::warn!("Failed to revoke rejected login candidate token: {error}"); + pub(crate) async fn stop_device_routing(&self) { + self.stop_routing().await; } -} - -fn clear_replaced_persisted_session() { - // Once this candidate has won the transition, the old account must never - // be restored after a crash. A finalized replacement is persisted below; - // a pending cloud-sync choice intentionally leaves no restorable session. - session_store::clear_session(); -} -fn replaced_account_revocation_target( - previous: Option, - replacement_relay_url: &str, - replacement_token: &str, -) -> Option { - previous.filter(|context| { - context.relay_url != replacement_relay_url || context.session.token != replacement_token - }) -} - -async fn revoke_replaced_account_context( - client: &AccountClient, - previous: Option, - replacement_relay_url: &str, - replacement_token: &str, -) { - let Some(previous) = - replaced_account_revocation_target(previous, replacement_relay_url, replacement_token) - else { - return; - }; - if let Err(error) = client - .revoke_token(&previous.relay_url, &previous.session) - .await - { - // B is already the committed in-memory owner. Relay cleanup of A is - // best-effort and must never roll the replacement back. - tracing::warn!("Failed to revoke replaced account token: {error}"); + async fn stop_routing_locked(&self) { + if let Some(client) = self.relay_client.write().await.take() { + client.disconnect().await; + } + crate::peer_host::update_controller_presence(Vec::new()).await; } -} - -fn resolve_cloud_settings_probe(result: Result>) -> Result { - result.map(|settings| settings.is_some()).map_err(|error| { - anyhow!("could not check cloud settings: {error}; the current account remains active") - }) -} - -struct DaemonRetirementFailure { - error: anyhow::Error, - daemon_may_exit: bool, -} - -enum CandidateAccountTransitionError { - DaemonRetirement(DaemonRetirementFailure), - AccountContextChanged, -} - -async fn begin_candidate_account_transition( - expected_generation: u64, - daemon_retirement: std::result::Result, -) -> std::result::Result<(bool, AccountContextTransitionGuard), CandidateAccountTransitionError> { - let retired_daemon = - daemon_retirement.map_err(CandidateAccountTransitionError::DaemonRetirement)?; - let transition_guard = invalidate_and_wait_if_account_current(expected_generation) - .await - .ok_or(CandidateAccountTransitionError::AccountContextChanged)?; - Ok((retired_daemon, transition_guard)) -} -async fn retire_running_daemon_for_account_switch( -) -> std::result::Result { - if !crate::daemon::is_daemon_running() { - return Ok(false); - } - if !crate::daemon::request_daemon_shutdown() { - return Err(DaemonRetirementFailure { - error: anyhow!("could not stop the CLI daemon; the current account remains active"), - daemon_may_exit: false, - }); + async fn is_current_routing_client(&self, client: &Arc) -> bool { + same_routing_client(self.relay_client.read().await.as_ref(), client) } - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); - while crate::daemon::is_daemon_running() { - if tokio::time::Instant::now() >= deadline { - return Err(DaemonRetirementFailure { - error: anyhow!( - "CLI daemon did not stop in time; the current account remains active" - ), - daemon_may_exit: true, - }); + async fn routing_loop_is_current( + &self, + account_generation: u64, + client: &Arc, + ) -> bool { + let Ok(runtime) = self.runtime() else { + return false; + }; + if !runtime.account_context_is_current(account_generation) { + return false; } - tokio::time::sleep(Duration::from_millis(50)).await; + let matches = self.is_current_routing_client(client).await; + matches && runtime.account_context_is_current(account_generation) } - Ok(true) -} -fn schedule_routing_recovery_after_daemon_exit(expected_generation: u64, device_name: String) { - if !account_context_is_current(expected_generation) - || ROUTING_RECOVERY_GENERATION.swap(expected_generation, Ordering::AcqRel) - == expected_generation - { - return; - } - tokio::spawn(async move { - while ROUTING_RECOVERY_GENERATION.load(Ordering::Acquire) == expected_generation - && account_context_is_current(expected_generation) - && crate::daemon::is_daemon_running() - { - tokio::time::sleep(Duration::from_millis(100)).await; - } - if ROUTING_RECOVERY_GENERATION.load(Ordering::Acquire) == expected_generation - && account_context_is_current(expected_generation) - && !crate::daemon::is_daemon_running() - { - if let Err(error) = restore_device_routing(&device_name).await { - tracing::warn!( - "Failed to restore old account routing after delayed daemon exit: {error}" - ); - } + async fn retire_routing_client_if_same(&self, client: &Arc) -> bool { + let _routing_guard = self.lifecycle.write().await; + let mut current = self.relay_client.write().await; + if !take_routing_client_if_same(&mut current, client) { + return false; } - let _ = ROUTING_RECOVERY_GENERATION.compare_exchange( - expected_generation, - 0, - Ordering::AcqRel, - Ordering::Acquire, - ); - }); -} - -/// Persist the in-memory session after the user accepts the sync choice, then -/// start device routing (same as a first login with no cloud settings). -pub(crate) async fn finalize_login_after_sync_choice() -> Result<()> { - let generation = account_context_generation(); - let sync_guard = lock_account_sync(generation).await?; - let device = current_device_identity()?; - let (session, relay_url) = read_account_context().await?; - let retired_daemon = retire_running_daemon_for_account_switch() - .await - .map_err(|failure| failure.error)?; - session_store::save_session_with_device( - &session.token, - &session.user_id, - &session.master_key, - &relay_url, - Some(device.device_id.as_str()), - ) - .map_err(|e| anyhow!("persist session: {e}"))?; - PENDING_SYNC_CHOICE.store(false, Ordering::Release); - - if retired_daemon { - tracing::info!( - "Stopped the previous CLI daemon before finalizing replacement account routing" - ); - } - drop(sync_guard); - spawn_device_routing(&relay_url, &device.device_name, generation) - .await - .map_err(|e| anyhow!("device routing failed: {e}")) -} - -/// Snapshot of the logged-in account for the Account status page. -#[derive(Debug, Clone)] -pub(crate) struct AccountInfo { - pub user_id: String, - pub relay_url: String, - pub device_id: String, - pub device_name: String, -} - -pub(crate) async fn account_info() -> Result { - let (session, relay_url) = read_account_context().await?; - let device = current_device_identity()?; - Ok(AccountInfo { - user_id: session.user_id, - relay_url, - device_id: device.device_id, - device_name: device.device_name, - }) -} - -/// Public wrapper for restoring device routing after session restore at startup. -pub(crate) async fn restore_device_routing(device_name: &str) -> Result<()> { - let generation = account_context_generation(); - let (_, relay_url) = read_account_context().await?; - spawn_device_routing(&relay_url, device_name, generation).await -} - -/// Connect to the account relay for device-to-device routing and spawn the -/// background task that handles incoming RPC commands. -async fn spawn_device_routing( - relay_url: &str, - device_name: &str, - account_generation: u64, -) -> Result<()> { - let _sync_guard = lock_account_sync(account_generation).await?; - let relay_url = normalize_relay_url(relay_url)?; - // Tear down any previous connection first. - stop_device_routing().await; - - let (session, current_relay_url) = read_account_context().await?; - if current_relay_url != relay_url { - return Err(anyhow!("account context changed")); + drop(current); + crate::peer_host::update_controller_presence(Vec::new()).await; + true } - let ws_url = format!( - "{}/ws", - relay_url - .replace("https://", "wss://") - .replace("http://", "ws://") - ); - - let (client, mut event_rx) = RelayClient::new(); - client.connect(&ws_url).await?; - client - .connect_authenticated(&session.token, device_name) - .await?; - let client_arc = Arc::new(client); - { - let _routing_guard = DEVICE_ROUTING_LIFECYCLE.write().await; - let mut current_client = device_relay_client().write().await; - if !account_context_is_current(account_generation) { - drop(current_client); - client_arc.disconnect().await; - return Err(anyhow!("account context changed")); + async fn handle_relay_event( + self: &Arc, + event: RelayEvent, + relay_client: &Arc, + account_generation: u64, + expected_token: &str, + ) { + if let RelayEvent::AuthError { message } = event { + self.handle_relay_auth_error(message, relay_client, account_generation, expected_token) + .await; + return; } - *current_client = Some(client_arc.clone()); - } - let account_context = account_context().clone(); - let relay_client_arc = client_arc.clone(); - tokio::spawn(async move { - loop { - if !routing_loop_is_current(account_generation, &relay_client_arc).await { - tracing::debug!("Stopping stale device routing event loop"); - break; - } - let Some(event) = event_rx.recv().await else { - break; - }; - if !routing_loop_is_current(account_generation, &relay_client_arc).await { - tracing::debug!("Stopping stale device routing event loop"); - break; - } - handle_relay_event( - event, - &account_context, - &relay_client_arc, - account_generation, - &session.token, - ) - .await; + let _routing_lease = self.lifecycle.read().await; + if !self + .routing_loop_is_current(account_generation, relay_client) + .await + { + tracing::debug!("Ignoring event from a stale device routing client"); + return; } - retire_routing_client_if_same(&relay_client_arc).await; - tracing::info!("Device routing event loop exited"); - }); - - Ok(()) -} - -/// Disconnect the device-routing connection (if any). -pub(crate) async fn stop_device_routing() { - let _routing_guard = DEVICE_ROUTING_LIFECYCLE.write().await; - stop_device_routing_locked().await; -} - -/// Stop routing while the caller holds the lifecycle write lease. -async fn stop_device_routing_locked() { - let client = { device_relay_client().write().await.take() }; - if let Some(client) = client { - client.disconnect().await; - } - crate::peer_host::update_controller_presence(Vec::new()).await; -} - -async fn is_current_routing_client(client: &Arc) -> bool { - same_routing_client(device_relay_client().read().await.as_ref(), client) -} - -fn same_routing_client(current: Option<&Arc>, expected: &Arc) -> bool { - current.is_some_and(|client| Arc::ptr_eq(client, expected)) -} - -fn take_routing_client_if_same(current: &mut Option>, expected: &Arc) -> bool { - if !same_routing_client(current.as_ref(), expected) { - return false; - } - current.take(); - true -} - -/// Validate both halves of a routing-loop lease. The generation is checked -/// again after awaiting the client slot so a concurrent account transition -/// cannot make the pre-lock snapshot look current. -async fn routing_loop_is_current(account_generation: u64, relay_client: &Arc) -> bool { - if !account_context_is_current(account_generation) { - return false; - } - let matches = is_current_routing_client(relay_client).await; - matches && account_context_is_current(account_generation) -} - -/// Retire only the client owned by this loop. Keep the lifecycle write lease -/// while clearing controller presence so a replacement cannot publish its -/// presence and then have it erased by the old loop's cleanup. -async fn retire_routing_client_if_same(relay_client: &Arc) -> bool { - let _routing_guard = DEVICE_ROUTING_LIFECYCLE.write().await; - let mut current = device_relay_client().write().await; - if !take_routing_client_if_same(&mut current, relay_client) { - return false; - } - drop(current); - crate::peer_host::update_controller_presence(Vec::new()).await; - true -} - -/// Log out: tear down routing, revoke the token (best-effort), clear state. -pub(crate) async fn logout() -> Result<()> { - let _sync_guard = invalidate_and_wait_for_account_sync().await; - stop_device_routing_locked().await; - // Take the always-on daemon down with the account: the token is revoked - // below, so leaving the daemon connected would keep this device online - // with a doomed token until its next reconnect fails. - if crate::daemon::request_daemon_shutdown() { - tracing::info!("Signalled the CLI daemon to shut down after logout"); - } - let result = read_account_context_raw().await; - if let Ok((session, relay_url)) = result { - let _ = AccountClient::new() - .revoke_token(&relay_url, &session) + let fanout_owner = PeerFanoutOwner { + account_generation, + account_token: expected_token.to_string(), + relay_client: Arc::clone(relay_client), + runtime: Arc::downgrade(&self.runtime().expect("bound account runtime")), + routing: Arc::downgrade(self), + }; + ACTIVE_PEER_FANOUT_OWNER + .scope(fanout_owner, async { + self.handle_current_relay_event( + event, + relay_client, + account_generation, + expected_token, + ) + .await; + }) .await; } - *account_context().write().await = None; - PENDING_SYNC_CHOICE.store(false, Ordering::Release); - session_store::clear_session(); - session_store::clear_credential_hint(); - TOKEN_EXPIRED.store(false, Ordering::Relaxed); - Ok(()) -} -/// Handle a single relay event for the device-routing loop. -async fn handle_relay_event( - event: RelayEvent, - account_context: &Arc>>, - relay_client: &Arc, - account_generation: u64, - expected_token: &str, -) { - let event = match event { - RelayEvent::AuthError { message } => { - handle_relay_auth_error( - message, - account_context, - relay_client, - account_generation, - expected_token, - ) - .await; - return; - } - event => event, - }; - - let _routing_lease = DEVICE_ROUTING_LIFECYCLE.read().await; - if !routing_loop_is_current(account_generation, relay_client).await { - tracing::debug!("Ignoring event from a stale device routing client"); - return; - } - let fanout_owner = PeerFanoutOwner { - account_generation, - account_token: expected_token.to_string(), - relay_client: Arc::clone(relay_client), - }; - ACTIVE_PEER_FANOUT_OWNER - .scope(fanout_owner, async { - match event { - RelayEvent::AuthOk { user_id, device_id } => { - tracing::info!("Device routing auth ok: user={user_id} device={device_id}"); - if let Err(e) = DeviceIdentity::adopt_account_device_id(&device_id) { - tracing::warn!("Failed to adopt AuthOk device_id: {e}"); - } else if let Some(context) = account_context.read().await.clone() { - if routing_loop_is_current(account_generation, relay_client).await - && context.session.token == expected_token - { - if let Err(e) = session_store::save_session_with_device( - &context.session.token, - &context.session.user_id, - &context.session.master_key, - &context.relay_url, - Some(device_id.as_str()), - ) { - tracing::warn!( - "Failed to persist AuthOk device_id into session: {e}" - ); - } + async fn handle_current_relay_event( + &self, + event: RelayEvent, + relay_client: &Arc, + account_generation: u64, + expected_token: &str, + ) { + let runtime = match self.runtime() { + Ok(runtime) => runtime, + Err(_) => return, + }; + match event { + RelayEvent::AuthOk { user_id, device_id } => { + tracing::info!("Device routing auth ok: user={user_id} device={device_id}"); + if let Err(error) = DeviceIdentity::adopt_account_device_id(&device_id) { + tracing::warn!("Failed to adopt AuthOk device_id: {error}"); + return; + } + if let Ok((session, relay_url)) = runtime + .read_account_context_for_generation(account_generation) + .await + { + if session.token == expected_token + && self + .routing_loop_is_current(account_generation, relay_client) + .await + { + if let Err(error) = session_store::save_session_with_device( + &session.token, + &session.user_id, + &session.master_key, + &relay_url, + Some(device_id.as_str()), + ) { + tracing::warn!("Failed to persist AuthOk device_id: {error}"); } } } - RelayEvent::AuthError { .. } => { - unreachable!("AuthError handled before routing read lease") + } + RelayEvent::DevicePresence { devices } => { + tracing::info!("Device presence updated: {} online", devices.len()); + if !self + .routing_loop_is_current(account_generation, relay_client) + .await + { + return; } - RelayEvent::DevicePresence { devices } => { - tracing::info!("Device presence updated: {} online", devices.len()); - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - crate::peer_host::update_controller_presence( - devices.into_iter().map(|device| device.device_id).collect(), - ) - .await; - if !routing_loop_is_current(account_generation, relay_client).await { - tracing::debug!("Account changed while applying device presence"); - } + crate::peer_host::update_controller_presence( + devices.into_iter().map(|device| device.device_id).collect(), + ) + .await; + } + RelayEvent::DeviceMessageReceived { + source_device_id, + correlation_id, + encrypted_data, + nonce, + } => { + let Ok((session, _)) = runtime + .read_account_context_for_generation(account_generation) + .await + else { + return; + }; + if session.token != expected_token + || !self + .routing_loop_is_current(account_generation, relay_client) + .await + { + return; } - RelayEvent::DeviceMessageReceived { - source_device_id, - correlation_id, - encrypted_data, - nonce, - } => { - let context = account_context.read().await.clone(); - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - let Some(context) = context else { - return; - }; - if context.session.token != expected_token { + let plaintext = match encryption::decrypt_from_base64( + &session.master_key, + &encrypted_data, + &nonce, + ) { + Ok(plaintext) => plaintext, + Err(error) => { + tracing::warn!("Failed to decrypt device message: {error}"); return; } - let plaintext = match encryption::decrypt_from_base64( - &context.session.master_key, - &encrypted_data, - &nonce, - ) { - Ok(p) => p, - Err(e) => { - tracing::warn!("Failed to decrypt device message: {e}"); - return; - } - }; - use remote_connect::remote_server::{RemoteCommand, RemoteResponse}; - let cmd: RemoteCommand = match serde_json::from_str(&plaintext) { - Ok(c) => c, - Err(e) => { - tracing::warn!("Could not parse device command: {e}"); - return; - } - }; - tracing::info!( - "Device command from {source_device_id}: {cmd:?} corr={correlation_id}" - ); - - if !routing_loop_is_current(account_generation, relay_client).await { + }; + use remote_connect::remote_server::{RemoteCommand, RemoteResponse}; + let command: RemoteCommand = match serde_json::from_str(&plaintext) { + Ok(command) => command, + Err(error) => { + tracing::warn!("Could not parse device command: {error}"); return; } - let response = match &cmd { - RemoteCommand::HostInvoke { command, args } => { - let response = - crate::peer_host::handle_host_invoke(command, args.clone()).await; - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - response - } - RemoteCommand::DeviceEvent { .. } => { - crate::peer_host::handle_device_event_command() - } - other => { - let server = RemoteServer::new(context.session.master_key); - let response = server.dispatch(other).await; - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - response - } - }; - - let resp_json = match serde_json::to_string(&response) { - Ok(s) => s, - Err(e) => { - tracing::warn!("Failed to serialize RPC response: {e}"); - serde_json::to_string(&RemoteResponse::Error { - message: format!("failed to serialize RPC response: {e}"), - }) - .unwrap_or_else(|_| { - r#"{"resp":"error","message":"serialize failed"}"#.to_string() - }) - } - }; - - match encryption::encrypt_to_base64(&context.session.master_key, &resp_json) { - Ok((enc_resp, resp_nonce)) => { - // HTTP RPC bridge expects replies targeted at "rpc". - let reply_target = if source_device_id == "rpc" { - "rpc" - } else { - source_device_id.as_str() - }; - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - let send_result = relay_client - .send_device_message( - reply_target, - &correlation_id, - &enc_resp, - &resp_nonce, - ) - .await; - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - if let Err(e) = send_result { - tracing::warn!("Failed to send RPC response: {e}"); - } - } - Err(e) => { - tracing::warn!("Failed to encrypt RPC response: {e}"); - } - } - } - RelayEvent::Disconnected => { - tracing::info!("Device routing disconnected"); - if !routing_loop_is_current(account_generation, relay_client).await { - return; + }; + tracing::info!( + "Device command from {source_device_id}: {command:?} corr={correlation_id}" + ); + let response = match &command { + RemoteCommand::HostInvoke { command, args } => { + crate::peer_host::handle_host_invoke(command, args.clone()).await } - crate::peer_host::update_controller_presence(Vec::new()).await; - if !routing_loop_is_current(account_generation, relay_client).await { - tracing::debug!("Account changed while clearing device presence"); + RemoteCommand::DeviceEvent { .. } => { + crate::peer_host::handle_device_event_command() } + other => RemoteServer::new(session.master_key).dispatch(other).await, + }; + if !self + .routing_loop_is_current(account_generation, relay_client) + .await + { + return; } - RelayEvent::Reconnected => { - tracing::info!("Device routing reconnected"); - } - RelayEvent::Error { message } => { - tracing::warn!("Device routing error: {message}"); + let response_json = serde_json::to_string(&response).unwrap_or_else(|error| { + serde_json::to_string(&RemoteResponse::Error { + message: format!("failed to serialize RPC response: {error}"), + }) + .unwrap_or_else(|_| { + r#"{"resp":"error","message":"serialize failed"}"#.to_string() + }) + }); + let Ok((encrypted_response, response_nonce)) = + encryption::encrypt_to_base64(&session.master_key, &response_json) + else { + tracing::warn!("Failed to encrypt RPC response"); + return; + }; + let reply_target = if source_device_id == "rpc" { + "rpc" + } else { + source_device_id.as_str() + }; + if let Err(error) = relay_client + .send_device_message( + reply_target, + &correlation_id, + &encrypted_response, + &response_nonce, + ) + .await + { + tracing::warn!("Failed to send RPC response: {error}"); } - _ => {} } + RelayEvent::Disconnected => { + tracing::info!("Device routing disconnected"); + crate::peer_host::update_controller_presence(Vec::new()).await; + } + RelayEvent::Reconnected => tracing::info!("Device routing reconnected"), + RelayEvent::Error { message } => { + tracing::warn!("Device routing error: {message}") + } + RelayEvent::AuthError { .. } => unreachable!("AuthError handled before routing lease"), + _ => {} + } + } + + async fn handle_relay_auth_error( + &self, + message: String, + relay_client: &Arc, + account_generation: u64, + expected_token: &str, + ) { + tracing::warn!("Device routing auth error: {message}"); + { + let _routing_guard = self.lifecycle.write().await; + let mut current = self.relay_client.write().await; + if !take_routing_client_if_same(&mut current, relay_client) { + tracing::debug!("Ignoring auth error from a replaced routing client"); + return; + } + drop(current); + relay_client.disconnect().await; + } + let Ok(runtime) = self.runtime() else { + return; + }; + if runtime + .expire_rejected_context(account_generation, expected_token) + .await + { + crate::peer_host::update_controller_presence(Vec::new()).await; + } + } + + pub(crate) async fn capture_peer_fanout_owner(&self) -> Result { + let runtime = self.runtime()?; + let generation = runtime.account_context_generation(); + let _routing_lease = self.lifecycle.read().await; + let (session, _) = runtime + .read_account_context_for_generation(generation) + .await?; + let relay_client = self + .relay_client + .read() + .await + .clone() + .ok_or_else(|| anyhow!("device routing not connected"))?; + if !self + .routing_loop_is_current(generation, &relay_client) + .await + { + return Err(anyhow!("account context changed")); + } + Ok(PeerFanoutOwner { + account_generation: generation, + account_token: session.token, + relay_client, + runtime: Arc::downgrade(&runtime), + routing: self.self_ref.clone(), }) - .await; + } } -/// Auth failure starts an account transition, which owns the lifecycle write -/// lease. It cannot be handled under the ordinary event read lease because -/// upgrading a Tokio `RwLock` would deadlock. -async fn handle_relay_auth_error( - message: String, - account_context: &Arc>>, - relay_client: &Arc, - account_generation: u64, - expected_token: &str, -) { - tracing::warn!("Device routing auth error: {message}"); - let Some(_transition_guard) = invalidate_and_wait_if_account_current(account_generation).await - else { - tracing::debug!("Ignoring auth error from a stale account generation"); - return; - }; - if !is_current_routing_client(relay_client).await { - tracing::debug!("Ignoring auth error from a replaced routing client"); - return; +#[async_trait] +impl AccountRuntimeHost for CliAccountRoutingHost { + async fn retire_background_routing_owner( + &self, + ) -> std::result::Result { + if !crate::daemon::is_daemon_running() { + return Ok(false); + } + if !crate::daemon::request_daemon_shutdown() { + return Err(BackgroundRoutingOwnerRetirementError { + error: anyhow!("could not stop the CLI daemon; the current account remains active"), + owner_may_exit: false, + }); + } + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while crate::daemon::is_daemon_running() { + if tokio::time::Instant::now() >= deadline { + return Err(BackgroundRoutingOwnerRetirementError { + error: anyhow!( + "CLI daemon did not stop in time; the current account remains active" + ), + owner_may_exit: true, + }); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + Ok(true) } - let token_matches = account_context - .read() - .await - .as_ref() - .is_some_and(|context| context.session.token == expected_token); - if !token_matches || !is_current_routing_client(relay_client).await { - tracing::debug!("Ignoring auth error from a replaced routing client"); - return; + + fn background_routing_owner_is_running(&self) -> bool { + crate::daemon::is_daemon_running() + } + + fn request_background_routing_owner_shutdown(&self) -> bool { + crate::daemon::request_daemon_shutdown() } - // Keep CLI/daemon semantics aligned with Desktop: a relay-rejected token - // is no longer a usable local login and must not be restored again on the - // next process start. Preserve the non-secret hint for the re-login form. - relay_client.disconnect().await; - if !is_current_routing_client(relay_client).await { - tracing::debug!("Ignoring auth error cleanup for a replaced routing client"); - return; + async fn start_device_routing(&self, request: AccountRoutingStartRequest) -> Result<()> { + self.start_routing(request).await } - let mut current_client = device_relay_client().write().await; - if !same_routing_client(current_client.as_ref(), relay_client) { - tracing::debug!("Ignoring auth error cleanup for a replaced routing client"); - return; + async fn stop_device_routing(&self) { + self.stop_routing().await; } - let mut current_context = account_context.write().await; - if current_context - .as_ref().is_none_or(|context| context.session.token != expected_token) - { - tracing::debug!("Ignoring auth error cleanup for a replaced account"); - return; + + fn notify_controllers_settings_changed(&self) { + crate::peer_host::notify_controllers_settings_changed(); } - take_routing_client_if_same(&mut current_client, relay_client); - *current_context = None; - drop(current_context); - drop(current_client); +} + +fn same_routing_client(current: Option<&Arc>, expected: &Arc) -> bool { + current.is_some_and(|client| Arc::ptr_eq(client, expected)) +} - TOKEN_EXPIRED.store(true, Ordering::Relaxed); - PENDING_SYNC_CHOICE.store(false, Ordering::Release); - session_store::clear_session(); - crate::peer_host::update_controller_presence(Vec::new()).await; +fn take_routing_client_if_same(current: &mut Option>, expected: &Arc) -> bool { + if !same_routing_client(current.as_ref(), expected) { + return false; + } + current.take(); + true } -/// Immutable routing owner captured when a Peer DeviceEvent enters the bounded -/// delivery queue. It prevents an event from account A being encrypted or sent -/// through account B after waiting behind older events. +/// Immutable routing owner captured when a Peer DeviceEvent enters the queue. #[derive(Clone)] pub(crate) struct PeerFanoutOwner { account_generation: u64, account_token: String, relay_client: Arc, + runtime: Weak, + routing: Weak, } tokio::task_local! { @@ -1127,6 +584,8 @@ impl PeerFanoutOwner { account_generation, account_token: account_token.to_string(), relay_client: Arc::new(relay_client), + runtime: Weak::new(), + routing: Weak::new(), } } @@ -1136,107 +595,58 @@ impl PeerFanoutOwner { } } -/// Stable fan-out context. The read lease is intentionally retained through -/// encryption and all target sends; account replacement takes the write lease. pub(crate) struct PeerFanoutLease { pub(crate) session: AccountSession, pub(crate) relay_client: Arc, - _routing_lease: tokio::sync::RwLockReadGuard<'static, ()>, -} - -pub(crate) async fn capture_peer_fanout_owner() -> Result { - let generation = account_context_generation(); - let _routing_lease = DEVICE_ROUTING_LIFECYCLE.read().await; - let (session, _) = read_account_context_for_generation(generation).await?; - let client = device_relay_client() - .read() - .await - .clone() - .ok_or_else(|| anyhow!("device routing not connected"))?; - if !account_context_is_current(generation) || !is_current_routing_client(&client).await { - return Err(anyhow!("account context changed")); - } - Ok(PeerFanoutOwner { - account_generation: generation, - account_token: session.token, - relay_client: client, - }) + _routing_lease: tokio::sync::OwnedRwLockReadGuard<()>, } pub(crate) async fn acquire_peer_fanout_lease(owner: &PeerFanoutOwner) -> Result { - let routing_lease = DEVICE_ROUTING_LIFECYCLE.read().await; - if !account_context_is_current(owner.account_generation) { + let runtime = owner + .runtime + .upgrade() + .ok_or_else(|| anyhow!("account runtime stopped"))?; + let routing = owner + .routing + .upgrade() + .ok_or_else(|| anyhow!("account routing stopped"))?; + let routing_lease = routing.lifecycle.clone().read_owned().await; + if !runtime.account_context_is_current(owner.account_generation) { return Err(anyhow!("queued Peer event account changed")); } - let context = account_context() - .read() - .await - .clone() - .ok_or_else(|| anyhow!("not logged in"))?; - let client = device_relay_client() + let (session, _) = runtime + .read_account_context_for_generation(owner.account_generation) + .await?; + let client = routing + .relay_client .read() .await .clone() .ok_or_else(|| anyhow!("device routing not connected"))?; - if !account_context_is_current(owner.account_generation) - || !owner.matches( - account_context_generation(), - &context.session.token, - &client, - ) - { + if !owner.matches( + runtime.account_context_generation(), + &session.token, + &client, + ) { return Err(anyhow!("queued Peer event routing owner changed")); } Ok(PeerFanoutLease { - session: context.session, + session, relay_client: client, _routing_lease: routing_lease, }) } -/// A textual device listing entry for display. -pub(crate) struct AccountDevice { - pub(crate) device_id: String, - pub(crate) device_name: String, - pub(crate) online: bool, -} - -/// List all devices in the account. -pub(crate) async fn list_devices() -> Result> { - let (session, relay_url) = read_account_context().await?; - let devices = AccountClient::new() - .list_devices(&relay_url, &session) - .await?; - Ok(devices - .into_iter() - .map(|d| AccountDevice { - device_id: d.device_id, - device_name: d.device_name, - online: d.online, - }) - .collect()) -} - #[cfg(test)] mod tests { - use std::sync::Arc; + use super::*; use std::time::Duration; - use super::{ - account_context_generation, automatic_account_sync_policy_for_pending, - begin_candidate_account_transition, clear_replaced_persisted_session, - inherited_peer_fanout_owner, login_with_credentials, replaced_account_revocation_target, - resolve_cloud_settings_probe, take_routing_client_if_same, AccountContextState, - CandidateAccountTransitionError, DaemonRetirementFailure, PeerFanoutOwner, - ACCOUNT_LOGIN_LOCK, ACTIVE_PEER_FANOUT_OWNER, DEVICE_ROUTING_LIFECYCLE, - }; - #[test] fn stale_routing_loop_cannot_clear_replacement_client() { let stale = Arc::new("stale"); let replacement = Arc::new("replacement"); let mut current = Some(Arc::clone(&replacement)); - assert!(!take_routing_client_if_same(&mut current, &stale)); assert!(current .as_ref() @@ -1244,226 +654,35 @@ mod tests { } #[test] - fn routing_loop_can_clear_only_its_own_client() { - let owned = Arc::new("owned"); - let mut current = Some(Arc::clone(&owned)); - - assert!(take_routing_client_if_same(&mut current, &owned)); - assert!(current.is_none()); + fn queued_fanout_owner_requires_generation_token_and_client_identity() { + let owner = PeerFanoutOwner::for_test(11, "token-a"); + let owned_client = Arc::clone(&owner.relay_client); + let replacement = PeerFanoutOwner::for_test(12, "token-b"); + assert!(owner.matches(11, "token-a", &owned_client)); + assert!(!owner.matches(12, "token-a", &owned_client)); + assert!(!owner.matches(11, "token-b", &owned_client)); + assert!(!owner.matches(11, "token-a", &replacement.relay_client)); } #[tokio::test] - async fn routing_replacement_waits_for_in_flight_event_lease() { - let event_lease = DEVICE_ROUTING_LIFECYCLE.read().await; + async fn routing_replacement_waits_for_an_in_flight_event_lease() { + let routing = CliAccountRoutingHost::new(); + let event_lease = routing.lifecycle.read().await; + let lifecycle = routing.lifecycle.clone(); let (attempting_tx, attempting_rx) = tokio::sync::oneshot::channel(); let replacement = tokio::spawn(async move { let _ = attempting_tx.send(()); - let _replacement_lease = DEVICE_ROUTING_LIFECYCLE.write().await; + let _replacement_lease = lifecycle.write().await; }); attempting_rx.await.expect("replacement task started"); tokio::task::yield_now().await; assert!(!replacement.is_finished()); - drop(event_lease); - tokio::time::timeout(Duration::from_secs(1), replacement) - .await - .expect("replacement should acquire the lifecycle after event completion") - .expect("replacement task should finish"); - } - #[tokio::test] - async fn inherited_fanout_owner_does_not_reacquire_routing_read_lease() { - let event_lease = DEVICE_ROUTING_LIFECYCLE.read().await; - let (attempting_tx, attempting_rx) = tokio::sync::oneshot::channel(); - let replacement = tokio::spawn(async move { - let _ = attempting_tx.send(()); - let _replacement_lease = DEVICE_ROUTING_LIFECYCLE.write().await; - }); - - attempting_rx.await.expect("replacement task started"); - tokio::task::yield_now().await; - assert!(!replacement.is_finished()); - - let owner = PeerFanoutOwner::for_test(21, "token-a"); - let inherited = tokio::time::timeout( - Duration::from_millis(100), - ACTIVE_PEER_FANOUT_OWNER.scope(owner, async { inherited_peer_fanout_owner() }), - ) - .await - .expect("inherited owner lookup must not wait behind the queued writer") - .expect("task-local owner should be visible"); - assert_eq!(inherited.generation_for_test(), 21); - assert!(!replacement.is_finished()); - - drop(event_lease); tokio::time::timeout(Duration::from_secs(1), replacement) .await - .expect("replacement should proceed after the outer event lease is released") + .expect("replacement should acquire the lifecycle after event completion") .expect("replacement task should finish"); } - - #[tokio::test] - async fn invalid_login_does_not_invalidate_the_current_account() { - let generation = account_context_generation(); - - let error = login_with_credentials("", "user", "password") - .await - .expect_err("empty relay URL must be rejected"); - - assert!(error.to_string().contains("Auth Server is required")); - assert_eq!(account_context_generation(), generation); - } - - #[test] - fn cloud_settings_probe_errors_are_not_treated_as_missing_settings() { - assert!(!resolve_cloud_settings_probe(Ok(None)).expect("missing settings is valid")); - assert!( - resolve_cloud_settings_probe(Ok(Some("encrypted settings".to_string()))) - .expect("existing settings is valid") - ); - - let error = resolve_cloud_settings_probe(Err(anyhow::anyhow!("relay unavailable"))) - .expect_err("probe failure must reject the candidate login"); - assert!(error.to_string().contains("could not check cloud settings")); - assert!(error.to_string().contains("relay unavailable")); - } - - #[test] - fn pending_sync_choice_blocks_automatic_pull_and_push_until_finalized() { - let pending = automatic_account_sync_policy_for_pending(true); - assert!(!pending.background_engine); - assert!(!pending.management_push); - - let finalized = automatic_account_sync_policy_for_pending(false); - assert!(finalized.background_engine); - assert!(finalized.management_push); - } - - #[tokio::test] - async fn daemon_retirement_failure_does_not_begin_account_transition() { - let generation = account_context_generation(); - let result = begin_candidate_account_transition( - generation, - Err(DaemonRetirementFailure { - error: anyhow::anyhow!("daemon stayed alive"), - daemon_may_exit: true, - }), - ) - .await; - - assert!(matches!( - result, - Err(CandidateAccountTransitionError::DaemonRetirement(_)) - )); - assert_eq!(account_context_generation(), generation); - } - - #[test] - fn pending_replacement_cannot_restore_the_previous_persisted_account() { - let directory = std::env::temp_dir().join(format!( - "bitfun-cli-account-session-{}", - uuid::Uuid::new_v4() - )); - bitfun_core::service::remote_connect::session_store::set_session_store_directory_for_test( - directory, - ); - let old_master_key = [7_u8; 32]; - bitfun_core::service::remote_connect::session_store::save_session_with_device( - "account-a-token", - "account-a", - &old_master_key, - "https://relay-a.example", - Some("device-a"), - ) - .expect("persist account A"); - assert_eq!( - bitfun_core::service::remote_connect::session_store::load_session_detailed() - .expect("load account A") - .expect("account A should be persisted") - .token, - "account-a-token" - ); - - // This is the disk step used after candidate B wins the transition and - // before B is exposed as awaiting its cloud/local sync choice. - clear_replaced_persisted_session(); - - assert!( - bitfun_core::service::remote_connect::session_store::load_session_detailed() - .expect("load after candidate B becomes pending") - .is_none() - ); - } - - #[test] - fn replacement_revokes_only_the_previous_distinct_token() { - let previous = AccountContextState { - session: bitfun_core::service::remote_connect::AccountSession { - token: "old-token".to_string(), - user_id: "same-account".to_string(), - master_key: [3_u8; 32], - }, - relay_url: "https://relay.example".to_string(), - }; - - let target = replaced_account_revocation_target( - Some(previous.clone()), - "https://relay.example", - "new-token", - ) - .expect("a new token for the same account must retire the old bearer"); - assert_eq!(target.session.token, "old-token"); - assert_eq!(target.session.user_id, "same-account"); - assert_eq!(target.relay_url, "https://relay.example"); - - assert!(replaced_account_revocation_target( - Some(previous.clone()), - "https://relay.example", - "old-token" - ) - .is_none()); - assert!(replaced_account_revocation_target( - Some(previous), - "https://other-relay.example", - "old-token" - ) - .is_some()); - assert!( - replaced_account_revocation_target(None, "https://relay.example", "new-token") - .is_none() - ); - } - - #[tokio::test] - async fn candidate_login_attempts_are_serialized() { - let first_candidate = ACCOUNT_LOGIN_LOCK.lock().await; - let (attempting_tx, attempting_rx) = tokio::sync::oneshot::channel(); - let second_candidate = tokio::spawn(async move { - let _ = attempting_tx.send(()); - let _guard = ACCOUNT_LOGIN_LOCK.lock().await; - }); - - attempting_rx.await.expect("second candidate started"); - tokio::task::yield_now().await; - assert!(!second_candidate.is_finished()); - - drop(first_candidate); - tokio::time::timeout(Duration::from_secs(1), second_candidate) - .await - .expect("second candidate should proceed after the first") - .expect("second candidate task should finish"); - } - - #[test] - fn queued_fanout_owner_requires_generation_token_and_client_identity() { - let owner = PeerFanoutOwner::for_test(11, "token-a"); - let owned_client = Arc::clone(&owner.relay_client); - let replacement = PeerFanoutOwner::for_test(12, "token-b"); - - assert!(owner.matches(11, "token-a", &owned_client)); - assert!(!owner.matches(12, "token-a", &owned_client)); - assert!(!owner.matches(11, "token-b", &owned_client)); - assert!(!owner.matches(11, "token-a", &replacement.relay_client)); - } } diff --git a/src/apps/cli/src/account_sync.rs b/src/apps/cli/src/account_sync.rs deleted file mode 100644 index 01119db9ed..0000000000 --- a/src/apps/cli/src/account_sync.rs +++ /dev/null @@ -1,507 +0,0 @@ -//! CLI account auto-sync (settings + session upload), matching Desktop semantics. - -use std::path::{Path, PathBuf}; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, OnceLock, -}; - -use anyhow::{anyhow, Result}; -use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; - -use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; -use bitfun_core::service::config::get_global_config_service; -use bitfun_core::service::remote_connect::account::{ - ensure_relay_session_history_exportable, relay_session_export_metadata, -}; -use bitfun_core::service::remote_connect::settings_sync; -use bitfun_core::service::remote_connect::{sync_state, AccountClient}; - -use crate::account::{ - account_context_generation, account_context_is_current, automatic_account_sync_policy, - await_account_sync_current, lock_account_sync, read_account_context, -}; - -const UPLOAD_CONCURRENCY_CHUNK: usize = 5; - -/// Start the continuous settings sync loop (debounced push + 30s pull). -/// Started once per process (interactive TUI and daemon); every cycle -/// silently skips while logged out and converges as soon as an account -/// session exists. Peer Mode controllers are notified via DeviceEvent when -/// this host's effective settings change. -pub(crate) fn start_settings_sync_loop() { - let hooks = settings_sync::SettingsSyncHooks { - account_context: Some(Arc::new(|| { - Box::pin(async { - if !automatic_account_sync_policy().background_engine { - return Err(anyhow!("account login is awaiting a sync choice")); - } - let generation = account_context_generation(); - if !account_context_is_current(generation) { - return Err(anyhow!("account context is transitioning")); - } - let (account, relay_url) = read_account_context().await?; - if !automatic_account_sync_policy().background_engine - || !account_context_is_current(generation) - { - return Err(anyhow!("account context changed while reading")); - } - Ok((account, relay_url, generation)) - }) - })), - is_account_context_current: Some(Arc::new(account_context_is_current)), - on_settings_applied: Some(Arc::new(|| { - crate::peer_host::notify_controllers_settings_changed(); - })), - on_settings_pushed: Some(Arc::new(|| { - crate::peer_host::notify_controllers_settings_changed(); - })), - on_token_expired: Some(Arc::new(crate::account::mark_token_expired)), - ..Default::default() - }; - settings_sync::start_settings_sync_engine(hooks); -} - -/// Notify the sync loop that local settings changed (TUI edits, peer -/// `set_config`). Upload is debounced and content-hash deduped. -pub(crate) fn notify_local_settings_changed() { - settings_sync::notify_settings_changed(); -} - -/// Best-effort one-shot settings push for short-lived management commands -/// (e.g. `bitfun models set-default`) where the sync loop never starts. -/// Silently no-ops when logged out; failures are logged, not fatal. -pub(crate) async fn push_settings_after_local_change() { - if !automatic_account_sync_policy().management_push { - return; - } - // Management commands never restore the persisted account session into - // memory — do it on demand so the push can authenticate. - if read_account_context().await.is_err() { - crate::account::try_restore_session().await; - } - let generation = account_context_generation(); - let Ok(_sync_guard) = lock_account_sync(generation).await else { - return; - }; - if !automatic_account_sync_policy().management_push { - return; - } - let Ok((account, relay_url)) = read_account_context().await else { - return; - }; - match settings_sync::push_settings_now(&account, &relay_url).await { - Ok(true) => tracing::info!("Settings pushed to account cloud"), - Ok(false) => {} - Err(e) => tracing::warn!("Settings push failed: {e}"), - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(crate) enum SyncStatus { - #[default] - Idle, - Syncing, - Done, - Failed, -} - -#[derive(Debug, Clone)] -pub(crate) struct SyncProgress { - pub status: SyncStatus, - pub phase: String, - pub percent: u8, - pub current: Option, - pub total: Option, - pub detail: Option, - pub error: Option, - pub settings_synced: bool, - pub sessions_exported: usize, -} - -impl Default for SyncProgress { - fn default() -> Self { - Self { - status: SyncStatus::Idle, - phase: String::new(), - percent: 0, - current: None, - total: None, - detail: None, - error: None, - settings_synced: false, - sessions_exported: 0, - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct AutoSyncResult { - pub settings_synced: bool, - pub sessions_exported: usize, - #[allow(dead_code)] - pub sessions_imported: usize, -} - -#[derive(Serialize, Deserialize)] -struct SessionBundle { - session_id: String, - metadata: serde_json::Value, - turns: Vec, - source_device_id: Option, - source_device_name: Option, -} - -static SYNC_PROGRESS: OnceLock>> = OnceLock::new(); -static AUTO_SYNC_IN_FLIGHT: AtomicBool = AtomicBool::new(false); - -fn sync_progress_store() -> &'static Arc> { - SYNC_PROGRESS.get_or_init(|| Arc::new(RwLock::new(SyncProgress::default()))) -} - -pub(crate) async fn current_sync_progress() -> SyncProgress { - sync_progress_store().read().await.clone() -} - -pub(crate) fn sync_in_flight() -> bool { - AUTO_SYNC_IN_FLIGHT.load(Ordering::SeqCst) -} - -async fn set_progress(mut update: impl FnMut(&mut SyncProgress)) { - let mut guard = sync_progress_store().write().await; - update(&mut guard); -} - -async fn emit_progress( - phase: &str, - percent: u8, - current: Option, - total: Option, - detail: Option<&str>, -) { - set_progress(|p| { - p.status = SyncStatus::Syncing; - p.phase = phase.to_string(); - p.percent = percent; - p.current = current; - p.total = total; - p.detail = detail.map(|s| s.to_string()); - p.error = None; - }) - .await; -} - -/// Start auto-sync in the background. Returns immediately; progress is in -/// [`current_sync_progress`]. -pub(crate) fn start_auto_sync_background( - compatibility: CoreAgentRuntimeCompatibility, - is_first_login: bool, - workspace_path: PathBuf, -) { - if AUTO_SYNC_IN_FLIGHT.swap(true, Ordering::SeqCst) { - tracing::warn!("Account auto-sync already in flight; skipping duplicate start"); - return; - } - tokio::spawn(async move { - let result = run_auto_sync(&compatibility, is_first_login, &workspace_path).await; - AUTO_SYNC_IN_FLIGHT.store(false, Ordering::SeqCst); - match result { - Ok(r) => { - set_progress(|p| { - p.status = SyncStatus::Done; - p.phase = "done".into(); - p.percent = 100; - p.settings_synced = r.settings_synced; - p.sessions_exported = r.sessions_exported; - p.error = None; - }) - .await; - } - Err(e) => { - set_progress(|p| { - p.status = SyncStatus::Failed; - p.error = Some(e.to_string()); - }) - .await; - tracing::warn!("Account auto-sync failed: {e}"); - } - } - }); -} - -pub(crate) async fn run_auto_sync( - compatibility: &CoreAgentRuntimeCompatibility, - is_first_login: bool, - workspace_path: &Path, -) -> Result { - let generation = account_context_generation(); - let _sync_guard = lock_account_sync(generation).await?; - set_progress(|p| { - *p = SyncProgress { - status: SyncStatus::Syncing, - phase: "starting".into(), - percent: 1, - ..SyncProgress::default() - }; - }) - .await; - - let (acct_session, relay_url) = read_account_context().await?; - let client = AccountClient::new(); - - let settings_synced = if is_first_login { - emit_progress("uploading_settings", 5, None, None, None).await; - let config_service = get_global_config_service() - .await - .map_err(|e| anyhow!("config service: {e}"))?; - let exported = config_service - .export_config() - .await - .map_err(|e| anyhow!("export config: {e}"))?; - let config_json = - serde_json::to_string(&exported).map_err(|e| anyhow!("serialize config: {e}"))?; - await_account_sync_current( - generation, - settings_sync::upload_settings_payload(&acct_session, &relay_url, &config_json), - ) - .await? - .map_err(|e| anyhow!("upload settings: {e}"))?; - emit_progress("settings_done", 15, None, None, None).await; - true - } else { - emit_progress("downloading_settings", 5, None, None, None).await; - let cloud = await_account_sync_current( - generation, - client.fetch_settings_with_version(&relay_url, &acct_session), - ) - .await? - .map_err(|e| anyhow!("fetch settings: {e}"))?; - if let Some(blob) = cloud { - emit_progress("applying_settings", 10, None, None, None).await; - // Explicit user choice ("use cloud") — always apply, even when the - // cursor says this device already has this version. - await_account_sync_current( - generation, - settings_sync::apply_settings_blob(&acct_session, &blob, true), - ) - .await? - .map_err(|e| anyhow!("apply cloud config: {e}"))?; - emit_progress("settings_done", 15, None, None, None).await; - true - } else { - emit_progress("settings_done", 15, None, None, None).await; - false - } - }; - - emit_progress("listing_sessions", 18, None, None, None).await; - let storage_path = workspace_path.to_path_buf(); - - let local_sessions = compatibility - .list_persisted_sessions(&storage_path) - .await - .map_err(|e| anyhow!("list sessions: {e}"))?; - - emit_progress( - "exporting_sessions", - 20, - Some(0), - Some(local_sessions.len()), - None, - ) - .await; - - let mut sync_state_local = sync_state::load(&acct_session.user_id); - let mut pending_uploads: Vec<(String, String, String)> = Vec::new(); - for meta in local_sessions.iter() { - if !account_context_is_current(generation) { - return Err(anyhow!("account sync cancelled")); - } - if let Err(error) = ensure_relay_session_history_exportable(meta) { - tracing::debug!("Skipping CLI account session export: {error}"); - continue; - } - let turns = compatibility - .load_persisted_session_turns(&storage_path, &meta.session_id, None) - .await - .map_err(|e| anyhow!("load turns: {e}"))?; - let metadata = relay_session_export_metadata(meta, turns.len()); - let metadata_json = - serde_json::to_value(metadata).map_err(|e| anyhow!("serialize metadata: {e}"))?; - let turns_json: Vec = turns - .iter() - .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null)) - .collect(); - let bundle = SessionBundle { - session_id: meta.session_id.clone(), - metadata: metadata_json, - turns: turns_json, - source_device_id: None, - source_device_name: None, - }; - let bundle_json = - serde_json::to_string(&bundle).map_err(|e| anyhow!("serialize bundle: {e}"))?; - let hash = sync_state::content_hash(&bundle_json); - if sync_state_local.uploaded_hash(&meta.session_id) == Some(hash.as_str()) { - continue; - } - pending_uploads.push((meta.session_id.clone(), bundle_json, hash)); - } - - let upload_total = pending_uploads.len(); - emit_progress("exporting_sessions", 20, Some(0), Some(upload_total), None).await; - - let mut uploaded: Vec<(String, String, i64)> = Vec::new(); - let mut upload_errors: Vec = Vec::new(); - for (chunk_idx, chunk) in pending_uploads.chunks(UPLOAD_CONCURRENCY_CHUNK).enumerate() { - let mut handles = Vec::new(); - for (session_id, bundle_json, hash) in chunk { - let client = AccountClient::new(); - let relay_url = relay_url.clone(); - let acct_session = acct_session.clone(); - let session_id = session_id.clone(); - let bundle_json = bundle_json.clone(); - let hash = hash.clone(); - handles.push(tokio::spawn(async move { - let result = await_account_sync_current( - generation, - client.upload_session(&relay_url, &acct_session, &session_id, &bundle_json), - ) - .await; - (session_id, hash, result) - })); - } - for handle in handles { - let done_base = chunk_idx * UPLOAD_CONCURRENCY_CHUNK; - match handle.await { - Ok((session_id, hash, Ok(Ok(version)))) => { - uploaded.push((session_id.clone(), hash, version)); - let done = uploaded.len(); - let percent = if upload_total == 0 { - 95u8 - } else { - 20 + ((75 * done) / upload_total) as u8 - }; - emit_progress( - "exporting_sessions", - percent.min(95), - Some(done), - Some(upload_total), - Some(&session_id), - ) - .await; - } - Ok((session_id, _, Ok(Err(e)))) => { - tracing::warn!("Auto-sync upload {session_id} failed: {e}"); - upload_errors.push(format!("{session_id}: {e}")); - let _ = done_base; - } - Ok((_, _, Err(e))) => return Err(e), - Err(e) => { - tracing::warn!("Auto-sync upload task join failed: {e}"); - upload_errors.push(format!("upload task join failed: {e}")); - } - } - } - if !account_context_is_current(generation) { - return Err(anyhow!("account sync cancelled")); - } - } - - let exported = uploaded.len(); - let mut max_uploaded_version = sync_state_local.last_session_since; - for (session_id, hash, version) in uploaded { - sync_state_local.set_uploaded_hash(&session_id, hash); - if version > max_uploaded_version { - max_uploaded_version = version; - } - } - if max_uploaded_version > sync_state_local.last_session_since { - sync_state_local.last_session_since = max_uploaded_version; - } - let _ = sync_state::save(&acct_session.user_id, &sync_state_local); - - ensure_session_backup_complete(upload_total, exported, &upload_errors)?; - - tracing::info!("Auto-sync: settings={settings_synced} exported={exported} imported=0"); - emit_progress("done", 100, Some(exported), Some(0), None).await; - - Ok(AutoSyncResult { - settings_synced, - sessions_exported: exported, - sessions_imported: 0, - }) -} - -fn ensure_session_backup_complete( - total: usize, - uploaded: usize, - upload_errors: &[String], -) -> Result<()> { - if uploaded == total { - return Ok(()); - } - let detail = upload_errors - .first() - .map(|err| err.as_str()) - .unwrap_or("retry will resume remaining sessions"); - Err(anyhow!( - "session backup incomplete: uploaded {uploaded} of {total}; {detail}" - )) -} - -pub(crate) fn sync_phase_label(progress: &SyncProgress) -> String { - match progress.phase.as_str() { - "uploading_settings" => "Uploading settings…".into(), - "downloading_settings" => "Downloading settings…".into(), - "applying_settings" => "Applying cloud settings…".into(), - "settings_done" => "Settings sync done".into(), - "listing_sessions" => "Listing local sessions…".into(), - "exporting_sessions" => { - if let (Some(c), Some(t)) = (progress.current, progress.total) { - format!("Uploading sessions ({c}/{t})…") - } else { - "Uploading sessions…".into() - } - } - "done" => format!("Sync complete (exported {})", progress.sessions_exported), - "starting" => "Starting sync…".into(), - other if other.is_empty() => "Sync".into(), - other => other.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::ensure_session_backup_complete; - - #[test] - fn partial_session_backup_is_not_reported_as_success() { - assert!(ensure_session_backup_complete(4, 4, &[]).is_ok()); - assert!(ensure_session_backup_complete( - 4, - 1, - &["s1: relay returned HTTP 507 Insufficient Storage".into()] - ) - .unwrap_err() - .to_string() - .contains("HTTP 507")); - } - - #[test] - fn cli_session_backup_uses_the_shared_import_guard_and_visible_count() { - let source = include_str!("account_sync.rs").replace("\r\n", "\n"); - let export_loop = source - .split_once("for meta in local_sessions.iter()") - .expect("CLI account Session export loop") - .1 - .split_once("let upload_total = pending_uploads.len()") - .expect("CLI account Session export loop boundary") - .0; - - assert!(export_loop.contains("ensure_relay_session_history_exportable(meta)")); - assert!(export_loop.contains("relay_session_export_metadata(meta, turns.len())")); - assert!(export_loop.contains("pending_uploads.push")); - } -} diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index fa49ea017a..c85e359182 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -143,62 +143,65 @@ pub(crate) fn shared_tui_image_attachment_error() -> String { format!("Image attachments are unavailable in Shared TUI. {SHARED_TUI_EMBEDDED_HANDOFF}.") } pub(crate) const SHARED_TUI_HELP_NOTE: &str = - "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Use `/sessions` and Ctrl+D to delete an idle, non-current Session; use `View subagents` in the command palette to inspect this Session's subagents; use `/timeline` to navigate user messages, `/fork` to branch the current idle Session, `/rename ` to rename it, `/compact` to compact its context, `/diff` to review workspace changes, `/agent`, Tab, or Shift+Tab to change its Agent mode, `/models` to change its model, and `/reload [skills|instructions]` to refresh declarative context for the next message. Model configuration, Agent/Subagent management, MCP, extension, account-sync, usage, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`."; + "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Use `/sessions` and Ctrl+D to delete an idle, non-current Session; use `View subagents` in the command palette to inspect this Session's subagents; use `/timeline` to navigate user messages, `/fork` to branch the current idle Session, `/rename ` to rename it, `/compact` to compact its context, `/diff` to review workspace changes, `/agent`, Tab, or Shift+Tab to change its Agent mode, `/models` and `/connect` to manage models, `/skills` to manage skills, `/mcp` to manage MCP servers, and `/reload [skills|instructions]` to refresh declarative context for the next message. Model, Skill, Subagent, and MCP management use this CLI process's local compatibility owner; MCP process state and tool registration are local to this CLI process and do not reconfigure an already-running Shared Runtime Host. Extensions, account-sync, usage, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`."; impl ActionHandler { - pub(crate) const fn available_in_shared_tui(self, context: ActionContext) -> bool { - (matches!(self, Self::SelectModel) && matches!(context, ActionContext::Chat)) - || matches!( - self, - Self::Help - | Self::SelectTheme - | Self::NewSession - | Self::Sessions - | Self::ViewSubagents - | Self::Timeline - | Self::ForkSession - | Self::UndoSession - | Self::RedoSession - | Self::RenameSession - | Self::AcpHelp - | Self::Init - | Self::Status - | Self::WorkspaceDiff - | Self::CompactSession - | Self::Editor - | Self::PromptStash - | Self::PromptStashPop - | Self::PromptStashList - | Self::ToggleTimestamps - | Self::ToggleThinking - | Self::ToggleToolDetails - | Self::CopyTranscript - | Self::ExportTranscript - | Self::ToggleAutoApprove - | Self::OpenAgentSelector - | Self::SwitchAgent - | Self::SwitchAgentReverse - | Self::Reload - | Self::Exit - | Self::OpenPalette - | Self::SubmitInput - | Self::Interrupt - | Self::ClosePopups - | Self::NavigateBack - | Self::InsertNewline - | Self::Paste - | Self::ToggleFocusedTool - | Self::PreviousTool - | Self::NextTool - | Self::HistoryPrevious - | Self::HistoryNext - | Self::JumpTop - | Self::JumpBottom - | Self::ClearInput - | Self::ToggleBrowse - | Self::ScrollUp - | Self::ScrollDown - ) + pub(crate) const fn available_in_shared_tui(self, _context: ActionContext) -> bool { + matches!( + self, + Self::Help + | Self::SelectTheme + | Self::NewSession + | Self::Sessions + | Self::ViewSubagents + | Self::SelectModel + | Self::AddModel + | Self::Skills + | Self::McpServers + | Self::Timeline + | Self::ForkSession + | Self::UndoSession + | Self::RedoSession + | Self::RenameSession + | Self::AcpHelp + | Self::Init + | Self::Status + | Self::WorkspaceDiff + | Self::CompactSession + | Self::Editor + | Self::PromptStash + | Self::PromptStashPop + | Self::PromptStashList + | Self::ToggleTimestamps + | Self::ToggleThinking + | Self::ToggleToolDetails + | Self::CopyTranscript + | Self::ExportTranscript + | Self::ToggleAutoApprove + | Self::OpenAgentSelector + | Self::SwitchAgent + | Self::SwitchAgentReverse + | Self::Reload + | Self::Exit + | Self::OpenPalette + | Self::SubmitInput + | Self::Interrupt + | Self::ClosePopups + | Self::NavigateBack + | Self::InsertNewline + | Self::Paste + | Self::ToggleFocusedTool + | Self::PreviousTool + | Self::NextTool + | Self::HistoryPrevious + | Self::HistoryNext + | Self::JumpTop + | Self::JumpBottom + | Self::ClearInput + | Self::ToggleBrowse + | Self::ScrollUp + | Self::ScrollDown + ) } const fn available_in_lineage_inspection(self) -> bool { @@ -583,9 +586,9 @@ static ACTION_SPECS: &[ActionSpec] = &[ }, ActionSpec { id: "extensions", - name: "External integrations", + name: "Extensions", aliases: &["/extensions"], - description: "View external source status and Safe Mode", + description: "View and manage extensions", contexts: CHAT, availability: ActionAvailability::Always, handler: ActionHandler::Extensions, @@ -600,7 +603,7 @@ static ACTION_SPECS: &[ActionSpec] = &[ id: "hooks", name: "Hooks", aliases: &["/hooks"], - description: "Review and manage native and imported Hooks", + description: "View and manage Hooks", contexts: CHAT, availability: ActionAvailability::Always, handler: ActionHandler::NativeHooks, @@ -622,7 +625,7 @@ static ACTION_SPECS: &[ActionSpec] = &[ default_bindings: &[], fallback_bindings: &[], shortcut_field: None, - palette: palette("Tools", false), + palette: None, shortcut_label: None, slash_on_startup: false, }, @@ -1324,6 +1327,7 @@ pub(crate) fn slash_actions(state: ActionState) -> Vec { .filter(|spec| { spec.available(state) && !spec.aliases.is_empty() + && spec.id != "hooks_external" && (state.context != ActionContext::Startup || spec.slash_on_startup) }) .flat_map(|spec| { @@ -2108,7 +2112,7 @@ mod tests { use super::*; #[test] - fn shared_tui_supports_current_session_model_selection_without_model_management() { + fn shared_tui_exposes_local_compatibility_management() { assert!(ActionHandler::Sessions.available_in_shared_tui(ActionContext::Chat)); assert!(ActionHandler::Interrupt.available_in_shared_tui(ActionContext::Chat)); for action in [ @@ -2116,6 +2120,9 @@ mod tests { ActionHandler::SwitchAgent, ActionHandler::SwitchAgentReverse, ActionHandler::SelectModel, + ActionHandler::AddModel, + ActionHandler::Skills, + ActionHandler::McpServers, ActionHandler::RenameSession, ] { assert!( @@ -2124,7 +2131,6 @@ mod tests { ); } for action in [ - ActionHandler::McpServers, ActionHandler::Tools, ActionHandler::Extensions, ActionHandler::NativeHooks, @@ -2137,17 +2143,25 @@ mod tests { "{action:?}" ); } - assert!(!ActionHandler::SelectModel.available_in_shared_tui(ActionContext::Startup)); + assert!(ActionHandler::SelectModel.available_in_shared_tui(ActionContext::Startup)); + assert!(ActionHandler::AddModel.available_in_shared_tui(ActionContext::Startup)); + assert!(ActionHandler::Skills.available_in_shared_tui(ActionContext::Startup)); + assert!(ActionHandler::McpServers.available_in_shared_tui(ActionContext::Startup)); assert!(SHARED_TUI_HELP_NOTE.contains("bitfun chat --shared")); assert!(SHARED_TUI_HELP_NOTE.contains("one Session")); assert!(SHARED_TUI_HELP_NOTE.contains("`/models`")); + assert!(SHARED_TUI_HELP_NOTE.contains("`/connect`")); + assert!(SHARED_TUI_HELP_NOTE.contains("`/skills`")); + assert!(SHARED_TUI_HELP_NOTE.contains("`/mcp`")); assert!(SHARED_TUI_HELP_NOTE.contains("`View subagents`")); assert!(SHARED_TUI_HELP_NOTE.contains("`/fork`")); assert!(SHARED_TUI_HELP_NOTE.contains("`/rename `")); assert!(SHARED_TUI_HELP_NOTE.contains("`/reload [skills|instructions]`")); assert!(SHARED_TUI_HELP_NOTE.contains("Ctrl+D")); assert!(SHARED_TUI_HELP_NOTE.contains("idle, non-current Session")); - assert!(SHARED_TUI_HELP_NOTE.contains("Agent/Subagent management")); + assert!(SHARED_TUI_HELP_NOTE.contains("local compatibility owner")); + assert!(SHARED_TUI_HELP_NOTE + .contains("do not reconfigure an already-running Shared Runtime Host")); assert!(SHARED_TUI_HELP_NOTE.contains("remain Embedded")); } @@ -2306,7 +2320,7 @@ mod tests { } #[test] - fn shared_tui_projections_hide_embedded_management_actions() { + fn shared_tui_projections_expose_compatibility_management_actions() { let state = ActionState::chat(false, false).for_shared_tui(); let slash_ids = slash_actions(state) .into_iter() @@ -2317,17 +2331,25 @@ mod tests { .map(|action| action.id) .collect::>(); - for unavailable in ["skills", "mcp_servers", "extensions", "hooks", "usage"] { + for unavailable in ["tools", "extensions", "hooks", "usage"] { assert!(!slash_ids.contains(&unavailable), "{unavailable}"); assert!(!palette_ids.contains(&unavailable), "{unavailable}"); } - for available in ["new_session", "sessions", "theme", "help", "exit"] { + for available in [ + "new_session", + "sessions", + "select_model", + "add_model", + "skills", + "mcp_servers", + "theme", + "help", + "exit", + ] { assert!(palette_ids.contains(&available), "{available}"); } assert!(slash_ids.contains(&"switch_agent")); assert!(palette_ids.contains(&"switch_agent")); - assert!(slash_ids.contains(&"select_model")); - assert!(palette_ids.contains(&"select_model")); assert!(slash_ids.contains(&"rename_session")); assert!(slash_ids.contains(&"reload")); assert!(!slash_ids.contains(&"reload_skills")); @@ -2350,7 +2372,7 @@ mod tests { ); let startup_state = ActionState::startup(false).for_shared_tui(); - assert!(!slash_actions(startup_state) + assert!(slash_actions(startup_state) .iter() .any(|action| action.id == "select_model")); } @@ -2463,7 +2485,22 @@ mod tests { assert_eq!(tools.handler, ActionHandler::Tools); let extensions = action_for_alias("/extensions", ActionContext::Chat).unwrap(); assert_eq!(extensions.handler, ActionHandler::Extensions); - assert!(extensions.description.contains("Safe Mode")); + assert_eq!(extensions.name, "Extensions"); + assert_eq!(extensions.description, "View and manage extensions"); + assert_eq!( + action_for_alias("/hooks_external", ActionContext::Chat) + .expect("legacy Hook alias remains parseable") + .handler, + ActionHandler::ExternalHooks + ); + assert!(!slash_actions(ActionState::chat(false, false)) + .iter() + .any(|action| action.id == "hooks_external")); + assert!(!palette_actions(ActionState::chat(false, false)) + .iter() + .any(|action| action.id == "hooks_external")); + let hooks = action_for_alias("/hooks", ActionContext::Chat).unwrap(); + assert_eq!(hooks.description, "View and manage Hooks"); let agents = action_for_alias("/agent", ActionContext::Chat).unwrap(); assert_eq!(agents.handler, ActionHandler::OpenAgentSelector); assert_eq!(agents.description, "Switch modes and manage agents"); diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 369677501a..906a667ffc 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -13,8 +13,8 @@ use tokio::sync::{broadcast, Mutex}; use bitfun_agent_runtime::sdk::{ AgentDialogSteerRequest, AgentDialogTurnExecution, AgentDialogTurnRequest, AgentEventReceiver, - AgentInputAttachment, AgentLocalCommandTurnRecordRequest, - AgentMessageWorkspaceReferencesRequest, AgentRuntime, AgentSessionCompactionRequest, + AgentInputAttachment, AgentMessageWorkspaceReferencesRequest, AgentRuntime, + AgentSessionCompactionRequest, AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionForkBeforeTurnRequest, AgentSessionForkRequest, AgentSessionForkResult, AgentSessionLineageCancellationRequest, AgentSessionLineageInspection, AgentSessionLineageRequest, AgentSessionLineageSnapshot, @@ -485,17 +485,6 @@ impl ExecAgentRuntimeClient { } } - pub(crate) async fn record_completed_local_command_turn( - &self, - request: AgentLocalCommandTurnRecordRequest, - ) -> Result<()> { - self.embedded_runtime("recording local command turns")? - .record_completed_local_command_turn(request) - .await - .map(|_| ()) - .map_err(|error| anyhow::anyhow!(error.into_message())) - } - pub(crate) fn set_approval_policy(&self, policy: CliApprovalPolicy) { *self .approval_policy @@ -1543,6 +1532,9 @@ impl ExecAgentRuntimeClient { turn_id: turn_id.clone(), content, display_content, + // The CLI steer prompt is text; attachments ride turn submissions. + attachments: Vec::new(), + metadata: serde_json::Map::new(), }; match &self.backend { diff --git a/src/apps/cli/src/agent/tui_client.rs b/src/apps/cli/src/agent/tui_client.rs index b42d1e9938..03b521f71a 100644 --- a/src/apps/cli/src/agent/tui_client.rs +++ b/src/apps/cli/src/agent/tui_client.rs @@ -5,22 +5,36 @@ use std::fmt; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; -use crate::tui_backend::{TuiBackend, TuiBackendError}; +use crate::tui_backend::{TuiBackend, TuiBackendError, TuiBackendErrorKind}; use anyhow::Result; -use async_trait::async_trait; use bitfun_app_server_client::AppServerEvent; +use bitfun_app_server_protocol::account::*; +use bitfun_app_server_protocol::agent::*; use bitfun_app_server_protocol::event::EventStreamState; -use bitfun_app_server_protocol::tui::*; +use bitfun_app_server_protocol::external_source::*; +use bitfun_app_server_protocol::hook::*; +use bitfun_app_server_protocol::mcp::*; +use bitfun_app_server_protocol::model::*; +use bitfun_app_server_protocol::session::*; +use bitfun_app_server_protocol::skill::*; +use bitfun_app_server_protocol::subagent::*; +use bitfun_app_server_protocol::workspace::*; +use bitfun_app_server_protocol::worktree::*; use bitfun_core_types::SessionUsageReport; use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; +use bitfun_product_domains::external_source_control::ExternalSourceControlRequestV1; +use bitfun_product_domains::external_sources::{ + ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourcePublicSnapshot, + NativePromptCommandDescriptor, PromptCommandShellReviewDecision, +}; use bitfun_product_domains::tool_permissions::{ PermissionReply, PermissionRequest, PermissionRequestEvent, }; use bitfun_runtime_ports::{ put_agent_workspace_references, AgentContextReloadRequest, AgentDialogSteerRequest, AgentDialogTurnExecution, AgentDialogTurnRequest, AgentInputAttachment, - AgentLocalCommandTurnRecordRequest, AgentMessageWorkspaceReferencesRequest, - AgentSessionCompactionRequest, AgentSessionCreateRequest, AgentSessionDeleteRequest, + AgentMessageWorkspaceReferencesRequest, AgentSessionCompactionRequest, + AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionLineageCancellationRequest, AgentSessionLineageInspection, AgentSessionLineageRequest, AgentSessionLineageSnapshot, AgentSessionLineageTranscriptRequest, AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, @@ -43,15 +57,6 @@ pub(crate) struct TuiAgentMode { pub(crate) is_external: bool, } -#[async_trait] -pub(crate) trait TuiHostCapabilities: Send + Sync { - async fn available_agent_modes( - &self, - session_id: Option, - workspace: PathBuf, - ) -> Result>; -} - #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum SessionMigrationNotice { Mode { @@ -190,7 +195,6 @@ impl TuiWorkspacePaths { pub(crate) struct TuiAgentClient { backend: Arc, - host: Arc, shared: bool, approval_policy: Arc>, workspace_paths: Arc>, @@ -198,33 +202,37 @@ pub(crate) struct TuiAgentClient { current_turn_id: Arc>>, agent_events: Arc>>>, permission_events: Arc>>>, + external_source_events: + Arc>>>, pending_permissions: Arc>>, } impl TuiAgentClient { pub(crate) fn new( backend: Arc, - host: Arc, workspace_path: Option, shared: bool, approval_policy: CliApprovalPolicy, ) -> Self { let (agent_sender, _) = broadcast::channel(256); let (permission_sender, _) = broadcast::channel(64); + let (external_source_sender, _) = broadcast::channel(64); let agent_events = Arc::new(RwLock::new(Some(agent_sender.clone()))); let permission_events = Arc::new(RwLock::new(Some(permission_sender.clone()))); + let external_source_events = Arc::new(RwLock::new(Some(external_source_sender.clone()))); let pending_permissions = Arc::new(RwLock::new(HashMap::new())); spawn_event_bridge( backend.subscribe_events(), agent_sender, permission_sender, + external_source_sender, agent_events.clone(), permission_events.clone(), + external_source_events.clone(), pending_permissions.clone(), ); Self { backend, - host, shared, approval_policy: Arc::new(RwLock::new(approval_policy)), workspace_paths: Arc::new(RwLock::new(TuiWorkspacePaths::new(workspace_path))), @@ -232,6 +240,7 @@ impl TuiAgentClient { current_turn_id: Arc::new(Mutex::new(None)), agent_events, permission_events, + external_source_events, pending_permissions, } } @@ -245,12 +254,475 @@ impl TuiAgentClient { } pub(crate) async fn available_agent_modes(&self) -> Result> { - self.host - .available_agent_modes( - self.session_id.lock().await.clone(), - self.workspace_path_buf(), - ) + let response = self + .backend + .list_agent_modes(ListAgentModesRequest { + workspace_path: Some(self.workspace_path_buf().to_string_lossy().to_string()), + include_external: true, + }) + .await + .map_err(|error| anyhow::anyhow!(error))?; + Ok(response + .modes + .into_iter() + .map(|mode| TuiAgentMode { + id: mode.id, + description: mode.description, + model_id: mode.model_id, + is_external: mode.is_external, + }) + .collect()) + } + + pub(crate) async fn list_models(&self) -> Result { + self.backend + .list_models() + .await + .map_err(|error| anyhow::anyhow!(error)) + } + + pub(crate) async fn get_model(&self, model_id: String) -> Result { + self.backend + .get_model(GetModelRequest { model_id }) + .await + .map_err(|error| anyhow::anyhow!(error)) + } + + pub(crate) async fn add_model(&self, request: AddModelRequest) -> Result { + self.backend + .add_model(request) + .await + .map_err(|error| anyhow::anyhow!(error)) + } + + pub(crate) async fn update_model( + &self, + request: UpdateModelRequest, + ) -> Result { + self.backend + .update_model(request) + .await + .map_err(|error| anyhow::anyhow!(error)) + } + + pub(crate) async fn delete_model(&self, model_id: String) -> Result { + self.backend + .delete_model(DeleteModelRequest { model_id }) + .await + .map_err(|error| anyhow::anyhow!(error)) + } + + pub(crate) async fn set_model_default( + &self, + request: SetModelDefaultRequest, + ) -> Result { + self.backend + .set_model_default(request) + .await + .map_err(|error| anyhow::anyhow!(error)) + } + + pub(crate) async fn list_skills( + &self, + mode_id: String, + manageable: bool, + ) -> Result { + self.backend + .list_skills(ListSkillsRequest { + workspace_path: self.workspace_path_buf().to_string_lossy().to_string(), + mode_id, + manageable, + }) + .await + .map_err(|error| anyhow::anyhow!(error)) + } + + pub(crate) async fn set_skill_enabled( + &self, + mode_id: String, + skill_key: String, + enabled: bool, + default_enabled: bool, + level: String, + ) -> Result { + self.backend + .set_skill_enabled(SetSkillEnabledRequest { + workspace_path: self.workspace_path_buf().to_string_lossy().to_string(), + mode_id, + skill_key, + enabled, + default_enabled, + level, + }) + .await + .map_err(|error| anyhow::anyhow!(error)) + } + + pub(crate) async fn list_subagents( + &self, + parent_mode_id: String, + management: bool, + ) -> Result { + self.backend + .list_subagents(ListSubagentsRequest { + workspace_path: self.workspace_path_buf().to_string_lossy().to_string(), + parent_mode_id, + management, + }) + .await + .map_err(|error| anyhow::anyhow!(error)) + } + + pub(crate) async fn set_subagent_enabled( + &self, + parent_mode_id: String, + subagent_id: String, + enabled: bool, + ) -> Result { + self.backend + .set_subagent_enabled(SetSubagentEnabledRequest { + workspace_path: self.workspace_path_buf().to_string_lossy().to_string(), + parent_mode_id, + subagent_id, + enabled, + }) + .await + .map_err(|error| anyhow::anyhow!(error)) + } + + pub(crate) async fn list_mcp_servers(&self) -> Result { + self.backend + .list_mcp_servers(ListMcpServersRequest { + workspace_path: self.workspace_path_buf().to_string_lossy().to_string(), + }) + .await + .map_err(|error| anyhow::anyhow!(error)) + } + + pub(crate) async fn toggle_mcp_server( + &self, + server_id: String, + ) -> Result { + self.backend + .toggle_mcp_server(ToggleMcpServerRequest { server_id }) + .await + .map_err(|error| anyhow::anyhow!(error)) + } + + pub(crate) async fn add_mcp_server( + &self, + name: String, + config: McpServerMutation, + ) -> Result { + self.backend + .add_mcp_server(AddMcpServerRequest { name, config }) + .await + .map_err(|error| anyhow::anyhow!(error)) + } + + pub(crate) async fn delete_mcp_server( + &self, + server_id: String, + ) -> Result { + self.backend + .delete_mcp_server(DeleteMcpServerRequest { server_id }) + .await + .map_err(|error| anyhow::anyhow!(error)) + } + + pub(crate) async fn external_mcp_decision( + &self, + request: ExternalMcpDecisionRequest, + ) -> Result { + self.backend + .external_mcp_decision(request) + .await + .map_err(|error| anyhow::anyhow!(error)) + } + + pub(crate) async fn mcp_conflict_choice( + &self, + request: McpConflictChoiceRequest, + ) -> Result { + self.backend + .mcp_conflict_choice(request) + .await + .map_err(|error| anyhow::anyhow!(error)) + } + + pub(crate) async fn external_source_snapshot( + &self, + force_refresh: bool, + ) -> std::result::Result { + self.backend + .external_source_snapshot(ExternalSourceSnapshotRequest { + workspace_path: self.workspace_path_string(), + force_refresh, + }) + .await + .map_err(external_source_backend_error) + } + + pub(crate) fn subscribe_external_source_updates( + &self, + ) -> Result> { + shared_receiver( + &self.external_source_events, + "App Server external source event stream is unavailable", + ) + } + + pub(crate) async fn external_source_control( + &self, + request: ExternalSourceControlRequestV1, + ) -> std::result::Result { + let operation_id = request.operation_id.clone(); + self.backend + .external_source_control(ExternalSourceControlRequest { + workspace_path: self.workspace_path_string(), + request, + }) + .await + .map_err(|error| external_source_backend_error_with_id(error, Some(&operation_id))) + } + + pub(crate) async fn external_source_review( + &self, + action: ExternalSourceReviewAction, + ) -> std::result::Result { + let operation_id = format!("tui-{}", uuid::Uuid::new_v4()); + self.backend + .external_source_review(ExternalSourceReviewRequest { + workspace_path: self.workspace_path_string(), + operation_id: operation_id.clone(), + action, + }) + .await + .map(|response| response.0) + .map_err(|error| external_source_backend_error_with_id(error, Some(&operation_id))) + } + + pub(crate) async fn set_native_command_choice( + &self, + native_commands: Vec, + selected_candidate_id: String, + expected_preference_revision: u64, + ) -> std::result::Result { + let operation_id = format!("tui-{}", uuid::Uuid::new_v4()); + self.backend + .set_native_command_choice(SetNativeCommandChoiceRequest { + workspace_path: self.workspace_path_string(), + operation_id: operation_id.clone(), + native_commands, + selected_candidate_id, + expected_preference_revision, + }) + .await + .map_err(|error| external_source_backend_error_with_id(error, Some(&operation_id))) + } + + pub(crate) async fn expand_external_command( + &self, + command_name: String, + arguments: String, + native_commands: Vec, + candidate_id: Option, + content_version: Option, + native_conflict_key: Option, + expected_preference_revision: Option, + shell_review_decision: Option, + ) -> std::result::Result { + let operation_id = format!("tui-{}", uuid::Uuid::new_v4()); + self.backend + .expand_external_command(ExpandExternalCommandRequest { + workspace_path: self.workspace_path_string(), + operation_id: operation_id.clone(), + command_name, + arguments, + native_commands, + candidate_id, + content_version, + native_conflict_key, + expected_preference_revision, + shell_review_decision, + }) + .await + .map_err(|error| external_source_backend_error_with_id(error, Some(&operation_id))) + } + + pub(crate) async fn native_hook_overview( + &self, + ) -> std::result::Result { + self.backend + .native_hook_overview(NativeHookOverviewRequest { + workspace_path: self.project_workspace_path_string(), + }) + .await + .map(|response| response.0) + .map_err(external_source_backend_error) + } + + pub(crate) async fn external_hook_snapshot( + &self, + refresh_updates: bool, + ) -> std::result::Result< + bitfun_product_domains::external_hook_import::ExternalHookImportSnapshotV1, + ExternalSourceOperationError, + > { + self.backend + .external_hook_snapshot(ExternalHookSnapshotRequest { + workspace_path: self.project_workspace_path_string(), + refresh_updates, + }) .await + .map(|response| response.0) + .map_err(external_source_backend_error) + } + + pub(crate) async fn external_hook_plan( + &self, + source: bitfun_product_domains::external_sources::SourceKey, + ) -> std::result::Result< + bitfun_product_domains::external_hook_import::ExternalHookImportPlanV1, + ExternalSourceOperationError, + > { + self.backend + .external_hook_plan(ExternalHookPlanRequest { + workspace_path: self.project_workspace_path_string(), + source, + }) + .await + .map(|response| response.0) + .map_err(external_source_backend_error) + } + + pub(crate) async fn external_hook_apply( + &self, + import_request: bitfun_product_domains::external_hook_import::ExternalHookImportApplyRequestV1, + ) -> std::result::Result< + bitfun_product_domains::external_hook_import::ExternalHookImportApplyResultV1, + ExternalSourceOperationError, + > { + self.backend + .external_hook_apply(ExternalHookApplyRequest { + workspace_path: self.project_workspace_path_string(), + operation_id: format!("tui-hook-{}", uuid::Uuid::new_v4()), + import_request, + }) + .await + .map(|response| response.0) + .map_err(external_source_backend_error) + } + + pub(crate) async fn external_hook_mutate( + &self, + mutation: bitfun_product_domains::external_hook_import::ExternalHookImportMutationRequestV1, + ) -> std::result::Result< + bitfun_product_domains::external_hook_import::ExternalHookImportSnapshotV1, + ExternalSourceOperationError, + > { + self.backend + .external_hook_mutate(ExternalHookMutationRequest { + workspace_path: self.project_workspace_path_string(), + operation_id: format!("tui-hook-{}", uuid::Uuid::new_v4()), + mutation, + }) + .await + .map(|response| response.0) + .map_err(external_source_backend_error) + } + + pub(crate) async fn account_snapshot(&self) -> Result { + self.backend + .account_snapshot(AccountSnapshotRequest { + workspace_path: self.project_workspace_path_string(), + }) + .await + .map_err(worktree_operation_error) + } + + pub(crate) async fn account_login( + &self, + relay_url: String, + username: String, + password: String, + ) -> Result { + self.backend + .account_login(AccountLoginRequest { + operation_id: account_operation_id(), + relay_url, + username, + password, + }) + .await + .map_err(worktree_operation_error) + } + + pub(crate) async fn account_finalize_login( + &self, + choice: AccountSyncChoice, + ) -> Result { + self.backend + .account_finalize_login(AccountFinalizeLoginRequest { + operation_id: account_operation_id(), + choice, + workspace_path: self.project_workspace_path_string(), + }) + .await + .map_err(worktree_operation_error) + } + + pub(crate) async fn account_logout(&self) -> Result { + self.backend + .account_logout(AccountLogoutRequest { + operation_id: account_operation_id(), + workspace_path: self.project_workspace_path_string(), + }) + .await + .map_err(Into::into) + } + + pub(crate) async fn settings_sync_start( + &self, + is_first_login: bool, + ) -> Result { + self.backend + .settings_sync_start(SettingsSyncStartRequest { + operation_id: account_operation_id(), + workspace_path: self.project_workspace_path_string(), + is_first_login, + }) + .await + .map_err(Into::into) + } + + pub(crate) async fn settings_sync_snapshot(&self) -> Result { + self.backend + .settings_sync_snapshot(SettingsSyncSnapshotRequest { + workspace_path: self.project_workspace_path_string(), + }) + .await + .map_err(Into::into) + } + + pub(crate) async fn settings_sync_cancel(&self) -> Result { + self.backend + .settings_sync_cancel(SettingsSyncCancelRequest { + operation_id: account_operation_id(), + workspace_path: self.project_workspace_path_string(), + }) + .await + .map_err(Into::into) + } + + pub(crate) async fn settings_sync_local_changed(&self) -> Result { + self.backend + .settings_sync_local_changed(SettingsSyncLocalChangedRequest { + operation_id: account_operation_id(), + workspace_path: self.project_workspace_path_string(), + }) + .await + .map_err(Into::into) } pub(crate) fn subscribe_events(&self) -> Result> { @@ -297,16 +769,6 @@ impl TuiAgentClient { Ok(()) } - pub(crate) async fn record_completed_local_command_turn( - &self, - request: AgentLocalCommandTurnRecordRequest, - ) -> Result<()> { - self.backend - .record_local_command_turn(RecordLocalCommandTurnRequest(request)) - .await?; - Ok(()) - } - pub(crate) fn set_approval_policy(&self, policy: CliApprovalPolicy) { *self .approval_policy @@ -348,6 +810,71 @@ impl TuiAgentClient { .apply_binding(binding); } + pub(crate) async fn worktree_repository_status( + &self, + workspace_path: String, + ) -> Result { + let paths = self + .workspace_paths + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + self.backend + .worktree_repository_status(WorktreeRepositoryStatusRequest { + workspace_path, + remote_connection_id: paths.remote_connection_id.clone(), + remote_ssh_host: paths.remote_ssh_host.clone(), + }) + .await + .map_err(Into::into) + } + + pub(crate) async fn worktree_bind_session( + &self, + session_id: String, + project_workspace_path: Option, + ) -> Result { + let (remote_connection_id, remote_ssh_host) = self.remote_workspace_scope(); + self.backend + .worktree_bind_session(WorktreeBindSessionRequest { + operation_id: worktree_operation_id(), + session_id, + project_workspace_path, + remote_connection_id, + remote_ssh_host, + }) + .await + .map_err(Into::into) + } + + pub(crate) async fn worktree_release_session( + &self, + session_id: String, + project_workspace_path: Option, + ) -> Result { + let (remote_connection_id, remote_ssh_host) = self.remote_workspace_scope(); + self.backend + .worktree_release_session(WorktreeReleaseSessionRequest { + operation_id: worktree_operation_id(), + session_id, + project_workspace_path, + remote_connection_id, + remote_ssh_host, + }) + .await + .map_err(Into::into) + } + + fn remote_workspace_scope(&self) -> (Option, Option) { + let paths = self + .workspace_paths + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + ( + paths.remote_connection_id.clone(), + paths.remote_ssh_host.clone(), + ) + } + pub(crate) async fn list_sessions(&self) -> Result> { Ok(self .backend @@ -892,6 +1419,8 @@ impl TuiAgentClient { turn_id, content, display_content, + attachments: Vec::new(), + metadata: serde_json::Map::new(), })) .await? .steering_id) @@ -1025,6 +1554,71 @@ impl TuiAgentClient { } } +fn external_source_backend_error(error: TuiBackendError) -> ExternalSourceOperationError { + external_source_backend_error_with_id(error, None) +} + +fn external_source_backend_error_with_id( + error: TuiBackendError, + operation_id: Option<&str>, +) -> ExternalSourceOperationError { + if let Some(decoded) = ExternalSourceOperationError::decode(&error.message) { + return attach_operation_id(decoded, operation_id); + } + let code = if error.outcome_unknown { + ExternalSourceOperationErrorCode::Timeout + } else if matches!(error.kind, TuiBackendErrorKind::Unsupported { .. }) { + ExternalSourceOperationErrorCode::HostCapabilityUnavailable + } else { + ExternalSourceOperationErrorCode::Internal + }; + let message = error.message; + let retryable = error.outcome_unknown; + attach_operation_id( + ExternalSourceOperationError::new(code, message, retryable).with_default_recovery_actions(), + operation_id, + ) +} + +fn attach_operation_id( + mut error: ExternalSourceOperationError, + operation_id: Option<&str>, +) -> ExternalSourceOperationError { + if let Some(operation_id) = operation_id.filter(|id| !id.is_empty()) { + if error.correlation_id.is_none() { + error = error.with_correlation_id(operation_id); + } else if error.causation_id.is_none() { + error = error.with_causation_id(operation_id); + } + } + error +} + +fn account_operation_id() -> String { + format!("tui-account-{}", uuid::Uuid::new_v4()) +} + +fn worktree_operation_error(error: TuiBackendError) -> anyhow::Error { + if let Some(worktree) = WorktreeOperationError::decode(&error.message) { + let recovery = worktree + .recovery_path + .as_deref() + .map(|path| format!(" Recovery path: {path}")) + .unwrap_or_default(); + return anyhow::anyhow!( + "{}: {}{}", + worktree.code.as_str(), + worktree.message, + recovery + ); + } + anyhow::anyhow!(error) +} + +fn worktree_operation_id() -> String { + format!("tui-worktree-{}", uuid::Uuid::new_v4()) +} + fn shared_receiver( source: &Arc>>>, message: &str, @@ -1041,8 +1635,12 @@ fn spawn_event_bridge( mut source: broadcast::Receiver, agent_sender: broadcast::Sender, permission_sender: broadcast::Sender, + external_source_sender: broadcast::Sender<(String, ExternalSourcePublicSnapshot)>, agent_owner: Arc>>>, permission_owner: Arc>>>, + external_source_owner: Arc< + RwLock>>, + >, pending: Arc>>, ) { tokio::spawn(async move { @@ -1069,6 +1667,16 @@ fn spawn_event_bridge( } let _ = permission_sender.send(notification.event); } + Ok(AppServerEvent::ExternalSource(notification)) => { + let _ = external_source_sender + .send((notification.workspace_path, notification.snapshot)); + } + Ok(AppServerEvent::StreamState(notification)) + if notification.stream + == bitfun_app_server_protocol::event::EventStream::ExternalSource => + { + // The next TUI snapshot request is the authoritative recovery path. + } Ok(AppServerEvent::StreamState(notification)) if matches!( notification.state, @@ -1101,6 +1709,9 @@ fn spawn_event_bridge( *permission_owner .write() .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + *external_source_owner + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; }); } diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index a59708b5bf..54c5c19da8 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -559,6 +559,10 @@ impl ChatState { self.worktree_control_available = available; } + pub(crate) fn worktree_control_available(&self) -> bool { + self.worktree_control_available + } + pub(crate) fn branch_label(&self) -> String { let execution_target = self .workspace_binding diff --git a/src/apps/cli/src/daemon/runner.rs b/src/apps/cli/src/daemon/runner.rs index 7f41286a9c..7f406dfe90 100644 --- a/src/apps/cli/src/daemon/runner.rs +++ b/src/apps/cli/src/daemon/runner.rs @@ -11,7 +11,7 @@ use anyhow::{anyhow, Result}; use bitfun_core::service::remote_connect::DeviceIdentity; -use crate::{account, runtime, BootstrapProfile}; +use crate::{runtime, BootstrapProfile}; use super::pid; @@ -27,14 +27,15 @@ pub(crate) async fn run_daemon() -> Result<()> { // The daemon is not bound to the caller's cwd; peer commands carry their // own workspace paths. Home is a stable root for the runtime context. let workspace_root = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - let _runtime = crate::initialize_core_services( + let runtime = crate::initialize_core_services( &workspace_root, runtime::approval::CliApprovalPolicy::Ask, BootstrapProfile::Interactive, ) .await?; - let Some(user_id) = account::try_restore_session().await else { + let account = runtime.account_runtime(); + let Some(user_id) = account.try_restore_session().await else { return Err(anyhow!( "not logged in; run `bitfun`, log in with `/login`, then start the daemon again" )); @@ -43,12 +44,12 @@ pub(crate) async fn run_daemon() -> Result<()> { let device = DeviceIdentity::from_current_machine().map_err(|e| anyhow!("detect device: {e}"))?; - account::restore_device_routing(&device.device_name).await?; + account.restore_device_routing(&device.device_name).await?; // Continuous account settings sync (30s pull + debounced push) so this // always-on host converges with cloud changes made on other devices and // attached controllers see fresh config without reconnecting. - crate::account_sync::start_settings_sync_loop(); + account.start_settings_sync_loop(); pid::write_pid_file()?; tracing::info!("bitfun daemon running (pid {})", std::process::id()); @@ -62,7 +63,7 @@ pub(crate) async fn run_daemon() -> Result<()> { break; } _ = expired_check.tick() => { - if account::is_token_expired() { + if account.is_token_expired() { // Exit 0 on purpose: re-authentication needs a human, so // Restart=on-failure must not loop the daemon. tracing::warn!("Account token rejected by the relay; daemon exiting"); @@ -72,7 +73,7 @@ pub(crate) async fn run_daemon() -> Result<()> { } } - account::stop_device_routing().await; + runtime.account_routing().stop_device_routing().await; pid::remove_pid_file(); crate::shutdown_mcp_servers().await; tracing::info!("bitfun daemon stopped"); diff --git a/src/apps/cli/src/dispatch/mod.rs b/src/apps/cli/src/dispatch/mod.rs index 195d48d2a1..3e5ff86bc1 100644 --- a/src/apps/cli/src/dispatch/mod.rs +++ b/src/apps/cli/src/dispatch/mod.rs @@ -128,6 +128,13 @@ async fn probe(request: DispatchProbeRequest) -> Result { .iter() .map(|capability| capability.to_string()) .collect(); + // Accepting the controller's model-sync audit row is a request-validation + // fact, not a runtime one, so it is advertised regardless of whether this + // platform can host detached workers. + capabilities.push( + bitfun_services_core::dispatch_contract::DISPATCH_SETUP_AUDIT_MODEL_SYNC_CAPABILITY + .to_string(), + ); if runner::is_supported() { capabilities.push( bitfun_services_core::dispatch_contract::DISPATCH_DETACHED_WORKER_CAPABILITY @@ -380,7 +387,9 @@ fn answer(request: DispatchAnswerRequest) -> Result { } fn append(request: DispatchAppendRequest) -> Result { - if request.content.trim().is_empty() { + // An attachment-only message is a real message; only one with neither text + // nor attachments is empty. + if request.content.trim().is_empty() && request.attachments.is_empty() { bail!("dispatch appended message cannot be empty"); } let total_bytes = request @@ -390,6 +399,7 @@ fn append(request: DispatchAppendRequest) -> Result { if total_bytes > MAX_DISPATCH_TEXT_BYTES { bail!("dispatch appended message exceeds the 32 KiB request limit"); } + validate_attachments(&request.attachments)?; let message_id = request.message_id.clone(); let store = DispatchStore::open_default()?; let accepted = store.enqueue_append_message(request)?; @@ -593,6 +603,7 @@ async fn inspect_model_readiness() -> Result { models: Vec::new(), provider_catalog: Default::default(), default_models: Default::default(), + models_dev_reasoning_catalog: None, reasoning_preset_selection_supported: true, session_model_id: None, session_reasoning_preset: None, @@ -851,7 +862,9 @@ fn validate_submit_request(request: &DispatchSubmitRequest) -> Result<()> { bail!("dispatch setup audit exceeds the 32-event safety limit"); } for event in &request.setup_audit { - if event.action != "cli-install" { + if !bitfun_services_core::dispatch_contract::dispatch_supported_setup_audit_actions() + .any(|action| action == event.action) + { bail!("dispatch setup audit contains an unsupported action"); } if event.timestamp.trim().is_empty() diff --git a/src/apps/cli/src/dispatch/protocol.rs b/src/apps/cli/src/dispatch/protocol.rs index feb61f00c6..9deaea7e34 100644 --- a/src/apps/cli/src/dispatch/protocol.rs +++ b/src/apps/cli/src/dispatch/protocol.rs @@ -165,6 +165,10 @@ pub(crate) struct DispatchAppendRequest { pub(crate) content: String, #[serde(default)] pub(crate) display_content: Option, + /// Attachments injected into the running turn with the message. An older + /// controller omits the field entirely, which decodes to an empty list. + #[serde(default)] + pub(crate) attachments: Vec, } #[derive(Clone, Debug, Serialize, PartialEq, Eq)] @@ -219,6 +223,13 @@ pub(crate) struct DispatchWorkspaceProvisionResponse { /// difference instead of the whole history. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) have_tips: Vec, + /// Why the target could not pull `base_commit` from the project's remote, + /// when it tried and failed. Absent when the remote served the commit, and + /// when there is no remote to try. Bundle delivery costs the whole history + /// on a cold cache, so the reason it was chosen belongs in the record rather + /// than only in the target's log. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) fetch_error: Option, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] diff --git a/src/apps/cli/src/dispatch/runner.rs b/src/apps/cli/src/dispatch/runner.rs index 0f4eec6aca..a5f115ddec 100644 --- a/src/apps/cli/src/dispatch/runner.rs +++ b/src/apps/cli/src/dispatch/runner.rs @@ -220,13 +220,55 @@ pub(crate) fn process_alive(pid: u32) -> bool { return false; }; // SAFETY: signal 0 performs liveness/permission checking only. - if unsafe { libc::kill(pid, 0) } == 0 { - return true; + if unsafe { libc::kill(pid, 0) } != 0 + && !matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EPERM) + ) + { + return false; + } + + #[cfg(target_os = "linux")] + { + // A zombie still answers to kill(0), but it has already exited and + // must not be treated as an authenticated leader for escalation. + if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) { + if stat + .rsplit_once(") ") + .and_then(|(_, fields)| fields.split_whitespace().next()) + == Some("Z") + { + return false; + } + } + } + + #[cfg(target_os = "macos")] + { + // macOS also reports zombies as present to kill(0). Query the process + // state before using a leader PID to authenticate SIGKILL escalation; + // a failed/empty query means the process disappeared during the check. + let output = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "stat="]) + .output(); + let Ok(output) = output else { + return false; + }; + if !output.status.success() { + return false; + } + return String::from_utf8_lossy(&output.stdout) + .trim_start() + .chars() + .next() + .is_some_and(|state| state != 'Z'); + } + + #[cfg(not(target_os = "macos"))] + { + true } - matches!( - std::io::Error::last_os_error().raw_os_error(), - Some(libc::EPERM) - ) } #[cfg(not(unix))] diff --git a/src/apps/cli/src/dispatch/store.rs b/src/apps/cli/src/dispatch/store.rs index 8ad9dcd157..02b594f0b8 100644 --- a/src/apps/cli/src/dispatch/store.rs +++ b/src/apps/cli/src/dispatch/store.rs @@ -2522,6 +2522,7 @@ mod tests { message_id: "message-1".to_string(), content: "Continue with tests".to_string(), display_content: None, + attachments: Vec::new(), }; assert!(store diff --git a/src/apps/cli/src/dispatch/worker.rs b/src/apps/cli/src/dispatch/worker.rs index 2a6679bce0..c37cbf41a8 100644 --- a/src/apps/cli/src/dispatch/worker.rs +++ b/src/apps/cli/src/dispatch/worker.rs @@ -505,6 +505,8 @@ async fn process_mailboxes( turn_id: turn_id.to_string(), content: request.content.clone(), display_content: request.display_content.clone(), + attachments: runtime_attachments(&request.attachments), + metadata: serde_json::Map::new(), }) .await .map_err(|error| anyhow!(error.into_message())) diff --git a/src/apps/cli/src/dispatch/workspace.rs b/src/apps/cli/src/dispatch/workspace.rs index bcefb1680e..465dccf592 100644 --- a/src/apps/cli/src/dispatch/workspace.rs +++ b/src/apps/cli/src/dispatch/workspace.rs @@ -15,11 +15,13 @@ use std::fs::{self, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; use anyhow::{bail, Context, Result}; use base64::Engine as _; use bitfun_services_core::dispatch_workspace::sha256_file; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use super::protocol::{ DispatchWorkspaceBundleBeginRequest, DispatchWorkspaceBundleBeginResponse, @@ -41,7 +43,30 @@ const BUNDLE_RECORD_FILE: &str = "bundle.json"; const SYNC_OPERATION_FILE: &str = "sync-operation.json"; const INCOMING_BUNDLE_FILE: &str = "incoming.bundle"; const RESULT_BUNDLE_FILE: &str = "result.bundle"; -/// Short job-id suffix that keeps two dispatches of one project apart, matching +/// Backstop for one fetch from the project's Git remote. +/// +/// The fetch used to be unbounded, so a dead transport parked the whole dispatch +/// on a single `git fetch` forever. This is deliberately not a performance +/// budget: a first fetch of a large project legitimately runs for many minutes, +/// and killing one that is still making progress would fall back to shipping the +/// same history over SSH instead — strictly slower than the fetch it replaced. +/// Stalls are caught by [`FETCH_STALL_SECONDS`]; this only has to fire before +/// the controller's own 30-minute workspace-operation ceiling, so that the +/// target reports the failure rather than the controller timing out on a target +/// that is still waiting. +const REMOTE_FETCH_TIMEOUT: Duration = Duration::from_secs(25 * 60); +/// Bytes per second below which an HTTP fetch counts as stalled. +const FETCH_STALL_BYTES_PER_SECOND: u32 = 1024; +/// How long an HTTP fetch may stay under [`FETCH_STALL_BYTES_PER_SECOND`]. +/// +/// Git aborts the transfer itself once both hold, which is the check that +/// actually wants to be tight: it separates "slow but arriving" from "hung", +/// which a total-time budget cannot tell apart. It only covers HTTP(S) remotes; +/// for SSH remotes the backstop above is the only bound. +const FETCH_STALL_SECONDS: u32 = 60; +/// How often a bounded Git child is checked for exit. +const GIT_WAIT_POLL_INTERVAL: Duration = Duration::from_millis(100); +/// Short per-job suffix that keeps two dispatches of one project apart, matching /// the local managed-worktree convention. const WORKTREE_SUFFIX_CHARS: usize = 8; /// Upper bound on the readable half of a worktree directory name. @@ -296,6 +321,7 @@ fn pending_provision_response( base_commit: request.base_commit.clone(), branch: request.branch.clone(), have_tips: Vec::new(), + fetch_error: None, } } @@ -355,19 +381,28 @@ fn provision_in_store( base_commit: request.base_commit, branch: request.branch, have_tips: Vec::new(), + fetch_error: None, }); } let repo = ensure_repository(store, &request.repo_key, request.remote_url.as_deref())?; + let mut fetch_error = None; if request.remote_url.is_some() && !commit_exists(&repo, &request.base_commit)? { // A fetch failure is not fatal on its own: the controller can still // deliver the missing objects by bundle, which is also the only path for - // a repository with no remote. - if let Err(error) = fetch_remote(&repo) { + // a repository with no remote. It is reported rather than swallowed, + // because "the target fell back to a full upload" and "the target could + // not reach the remote" look identical from the controller otherwise. + if let Err(error) = fetch_base_commit(&repo, &request.base_commit) { tracing::warn!("Dispatch target could not fetch from the Git remote: {error:#}"); + fetch_error = Some(truncate_utf8(&format!("{error:#}"))); } } if !commit_exists(&repo, &request.base_commit)? { + // A fetch that died partway leaves its pack under a temporary name, so + // the bytes are neither usable nor visible to the tips below. Clearing + // them keeps a repeatedly failing remote from filling the target's disk. + remove_pack_temporaries(&repo); return Ok(DispatchWorkspaceProvisionResponse { pending: false, provisioned: false, @@ -376,6 +411,7 @@ fn provision_in_store( base_commit: request.base_commit, branch: request.branch, have_tips: repository_tips(&repo)?, + fetch_error, }); } @@ -392,6 +428,7 @@ fn provision_in_store( base_commit: request.base_commit, branch: request.branch, have_tips: Vec::new(), + fetch_error: None, }) } @@ -1219,8 +1256,16 @@ fn existing_worktree( quarantine_partial_directory(worktree_path, "worktree")?; return Ok(None); } + // Everything below reports the directory it is judging. A dispatch worktree + // is only ever reached through a name derived from the job, so an occupant + // that fails these checks is either another job's checkout or a hand-edited + // one — and naming which is which is the whole difference between a + // recoverable report and a dead end. if !commit_exists(worktree_path, base_commit)? { - bail!("dispatch worktree exists without the requested base commit"); + bail!( + "dispatch worktree {} exists without the requested base commit {base_commit}", + worktree_path.display() + ); } let current_branch = git( worktree_path, @@ -1231,7 +1276,8 @@ fn existing_worktree( .to_string(); if current_branch != branch { bail!( - "dispatch worktree is on branch '{current_branch}' instead of its managed branch '{branch}'" + "dispatch worktree {} is on branch '{current_branch}' instead of its managed branch '{branch}'", + worktree_path.display() ); } let head = git(worktree_path, &["rev-parse", "HEAD"])?; @@ -1239,7 +1285,10 @@ fn existing_worktree( worktree_path, &["merge-base", "--is-ancestor", base_commit, head.trim()], )? { - bail!("existing dispatch worktree does not descend from its requested base commit"); + bail!( + "dispatch worktree {} does not descend from its requested base commit {base_commit}", + worktree_path.display() + ); } Ok(Some(canonical_utf8(worktree_path)?)) } @@ -1307,18 +1356,102 @@ fn set_origin(repo: &Path, url: &str) -> Result<()> { Ok(()) } -fn fetch_remote(repo: &Path) -> Result<()> { - git( +/// Bring one commit into the repository cache from the project's remote. +/// +/// Asks the server for exactly the commit this job needs. The previous refspec +/// — `+refs/heads/*:refs/remotes/origin/*` — made the first fetch of a project +/// download every branch the server has (192 of them for this repository) when +/// a dispatch only ever checks out `base_commit`. Servers that will not serve a +/// bare object id fall back to the old refspec, so an older or restricted host +/// still works, just as slowly as before. +/// +/// A successful fetch is anchored under `refs/dispatch/bases/`. Asking for a +/// bare object id writes no ref of its own, and the job branch that would hold +/// it goes away with the job, which would leave the cache holding a project's +/// whole history with nothing pointing at it — invisible to `have_tips`, so the +/// next job bundles everything again, and eligible for `gc` to throw away. +fn fetch_base_commit(repo: &Path, base_commit: &str) -> Result<()> { + remove_pack_temporaries(repo); + let targeted = fetch_with_stall_guard(repo, &["--no-tags", "origin", base_commit]); + let Err(error) = targeted else { + anchor_base_commit(repo, base_commit); + return Ok(()); + }; + tracing::debug!( + "Dispatch target could not fetch {base_commit} directly, retrying with every branch: {error:#}" + ); + // The targeted attempt may have died partway through indexing. + remove_pack_temporaries(repo); + fetch_with_stall_guard( repo, &[ - "fetch", "--no-tags", "--prune", "origin", "+refs/heads/*:refs/remotes/origin/*", ], ) - .map(|_| ()) +} + +/// Keep a fetched base commit reachable after its job is gone. +/// +/// Best effort on purpose: the fetch already succeeded, and the worktree about +/// to be created keeps the objects alive for this job either way. Failing here +/// would trade a warm cache for a failed dispatch. +fn anchor_base_commit(repo: &Path, base_commit: &str) { + if let Err(error) = git( + repo, + &[ + "update-ref", + &format!("refs/dispatch/bases/{base_commit}"), + base_commit, + ], + ) { + tracing::debug!("Could not anchor dispatch base commit {base_commit}: {error:#}"); + } +} + +/// `git fetch` with the stall guard and the deadline both applied. +fn fetch_with_stall_guard(repo: &Path, fetch_args: &[&str]) -> Result<()> { + let low_speed_limit = format!("http.lowSpeedLimit={FETCH_STALL_BYTES_PER_SECOND}"); + let low_speed_time = format!("http.lowSpeedTime={FETCH_STALL_SECONDS}"); + let mut args = vec![ + "-c", + low_speed_limit.as_str(), + "-c", + low_speed_time.as_str(), + "fetch", + ]; + args.extend_from_slice(fetch_args); + git_within(repo, REMOTE_FETCH_TIMEOUT, &args) +} + +/// Drop `tmp_pack_*` files left behind by a fetch that died while indexing. +/// +/// Git writes the incoming pack under a temporary name and only renames it once +/// the index is complete, so a killed or timed-out fetch strands the whole +/// download. Nothing else ever collects them — `git gc` does not consider them +/// its business, and they are invisible to every object count — so one broken +/// first fetch of a large project parks hundreds of megabytes in the cache +/// permanently, and every retry adds another copy. +/// +/// Callers hold the repository lock, which serializes every Git operation on +/// this clone, so a temporary seen here cannot belong to a live fetch. +fn remove_pack_temporaries(repo: &Path) { + let pack_dir = repo.join("objects").join("pack"); + let Ok(entries) = fs::read_dir(&pack_dir) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if !name.starts_with("tmp_pack_") { + continue; + } + if let Err(error) = fs::remove_file(entry.path()) { + tracing::debug!("Could not remove stale dispatch pack temporary {name}: {error}"); + } + } } fn repository_tips(repo: &Path) -> Result> { @@ -1370,6 +1503,13 @@ fn create_worktree( /// advisory input from the controller, so it is sanitized here and falls back to /// the remote URL's basename and finally to a constant — the path must never be /// shaped by an untrusted string. +/// +/// The suffix digests the job id rather than slicing it. Job ids are minted as +/// `dispatch-`, so the leading alphanumerics every id shares — `dispatch` +/// — were all that survived the slice, and every job of one project resolved to +/// the same directory. The second session to start then met the first session's +/// checkout and provisioning refused it. A digest depends on the whole id, so no +/// shared prefix, suffix, or length can collapse two jobs onto one directory. fn worktree_directory_name( project_label: Option<&str>, remote_url: Option<&str>, @@ -1378,18 +1518,25 @@ fn worktree_directory_name( let label = sanitize_label(project_label.unwrap_or_default()) .or_else(|| sanitize_label(&remote_basename(remote_url.unwrap_or_default()))) .unwrap_or_else(|| "workspace".to_string()); - let suffix = job_id - .chars() - .filter(|character| character.is_ascii_alphanumeric()) - .take(WORKTREE_SUFFIX_CHARS) - .collect::(); - if suffix.is_empty() { - label - } else { - format!("{label}-{suffix}") + match job_directory_suffix(job_id) { + Some(suffix) => format!("{label}-{suffix}"), + None => label, } } +fn job_directory_suffix(job_id: &str) -> Option { + if job_id.is_empty() { + return None; + } + let digest = Sha256::digest(job_id.as_bytes()); + Some( + format!("{digest:x}") + .chars() + .take(WORKTREE_SUFFIX_CHARS) + .collect(), + ) +} + fn sanitize_label(value: &str) -> Option { let cleaned = value .chars() @@ -1456,6 +1603,93 @@ fn git(dir: &Path, args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } +/// Run Git with a deadline, killing it if the deadline passes. +/// +/// Only the remote-facing operations need this. Everything else here works on +/// local objects and finishes in bounded time on its own, whereas a fetch is at +/// the mercy of a network that may never answer — and an unbounded one holds the +/// whole dispatch, and the caller's progress display, hostage. +fn git_within(dir: &Path, timeout: Duration, args: &[&str]) -> Result<()> { + let mut command = git_command(dir); + command + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + // `git fetch` is a supervisor: the transfer really happens in the transport + // helper and `index-pack` that it spawns. Killing only the parent leaves + // those downloading into the cache with nobody waiting for them, which is + // how a cancelled fetch ends up stranding hundreds of megabytes. Giving the + // whole thing its own process group makes it killable as a unit on Unix; + // Windows keeps the old parent-only kill, and the sweep above is what + // reclaims whatever a surviving helper leaves behind. + #[cfg(unix)] + std::os::unix::process::CommandExt::process_group(&mut command, 0); + let mut child = command + .spawn() + .with_context(|| format!("run git {}", args.join(" ")))?; + // Drain stderr on its own thread: a child that fills the pipe while nobody + // reads it blocks forever, which would defeat the deadline below. + let mut pipe = child.stderr.take(); + let drain = std::thread::spawn(move || { + let mut captured = String::new(); + if let Some(pipe) = pipe.as_mut() { + let _ = pipe.read_to_string(&mut captured); + } + captured + }); + + let deadline = Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => {} + Err(error) => { + return Err(error).with_context(|| format!("wait for git {}", args.join(" "))) + } + } + if Instant::now() >= deadline { + kill_process_tree(&mut child); + // Deliberately not joining the drain: any helper that outlived the + // kill still holds the write end, and waiting on it here would put + // the deadline right back where it started. + bail!( + "git {} did not finish within {} seconds", + args.join(" "), + timeout.as_secs() + ); + } + std::thread::sleep(GIT_WAIT_POLL_INTERVAL); + }; + let stderr = drain.join().unwrap_or_default(); + if !status.success() { + bail!( + "git {} failed: {}", + args.join(" "), + truncate_utf8(stderr.trim()) + ); + } + Ok(()) +} + +/// Stop a timed-out Git child and everything it spawned. +fn kill_process_tree(child: &mut std::process::Child) { + #[cfg(unix)] + { + // The child leads its own group (see `process_group` above), so its pid + // doubles as the group id. + let group = child.id() as i32; + if group > 1 { + // SAFETY: `kill` takes a pid and a signal by value and borrows + // nothing; a group that already exited just yields ESRCH. + unsafe { + libc::kill(-group, libc::SIGKILL); + } + } + } + let _ = child.kill(); + let _ = child.wait(); +} + fn git_succeeds(dir: &Path, args: &[&str]) -> Result { let status = git_command(dir) .args(args) @@ -1703,6 +1937,168 @@ mod tests { assert!(!response.provisioned); assert!(response.needs_bundle); assert!(response.workspace_path.is_none()); + // No remote to try, so nothing to explain. + assert_eq!(response.fetch_error, None); + } + + /// A local path is a valid Git remote, so the fast path is testable without + /// a network: the target should pull the commit itself and never ask for a + /// bundle. + #[test] + fn provision_fetches_the_base_commit_from_the_remote_instead_of_bundling() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let source = temp.path().join("source"); + let base_commit = init_source_repository(&source); + + let response = provision_in_store( + &store, + DispatchWorkspaceProvisionRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-1".to_string(), + repo_key: "abcdef0123456789".to_string(), + project_label: Some("BitFun".to_string()), + remote_url: Some(source.to_string_lossy().to_string()), + base_commit: base_commit.clone(), + branch: "bitfun/dispatch/job-1".to_string(), + }, + ) + .expect("provision"); + + assert!(response.provisioned, "the remote had the commit"); + assert!( + !response.needs_bundle, + "no upload should have been asked for" + ); + assert_eq!(response.fetch_error, None); + let workspace = response.workspace_path.expect("workspace path"); + assert_eq!( + fs::read(Path::new(&workspace).join("file.txt")).expect("checked out file"), + b"base" + ); + + // The fetched history must outlive the job that pulled it, or the next + // dispatch of this project pays for the whole download again. + let repo = store + .repo_dir("abcdef0123456789") + .expect("repo dir") + .join("git"); + let anchored = git( + &repo, + &["rev-parse", &format!("refs/dispatch/bases/{base_commit}")], + ) + .expect("the base commit should be anchored"); + assert_eq!(anchored.trim(), base_commit); + assert!( + repository_tips(&repo).expect("tips").contains(&base_commit), + "an anchored base must count as a tip the controller can bundle against" + ); + } + + /// An unreachable remote must degrade to the bundle path *and say why*: on a + /// cold cache that fallback re-sends the project's whole history. + #[test] + fn an_unreachable_remote_reports_why_it_fell_back_to_a_bundle() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let missing = temp.path().join("no-such-repository"); + + let response = provision_in_store( + &store, + DispatchWorkspaceProvisionRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-1".to_string(), + repo_key: "abcdef0123456789".to_string(), + project_label: Some("BitFun".to_string()), + remote_url: Some(missing.to_string_lossy().to_string()), + base_commit: "0".repeat(40), + branch: "bitfun/dispatch/job-1".to_string(), + }, + ) + .expect("provision"); + + assert!(response.needs_bundle); + // The reason has to name the operation and carry Git's own diagnosis; + // "the target fell back to a bundle" on its own is not actionable. + let reason = response.fetch_error.expect("the fallback reason"); + assert!(reason.contains("fetch"), "unhelpful reason: {reason}"); + assert!( + reason.contains("does not appear to be a git repository"), + "the reason dropped Git's own diagnosis: {reason}" + ); + } + + /// A fetch killed while indexing strands its pack under a temporary name. + /// Nothing else collects those, so one broken fetch of a large project used + /// to park its whole download in the cache permanently. + #[test] + fn a_failed_fetch_does_not_leave_its_partial_pack_behind() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let repo_root = store.repo_dir("abcdef0123456789").expect("repo dir"); + // Seed a real cache: a directory that is not a valid bare repository is + // quarantined and rebuilt, which would retire the packs before the + // sweep under test ever sees them. + create_private_dir(&repo_root).expect("repo root"); + git(&repo_root, &["init", "--bare", "--quiet", "git"]).expect("bare repo"); + let pack_dir = repo_root.join("git").join("objects").join("pack"); + fs::create_dir_all(&pack_dir).expect("pack dir"); + fs::write(pack_dir.join("tmp_pack_abandoned"), b"partial download").expect("stranded pack"); + // A real pack must survive: it is the cache this whole path exists for. + fs::write(pack_dir.join("pack-real.pack"), b"kept").expect("real pack"); + + provision_in_store( + &store, + DispatchWorkspaceProvisionRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-1".to_string(), + repo_key: "abcdef0123456789".to_string(), + project_label: Some("BitFun".to_string()), + remote_url: Some( + temp.path() + .join("no-such-repository") + .to_string_lossy() + .to_string(), + ), + base_commit: "0".repeat(40), + branch: "bitfun/dispatch/job-1".to_string(), + }, + ) + .expect("provision"); + + assert!( + !pack_dir.join("tmp_pack_abandoned").exists(), + "the stranded pack survived" + ); + assert!( + pack_dir.join("pack-real.pack").exists(), + "a real pack was deleted" + ); + } + + #[test] + fn a_git_command_that_never_finishes_is_killed_at_its_deadline() { + let temp = tempfile::tempdir().expect("tempdir"); + let repo = temp.path().to_path_buf(); + git(&repo, &["init", "--quiet", "--bare"]).expect("init"); + + let started = Instant::now(); + // `--stdin` with a null stdin returns immediately; a long sleep does not. + let error = git_within( + &repo, + Duration::from_millis(300), + &["-c", "alias.stall=!sleep 30", "stall"], + ) + .expect_err("the deadline should have fired"); + + assert!( + started.elapsed() < Duration::from_secs(10), + "it waited too long" + ); + assert!( + format!("{error:#}").contains("did not finish within"), + "unexpected error: {error:#}" + ); } #[test] @@ -2302,26 +2698,186 @@ mod tests { provision_in_store(store, request).expect("second provision"); } + /// Two sessions started from one workspace, with the job ids the controller + /// actually mints. Before the directory suffix digested the id, the second + /// one landed on the first one's checkout and provisioning refused it. + #[test] + fn a_second_session_on_one_workspace_provisions_alongside_the_first() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let source = temp.path().join("source"); + let base_commit = init_source_repository(&source); + let first = "dispatch-3d82ff46-bbf9-44c3-9c0f-2a1b0c4d5e6f"; + let second = "dispatch-ac8fe8a3-85c7-4091-9ba4-856db2d55c2b"; + + // The controller branches its baseline before bundling, so the managed + // branch is what the target fetches out of the bundle. + let bundle = temp.path().join("base.bundle"); + git(&source, &["branch", &format!("bitfun/dispatch/{first}")]).expect("managed branch"); + git( + &source, + &[ + "bundle", + "create", + path_arg(&bundle).expect("path"), + &format!("bitfun/dispatch/{first}"), + ], + ) + .expect("bundle"); + + let request_for = |job_id: &str| DispatchWorkspaceProvisionRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: job_id.to_string(), + repo_key: "abcdef0123456789".to_string(), + remote_url: None, + project_label: Some("BitFun".to_string()), + base_commit: base_commit.clone(), + branch: format!("bitfun/dispatch/{job_id}"), + }; + + // The first session has to carry the objects over: nothing is cached yet. + assert!( + provision_in_store(&store, request_for(first)) + .expect("first provision") + .needs_bundle + ); + bundle_begin_in_store( + &store, + DispatchWorkspaceBundleBeginRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: first.to_string(), + sha256: sha256_file(&bundle).expect("digest"), + size: fs::symlink_metadata(&bundle).expect("metadata").len(), + }, + ) + .expect("bundle begin"); + fs::copy( + &bundle, + store + .workspace_upload_dir(first) + .expect("job dir") + .join(INCOMING_BUNDLE_FILE), + ) + .expect("stage bundle"); + bundle_commit_in_store( + &store, + DispatchWorkspaceBundleCommitRequest { + job_id: first.to_string(), + }, + ) + .expect("bundle commit"); + let first_response = provision_in_store(&store, request_for(first)).expect("first publish"); + assert!(first_response.provisioned); + + // The second session shares the repository cache, so it needs no bundle + // — but it must still get a checkout of its own. + let second_response = + provision_in_store(&store, request_for(second)).expect("second session provision"); + assert!(second_response.provisioned, "second session was refused"); + assert!(!second_response.needs_bundle); + + let first_path = first_response.workspace_path.expect("first workspace"); + let second_path = second_response.workspace_path.expect("second workspace"); + assert_ne!( + first_path, second_path, + "both sessions shared one worktree directory" + ); + for path in [&first_path, &second_path] { + assert_eq!( + fs::read(Path::new(path).join("file.txt")).expect("checked out file"), + b"base" + ); + } + // Each checkout is parked on its own managed branch. + for (path, job_id) in [(&first_path, first), (&second_path, second)] { + let branch = git( + Path::new(path), + &["symbolic-ref", "--quiet", "--short", "HEAD"], + ) + .expect("branch"); + assert_eq!(branch.trim(), format!("bitfun/dispatch/{job_id}")); + } + + // And re-provisioning either one is still idempotent. + let repeat = provision_in_store(&store, request_for(first)).expect("first reprovision"); + assert_eq!(repeat.workspace_path.as_deref(), Some(first_path.as_str())); + } + #[test] - fn worktree_directories_are_named_after_the_project_not_the_job() { + fn worktree_directories_lead_with_the_project_label() { + let label = |name: &str| { + name.rsplit_once('-') + .map(|(head, _)| head.to_string()) + .expect("suffixed name") + }; assert_eq!( - worktree_directory_name(Some("BitFun"), None, "dispatch-3d82ff46-bbf9-44c3"), - "BitFun-dispatch" + label(&worktree_directory_name( + Some("BitFun"), + None, + "dispatch-3d82ff46-bbf9-44c3" + )), + "BitFun" ); // No label: the remote's own basename is the next most recognizable name. assert_eq!( - worktree_directory_name(None, Some("git@example.com:acme/app.git"), "abcdef123456"), - "app-abcdef12" + label(&worktree_directory_name( + None, + Some("git@example.com:acme/app.git"), + "abcdef123456" + )), + "app" ); assert_eq!( - worktree_directory_name(None, Some("https://example.com/acme/app/"), "abcdef123456"), - "app-abcdef12" + label(&worktree_directory_name( + None, + Some("https://example.com/acme/app/"), + "abcdef123456" + )), + "app" ); // Neither available: a constant, never an empty or job-shaped path. assert_eq!( - worktree_directory_name(None, None, "abcdef123456"), - "workspace-abcdef12" + label(&worktree_directory_name(None, None, "abcdef123456")), + "workspace" ); + // An id with no characters to digest still yields a usable directory. + assert_eq!(worktree_directory_name(Some("BitFun"), None, ""), "BitFun"); + } + + /// Every job id is minted as `dispatch-`, so a suffix sliced off the + /// front of the id is the same for all of them. That collapsed every session + /// of a project onto one directory and made the second one fail to provision. + #[test] + fn every_job_of_one_project_gets_its_own_worktree_directory() { + let name = |job_id: &str| worktree_directory_name(Some("BitFun"), None, job_id); + let first = name("dispatch-3d82ff46-bbf9-44c3-9c0f-2a1b0c4d5e6f"); + let second = name("dispatch-ac8fe8a3-85c7-4091-9ba4-856db2d55c2b"); + + assert_ne!(first, second); + assert!(first.starts_with("BitFun-"), "{first} lost its label"); + assert!(second.starts_with("BitFun-"), "{second} lost its label"); + // Stable across calls: a retry must land on the directory it already has. + assert_eq!(first, name("dispatch-3d82ff46-bbf9-44c3-9c0f-2a1b0c4d5e6f")); + // Ids that differ only past the slice window still separate. + assert_ne!(name("dispatch-aaaaaaaa-1"), name("dispatch-aaaaaaaa-2")); + } + + #[test] + fn worktree_directory_names_stay_safe_path_components() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let name = worktree_directory_name( + Some("BitFun"), + None, + "dispatch-3d82ff46-bbf9-44c3-9c0f-2a1b0c4d5e6f", + ); + + // `worktree_dir` is the real gate; the digest must clear it unchanged. + let path = store + .worktree_dir("abcdef0123456789", &name) + .expect("worktree path"); + assert!(path.starts_with(store.worktrees_root())); + assert!(path.ends_with(&name)); } #[test] diff --git a/src/apps/cli/src/embedded_app_server.rs b/src/apps/cli/src/embedded_app_server.rs index 2652598522..062d463f8f 100644 --- a/src/apps/cli/src/embedded_app_server.rs +++ b/src/apps/cli/src/embedded_app_server.rs @@ -2,53 +2,19 @@ use std::sync::Arc; +use crate::runtime::CliRuntimeContext; use crate::tui_backend::{AppServerTuiBackend, TuiBackend}; use anyhow::{Context, Result}; -use bitfun_app_server::{BitfunAppRuntime, BitfunAppServer}; +use bitfun_app_server::{AppManagementService, BitfunAppRuntime, BitfunAppServer}; use bitfun_app_server_protocol::app::{ClientInfo, HealthStatus, InitializeRequest}; use bitfun_app_server_protocol::PROTOCOL_VERSION; -use crate::agent::tui_client::{TuiAgentMode, TuiHostCapabilities}; -use crate::runtime::CliRuntimeContext; - pub(crate) struct EmbeddedAppServerHost { backend: Arc, shutdown_tx: Option>, server_thread: Option>, } -pub(crate) struct EmbeddedTuiHostCapabilities; - -#[async_trait::async_trait] -impl TuiHostCapabilities for EmbeddedTuiHostCapabilities { - async fn available_agent_modes( - &self, - _session_id: Option, - workspace: std::path::PathBuf, - ) -> Result> { - if let Err(error) = - bitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some( - &workspace, - )) - .await - { - tracing::warn!("Failed to initialize external agent sources: {error}"); - } - let registry = bitfun_core::agentic::agents::get_agent_registry(); - Ok(registry - .get_modes_info_for_workspace(Some(&workspace), true) - .await - .into_iter() - .map(|mode| TuiAgentMode { - id: mode.id, - description: mode.description, - model_id: mode.model, - is_external: mode.source == bitfun_core::agentic::agents::AgentSource::External, - }) - .collect()) - } -} - impl EmbeddedAppServerHost { pub(crate) async fn start(runtime: &CliRuntimeContext) -> Result { let (server_transport, client_transport) = @@ -58,9 +24,14 @@ impl EmbeddedAppServerHost { runtime.agent_event_source(), ) .with_context_reload(Arc::new(runtime.compatibility().clone())); + let management = Arc::new( + AppManagementService::load_for_local_host(Some(runtime.account_runtime().clone())) + .await?, + ); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let server_thread = std::thread::Builder::new() .name("bitfun-embedded-app-server".to_string()) + .stack_size(16 * 1024 * 1024) .spawn(move || { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -69,7 +40,9 @@ impl EmbeddedAppServerHost { let local = tokio::task::LocalSet::new(); runtime.block_on(local.run_until(async move { tokio::select! { - result = BitfunAppServer::new(app_runtime).serve(server_transport) => { + result = BitfunAppServer::new(app_runtime) + .with_management(management) + .serve(server_transport) => { if let Err(error) = result { tracing::warn!("Embedded App Server stopped with an error: {error}"); } diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 1b403aa630..2cfc3d4f36 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -9,7 +9,6 @@ /// - Single command execution /// - Batch task processing mod account; -mod account_sync; mod acp_cli; mod actions; mod agent; @@ -46,7 +45,7 @@ use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, OnceLock}; -use agent::tui_client::{TuiAgentClient, TuiHostCapabilities}; +use agent::tui_client::TuiAgentClient; use config::CliConfig; use hook_import::HookAction; use mcp_import::{McpImportCommand, McpImportOutputFormat}; @@ -90,6 +89,48 @@ pub fn get_mcp_service() -> Option<&'static std::sync::Arc, +) -> Option> { + if let Some(service) = get_mcp_service() { + return Some(service.clone()); + } + + let service = match bitfun_core::service::mcp::MCPService::new(config_service) { + Ok(service) => Arc::new(service), + Err(error) => { + tracing::warn!("Failed to create MCP service: {}", error); + get_mcp_init_status().store(3, Ordering::Relaxed); + return None; + } + }; + + if MCP_SERVICE.set(service.clone()).is_err() { + return get_mcp_service().cloned(); + } + bitfun_core::service::mcp::set_global_mcp_service(service.clone()); + get_mcp_init_status().store(1, Ordering::Relaxed); + + // Shared TUI keeps the pre-migration CLI-local MCP compatibility path. It + // is intentionally separate from the MCP manager inside Shared Runtime; + // this process must not be mistaken for that Runtime's owner. + let initializing = service.clone(); + tokio::spawn(async move { + match initializing.server_manager().initialize_all().await { + Ok(_) => { + tracing::info!("MCP servers initialized successfully"); + get_mcp_init_status().store(2, Ordering::Relaxed); + } + Err(error) => { + tracing::warn!("Failed to initialize MCP servers: {}", error); + get_mcp_init_status().store(3, Ordering::Relaxed); + } + } + }); + + Some(service) +} + #[derive(Parser)] #[command(name = "bitfun")] #[command(about = "BitFun CLI - AI agent-driven command-line programming assistant", long_about = None)] @@ -107,6 +148,22 @@ struct Cli { /// Automation, desktop, and remote modes remain unchanged. #[arg(long, verbatim_doc_comment)] shared: bool, + + /// Continue the most recent session (skip startup page) + #[arg(long = "continue", conflicts_with = "session")] + continue_last: bool, + + /// Open a specific session by ID (or "last" for the most recent) + #[arg(long, conflicts_with = "continue_last")] + session: Option, + + /// Specify the model ID for this session + #[arg(long)] + model: Option, + + /// Specify the agent type for this session + #[arg(long)] + agent: Option, } fn shared_tui_requested(shared: bool, command: &Option) -> Result { @@ -820,38 +877,10 @@ async fn initialize_core_services_for_deployment( } } - // Initialize MCP service in background (non-blocking) + // Initialize MCP service in background (non-blocking). if bootstrap_profile.starts_mcp() { - if let Some(ref cfg_svc) = config_service { - match bitfun_core::service::mcp::MCPService::new(cfg_svc.clone()) { - Ok(mcp_service) => { - let mcp_service = std::sync::Arc::new(mcp_service); - MCP_SERVICE.set(mcp_service.clone()).ok(); - bitfun_core::service::mcp::set_global_mcp_service(mcp_service.clone()); - - // Mark as in progress - get_mcp_init_status().store(1, Ordering::Relaxed); - - // Background async initialization - tokio::spawn(async move { - let result = mcp_service.server_manager().initialize_all().await; - match result { - Ok(_) => { - tracing::info!("MCP servers initialized successfully"); - get_mcp_init_status().store(2, Ordering::Relaxed); - } - Err(e) => { - tracing::warn!("Failed to initialize MCP servers: {}", e); - get_mcp_init_status().store(3, Ordering::Relaxed); - } - } - }); - } - Err(e) => { - tracing::warn!("Failed to create MCP service: {}", e); - get_mcp_init_status().store(3, Ordering::Relaxed); - } - } + if let Some(config_service) = config_service { + ensure_cli_mcp_service(config_service); } } @@ -877,6 +906,9 @@ async fn run_interactive( default_agent: String, _workspace_str: String, shared: bool, + agent_override: Option, + model_id: Option, + session_override: Option, ) -> Result<()> { use ui::startup::{StartupPage, StartupResult}; @@ -914,21 +946,22 @@ async fn run_interactive( .as_ref() .expect("Embedded App Server should be started with the Runtime") .backend(); - let host: Arc = - Arc::new(embedded_app_server::EmbeddedTuiHostCapabilities); Arc::new(TuiAgentClient::new( backend, - host, Some(workspace_path.clone()), false, runtime.approval_policy(), )) } else { let client = shared_runtime::connect_or_start(&workspace_path).await?; - let backend: Arc = - Arc::new(shared_tui_backend::SharedTuiBackend::new(client.clone())); - let host: Arc = - Arc::new(shared_tui_backend::SharedTuiHostCapabilities::new(client)); + let config_service = bitfun_core::service::config::get_global_config_service() + .await + .map_err(|error| anyhow!("Failed to load Shared TUI management config: {error}"))?; + ensure_cli_mcp_service(config_service); + let management = Arc::new(bitfun_app_server::AppManagementService::load().await?); + let backend: Arc = Arc::new( + shared_tui_backend::SharedTuiBackend::new(client, management), + ); let backend_initialized = backend .initialize(bitfun_app_server_protocol::app::InitializeRequest { protocol_version: bitfun_app_server_protocol::PROTOCOL_VERSION, @@ -944,28 +977,17 @@ async fn run_interactive( backend.health().await?; Arc::new(TuiAgentClient::new( backend, - host, Some(workspace_path.clone()), true, runtime::approval::CliApprovalPolicy::Ask, )) }; - let compatibility = runtime - .as_ref() - .map(|runtime| runtime.compatibility().clone()); - if !shared { - if let Err(error) = - bitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some( - &workspace_path, - )) - .await - { - tracing::warn!("Failed to initialize external agent sources: {error}"); - } - } // 3.5 Restore persisted account session (if any) if !shared { - if let Some(user_id) = account::try_restore_session().await { + let runtime = runtime + .as_ref() + .expect("Embedded account startup requires the CLI Runtime"); + if let Some(user_id) = runtime.account_runtime().try_restore_session().await { tracing::info!("Restored account session for user {user_id}"); if daemon::is_daemon_running() { tracing::info!( @@ -974,7 +996,11 @@ async fn run_interactive( } else { let device = DeviceIdentity::from_current_machine() .map_err(|e| anyhow!("detect device: {e}"))?; - if let Err(e) = account::restore_device_routing(&device.device_name).await { + if let Err(e) = runtime + .account_runtime() + .restore_device_routing(&device.device_name) + .await + { tracing::warn!("Failed to restore device routing: {e}"); } } @@ -984,23 +1010,58 @@ async fn run_interactive( // 3.6 Continuous account settings sync (30s pull + debounced push). // Safe to start before login: cycles skip while logged out. if !shared { - account_sync::start_settings_sync_loop(); + runtime + .as_ref() + .expect("Embedded settings sync requires the CLI Runtime") + .account_runtime() + .start_settings_sync_loop(); + } + + // Resolve agent override: validate against the agent registry AFTER core services init + let effective_agent = if let Some(ref override_val) = agent_override { + match resolve_agent_override(override_val).await { + Ok(valid_id) => valid_id, + Err(warning) => { + eprintln!("{warning}"); + default_agent.clone() + } + } + } else { + default_agent.clone() + }; + + // If --continue or --session was given, skip the startup page and go directly + // to chat with the resolved session. + if let Some(ref session_spec) = session_override { + let restore_session_id = resolve_startup_session_override(&agent, session_spec).await?; + + let mut chat_mode = ChatMode::new(config, effective_agent, workspace, agent) + .with_restore_session(restore_session_id); + if let Some(mid) = model_id { + chat_mode = chat_mode.with_model(mid); + } + let chat_result = chat_mode.run(Some(terminal)); + + if !shared { + shutdown_mcp_servers().await; + } + let _exit_reason = chat_result?; + println!("Goodbye!"); + return Ok(()); } // 4. Show startup page (with full command support) let mut startup_page = StartupPage::new( config, Arc::clone(&agent), - compatibility.clone(), - default_agent, + effective_agent, workspace.clone(), ); + startup_page.set_model_override(model_id.clone()); let startup_result = startup_page.run(&mut terminal)?; if let StartupResult::Exit = startup_result { - if !shared { - shutdown_mcp_servers().await; - } + shutdown_mcp_servers().await; ui::restore_terminal(terminal)?; println!("Goodbye!"); return Ok(()); @@ -1024,25 +1085,60 @@ async fn run_interactive( // Use the current project workspace selected at process start. let workspace = startup_page.workspace(); let config = startup_page.config().clone(); - let mut chat_mode = ChatMode::new(config, agent_type, workspace, agent, compatibility); + let mut chat_mode = ChatMode::new(config, agent_type, workspace, agent); if let Some(session_id) = restore_session_id { chat_mode = chat_mode.with_restore_session(session_id); } if let Some(prompt) = initial_prompt { chat_mode = chat_mode.with_initial_prompt(prompt); } + if let Some(mid) = model_id { + chat_mode = chat_mode.with_model(mid); + } let chat_result = chat_mode.run(Some(terminal)); // 6. Cleanup, including fatal event-stream exits. - if !shared { - shutdown_mcp_servers().await; - } + shutdown_mcp_servers().await; let _exit_reason = chat_result?; println!("Goodbye!"); Ok(()) } +/// Resolve a `--session` / `--continue` override to a concrete session ID. +/// "last" (or empty for --continue) resolves to the most recent session. +async fn resolve_startup_session_override( + agent: &Arc, + session_spec: &str, +) -> Result { + if session_spec == "last" || session_spec.is_empty() { + let sessions = agent.list_sessions().await?; + return sessions + .first() + .map(|s| s.session_id.clone()) + .ok_or_else(|| anyhow!("No history sessions for current project")); + } + bitfun_agent_runtime::session_control::validate_session_id(session_spec) + .map_err(anyhow::Error::msg)?; + Ok(session_spec.to_string()) +} + +/// Validate an agent override against the agent registry. +/// Returns the valid agent ID, or an error with a warning message. +async fn resolve_agent_override(agent_override: &str) -> std::result::Result { + let registry = bitfun_core::agentic::get_agent_registry(); + let modes = registry.get_modes_info().await; + if modes.iter().any(|m| m.id == agent_override) { + Ok(agent_override.to_string()) + } else { + let available: Vec<&str> = modes.iter().map(|m| m.id.as_str()).collect(); + Err(format!( + "Warning: Agent '{agent_override}' not found. Available: {}. Using default.", + available.join(", ") + )) + } +} + // ======================== Main ======================== #[derive(Debug)] @@ -1149,7 +1245,16 @@ async fn run_cli() -> Result<()> { match cli.command { Some(Commands::Chat { agent, .. }) => { // Interactive mode with startup page, scoped to the current directory. - run_interactive(config, agent, ".".to_string(), use_shared_runtime).await?; + run_interactive( + config, + agent, + ".".to_string(), + use_shared_runtime, + cli.agent.clone(), + cli.model.clone(), + None, + ) + .await?; } Some(Commands::SharedRuntime { @@ -1410,7 +1515,24 @@ async fn run_cli() -> Result<()> { let workspace_str = ".".to_string(); let default_agent = config.behavior.default_agent.clone(); - run_interactive(config, default_agent, workspace_str, use_shared_runtime).await?; + + // Resolve --continue / --session into a session override spec. + let session_override = if cli.continue_last { + Some("last".to_string()) + } else { + cli.session.clone() + }; + + run_interactive( + config, + default_agent, + workspace_str, + use_shared_runtime, + cli.agent.clone(), + cli.model.clone(), + session_override, + ) + .await?; } } @@ -1445,16 +1567,12 @@ async fn run_interactive_with_session( let workspace_path = runtime.workspace_root().to_path_buf(); let workspace = Some(workspace_path.to_string_lossy().to_string()); let embedded_app_server = embedded_app_server::EmbeddedAppServerHost::start(&runtime).await?; - let host: Arc = - Arc::new(embedded_app_server::EmbeddedTuiHostCapabilities); let agent = Arc::new(TuiAgentClient::new( embedded_app_server.backend(), - host, Some(workspace_path), false, runtime.approval_policy(), )); - let compatibility = runtime.compatibility().clone(); let sessions = agent.list_sessions().await?; let agent_type = sessions .iter() @@ -1467,8 +1585,8 @@ async fn run_interactive_with_session( ) })?; - let mut chat_mode = ChatMode::new(config, agent_type, workspace, agent, Some(compatibility)) - .with_restore_session(session_id); + let mut chat_mode = + ChatMode::new(config, agent_type, workspace, agent).with_restore_session(session_id); let run_result = chat_mode.run(Some(terminal)); shutdown_mcp_servers().await; diff --git a/src/apps/cli/src/management.rs b/src/apps/cli/src/management.rs index 26ea57db5f..dba216345b 100644 --- a/src/apps/cli/src/management.rs +++ b/src/apps/cli/src/management.rs @@ -189,7 +189,9 @@ pub(crate) async fn set_default_model(model_id: &str) -> Result<()> { // Short-lived management process: the sync loop never runs here, so push // the change directly (no-op when logged out). - crate::account_sync::push_settings_after_local_change().await; + crate::account::build_management_account_runtime() + .push_settings_after_local_change() + .await; Ok(()) } diff --git a/src/apps/cli/src/model_selection.rs b/src/apps/cli/src/model_selection.rs index b4bd59ac1c..af13622232 100644 --- a/src/apps/cli/src/model_selection.rs +++ b/src/apps/cli/src/model_selection.rs @@ -1,3 +1,4 @@ +use bitfun_app_server_protocol::model::{ListModelsResponse, ModelSummary}; use bitfun_core::service::config::AIConfig; fn resolve_model_selector(ai_config: &AIConfig, selector: &str) -> Option { @@ -27,6 +28,48 @@ pub(crate) fn resolve_session_model_display_id( resolve_model_selector(ai_config, selector) } +pub(crate) fn resolve_tui_model_id( + catalog: &ListModelsResponse, + session_selector: Option<&str>, +) -> Option { + let selector = session_selector + .map(str::trim) + .filter(|selector| !selector.is_empty()); + match selector { + None => catalog.mode_default_model_id.clone(), + Some("auto" | "default" | "primary") => catalog.primary_model_id.clone(), + Some("fast") => catalog + .fast_model_id + .clone() + .or_else(|| catalog.primary_model_id.clone()), + Some(model_id) => catalog + .models + .iter() + .find(|model| model.enabled && model.id == model_id) + .map(|model| model.id.clone()), + } +} + +pub(crate) fn tui_model_display_name(model: &ModelSummary) -> String { + let raw_name = model.name.trim(); + let model_name = model.model_name.trim(); + let provider = if !raw_name.is_empty() && !model_name.is_empty() { + let dashed_suffix = format!(" - {model_name}"); + let slash_suffix = format!("/{model_name}"); + raw_name + .strip_suffix(&dashed_suffix) + .or_else(|| raw_name.strip_suffix(&slash_suffix)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(raw_name) + } else if raw_name.is_empty() { + &model.provider + } else { + raw_name + }; + format!("{} / {}", model.model_name, provider) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 4fb56eaa9c..bba902a043 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -20,10 +20,12 @@ use std::sync::{ use std::time::{Duration, Instant}; use tokio::sync::broadcast::error::TryRecvError; -use bitfun_core_types::SessionUsageReport; +use bitfun_app_server_protocol::model::{AddModelRequest, UpdateModelRequest}; +use bitfun_app_server_protocol::skill::SkillSummary; +use bitfun_app_server_protocol::subagent::SubagentSummary; use bitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity}; use bitfun_runtime_ports::{ - AgentLocalCommandTurnRecordRequest, AgentSessionComposerUpdate, AgentSessionLineageEntry, + AgentSessionComposerUpdate, AgentSessionLineageEntry, AgentSessionLineageInspection, AgentSessionLineageSnapshot, AgentSessionUsageRequest, AgentTurnCancellationResult, AgentWorkspaceReferenceSearchResult, SessionTranscript, WorkspaceDiffSnapshot, @@ -65,53 +67,37 @@ use crate::ui::theme::{ }; use crate::ui::theme_selector::ThemeItem; use crate::ui::{init_terminal, restore_terminal, TerminalGuard}; -use bitfun_core::agentic::agents::{ - get_agent_registry, AgentInfo, SubAgentSource, SubagentListScope, SubagentQueryContext, +use bitfun_app_server_protocol::external_source::ExternalSourceReviewAction; +use bitfun_app_server_protocol::hook::{ + NativeHookOverview, NativeHookRuleSummary as NativeHookRuleView, }; -use bitfun_core::agentic::tools::implementations::skills::{ - mode_overrides::{ - load_project_mode_skills_document_local, save_project_mode_skills_document_local, - set_mode_skill_disabled_in_document, set_user_mode_skill_state, - }, - registry::SkillRegistry, - ModeSkillInfo, SkillInfo, -}; -use bitfun_core::external_hooks::{ +use bitfun_core::service::session_usage::render_usage_report_markdown; +use bitfun_product_domains::external_hook_catalog::{ ExternalHookCatalogSnapshotV1, ExternalHookMatcherSummary, ExternalHookNativeActivation, ExternalHookProjectionStatus, }; -use bitfun_core::external_sources::{ - apply_external_source_control_action, choose_external_subagent_conflict, - expand_external_prompt_command, external_source_conflict_choices, external_source_snapshot, - get_external_source_control_snapshot, native_prompt_command_conflict_key, - sanitize_external_source_operation_error, set_external_prompt_command_conflict_choice, - set_external_subagent_activation, set_external_subagent_model_binding, - set_external_tool_conflict_choice, set_external_tool_target_decision, - set_native_prompt_command_conflict_choice, subscribe_external_source_updates, - ExternalSourceAssetKind, ExternalSourceCatalogSnapshot, ExternalSourceControlActionV1, - ExternalSourceControlRequestV1, ExternalSourceDiagnosticSeverity, - ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode, - ExternalSubagentActivationState, ExternalSubagentCompatibilityState, - ExternalSubagentModelBindingMethod, ExternalSubagentModelBindingTarget, - ExternalSubagentModelProfileRequest, ExternalSubagentModelRequest, ExternalToolActivationState, - ExternalToolCapability, ExternalToolCatalogEntry, ExternalToolRuntimeKind, - NativePromptCommandDescriptor, PromptCommandAvailability, PromptCommandExecutionTarget, - PromptCommandInvocationOutcome, PromptCommandShellReviewDecision, PromptCommandShellReviewMode, - PromptCommandShellReviewPlan, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, -}; -use bitfun_core::native_hooks::{ - overview as native_hook_overview, NativeHookOverview, NativeHookRuleView, -}; -use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; -use bitfun_core::service::config::GlobalConfigManager; -use bitfun_core::service::session_usage::render_usage_report_markdown; use bitfun_product_domains::external_hook_import::{ ExternalHookImportApplyOutcomeV1, ExternalHookImportApplyRequestV1, - ExternalHookImportMutationV1, ExternalHookImportPlanV1, ExternalHookImportSnapshotV1, - EXTERNAL_HOOK_IMPORT_SCHEMA_V1, + ExternalHookImportMutationRequestV1, ExternalHookImportMutationV1, ExternalHookImportPlanV1, + ExternalHookImportSnapshotV1, EXTERNAL_HOOK_IMPORT_SCHEMA_V1, +}; +use bitfun_product_domains::external_source_control::{ + ExternalSourceControlActionV1, ExternalSourceControlRequestV1, + EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, }; use bitfun_product_domains::external_sources::{ - ExternalSourceHealth, ExternalSourceScope, SourceKey, + native_prompt_command_conflict_key, ExternalSourceAssetKind, ExternalSourceDiagnosticSeverity, + ExternalSourceHealth, ExternalSourceOperationError, ExternalSourceOperationErrorCode, + ExternalSourcePublicSnapshot as ExternalSourceCatalogSnapshot, ExternalSourceScope, + ExternalToolActivationState, ExternalToolCapability, ExternalToolCatalogEntry, + ExternalToolRuntimeKind, NativePromptCommandDescriptor, PromptCommandAvailability, + PromptCommandExecutionTarget, PromptCommandInvocationOutcome, PromptCommandShellReviewDecision, + PromptCommandShellReviewMode, PromptCommandShellReviewPlan, SourceKey, +}; +use bitfun_product_domains::external_subagents::{ + ExternalSubagentActivationState, ExternalSubagentCompatibilityState, + ExternalSubagentModelBindingMethod, ExternalSubagentModelBindingTarget, + ExternalSubagentModelProfileRequest, ExternalSubagentModelRequest, }; /// Spinner/UI redraw interval while a turn is processing. @@ -292,20 +278,20 @@ enum PendingMcpOp { enum PendingMcpTask { Toggle { server_id: String, - handle: tokio::task::JoinHandle>, + handle: tokio::task::JoinHandle>, }, Add { name: String, - handle: tokio::task::JoinHandle>, + handle: tokio::task::JoinHandle>, }, Delete { server_id: String, - handle: tokio::task::JoinHandle>, + handle: tokio::task::JoinHandle>, }, External { item_id: String, item_name: String, - handle: tokio::task::JoinHandle>, + handle: tokio::task::JoinHandle>, }, } @@ -464,7 +450,7 @@ fn terminal_event_allowed_while_local_effect_pending(event: &Event) -> bool { } const SESSION_OPERATION_SLOW_NOTICE: Duration = Duration::from_secs(15); -const SHARED_TUI_CHAT_STATUS: &str = "Shared TUI preview: this view controls sessions, including deleting an idle Session, turns, the current Session name, current Session Agent mode, current Session model, and declarative context via /reload [skills|instructions]; model management remains Embedded, along with local extension, MCP, account-sync, and Agent/Subagent management."; +const SHARED_TUI_CHAT_STATUS: &str = "Shared TUI preview: this view controls sessions, including deleting an idle Session, turns, the current Session name, current Session Agent mode, and declarative context via /reload [skills|instructions]. Model, Skill, Subagent, and MCP management use this CLI process's local compatibility owner; MCP process state and tool registration are local to this CLI process and do not reconfigure an already-running Shared Runtime Host. Local extension, account-sync, usage, and other management remain Embedded."; #[derive(Default)] struct NonKeyEventOutcome { @@ -513,7 +499,6 @@ pub(crate) struct ChatMode { workspace: Option, local_cwd: std::path::PathBuf, agent: Arc, - compatibility: Option, /// User-level default resolved from shared config for this TUI run. auto_approve_ask_default: bool, /// Temporary override for the current session only. @@ -522,6 +507,8 @@ pub(crate) struct ChatMode { restore_session_id: Option, /// If set, send this prompt automatically when the session starts initial_prompt: Option, + /// If set, override the session model after create/restore + model_id: Option, /// Pending MCP operation — set in key handler, executed after one render frame pending_mcp_op: Option, /// Running MCP tasks (non-blocking, polled in main loop) @@ -569,18 +556,14 @@ pub(crate) struct ChatMode { external_tool_notice_key: Option, external_tool_review_snapshot: Option, external_tool_mutation_rx: Option>, + external_control_snapshot: + Option, external_control_mutation_rx: Option>, external_agent_notice_key: Option, external_agent_review_snapshot: Option, external_agent_mutation_rx: Option>, - hook_management_rx: Option< - Receiver< - std::result::Result< - HookManagementResult, - bitfun_core::external_sources::ExternalSourceOperationError, - >, - >, - >, + hook_management_rx: + Option>>, hook_management_snapshot: Option, pending_hook_plan: Option, } @@ -599,7 +582,6 @@ impl ChatMode { agent_type: String, workspace: Option, agent: Arc, - compatibility: Option, ) -> Self { let keymap = ResolvedKeymap::new(&config.shortcuts); Self { @@ -609,11 +591,11 @@ impl ChatMode { workspace, local_cwd: std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), agent, - compatibility, auto_approve_ask_default: false, auto_approve_ask_override: None, restore_session_id: None, initial_prompt: None, + model_id: None, pending_mcp_op: None, pending_mcp_tasks: Vec::new(), pending_session_operation: None, @@ -641,6 +623,7 @@ impl ChatMode { external_tool_notice_key: None, external_tool_review_snapshot: None, external_tool_mutation_rx: None, + external_control_snapshot: None, external_control_mutation_rx: None, external_agent_notice_key: None, external_agent_review_snapshot: None, @@ -666,6 +649,12 @@ impl ChatMode { self } + /// Set a model ID to override the session model after create/restore + pub(crate) fn with_model(mut self, model_id: String) -> Self { + self.model_id = Some(model_id); + self + } + fn action_state(&self, is_processing: bool, popup_open: bool) -> ActionState { ActionState::chat(is_processing, popup_open) .with_shared_tui(self.agent.is_shared()) diff --git a/src/apps/cli/src/modes/chat/account.rs b/src/apps/cli/src/modes/chat/account.rs index 8ff66ec858..96d6866912 100644 --- a/src/apps/cli/src/modes/chat/account.rs +++ b/src/apps/cli/src/modes/chat/account.rs @@ -8,70 +8,65 @@ impl ChatMode { self.external_source_conflicted_candidate_ids = preferences.conflicted_candidate_ids; } - fn workspace_path_for_sync(&self, chat_state: &ChatState) -> std::path::PathBuf { - chat_state - .workspace - .as_ref() - .map(std::path::PathBuf::from) - .or_else(|| self.workspace.clone().map(std::path::PathBuf::from)) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| std::path::PathBuf::from(".")) - } - fn open_login_or_account_panel( &self, chat_view: &mut ChatView, chat_state: &ChatState, rt_handle: &tokio::runtime::Handle, ) { - let logged_in = - tokio::task::block_in_place(|| rt_handle.block_on(crate::account::is_logged_in())); - if logged_in { - self.open_account_panel(chat_view, rt_handle); - } else { - chat_view.show_login_form(); + let snapshot = + tokio::task::block_in_place(|| rt_handle.block_on(self.agent.account_snapshot())); + match snapshot { + Ok(snapshot) if snapshot.logged_in => self.open_account_panel(chat_view, snapshot), + Ok(_) => chat_view.show_login_form(), + Err(error) => { + chat_view.show_login_form(); + chat_view.login_form_set_error(format!("Failed to load account: {error}")); + } } let _ = chat_state; } - fn open_account_panel(&self, chat_view: &mut ChatView, rt_handle: &tokio::runtime::Handle) { - let (info, devices, progress) = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let info = crate::account::account_info().await; - let devices = crate::account::list_devices().await.unwrap_or_default(); - let progress = crate::account_sync::current_sync_progress().await; - (info, devices, progress) - }) - }); - match info { - Ok(info) => chat_view.show_account_panel(info, devices, progress), - Err(e) => { - chat_view.set_status(Some(format!("Failed to load account: {e}"))); - chat_view.show_login_form(); - } - } + fn open_account_panel( + &self, + chat_view: &mut ChatView, + snapshot: bitfun_app_server_protocol::account::AccountSnapshotResponse, + ) { + let Some(info) = snapshot.info else { + chat_view.show_login_form(); + return; + }; + chat_view.show_account_panel(info, snapshot.devices, snapshot.sync); } - fn refresh_account_panel_live(&self, chat_view: &mut ChatView) { + fn refresh_account_panel_live(&self, chat_view: &mut ChatView) -> bool { if !chat_view.login_form_visible() { - return; + return false; } - let progress = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(crate::account_sync::current_sync_progress()) - }); + let Ok(progress) = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.agent.settings_sync_snapshot()) + }) else { + return false; + }; + let progress = progress.progress; let devices = if matches!( progress.status, - crate::account_sync::SyncStatus::Syncing | crate::account_sync::SyncStatus::Done + bitfun_app_server_protocol::account::SettingsSyncStatus::Syncing + | bitfun_app_server_protocol::account::SettingsSyncStatus::Done ) { tokio::task::block_in_place(|| { tokio::runtime::Handle::current() - .block_on(crate::account::list_devices()) + .block_on(self.agent.account_snapshot()) .ok() + .map(|snapshot| snapshot.devices) }) } else { None }; + let syncing = + progress.status == bitfun_app_server_protocol::account::SettingsSyncStatus::Syncing; chat_view.update_account_panel_progress(devices, progress); + syncing } fn start_sync_and_show_account( @@ -81,16 +76,18 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - let Some(compatibility) = self.compatibility.clone() else { - self.open_account_panel(chat_view, rt_handle); - chat_state.add_system_message(format!( - "Account settings sync is unavailable in Shared TUI preview. {SHARED_TUI_EMBEDDED_HANDOFF}" - )); + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.settings_sync_start(is_first_login)) + }); + if let Err(error) = result { + chat_state.add_system_message(format!("Account settings sync failed: {error}")); return; - }; - let workspace = self.workspace_path_for_sync(chat_state); - crate::account_sync::start_auto_sync_background(compatibility, is_first_login, workspace); - self.open_account_panel(chat_view, rt_handle); + } + if let Ok(snapshot) = + tokio::task::block_in_place(|| rt_handle.block_on(self.agent.account_snapshot())) + { + self.open_account_panel(chat_view, snapshot); + } chat_state.add_system_message(if is_first_login { "Sync started (use local / upload settings).".to_string() } else { @@ -108,10 +105,10 @@ impl ChatMode { match action { LoginFormAction::Submit(creds) => { let result = tokio::task::block_in_place(|| { - rt_handle.block_on(crate::account::login_with_credentials( - &creds.relay_url, - &creds.username, - &creds.password, + rt_handle.block_on(self.agent.account_login( + creds.relay_url, + creds.username, + creds.password, )) }); match result { @@ -131,40 +128,60 @@ impl ChatMode { } } LoginFormAction::SyncUseLocal => { - if let Err(e) = tokio::task::block_in_place(|| { - rt_handle.block_on(crate::account::finalize_login_after_sync_choice()) - }) { - chat_view.login_form_set_error(format!("Finalize login failed: {e}")); - let _ = tokio::task::block_in_place(|| { - rt_handle.block_on(crate::account::logout()) - }); - chat_view.show_login_form(); - return Ok(None); - } - self.start_sync_and_show_account(true, chat_view, chat_state, rt_handle); + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.account_finalize_login( + bitfun_app_server_protocol::account::AccountSyncChoice::Local, + )) + }); + let snapshot = match result { + Ok(snapshot) => snapshot, + Err(error) => { + chat_view.login_form_set_error(format!("Finalize login failed: {error}")); + let _ = tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.account_logout()) + }); + chat_view.show_login_form(); + return Ok(None); + } + }; + self.open_account_panel(chat_view, snapshot); + chat_state + .add_system_message("Sync started (use local / upload settings).".to_string()); } LoginFormAction::SyncUseCloud => { - if let Err(e) = tokio::task::block_in_place(|| { - rt_handle.block_on(crate::account::finalize_login_after_sync_choice()) - }) { - chat_view.login_form_set_error(format!("Finalize login failed: {e}")); - let _ = tokio::task::block_in_place(|| { - rt_handle.block_on(crate::account::logout()) - }); - chat_view.show_login_form(); - return Ok(None); - } - self.start_sync_and_show_account(false, chat_view, chat_state, rt_handle); + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.account_finalize_login( + bitfun_app_server_protocol::account::AccountSyncChoice::Cloud, + )) + }); + let snapshot = match result { + Ok(snapshot) => snapshot, + Err(error) => { + chat_view.login_form_set_error(format!("Finalize login failed: {error}")); + let _ = tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.account_logout()) + }); + chat_view.show_login_form(); + return Ok(None); + } + }; + self.open_account_panel(chat_view, snapshot); + chat_state.add_system_message( + "Sync started (use cloud / download settings).".to_string(), + ); } LoginFormAction::SyncCancel => { - let _ = - tokio::task::block_in_place(|| rt_handle.block_on(crate::account::logout())); + let _ = tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.settings_sync_cancel()) + }); chat_view.show_login_form(); chat_state.add_system_message("Sync cancelled; logged out.".to_string()); } LoginFormAction::Logout => { - match tokio::task::block_in_place(|| rt_handle.block_on(crate::account::logout())) { - Ok(()) => { + match tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.account_logout()) + }) { + Ok(_) => { chat_view.show_login_form(); chat_state.add_system_message("Logged out.".to_string()); } diff --git a/src/apps/cli/src/modes/chat/capabilities.rs b/src/apps/cli/src/modes/chat/capabilities.rs index 741e862449..58e67c47eb 100644 --- a/src/apps/cli/src/modes/chat/capabilities.rs +++ b/src/apps/cli/src/modes/chat/capabilities.rs @@ -52,18 +52,15 @@ impl ChatMode { rt_handle: &tokio::runtime::Handle, ) { let skills = tokio::task::block_in_place(|| { - let workspace = self.agent.workspace_path_buf(); - let agent_type = self.agent_type.clone(); - rt_handle.block_on(async { - let registry = SkillRegistry::global(); - registry - .get_user_invocable_skills_for_workspace( - Some(workspace.as_path()), - Some(&agent_type), - ) - .await - }) + rt_handle.block_on(self.agent.list_skills(self.agent_type.clone(), false)) }); + let skills = match skills { + Ok(response) => response.skills, + Err(error) => { + chat_state.add_system_message(format!("Could not load skills: {error}")); + return; + } + }; if skills.is_empty() { chat_state.add_system_message(format!( @@ -73,8 +70,10 @@ impl ChatMode { return; } - let skill_items: Vec = - skills.into_iter().map(Self::skill_item_from_info).collect(); + let skill_items: Vec = skills + .into_iter() + .map(Self::skill_item_from_summary) + .collect(); if skill_items.is_empty() { chat_state.add_system_message("No skills found.".to_string()); @@ -91,19 +90,19 @@ impl ChatMode { rt_handle: &tokio::runtime::Handle, ) { let skills = tokio::task::block_in_place(|| { - let workspace = self.agent.workspace_path_buf(); - let agent_type = self.agent_type.clone(); - rt_handle.block_on(async { - let registry = SkillRegistry::global(); - registry - .get_mode_skill_infos_for_workspace(Some(workspace.as_path()), &agent_type) - .await - }) + rt_handle.block_on(self.agent.list_skills(self.agent_type.clone(), true)) }); + let skills = match skills { + Ok(response) => response.skills, + Err(error) => { + chat_state.add_system_message(format!("Could not load skills: {error}")); + return; + } + }; let skill_items: Vec = skills .into_iter() - .map(Self::skill_item_from_mode_info) + .map(Self::skill_item_from_summary) .collect(); if skill_items.is_empty() { @@ -152,49 +151,21 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - let workspace = self.agent.workspace_path_buf(); let mode_id = self.agent_type.clone(); let skill = selected.clone(); - let result: Result<(), String> = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - match skill.level.as_str() { - "user" => { - set_user_mode_skill_state( - &mode_id, - &skill.key, - enabled, - skill.default_enabled, - ) - .await - .map_err(|error| error.to_string())?; - } - "project" => { - let mut document = load_project_mode_skills_document_local(&workspace) - .await - .map_err(|error| error.to_string())?; - set_mode_skill_disabled_in_document( - &mut document, - &mode_id, - &skill.key, - !enabled, - ) - .map_err(|error| error.to_string())?; - save_project_mode_skills_document_local(&workspace, &document) - .await - .map_err(|error| error.to_string())?; - } - other => { - return Err(format!("Unsupported skill level '{}'", other)); - } - } - - Ok(()) - }) + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.set_skill_enabled( + mode_id, + skill.key, + enabled, + skill.default_enabled, + skill.level, + )) }); match result { - Ok(()) => chat_state.add_system_message(format!( + Ok(_) => chat_state.add_system_message(format!( "Skill '{}' {} for mode '{}'.", selected.name, if enabled { "enabled" } else { "disabled" }, @@ -207,40 +178,23 @@ impl ChatMode { } } - fn skill_item_from_info(info: SkillInfo) -> SkillItem { + fn skill_item_from_summary(info: SkillSummary) -> SkillItem { SkillItem { key: info.key, name: info.name, description: info.description, - level: info.level.as_str().to_string(), - source_slot: info.source_slot, - source_label: info.source_label, - enabled: true, - selected_for_runtime: true, - default_enabled: true, + level: info.level, + source_slot: info.source_slot.unwrap_or_default(), + source_label: info.source_label.unwrap_or_default(), + enabled: info.enabled, + selected_for_runtime: info.selected_for_runtime, + default_enabled: info.default_enabled, is_shadowed: info.is_shadowed, shadowed_by_key: info.shadowed_by_key, argument_hint: info.argument_hint, } } - fn skill_item_from_mode_info(info: ModeSkillInfo) -> SkillItem { - SkillItem { - key: info.skill.key, - name: info.skill.name, - description: info.skill.description, - level: info.skill.level.as_str().to_string(), - source_slot: info.skill.source_slot, - source_label: info.skill.source_label, - enabled: info.effective_enabled, - selected_for_runtime: info.selected_for_runtime, - default_enabled: info.default_enabled, - is_shadowed: info.skill.is_shadowed, - shadowed_by_key: info.skill.shadowed_by_key, - argument_hint: info.skill.argument_hint, - } - } - /// Show subagent list/configuration menu. fn show_subagent_selector( &self, @@ -257,18 +211,16 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - let registry = get_agent_registry(); let subagents = tokio::task::block_in_place(|| { - let workspace = self.agent.workspace_path_buf(); - let agent_type = self.agent_type.clone(); - rt_handle.block_on(registry.get_subagents_for_query(&SubagentQueryContext { - parent_agent_type: Some(&agent_type), - workspace_root: Some(workspace.as_path()), - list_scope: SubagentListScope::TaskVisible, - include_disabled: false, - external_sources_supported: true, - })) + rt_handle.block_on(self.agent.list_subagents(self.agent_type.clone(), false)) }); + let subagents = match subagents { + Ok(response) => response.subagents, + Err(error) => { + chat_state.add_system_message(format!("Could not load subagents: {error}")); + return; + } + }; if subagents.is_empty() { chat_state.add_system_message(format!( @@ -280,7 +232,7 @@ impl ChatMode { let subagent_items: Vec = subagents .into_iter() - .map(Self::subagent_item_from_info) + .map(Self::subagent_item_from_summary) .collect(); if subagent_items.is_empty() { @@ -297,26 +249,21 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - let registry = get_agent_registry(); let subagents = tokio::task::block_in_place(|| { - let workspace = self.agent.workspace_path_buf(); - let agent_type = self.agent_type.clone(); - rt_handle.block_on(registry.get_subagents_for_query(&SubagentQueryContext { - parent_agent_type: Some(&agent_type), - workspace_root: Some(workspace.as_path()), - list_scope: SubagentListScope::RegistryManagement, - include_disabled: true, - external_sources_supported: true, - })) + rt_handle.block_on(self.agent.list_subagents(self.agent_type.clone(), true)) }); - - let has_external_subagents = subagents - .iter() - .any(|info| info.subagent_source == Some(SubAgentSource::External)); - let subagent_items: Vec = subagents + let response = match subagents { + Ok(response) => response, + Err(error) => { + chat_state.add_system_message(format!("Could not load subagents: {error}")); + return; + } + }; + let has_external_subagents = response.has_external; + let subagent_items: Vec = response + .subagents .into_iter() - .filter(|info| info.subagent_source != Some(SubAgentSource::External)) - .map(Self::subagent_item_from_info) + .map(Self::subagent_item_from_summary) .collect(); if subagent_items.is_empty() { @@ -373,27 +320,18 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - let registry = get_agent_registry(); - let workspace = self.agent.workspace_path_buf(); let mode_id = self.agent_type.clone(); let subagent = selected.clone(); - let result: Result<(), String> = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - registry - .update_subagent_override( - &mode_id, - &subagent.id, - enabled, - Some(workspace.as_path()), - ) - .await - .map_err(|error| error.to_string()) - }) + let result = tokio::task::block_in_place(|| { + rt_handle.block_on( + self.agent + .set_subagent_enabled(mode_id, subagent.id, enabled), + ) }); match result { - Ok(()) => chat_state.add_system_message(format!( + Ok(_) => chat_state.add_system_message(format!( "Subagent '{}' {} for mode '{}'.", selected.name, if enabled { "enabled" } else { "disabled" }, @@ -406,23 +344,14 @@ impl ChatMode { } } - fn subagent_item_from_info(info: AgentInfo) -> SubagentItem { - let source = match info.subagent_source { - Some(SubAgentSource::Builtin) => "builtin", - Some(SubAgentSource::Project) => "project", - Some(SubAgentSource::User) => "user", - Some(SubAgentSource::External) => "external", - None => "builtin", - } - .to_string(); - + fn subagent_item_from_summary(info: SubagentSummary) -> SubagentItem { SubagentItem { key: info.key, id: info.id, name: info.name, description: info.description, - source, - enabled: info.effective_enabled, + source: info.source, + enabled: info.enabled, } } } diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 31251d90dc..e684ddd954 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -426,42 +426,11 @@ impl ChatMode { } let builtin_alias = format!("/{command_name}"); let builtin_action = action_for_alias(&builtin_alias, ActionContext::Chat); - if self.agent.is_shared() { - if let Some(action) = builtin_action { - let state = self.action_state(chat_state.is_processing, false); - if let Some(usage) = - builtin_arguments_error(CommandRoute::Builtin, action.handler, arguments) - { - chat_view.set_status(Some(usage.to_string())); - return Ok(None); - } - if builtin_arguments_route(CommandRoute::Builtin, action.handler) { - if !action.available(state) { - chat_view.set_status(Some(action.unavailable_message(state))); - return Ok(None); - } - return self.start_session_rename(arguments, chat_view, chat_state, rt_handle); - } - if action.handler == ActionHandler::Reload { - return self.handle_reload_invocation( - reload_invocation.expect("reload action requires a parsed invocation"), - chat_view, - chat_state, - rt_handle, - ); - } - return self.dispatch_action(action, state, chat_view, chat_state, rt_handle); - } - chat_state.add_system_message(format!( - "External prompt command /{command_name} is unavailable in Shared TUI preview. {SHARED_TUI_EMBEDDED_HANDOFF}." - )); - return Ok(None); - } let mut external = self.external_command_projection(command_name); let authoritative_preferences = tokio::task::block_in_place(|| { rt_handle - .block_on(external_source_conflict_choices()) - .map(Into::into) + .block_on(self.agent.external_source_snapshot(false)) + .map(|response| response.preferences.into()) }); if let Ok(authoritative_preferences) = authoritative_preferences { if authoritative_preferences != self.external_conflict_preferences() { @@ -731,29 +700,23 @@ impl ChatMode { return; } let native_commands = cli_native_prompt_command_descriptors(command_name); - let workspace = self.agent.workspace_path_buf(); let expected_preference_revision = self .external_source_snapshot .as_ref() .map(|snapshot| snapshot.preference_revision) .unwrap_or(0); let persisted = tokio::task::block_in_place(|| { - rt_handle.block_on(set_native_prompt_command_conflict_choice( - Some(&workspace), + rt_handle.block_on(self.agent.set_native_command_choice( native_commands, - candidate_id, + candidate_id.to_string(), expected_preference_revision, )) }); match persisted { - Ok(projection) => { - if let Ok(preferences) = tokio::task::block_in_place(|| { - rt_handle.block_on(external_source_conflict_choices()) - }) { - self.replace_external_conflict_preferences(preferences.into()); - } + Ok(response) => { + self.replace_external_conflict_preferences(response.preferences.into()); if let Some(snapshot) = &mut self.external_source_snapshot { - snapshot.preference_revision = projection.preference_revision; + snapshot.preference_revision = response.conflicts.preference_revision; } } Err(error) => { @@ -788,22 +751,25 @@ impl ChatMode { return Ok(None); } if let Some(provider_conflict_key) = &projection.provider_conflict_key { - let workspace = self.agent.workspace_path_buf(); let expected_preference_revision = self .external_source_snapshot .as_ref() .map(|snapshot| snapshot.preference_revision) .unwrap_or(0); let snapshot = tokio::task::block_in_place(|| { - rt_handle.block_on(set_external_prompt_command_conflict_choice( - Some(&workspace), - provider_conflict_key, - &projection.candidate_id, - expected_preference_revision, + rt_handle.block_on(self.agent.external_source_review( + ExternalSourceReviewAction::SetPromptCommandConflictChoice { + conflict_key: provider_conflict_key.clone(), + candidate_id: projection.candidate_id.clone(), + expected_preference_revision, + }, )) }); let snapshot = match snapshot { - Ok(snapshot) => snapshot, + Ok(response) => { + self.replace_external_conflict_preferences(response.preferences.into()); + response.snapshot + } Err(error) => { chat_state.add_system_message(format!( "Could not select {}: {error}", @@ -907,25 +873,25 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) -> Result> { - let workspace = self.agent.workspace_path_buf(); let expanded = tokio::task::block_in_place(|| { - rt_handle.block_on(expand_external_prompt_command( - Some(&workspace), - &invocation.command_name, - &invocation.arguments, + rt_handle.block_on(self.agent.expand_external_command( + invocation.command_name.clone(), + invocation.arguments.clone(), invocation.native_commands.clone(), - invocation.candidate_id.as_deref(), - invocation.content_version.as_deref(), - invocation.native_conflict_key.as_deref(), + invocation.candidate_id.clone(), + invocation.content_version.clone(), + invocation.native_conflict_key.clone(), invocation.expected_preference_revision, - shell_review_decision.as_ref(), + shell_review_decision, )) }); match expanded { - Ok(PromptCommandInvocationOutcome::Ready { - content, - execution_target, - }) => { + Ok(bitfun_app_server_protocol::external_source::ExpandExternalCommandResponse( + PromptCommandInvocationOutcome::Ready { + content, + execution_target, + }, + )) => { match execution_target { PromptCommandExecutionTarget::Inline => { self.send_message_to_agent(content, chat_view, chat_state, rt_handle); @@ -952,13 +918,15 @@ impl ChatMode { } Ok(None) } - Ok(PromptCommandInvocationOutcome::ReviewRequired { review }) => { + Ok(bitfun_app_server_protocol::external_source::ExpandExternalCommandResponse( + PromptCommandInvocationOutcome::ReviewRequired { review }, + )) => { chat_view.show_prompt_command_shell_review(review.clone()); self.pending_prompt_command_shell_invocation = Some(PendingPromptCommandShellInvocation { invocation, review }); Ok(None) } - Err(error) if error.contains("command not found") => Err(anyhow!(error)), + Err(error) if error.detail.contains("command not found") => Err(anyhow!(error.detail)), Err(error) => { chat_state.add_system_message(format!( "External command /{} is unavailable: {error}", diff --git a/src/apps/cli/src/modes/chat/external_hooks.rs b/src/apps/cli/src/modes/chat/external_hooks.rs index 3ee530c48c..51bc0cbe7e 100644 --- a/src/apps/cli/src/modes/chat/external_hooks.rs +++ b/src/apps/cli/src/modes/chat/external_hooks.rs @@ -332,7 +332,9 @@ fn matcher_label(matcher: &ExternalHookMatcherSummary) -> String { } } -fn projection_label(entry: &bitfun_core::external_hooks::ExternalHookCatalogEntry) -> &'static str { +fn projection_label( + entry: &bitfun_product_domains::external_hook_catalog::ExternalHookCatalogEntry, +) -> &'static str { match entry.projection_status { ExternalHookProjectionStatus::Mapped => match entry .mapping @@ -381,8 +383,10 @@ fn source_health_label(health: ExternalSourceHealth) -> &'static str { } } -fn hook_handler_label(kind: bitfun_core::external_hooks::ExternalHookHandlerKind) -> &'static str { - use bitfun_core::external_hooks::ExternalHookHandlerKind; +fn hook_handler_label( + kind: bitfun_product_domains::external_hook_catalog::ExternalHookHandlerKind, +) -> &'static str { + use bitfun_product_domains::external_hook_catalog::ExternalHookHandlerKind; match kind { ExternalHookHandlerKind::Function => "function", ExternalHookHandlerKind::Command => "command", @@ -455,21 +459,16 @@ impl ChatMode { return; } }; - let workspace_root = self.workspace_path_for_sync(chat_state); match action { HookManagementAction::Show { refresh } => { if let Some(snapshot) = &self.hook_management_snapshot { chat_state.add_system_message(render_hook_management(snapshot)); } + let agent = Arc::clone(&self.agent); self.spawn_hook_management( async move { - let imports = - bitfun_core::external_hook_import::external_hook_import_snapshot( - Some(workspace_root.as_path()), - refresh, - ) - .await?; - let native = native_hook_overview(Some(workspace_root.as_path())).await; + let imports = agent.external_hook_snapshot(refresh).await?; + let native = agent.native_hook_overview().await?; Ok(HookManagementResult::Snapshot(HookManagementSnapshot { native, imports, @@ -506,14 +505,7 @@ impl ChatMode { )); return; }; - self.start_hook_plan_or_apply( - source, - confirm, - workspace_root, - chat_view, - chat_state, - rt_handle, - ); + self.start_hook_plan_or_apply(source, confirm, chat_view, chat_state, rt_handle); } HookManagementAction::Update { import_number, @@ -525,21 +517,13 @@ impl ChatMode { else { return; }; - self.start_hook_plan_or_apply( - source, - confirm, - workspace_root, - chat_view, - chat_state, - rt_handle, - ); + self.start_hook_plan_or_apply(source, confirm, chat_view, chat_state, rt_handle); } HookManagementAction::Enable { import_number } => { self.start_hook_mutation( import_number, true, false, - workspace_root, chat_view, chat_state, rt_handle, @@ -550,7 +534,6 @@ impl ChatMode { import_number, false, false, - workspace_root, chat_view, chat_state, rt_handle, @@ -561,20 +544,13 @@ impl ChatMode { import_number, false, true, - workspace_root, chat_view, chat_state, rt_handle, ); } HookManagementAction::Reset { scope } => { - self.start_hook_store_reset( - scope, - workspace_root, - chat_view, - chat_state, - rt_handle, - ); + self.start_hook_store_reset(scope, chat_view, chat_state, rt_handle); } } } @@ -583,20 +559,18 @@ impl ChatMode { &mut self, source: SourceKey, confirm: bool, - workspace_root: std::path::PathBuf, chat_view: &mut ChatView, chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { if !confirm { + let agent = Arc::clone(&self.agent); self.spawn_hook_management( async move { - bitfun_core::external_hook_import::plan_external_hook_import( - Some(workspace_root.as_path()), - source, - ) - .await - .map(HookManagementResult::Plan) + agent + .external_hook_plan(source) + .await + .map(HookManagementResult::Plan) }, "Preparing Hook import review...", chat_view, @@ -616,17 +590,16 @@ impl ChatMode { ); return; }; + let agent = Arc::clone(&self.agent); self.spawn_hook_management( async move { - let result = bitfun_core::external_hook_import::apply_external_hook_import( - Some(workspace_root.as_path()), - ExternalHookImportApplyRequestV1 { + let result = agent + .external_hook_apply(ExternalHookImportApplyRequestV1 { schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, source: source.clone(), plan_fingerprint: plan.plan_fingerprint, - }, - ) - .await?; + }) + .await?; let (snapshot, applied) = match result.outcome { ExternalHookImportApplyOutcomeV1::Stale { refreshed_plan } => { return Ok(HookManagementResult::Plan(refreshed_plan)); @@ -637,7 +610,7 @@ impl ChatMode { let status = crate::hook_import::completed_import_status(&snapshot, &source, applied) .to_string(); - let native = native_hook_overview(Some(workspace_root.as_path())).await; + let native = agent.native_hook_overview().await?; Ok(HookManagementResult::Changed { snapshot: HookManagementSnapshot { native, @@ -676,7 +649,6 @@ impl ChatMode { import_number: usize, enabled: bool, remove: bool, - workspace_root: std::path::PathBuf, chat_view: &mut ChatView, chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, @@ -695,11 +667,22 @@ impl ChatMode { enabled, } }; + let expected_revision = self + .hook_management_snapshot + .as_ref() + .map(|snapshot| snapshot.imports.revision.clone()) + .expect("import_at requires a loaded Hook snapshot"); + let agent = Arc::clone(&self.agent); self.spawn_hook_management( async move { - let imports = - crate::hook_import::mutate(Some(workspace_root.as_path()), action).await?; - let native = native_hook_overview(Some(workspace_root.as_path())).await; + let imports = agent + .external_hook_mutate(ExternalHookImportMutationRequestV1 { + schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, + expected_revision, + action, + }) + .await?; + let native = agent.native_hook_overview().await?; let status = if remove { format!( "Removed BitFun's managed copy of {import_id}; the source was unchanged." @@ -723,7 +706,6 @@ impl ChatMode { fn start_hook_store_reset( &mut self, scope: ExternalSourceScope, - workspace_root: std::path::PathBuf, chat_view: &mut ChatView, chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, @@ -753,14 +735,18 @@ impl ChatMode { )); return; } + let expected_revision = snapshot.imports.revision.clone(); + let agent = Arc::clone(&self.agent); self.spawn_hook_management( async move { - let imports = crate::hook_import::mutate( - Some(workspace_root.as_path()), - ExternalHookImportMutationV1::ResetCorruptStore { scope }, - ) - .await?; - let native = native_hook_overview(Some(workspace_root.as_path())).await; + let imports = agent + .external_hook_mutate(ExternalHookImportMutationRequestV1 { + schema_version: EXTERNAL_HOOK_IMPORT_SCHEMA_V1, + expected_revision, + action: ExternalHookImportMutationV1::ResetCorruptStore { scope }, + }) + .await?; + let native = agent.native_hook_overview().await?; Ok(HookManagementResult::Changed { snapshot: HookManagementSnapshot { native, imports }, status: format!( @@ -782,10 +768,7 @@ impl ChatMode { rt_handle: &tokio::runtime::Handle, ) where F: std::future::Future< - Output = std::result::Result< - HookManagementResult, - bitfun_core::external_sources::ExternalSourceOperationError, - >, + Output = std::result::Result, > + Send + 'static, { @@ -845,10 +828,7 @@ impl ChatMode { self.hook_management_snapshot = Some(snapshot); self.pending_hook_plan = None; } - Err(error) - if error.code - == bitfun_core::external_sources::ExternalSourceOperationErrorCode::StaleRevision => - { + Err(error) if error.code == ExternalSourceOperationErrorCode::StaleRevision => { chat_state.add_system_message( "Hook import state changed; the action was not applied. Run /hooks to refresh, review the new state, and try again." .to_string(), diff --git a/src/apps/cli/src/modes/chat/external_review.rs b/src/apps/cli/src/modes/chat/external_review.rs index 7318bd7c67..4aeb970379 100644 --- a/src/apps/cli/src/modes/chat/external_review.rs +++ b/src/apps/cli/src/modes/chat/external_review.rs @@ -1,7 +1,6 @@ // Pure projections and review text derived from the external-source catalog. use bitfun_product_domains::external_source_control::{ ExternalSourceDesiredState, ExternalSourceEffectiveStatus, ExternalSourceRecoveryActionV1, - ExternalSourceSupportState, }; fn external_command_projections( @@ -36,7 +35,7 @@ fn external_command_projections( .iter() .find(|source| source.record.key == entry.definition.id.source)?; let native_candidate_id = format!("bitfun.cli:{}", action.id); - let external_candidate_id = entry.definition.id.stable_key(); + let external_candidate_id = entry.candidate_id.clone(); let conflict_key = native_prompt_command_conflict_key( source.record.execution_domain_id.as_str(), &entry.definition.name, @@ -63,7 +62,7 @@ fn external_command_projections( action_id: format!("external-command:{}", entry.definition.name), command_name: entry.definition.name.clone(), invocation_alias: format!("/{}", entry.definition.name), - candidate_id: entry.definition.id.stable_key(), + candidate_id: entry.candidate_id.clone(), content_version: entry.definition.content_version.clone(), description: format!("{} · {}", entry.definition.description, ecosystem), restricted, @@ -166,7 +165,7 @@ fn external_command_counts(snapshot: &ExternalSourceCatalogSnapshot) -> (usize, fn external_integration_policy_lines(snapshot: &ExternalSourceCatalogSnapshot) -> Vec { let policy = &snapshot.integration_policy; if policy.status - == bitfun_core::external_sources::ExternalIntegrationPolicyStatus::IncompatibleSchema + == bitfun_product_domains::external_integration_policy::ExternalIntegrationPolicyStatus::IncompatibleSchema { return vec![ format!( @@ -262,7 +261,7 @@ enum ExternalControlUiAction { Show, Refresh, SetSafeMode(bool), - SetSourceEnabled { source_key: String, enabled: bool }, + SetSourceEnabled { source_index: usize, enabled: bool }, } fn parse_external_control_action(arguments: &str) -> Result { @@ -271,137 +270,106 @@ fn parse_external_control_action(arguments: &str) -> Result Ok(ExternalControlUiAction::Refresh), ["safe-mode", "on"] => Ok(ExternalControlUiAction::SetSafeMode(true)), ["safe-mode", "off"] => Ok(ExternalControlUiAction::SetSafeMode(false)), - ["source", "enable", source_key] => Ok(ExternalControlUiAction::SetSourceEnabled { - source_key: (*source_key).to_string(), + ["enable", source_number] => Ok(ExternalControlUiAction::SetSourceEnabled { + source_index: parse_positive_index(Some(source_number), "extension number")?, enabled: true, }), - ["source", "disable", source_key] => Ok(ExternalControlUiAction::SetSourceEnabled { - source_key: (*source_key).to_string(), + ["disable", source_number] => Ok(ExternalControlUiAction::SetSourceEnabled { + source_index: parse_positive_index(Some(source_number), "extension number")?, enabled: false, }), - _ => Err("usage: /extensions [status | refresh | safe-mode on | safe-mode off | source enable | source disable ]".to_string()), + _ => Err( + "usage: /extensions [status | refresh | enable | disable ]" + .to_string(), + ), } } -fn external_control_review_text( - control: &bitfun_core::external_sources::ExternalSourceControlSnapshotV1, +fn external_control_status_text( + control: &bitfun_product_domains::external_source_control::ExternalSourceControlSnapshotV1, ) -> String { - use bitfun_core::external_sources::{ExternalCapabilityKindV1, ExternalSourceRuntimeState}; - - let mut lines = vec![ - "External integrations".to_string(), - String::new(), - format!( - "Safe Mode: {}", - if control.safe_mode { "on" } else { "off" } - ), - format!("Execution domain: {}", control.execution_domain_id), - format!("Generation: {}", control.refresh_generation), - format!("Sources: {}", control.sources.len()), - ]; + let mut lines = vec!["Extensions".to_string(), String::new()]; if control.safe_mode { - lines.push( - "New external Tool, Agent, and MCP calls are blocked; calls already in progress are not cancelled." - .to_string(), - ); - lines.push( - "Safe Mode applies only to this Host process and execution domain; restarting the Host turns it off." - .to_string(), - ); + lines.push("External access is paused. Resume: /extensions safe-mode off".to_string()); + lines.push(String::new()); } - for source in &control.sources { - let desired = match source.desired { - ExternalSourceDesiredState::Enabled => "enabled", - ExternalSourceDesiredState::Disabled => "disabled", - }; + + if control.sources.is_empty() { + lines.push("No extensions found.".to_string()); + } + for (index, source) in control.sources.iter().enumerate() { let effective = match source.effective_status { - ExternalSourceEffectiveStatus::Discovering => "discovering", - ExternalSourceEffectiveStatus::Disabled => "disabled", - ExternalSourceEffectiveStatus::ReviewRequired => "review required", - ExternalSourceEffectiveStatus::Conflict => "conflict", - ExternalSourceEffectiveStatus::Active => "active", - ExternalSourceEffectiveStatus::Degraded => "degraded", - ExternalSourceEffectiveStatus::Unsupported => "unsupported", - ExternalSourceEffectiveStatus::Available => "available", - ExternalSourceEffectiveStatus::Removed => "removed", + ExternalSourceEffectiveStatus::Discovering => "Checking", + ExternalSourceEffectiveStatus::Disabled => "Off", + ExternalSourceEffectiveStatus::ReviewRequired => "Needs permission", + ExternalSourceEffectiveStatus::Conflict => "Needs attention", + ExternalSourceEffectiveStatus::Active => "On", + ExternalSourceEffectiveStatus::Degraded => "Needs attention", + ExternalSourceEffectiveStatus::Unsupported => "Unavailable", + ExternalSourceEffectiveStatus::Available => "Available", + ExternalSourceEffectiveStatus::Removed => "Not found", }; - lines.push(format!( - "Source {}: {} ({desired}, {effective})", - source.stable_key, source.display_name - )); + let number = index + 1; + lines.push(format!("{number}. {} - {effective}", source.display_name)); + if control.host_capabilities.can_manage_sources { + let (verb, command) = match source.desired { + ExternalSourceDesiredState::Enabled => ("Disable", "disable"), + ExternalSourceDesiredState::Disabled => ("Enable", "enable"), + }; + lines.push(format!(" {verb}: /extensions {command} {number}")); + } } - for capability in &control.capabilities { - let label = match capability.kind { - ExternalCapabilityKindV1::Command => "Commands", - ExternalCapabilityKindV1::Tool => "Tools", - ExternalCapabilityKindV1::Subagent => "Agents", - ExternalCapabilityKindV1::Mcp => "MCP servers", - }; - let runtime = match capability.runtime { - ExternalSourceRuntimeState::NotApplicable => "not applicable", - ExternalSourceRuntimeState::Inactive => "inactive", - ExternalSourceRuntimeState::Starting => "starting", - ExternalSourceRuntimeState::Active => "active", - ExternalSourceRuntimeState::Degraded => "degraded", - ExternalSourceRuntimeState::Quarantined => "quarantined", - ExternalSourceRuntimeState::Unsupported => "unsupported", - }; - let support = match capability.support { - ExternalSourceSupportState::Supported => "", - ExternalSourceSupportState::Partial => ", support: partial", - ExternalSourceSupportState::Unsupported => ", support: unsupported", - ExternalSourceSupportState::Unavailable => ", support: unavailable", - }; - lines.push(format!( - "{label}: {} items, {} review, {} conflicts, {runtime}{support}", - capability.item_count, - capability.pending_review_count, - capability.unresolved_conflict_count, - )); + + if !control.host_capabilities.can_manage_sources { + lines.push("This connection can only show extension status.".to_string()); } + if control.sources.iter().any(|source| { + matches!( + source.effective_status, + ExternalSourceEffectiveStatus::ReviewRequired | ExternalSourceEffectiveStatus::Conflict + ) + }) { + lines.push("Manage permissions: /tools, /agent, /mcp, or /hooks".to_string()); + } + const MAX_STATUS_DETAILS: usize = 4; if !control.diagnostics.is_empty() { lines.push(String::new()); - lines.push("Issues".to_string()); + lines.push("Needs attention".to_string()); for diagnostic in control.diagnostics.iter().take(MAX_STATUS_DETAILS) { - let severity = match diagnostic.severity { - ExternalSourceDiagnosticSeverity::Info => "info", - ExternalSourceDiagnosticSeverity::Warning => "warning", - ExternalSourceDiagnosticSeverity::Error => "error", - _ => "notice", - }; lines.push(format!( - " - {severity}: [{}] {}", - diagnostic.code, + " - {}", external_source_diagnostic_summary(&diagnostic.code) )); } let hidden = control.diagnostics.len().saturating_sub(MAX_STATUS_DETAILS); if hidden > 0 { - lines.push(format!( - " - {hidden} more; refresh after fixing the listed issue(s)." - )); + lines.push(format!(" - {hidden} more issue(s).")); } } if !control.recovery_actions.is_empty() { - lines.push(String::new()); - lines.push("Recovery".to_string()); - for action in control.recovery_actions.iter().take(MAX_STATUS_DETAILS) { - lines.push(format!( - " - {}", - external_recovery_action_label(action, "extensions") - )); + let recovery = control + .recovery_actions + .iter() + .filter(|action| { + !matches!( + action, + ExternalSourceRecoveryActionV1::Review + | ExternalSourceRecoveryActionV1::ExitSafeMode + ) + }) + .take(MAX_STATUS_DETAILS) + .map(|action| external_recovery_action_label(action, "extensions")) + .collect::>(); + if !recovery.is_empty() { + lines.push(String::new()); + lines.push(format!("Next: {}", recovery.join("; "))); } } lines.push(String::new()); - lines.push("Refresh: /extensions refresh".to_string()); - lines.push(if control.safe_mode { - "Exit Safe Mode: /extensions safe-mode off".to_string() - } else { - "Enter Safe Mode: /extensions safe-mode on".to_string() - }); - lines.push("Enable source: /extensions source enable ".to_string()); - lines.push("Disable source: /extensions source disable ".to_string()); + if control.host_capabilities.can_refresh { + lines.push("Refresh: /extensions refresh".to_string()); + } lines.join("\n") } @@ -409,8 +377,9 @@ struct ExternalControlMutationResult { action: ExternalControlUiAction, result: std::result::Result< ( - bitfun_core::external_sources::ExternalSourceSurfaceSnapshotV1, + bitfun_product_domains::external_source_control::ExternalSourceControlSnapshotV1, Option, + Option, ), ExternalSourceOperationError, >, @@ -1035,7 +1004,7 @@ enum ExternalIssueSurface { } fn is_external_agent_diagnostic( - diagnostic: &bitfun_core::external_sources::ExternalSourceDiagnostic, + diagnostic: &bitfun_product_domains::external_sources::ExternalSourceDiagnostic, ) -> bool { matches!(diagnostic.asset_kind, ExternalSourceAssetKind::Subagent) } diff --git a/src/apps/cli/src/modes/chat/external_sources.rs b/src/apps/cli/src/modes/chat/external_sources.rs index c5486a2e7d..b98017f2f1 100644 --- a/src/apps/cli/src/modes/chat/external_sources.rs +++ b/src/apps/cli/src/modes/chat/external_sources.rs @@ -231,6 +231,20 @@ impl } } +impl From + for ExternalSourceConflictPreferences +{ + fn from( + preferences: bitfun_app_server_protocol::external_source::ExternalSourceConflictPreferences, + ) -> Self { + Self { + choices: preferences.choices, + lineage_current_keys: preferences.lineage_current_keys, + conflicted_candidate_ids: preferences.conflicted_candidate_ids, + } + } +} + fn builtin_command_reconfirmation( action_id: &str, command_name: &str, @@ -365,7 +379,7 @@ impl ChatMode { &mut self, arguments: &str, chat_view: &mut ChatView, - chat_state: &ChatState, + _chat_state: &ChatState, rt_handle: &tokio::runtime::Handle, ) { let action = match parse_external_control_action(arguments) { @@ -377,29 +391,54 @@ impl ChatMode { }; if self.external_control_mutation_rx.is_some() { chat_view.set_status(Some( - "An external integration update is already running; input remains available." - .to_string(), + "An extension update is already running; input remains available.".to_string(), )); return; } - let workspace = self.workspace_path_for_sync(chat_state); + let source_selection = match &action { + ExternalControlUiAction::SetSourceEnabled { + source_index, + enabled, + } => { + let Some(control) = self.external_control_snapshot.as_ref() else { + chat_view.set_status(Some( + "Open /extensions before changing an extension.".to_string(), + )); + return; + }; + if !control.host_capabilities.can_manage_sources { + chat_view.set_status(Some( + "This connection can only show extension status.".to_string(), + )); + return; + } + let Some(source) = control.sources.get(*source_index) else { + chat_view.set_status(Some( + "That extension is no longer listed. Run /extensions refresh.".to_string(), + )); + return; + }; + Some((source.stable_key.clone(), *enabled)) + } + _ => None, + }; let expected_preference_revision = self - .external_source_snapshot + .external_control_snapshot .as_ref() .map(|snapshot| snapshot.preference_revision); let task_action = action.clone(); + let agent = self.agent.clone(); let (sender, receiver) = mpsc::channel(); rt_handle.spawn(async move { let result = async { if matches!(&task_action, ExternalControlUiAction::Show) { - let surface = get_external_source_control_snapshot( - Some(&workspace), - false, - ExternalSourceHostCapabilities::read_write(), - ) - .await?; - return Ok((surface, None)); + let response = agent.external_source_snapshot(false).await?; + return Ok(( + response.control, + Some(response.snapshot), + Some(response.preferences.into()), + )); } let action = match &task_action { @@ -407,29 +446,30 @@ impl ChatMode { ExternalControlUiAction::SetSafeMode(enabled) => { ExternalSourceControlActionV1::SetSafeMode { enabled: *enabled } } - ExternalControlUiAction::SetSourceEnabled { - source_key, - enabled, - } => ExternalSourceControlActionV1::SetSourceEnabled { - source_key: source_key.clone(), - enabled: *enabled, - }, + ExternalControlUiAction::SetSourceEnabled { .. } => { + let (source_key, enabled) = source_selection + .clone() + .expect("source selection was resolved before spawning"); + ExternalSourceControlActionV1::SetSourceEnabled { + source_key, + enabled, + } + } ExternalControlUiAction::Show => unreachable!(), }; - let surface = apply_external_source_control_action( - Some(&workspace), - ExternalSourceControlRequestV1 { + let response = agent + .external_source_control(ExternalSourceControlRequestV1 { schema_version: EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, operation_id: format!("tui-{}", uuid::Uuid::new_v4()), expected_preference_revision, action, - }, - ) - .await?; - let catalog = external_source_snapshot(Some(&workspace), false) - .await - .map_err(sanitize_external_source_operation_error)?; - Ok((surface, Some(catalog))) + }) + .await?; + Ok(( + response.surface.control, + Some(response.snapshot.snapshot), + Some(response.snapshot.preferences.into()), + )) } .await; let _ = sender.send(ExternalControlMutationResult { @@ -439,15 +479,13 @@ impl ChatMode { }); self.external_control_mutation_rx = Some(receiver); let status = match action { - ExternalControlUiAction::Show => "Reading external integration status", - ExternalControlUiAction::Refresh => "Refreshing external integrations", - ExternalControlUiAction::SetSafeMode(true) => "Entering External Safe Mode", - ExternalControlUiAction::SetSafeMode(false) => "Exiting External Safe Mode", - ExternalControlUiAction::SetSourceEnabled { enabled: true, .. } => { - "Enabling external source" - } + ExternalControlUiAction::Show => "Reading extension status", + ExternalControlUiAction::Refresh => "Refreshing extensions", + ExternalControlUiAction::SetSafeMode(true) => "Pausing external access", + ExternalControlUiAction::SetSafeMode(false) => "Resuming external access", + ExternalControlUiAction::SetSourceEnabled { enabled: true, .. } => "Enabling extension", ExternalControlUiAction::SetSourceEnabled { enabled: false, .. } => { - "Disabling external source" + "Disabling extension" } }; chat_view.set_status(Some(format!( @@ -474,24 +512,26 @@ impl ChatMode { }; self.external_control_mutation_rx = None; match outcome.result { - Ok((surface, catalog)) => { + Ok((control, catalog, preferences)) => { + if let Some(preferences) = preferences { + self.replace_external_conflict_preferences(preferences); + } if let Some(catalog) = catalog { self.update_external_source_view(chat_view, &catalog); self.external_source_snapshot = Some(catalog); } - chat_view.show_info_popup(external_control_review_text(&surface.control)); + chat_view.show_info_popup(external_control_status_text(&control)); + self.external_control_snapshot = Some(control); let status = match outcome.action { - ExternalControlUiAction::Show => "External integration status updated", - ExternalControlUiAction::Refresh => "External integrations refreshed", - ExternalControlUiAction::SetSafeMode(true) => "External Safe Mode is active", - ExternalControlUiAction::SetSafeMode(false) => { - "External Safe Mode is off; eligible integrations were reconciled" - } + ExternalControlUiAction::Show => "Extension status updated", + ExternalControlUiAction::Refresh => "Extensions refreshed", + ExternalControlUiAction::SetSafeMode(true) => "External access is paused", + ExternalControlUiAction::SetSafeMode(false) => "External access resumed", ExternalControlUiAction::SetSourceEnabled { enabled: true, .. } => { - "External source enabled" + "Extension enabled" } ExternalControlUiAction::SetSourceEnabled { enabled: false, .. } => { - "External source disabled" + "Extension disabled" } }; chat_view.set_status(Some(status.to_string())); @@ -543,7 +583,7 @@ impl ChatMode { return; } - let workspace = self.workspace_path_for_sync(chat_state); + let _ = chat_state; let expected_preference_revision = self .external_source_snapshot .as_ref() @@ -559,41 +599,44 @@ impl ChatMode { ExternalToolReviewAction::Show => unreachable!(), }; let task_action = action.clone(); + let agent = self.agent.clone(); let (sender, receiver) = mpsc::channel(); rt_handle.spawn(async move { let result = match &task_action { ExternalToolReviewAction::Refresh => { - external_source_snapshot(Some(&workspace), true).await + agent + .external_source_review(ExternalSourceReviewAction::Refresh) + .await } ExternalToolReviewAction::Decide { approval_key, decision_key, approved, } => { - set_external_tool_target_decision( - Some(&workspace), - approval_key, - decision_key, - *approved, - expected_preference_revision, - ) - .await + agent + .external_source_review(ExternalSourceReviewAction::SetToolTargetDecision { + approval_key: approval_key.clone(), + decision_key: decision_key.clone(), + approved: *approved, + expected_preference_revision, + }) + .await } ExternalToolReviewAction::Choose { conflict_key, candidate_id, } => { - set_external_tool_conflict_choice( - Some(&workspace), - conflict_key, - candidate_id, - expected_preference_revision, - ) - .await + agent + .external_source_review(ExternalSourceReviewAction::SetToolConflictChoice { + conflict_key: conflict_key.clone(), + candidate_id: candidate_id.clone(), + expected_preference_revision, + }) + .await } ExternalToolReviewAction::Show => unreachable!(), } - .map_err(sanitize_external_source_operation_error); + .map(|response| response.snapshot); let _ = sender.send(ExternalToolMutationResult { action: task_action, result, @@ -741,7 +784,7 @@ impl ChatMode { return; } - let workspace = self.workspace_path_for_sync(chat_state); + let _ = chat_state; let pending_status = match &action { ExternalAgentReviewAction::Refresh => "Refreshing external agents", ExternalAgentReviewAction::Decide { approved: true, .. } => "Enabling external agent", @@ -753,11 +796,14 @@ impl ChatMode { ExternalAgentReviewAction::Show => unreachable!(), }; let task_action = action.clone(); + let agent = self.agent.clone(); let (sender, receiver) = mpsc::channel(); rt_handle.spawn(async move { let result = match &task_action { ExternalAgentReviewAction::Refresh => { - external_source_snapshot(Some(&workspace), true).await + agent + .external_source_review(ExternalSourceReviewAction::Refresh) + .await } ExternalAgentReviewAction::Decide { candidate_id, @@ -766,15 +812,15 @@ impl ChatMode { expected_subagent_generation, expected_preference_revision, } => { - set_external_subagent_activation( - Some(&workspace), - candidate_id, - *approved, - *expected_subagent_generation, - *expected_preference_revision, - decision_key, - ) - .await + agent + .external_source_review(ExternalSourceReviewAction::SetSubagentActivation { + candidate_id: candidate_id.clone(), + approved: *approved, + expected_subagent_generation: *expected_subagent_generation, + expected_preference_revision: *expected_preference_revision, + decision_key: decision_key.clone(), + }) + .await } ExternalAgentReviewAction::Choose { conflict_key, @@ -783,15 +829,17 @@ impl ChatMode { expected_subagent_generation, expected_preference_revision, } => { - choose_external_subagent_conflict( - Some(&workspace), - conflict_key, - candidate_id, - *approve_external, - *expected_subagent_generation, - *expected_preference_revision, - ) - .await + agent + .external_source_review( + ExternalSourceReviewAction::ChooseSubagentConflict { + conflict_key: conflict_key.clone(), + candidate_id: candidate_id.clone(), + approve_external: *approve_external, + expected_subagent_generation: *expected_subagent_generation, + expected_preference_revision: *expected_preference_revision, + }, + ) + .await } ExternalAgentReviewAction::Bind { binding_key, @@ -799,18 +847,20 @@ impl ChatMode { expected_subagent_generation, expected_preference_revision, } => { - set_external_subagent_model_binding( - Some(&workspace), - binding_key, - target.clone(), - *expected_subagent_generation, - *expected_preference_revision, - ) - .await + agent + .external_source_review( + ExternalSourceReviewAction::SetSubagentModelBinding { + binding_key: binding_key.clone(), + target: target.clone(), + expected_subagent_generation: *expected_subagent_generation, + expected_preference_revision: *expected_preference_revision, + }, + ) + .await } ExternalAgentReviewAction::Show => unreachable!(), } - .map_err(sanitize_external_source_operation_error); + .map(|response| response.snapshot); let _ = sender.send(ExternalAgentMutationResult { action: task_action, result, diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs index f34acd6f7f..0d40701b20 100644 --- a/src/apps/cli/src/modes/chat/input.rs +++ b/src/apps/cli/src/modes/chat/input.rs @@ -423,7 +423,7 @@ impl ChatMode { ); } else { chat_view.hide_mcp_selector(); - self.open_mcp_config(chat_state); + self.open_mcp_config(chat_state, rt_handle); } } // Note: Esc is handled globally for navigation back diff --git a/src/apps/cli/src/modes/chat/mcp.rs b/src/apps/cli/src/modes/chat/mcp.rs index 8f20a4b393..aafb1a539e 100644 --- a/src/apps/cli/src/modes/chat/mcp.rs +++ b/src/apps/cli/src/modes/chat/mcp.rs @@ -1,3 +1,8 @@ +use bitfun_app_server_protocol::mcp::{ + ExternalMcpDecisionRequest, McpConflictChoiceRequest, McpServerAction, McpServerMutation, + McpServerSummary, McpTransport, +}; + fn bounded_mcp_terminal_text(value: &str) -> String { let escaped = crate::plugin_diagnostics::escape_terminal_text(value); let mut chars = escaped.chars(); @@ -9,371 +14,101 @@ fn bounded_mcp_terminal_text(value: &str) -> String { } } -fn external_mcp_timeout_detail( - timeouts: &bitfun_product_domains::external_sources::ExternalMcpTimeouts, -) -> Option { - let phases = [ - ("startup", timeouts.startup_ms), - ("catalog", timeouts.catalog_ms), - ("execution", timeouts.execution_ms), - ] - .into_iter() - .filter_map(|(phase, timeout)| timeout.map(|timeout| format!("{phase} {timeout} ms"))) - .collect::>(); - (!phases.is_empty()).then(|| format!("timeouts: {}", phases.join(", "))) +fn mcp_item_from_summary(server: McpServerSummary) -> McpItem { + let action = match server.action { + McpServerAction::NativeToggle => McpItemAction::NativeToggle, + McpServerAction::ReadOnly { reason } => McpItemAction::ReadOnly { + reason: bounded_mcp_terminal_text(&reason), + }, + McpServerAction::ExternalDecision { + candidate_id, + decision_key, + approved, + expected_mcp_generation, + expected_preference_revision, + } => McpItemAction::ExternalDecision { + candidate_id, + decision_key, + approved, + expected_mcp_generation, + expected_preference_revision, + }, + McpServerAction::ConflictChoice { + conflict_key, + candidate_id, + approve_external, + expected_mcp_generation, + expected_preference_revision, + } => McpItemAction::ConflictChoice { + conflict_key, + candidate_id, + approve_external, + expected_mcp_generation, + expected_preference_revision, + }, + }; + McpItem { + id: server.id, + name: bounded_mcp_terminal_text(&server.name), + server_type: bounded_mcp_terminal_text(&server.server_type), + status: bounded_mcp_terminal_text(&server.status), + tool_count: server.tool_count, + source_label: bounded_mcp_terminal_text(&server.source_label), + external: server.external, + detail: bounded_mcp_terminal_text(&server.detail), + action, + } } -fn external_mcp_state_label( - state: &bitfun_core::external_sources::ExternalMcpActivationState, -) -> &'static str { - use bitfun_core::external_sources::ExternalMcpActivationState as State; - match state { - State::ApprovalRequired => "Confirmation required", - State::Starting => "Starting", - State::Active => "Enabled", - State::Declined => "Kept disabled", - State::Conflict => "Choice required", - State::Covered { .. } => "Not selected", - State::SourceDisabled => "Source disabled", - State::ConfigurationChanged => "Changed; confirm again", - State::Unsupported { .. } => "Not supported", - State::RuntimeUnavailable { .. } => "Unavailable", - State::Removed => "Removed", - _ => "Unavailable", +/// Completion message for the `/mcp` add flow. In Shared TUI mode the add +/// mutates the local MCP compatibility owner of this CLI process, not the +/// already-running Shared Runtime Host, so it must not be reported as +/// "started" for that Runtime. +fn mcp_add_completion_message(name: &str, shared: bool) -> String { + if shared { + format!( + "MCP server '{name}' added to the local compatibility owner; the Shared Runtime Host is not reconfigured" + ) + } else { + format!("MCP server '{name}' added and started") } } impl ChatMode { - /// Show MCP server selector popup fn show_mcp_selector( &self, chat_view: &mut ChatView, - _chat_state: &mut ChatState, + chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - let items = self.get_mcp_items(rt_handle); - // Show even if empty — user can press 'a' to add - chat_view.show_mcp_selector(items); + match tokio::task::block_in_place(|| rt_handle.block_on(self.agent.list_mcp_servers())) { + Ok(response) => chat_view.show_mcp_selector( + response + .servers + .into_iter() + .map(mcp_item_from_summary) + .collect(), + ), + Err(error) => { + chat_state.add_system_message(format!("Could not load MCP servers: {error}")); + chat_view.show_mcp_selector(Vec::new()); + } + } } - /// Get MCP server items for display pub(super) fn get_mcp_items(&self, rt_handle: &tokio::runtime::Handle) -> Vec { - let mcp_service = match crate::get_mcp_service() { - Some(svc) => svc, - None => return Vec::new(), - }; - - let server_manager = mcp_service.server_manager(); - let config_service = mcp_service.config_service(); - let external_snapshot = self.external_source_snapshot.clone(); - - tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let configs = match config_service.load_all_configs().await { - Ok(c) => c, - Err(e) => { - tracing::error!("Failed to load MCP configs: {}", e); - return Vec::new(); - } - }; - - let tool_registry = - bitfun_core::agentic::tools::registry::get_global_tool_registry(); - let registry_lock = tool_registry.read().await; - let all_tools = registry_lock.get_all_tools(); - - let mut items = Vec::new(); - for config in configs { - let status = if !config.enabled { - "Stopped".to_string() - } else { - // Avoid blocking UI while a slow auto-start server holds internal write lock. - match tokio::time::timeout( - Duration::from_millis(30), - server_manager.get_server_status(&config.id), - ) - .await - { - Ok(Ok(s)) => format!("{:?}", s), - Ok(Err(_)) => "Unknown".to_string(), - Err(_) => "Starting".to_string(), - } - }; - - // Count tools from this server - let prefix = format!("mcp_{}_", config.id); - let tool_count = all_tools - .iter() - .filter(|t| t.name().starts_with(&prefix)) - .count(); - - let server_type = format!("{:?}", config.server_type).to_lowercase(); - - let native_candidate_id = - bitfun_core::external_sources::native_mcp_candidate_id(&config.id); - let native_conflict = external_snapshot.as_ref().and_then(|snapshot| { - snapshot.mcp_conflicts.iter().find(|conflict| { - conflict.candidates.iter().any(|candidate| { - candidate.candidate_id == native_candidate_id - }) - }) - }); - let (status, action) = if let Some(conflict) = native_conflict { - let native_choice = conflict - .candidates - .iter() - .find(|candidate| candidate.candidate_id == native_candidate_id); - if native_choice.is_some_and(|candidate| !candidate.available) { - ( - "Unavailable".to_string(), - McpItemAction::ReadOnly { - reason: native_choice - .and_then(|candidate| candidate.unavailable_reason.clone()) - .unwrap_or_else(|| { - "Enable this BitFun server in its MCP configuration, then reopen /mcp" - .to_string() - }), - }, - ) - } else if conflict.selected_candidate_id.as_deref() - == Some(&native_candidate_id) - { - (status, McpItemAction::NativeToggle) - } else { - ( - if conflict.selected_candidate_id.is_some() { - "Not selected".to_string() - } else { - "Choice required".to_string() - }, - McpItemAction::ConflictChoice { - conflict_key: conflict.conflict_key.clone(), - candidate_id: native_candidate_id, - approve_external: false, - expected_mcp_generation: external_snapshot - .as_ref() - .map_or(0, |snapshot| snapshot.mcp_generation), - expected_preference_revision: external_snapshot - .as_ref() - .map_or(0, |snapshot| snapshot.preference_revision), - }, - ) - } - } else { - (status, McpItemAction::NativeToggle) - }; - - items.push(McpItem { - id: config.id.clone(), - name: bounded_mcp_terminal_text(&config.name), - server_type, - status, - tool_count, - source_label: "BitFun".to_string(), - external: false, - detail: "BitFun configuration".to_string(), - action, - }); - } - - if let Some(snapshot) = external_snapshot.as_ref() { - for entry in &snapshot.mcp_servers { - let source = snapshot - .sources - .iter() - .find(|source| source.record.key == entry.definition.id.source) - .map(|source| source.record.clone()); - let source_label = source - .as_ref() - .map(|source| source.display_name.clone()) - .unwrap_or_else(|| "External AI app".to_string()); - let source_location = source - .as_ref() - .map(|source| source.location.as_str()) - .unwrap_or("unknown source"); - let conflict = snapshot.mcp_conflicts.iter().find(|conflict| { - conflict.candidates.iter().any(|candidate| { - candidate.candidate_id == entry.candidate_id - }) - }); - let action = match &entry.activation_state { - bitfun_core::external_sources::ExternalMcpActivationState::ApprovalRequired - | bitfun_core::external_sources::ExternalMcpActivationState::Declined - | bitfun_core::external_sources::ExternalMcpActivationState::ConfigurationChanged => { - McpItemAction::ExternalDecision { - candidate_id: entry.candidate_id.clone(), - decision_key: entry.decision_key.clone(), - approved: true, - expected_mcp_generation: snapshot.mcp_generation, - expected_preference_revision: snapshot.preference_revision, - } - } - bitfun_core::external_sources::ExternalMcpActivationState::Starting - | bitfun_core::external_sources::ExternalMcpActivationState::Active - | bitfun_core::external_sources::ExternalMcpActivationState::RuntimeUnavailable { .. } => { - McpItemAction::ExternalDecision { - candidate_id: entry.candidate_id.clone(), - decision_key: entry.decision_key.clone(), - approved: false, - expected_mcp_generation: snapshot.mcp_generation, - expected_preference_revision: snapshot.preference_revision, - } - } - bitfun_core::external_sources::ExternalMcpActivationState::Conflict - | bitfun_core::external_sources::ExternalMcpActivationState::Covered { .. } => { - if let Some(conflict) = conflict { - McpItemAction::ConflictChoice { - conflict_key: conflict.conflict_key.clone(), - candidate_id: entry.candidate_id.clone(), - approve_external: true, - expected_mcp_generation: snapshot.mcp_generation, - expected_preference_revision: snapshot.preference_revision, - } - } else { - McpItemAction::ReadOnly { - reason: "Refresh to review the current conflict".to_string(), - } - } - } - bitfun_core::external_sources::ExternalMcpActivationState::Unsupported { reason } => { - McpItemAction::ReadOnly { - reason: format!( - "Not supported: {}. Change this server in the source application; the list refreshes automatically", - bounded_mcp_terminal_text(reason), - ), - } - } - bitfun_core::external_sources::ExternalMcpActivationState::SourceDisabled => { - McpItemAction::ReadOnly { - reason: "Enable this server in the source application; the list refreshes automatically" - .to_string(), - } - } - state => McpItemAction::ReadOnly { - reason: external_mcp_state_label(state).to_string(), - }, - }; - let status = match &entry.activation_state { - bitfun_core::external_sources::ExternalMcpActivationState::Active => { - if let Some(runtime_id) = entry.runtime_id.as_deref() { - match tokio::time::timeout( - Duration::from_millis(30), - server_manager.get_server_status(runtime_id), - ) - .await - { - Ok(Ok(status)) => format!("{status:?}"), - Ok(Err(_)) => "Unavailable".to_string(), - Err(_) => "Starting".to_string(), - } - } else { - "Enabled".to_string() - } - } - bitfun_core::external_sources::ExternalMcpActivationState::RuntimeUnavailable { reason } => { - format!( - "Unavailable - {}", - bounded_mcp_terminal_text(reason), - ) - } - state => external_mcp_state_label(state).to_string(), - }; - let tool_count = entry.runtime_id.as_deref().map_or(0, |runtime_id| { - let prefix = format!("mcp_{runtime_id}_"); - all_tools - .iter() - .filter(|tool| tool.name().starts_with(&prefix)) - .count() - }); - let mut detail = match entry.definition.transport { - bitfun_core::external_sources::ExternalMcpTransportKind::LocalStdio => format!( - "source: {}; local command: {}; arguments: {}; starts in: {}; environment variables set: {}; reads from BitFun environment: {}; security: runs with your user permissions without an additional OS sandbox", - bounded_mcp_terminal_text(source_location), - entry.definition.command_preview.as_deref().unwrap_or("unknown"), - entry.definition.argument_count, - entry.definition.working_directory.as_deref().unwrap_or("default"), - if entry.definition.environment_keys.is_empty() { - "none".to_string() - } else { - entry.definition.environment_keys.join(", ") - }, - if entry.definition.environment_reference_names.is_empty() { - "none".to_string() - } else { - entry.definition.environment_reference_names.join(", ") - }, - ), - bitfun_core::external_sources::ExternalMcpTransportKind::StreamableHttp => format!( - "source: {}; remote origin: {}; HTTP headers: {}; reads from BitFun environment: {}; security: connects to the shown service with your user permissions", - bounded_mcp_terminal_text(source_location), - entry.definition.remote_url_preview.as_deref().unwrap_or("unknown"), - if entry.definition.header_names.is_empty() { - "none".to_string() - } else { - entry.definition.header_names.join(", ") - }, - if entry.definition.environment_reference_names.is_empty() { - "none".to_string() - } else { - entry.definition.environment_reference_names.join(", ") - }, - ), - _ => "unsupported external MCP transport".to_string(), - }; - if let Some(timeouts) = - external_mcp_timeout_detail(&entry.definition.timeouts) - { - detail.push_str("; "); - detail.push_str(&timeouts); - } - if let bitfun_core::external_sources::ExternalMcpActivationState::RuntimeUnavailable { reason } = &entry.activation_state { - detail.push_str(&format!( - "; unavailable reason: {}; next step: disable this server, fix its source configuration or authentication, then enable it", - bounded_mcp_terminal_text(reason), - )); - } - items.push(McpItem { - id: entry - .runtime_id - .clone() - .unwrap_or_else(|| entry.candidate_id.clone()), - name: bounded_mcp_terminal_text(&entry.definition.name), - server_type: match entry.definition.transport { - bitfun_core::external_sources::ExternalMcpTransportKind::LocalStdio => "local".to_string(), - bitfun_core::external_sources::ExternalMcpTransportKind::StreamableHttp => "remote".to_string(), - _ => "unsupported".to_string(), - }, - status, - tool_count, - source_label: bounded_mcp_terminal_text(&source_label), - external: true, - detail, - action, - }); - } - } - if items.is_empty() - && external_snapshot - .as_ref() - .is_some_and(|snapshot| snapshot.discovery_pending) - { - items.push(McpItem { - id: "external-mcp-discovery-pending".to_string(), - name: "External MCP servers".to_string(), - server_type: "external".to_string(), - status: "Checking".to_string(), - tool_count: 0, - source_label: "External AI applications".to_string(), - external: true, - detail: "BitFun is still checking compatible MCP settings".to_string(), - action: McpItemAction::ReadOnly { - reason: "Still checking; this list updates automatically".to_string(), - }, - }); - } - items + tokio::task::block_in_place(|| rt_handle.block_on(self.agent.list_mcp_servers())) + .map(|response| { + response + .servers + .into_iter() + .map(mcp_item_from_summary) + .collect() + }) + .unwrap_or_else(|error| { + tracing::warn!("Failed to load MCP server catalog: {error}"); + Vec::new() }) - }) } fn activate_mcp_item( @@ -397,49 +132,31 @@ impl ChatMode { } } - /// Schedule an MCP server toggle (deferred to allow loading state to render) fn toggle_mcp_server(&mut self, server_id: &str, chat_view: &mut ChatView) { if self.pending_mcp_op.is_some() || self.is_mcp_server_task_running(server_id) { return; } - - // Set loading indicator immediately — will be rendered before execution chat_view.mcp_selector_set_loading(Some(server_id.to_string())); self.pending_mcp_op = Some(PendingMcpOp::Toggle(server_id.to_string())); } - /// Execute MCP server toggle (called from main loop after render) fn execute_mcp_toggle( &mut self, server_id: &str, - chat_view: &mut ChatView, - chat_state: &mut ChatState, + _chat_view: &mut ChatView, + _chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - let mcp_service = match crate::get_mcp_service() { - Some(svc) => svc.clone(), - None => { - chat_state.add_system_message("MCP service not initialized".to_string()); - chat_view.mcp_selector_set_loading(None); - return; - } - }; - - let server_manager = mcp_service.server_manager(); - let task_server_id = server_id.to_string(); - let tracked_server_id = task_server_id.clone(); - + let agent = Arc::clone(&self.agent); + let server_id = server_id.to_string(); + let tracked_server_id = server_id.clone(); let handle = rt_handle.spawn(async move { - let status = server_manager.get_server_status(&task_server_id).await; - match status { - Ok(bitfun_core::service::mcp::MCPServerStatus::Connected) - | Ok(bitfun_core::service::mcp::MCPServerStatus::Healthy) => { - server_manager.stop_server(&task_server_id).await - } - _ => server_manager.start_server(&task_server_id).await, - } + agent + .toggle_mcp_server(server_id) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) }); - self.pending_mcp_tasks.push(PendingMcpTask::Toggle { server_id: tracked_server_id, handle, @@ -453,7 +170,8 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - let workspace = self.agent.workspace_path_buf(); + let agent = Arc::clone(&self.agent); + let workspace_path = self.agent.workspace_path_string(); let action = item.action.clone(); let item_id = item.id.clone(); let item_name = item.name.clone(); @@ -465,38 +183,39 @@ impl ChatMode { approved, expected_mcp_generation, expected_preference_revision, - } => { - bitfun_core::external_sources::set_external_mcp_server_decision( - Some(workspace.as_path()), - &candidate_id, - &decision_key, + } => agent + .external_mcp_decision(ExternalMcpDecisionRequest { + workspace_path, + candidate_id, + decision_key, approved, expected_mcp_generation, expected_preference_revision, - ) + }) .await - } + .map(|_| ()), McpItemAction::ConflictChoice { conflict_key, candidate_id, approve_external, expected_mcp_generation, expected_preference_revision, - } => { - bitfun_core::external_sources::choose_external_mcp_conflict( - Some(workspace.as_path()), - &conflict_key, - &candidate_id, + } => agent + .mcp_conflict_choice(McpConflictChoiceRequest { + workspace_path, + conflict_key, + candidate_id, approve_external, expected_mcp_generation, expected_preference_revision, - ) + }) .await - } - McpItemAction::NativeToggle | McpItemAction::ReadOnly { .. } => { - Err("The MCP action is no longer available; reopen /mcp".to_string()) - } + .map(|_| ()), + McpItemAction::NativeToggle | McpItemAction::ReadOnly { .. } => Err(anyhow!( + "The MCP action is no longer available; reopen /mcp" + )), } + .map_err(|error| error.to_string()) }); self.pending_mcp_tasks.push(PendingMcpTask::External { item_id, @@ -532,272 +251,159 @@ impl ChatMode { rt_handle: &tokio::runtime::Handle, ) -> bool { let mut changed = false; - let mut i = 0; - while i < self.pending_mcp_tasks.len() { - let finished = match &self.pending_mcp_tasks[i] { + let mut index = 0; + while index < self.pending_mcp_tasks.len() { + let finished = match &self.pending_mcp_tasks[index] { PendingMcpTask::Toggle { handle, .. } | PendingMcpTask::Add { handle, .. } - | PendingMcpTask::Delete { handle, .. } => handle.is_finished(), - PendingMcpTask::External { handle, .. } => handle.is_finished(), + | PendingMcpTask::Delete { handle, .. } + | PendingMcpTask::External { handle, .. } => handle.is_finished(), }; if !finished { - i += 1; + index += 1; continue; } - let task = self.pending_mcp_tasks.swap_remove(i); changed = true; - match task { - PendingMcpTask::Toggle { server_id, handle } => { - let join_result = tokio::task::block_in_place(|| rt_handle.block_on(handle)); - - match join_result { - Ok(Ok(())) => {} - Ok(Err(e)) => { - tracing::error!("Failed to toggle MCP server {}: {}", server_id, e); - chat_state.add_system_message(format!( - "Failed to toggle MCP server '{}': {}", - server_id, e - )); - } - Err(e) => { - tracing::error!("MCP toggle task join error for {}: {}", server_id, e); - chat_state.add_system_message(format!( - "MCP server '{}' task failed: {}", - server_id, e - )); - } - } - - chat_view.mcp_selector_set_loading(None); - let updated_items = self.get_mcp_items(rt_handle); - chat_view.mcp_selector_update_items(updated_items); - } - PendingMcpTask::Add { name, handle } => { - let join_result = tokio::task::block_in_place(|| rt_handle.block_on(handle)); - - match join_result { - Ok(Ok(())) => { - chat_state.add_system_message(format!( - "MCP server '{}' added and started", - name - )); - self.show_mcp_selector(chat_view, chat_state, rt_handle); - } - Ok(Err(e)) => { - chat_state - .add_system_message(format!("Failed to add MCP server: {}", e)); - } - Err(e) => { - chat_state.add_system_message(format!( - "MCP add task failed for '{}': {}", - name, e - )); - } - } - chat_view.set_status(None); - } - PendingMcpTask::Delete { server_id, handle } => { - let join_result = tokio::task::block_in_place(|| rt_handle.block_on(handle)); - - match join_result { - Ok(Ok(())) => { - chat_state - .add_system_message(format!("MCP server '{}' deleted", server_id)); - } - Ok(Err(e)) => { - chat_state - .add_system_message(format!("Failed to delete MCP server: {}", e)); - } - Err(e) => { - chat_state.add_system_message(format!( - "MCP delete task failed for '{}': {}", - server_id, e - )); - } - } - - chat_view.mcp_selector_set_loading(None); - let updated_items = self.get_mcp_items(rt_handle); - if updated_items.is_empty() { - chat_view.hide_mcp_selector(); - } else { - chat_view.mcp_selector_update_items(updated_items); - } - } + let task = self.pending_mcp_tasks.swap_remove(index); + let (success_message, failure_context, result) = match task { + PendingMcpTask::Toggle { server_id, handle } => ( + None, + format!("toggle MCP server '{server_id}'"), + tokio::task::block_in_place(|| rt_handle.block_on(handle)), + ), + PendingMcpTask::Add { name, handle } => ( + Some(mcp_add_completion_message(&name, self.agent.is_shared())), + format!("add MCP server '{name}'"), + tokio::task::block_in_place(|| rt_handle.block_on(handle)), + ), + PendingMcpTask::Delete { server_id, handle } => ( + Some(format!("MCP server '{server_id}' deleted")), + format!("delete MCP server '{server_id}'"), + tokio::task::block_in_place(|| rt_handle.block_on(handle)), + ), PendingMcpTask::External { - item_id: _, - item_name, - handle, - } => { - let join_result = tokio::task::block_in_place(|| rt_handle.block_on(handle)); - match join_result { - Ok(Ok(snapshot)) => { - self.external_source_snapshot = Some(snapshot); - chat_state.add_system_message(format!( - "MCP server choice saved for '{}'", - item_name - )); - } - Ok(Err(error)) => chat_state.add_system_message(format!( - "Could not save the MCP server choice for '{}': {}", - item_name, error - )), - Err(error) => chat_state.add_system_message(format!( - "MCP server update failed for '{}': {}", - item_name, error - )), + item_name, handle, .. + } => ( + Some(format!("MCP server choice saved for '{item_name}'")), + format!("save the MCP server choice for '{item_name}'"), + tokio::task::block_in_place(|| rt_handle.block_on(handle)), + ), + }; + match result { + Ok(Ok(())) => { + if let Some(message) = success_message { + chat_state.add_system_message(message); } - chat_view.mcp_selector_set_loading(None); - chat_view.mcp_selector_update_items(self.get_mcp_items(rt_handle)); } + Ok(Err(error)) => { + chat_state.add_system_message(format!("Could not {failure_context}: {error}")) + } + Err(error) => chat_state.add_system_message(format!( + "MCP task failed while trying to {failure_context}: {error}" + )), } + chat_view.set_status(None); + chat_view.mcp_selector_set_loading(None); + chat_view.mcp_selector_update_items(self.get_mcp_items(rt_handle)); } changed } - /// Schedule adding a new MCP server (deferred to allow loading state to render) fn add_mcp_server(&mut self, name: &str, config_json_str: &str, chat_view: &mut ChatView) { if self.pending_mcp_op.is_some() || self.has_pending_mcp_add_task() { return; } - - chat_view.set_status(Some(format!("Adding MCP server '{}'...", name))); + chat_view.set_status(Some(format!("Adding MCP server '{name}'..."))); self.pending_mcp_op = Some(PendingMcpOp::Add { name: name.to_string(), config_json: config_json_str.to_string(), }); } - /// Execute MCP server add (called from main loop after render) fn execute_mcp_add( &mut self, name: &str, config_json_str: &str, - _chat_view: &mut ChatView, + chat_view: &mut ChatView, chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - let mcp_service = match crate::get_mcp_service() { - Some(svc) => svc.clone(), - None => { - chat_state.add_system_message("MCP service not initialized".to_string()); + let value: serde_json::Value = match serde_json::from_str(config_json_str) { + Ok(config) => config, + Err(error) => { + chat_state.add_system_message(format!("Invalid JSON: {error}")); + chat_view.set_status(None); return; } }; - - let config_value: serde_json::Value = match serde_json::from_str(config_json_str) { - Ok(v) => v, - Err(e) => { - chat_state.add_system_message(format!("Invalid JSON: {}", e)); - _chat_view.set_status(None); - return; - } + let Some(config) = value.as_object() else { + chat_state.add_system_message("MCP server config must be a JSON object".to_string()); + chat_view.set_status(None); + return; }; - - let name_owned = name.to_string(); - let task_name = name_owned.clone(); - let handle = rt_handle.spawn(async move { - let config_obj = config_value.as_object().ok_or_else(|| { - bitfun_core::util::errors::BitFunError::Validation( - "MCP server config must be a JSON object".to_string(), - ) - })?; - - let server_type = match config_obj.get("type").and_then(|v| v.as_str()) { - Some("sse") => bitfun_core::service::mcp::MCPServerType::Remote, - Some("streamable-http") | Some("streamable_http") | Some("http") => { - bitfun_core::service::mcp::MCPServerType::Remote - } - _ => bitfun_core::service::mcp::MCPServerType::Local, - }; - - let transport = match config_obj.get("type").and_then(|v| v.as_str()) { - Some("sse") => bitfun_core::service::mcp::MCPServerTransport::Sse, - Some("streamable-http") | Some("streamable_http") | Some("http") => { - bitfun_core::service::mcp::MCPServerTransport::StreamableHttp - } - _ => bitfun_core::service::mcp::MCPServerTransport::Stdio, - }; - - let command = config_obj + let string_map = |key: &str| { + config + .get(key) + .and_then(serde_json::Value::as_object) + .map(|values| { + values + .iter() + .filter_map(|(key, value)| { + value.as_str().map(|value| (key.clone(), value.to_string())) + }) + .collect() + }) + .unwrap_or_default() + }; + let transport = match config.get("type").and_then(serde_json::Value::as_str) { + Some("sse") => McpTransport::Sse, + Some("streamable-http" | "streamable_http" | "http") => McpTransport::StreamableHttp, + _ => McpTransport::Stdio, + }; + let mutation = McpServerMutation { + transport, + command: config .get("command") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let args = config_obj + .and_then(serde_json::Value::as_str) + .map(str::to_string), + args: config .get("args") - .and_then(|v| v.as_array()) + .and_then(serde_json::Value::as_array) .map(|values| { values .iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect::>() - }) - .unwrap_or_default(); - let env = config_obj - .get("env") - .and_then(|v| v.as_object()) - .map(|map| { - map.iter() - .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) - .collect::>() + .filter_map(|value| value.as_str().map(str::to_string)) + .collect() }) - .unwrap_or_default(); - let headers = config_obj - .get("headers") - .and_then(|v| v.as_object()) - .map(|map| { - map.iter() - .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) - .collect::>() - }) - .unwrap_or_default(); - let url = config_obj + .unwrap_or_default(), + env: string_map("env"), + headers: string_map("headers"), + url: config .get("url") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let auto_start = config_obj + .and_then(serde_json::Value::as_str) + .map(str::to_string), + auto_start: config .get("autoStart") - .or_else(|| config_obj.get("auto_start")) - .and_then(|v| v.as_bool()) - .unwrap_or(true); - let enabled = config_obj + .or_else(|| config.get("auto_start")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + enabled: config .get("enabled") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - - let config = bitfun_core::service::mcp::MCPServerConfig { - id: name_owned.clone(), - name: name_owned.clone(), - server_type, - transport: Some(transport), - command, - args, - env, - working_directory: None, - inherit_parent_environment: None, - headers, - url, - auto_start, - enabled, - location: bitfun_core::service::mcp::ConfigLocation::User, - capabilities: Vec::new(), - settings: Default::default(), - oauth: config_obj - .get("oauth") - .cloned() - .and_then(|value| serde_json::from_value(value).ok()), - oauth_enabled: None, - xaa: config_obj - .get("xaa") - .cloned() - .and_then(|value| serde_json::from_value(value).ok()), - timeouts: Default::default(), - }; - - mcp_service.server_manager().add_server(config).await?; - - Ok::<(), bitfun_core::util::errors::BitFunError>(()) + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + oauth: config.get("oauth").cloned(), + xaa: config.get("xaa").cloned(), + }; + let agent = Arc::clone(&self.agent); + let name = name.to_string(); + let task_name = name.clone(); + let handle = rt_handle.spawn(async move { + agent + .add_mcp_server(name, mutation) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) }); self.pending_mcp_tasks.push(PendingMcpTask::Add { name: task_name, @@ -805,135 +411,102 @@ impl ChatMode { }); } - /// Schedule deleting an MCP server (deferred to allow loading state to render) fn delete_mcp_server(&mut self, server_id: &str, chat_view: &mut ChatView) { if self.pending_mcp_op.is_some() || self.is_mcp_server_task_running(server_id) { return; } - chat_view.mcp_selector_set_loading(Some(server_id.to_string())); chat_view.mcp_selector_cancel_confirm_delete(); self.pending_mcp_op = Some(PendingMcpOp::Delete(server_id.to_string())); } - /// Execute MCP server delete (called from main loop after render) fn execute_mcp_delete( &mut self, server_id: &str, - chat_view: &mut ChatView, - chat_state: &mut ChatState, + _chat_view: &mut ChatView, + _chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - let mcp_service = match crate::get_mcp_service() { - Some(svc) => svc.clone(), - None => { - chat_state.add_system_message("MCP service not initialized".to_string()); - chat_view.mcp_selector_set_loading(None); - return; - } - }; - - let server_id_owned = server_id.to_string(); - let task_server_id = server_id_owned.clone(); + let agent = Arc::clone(&self.agent); + let server_id = server_id.to_string(); + let task_server_id = server_id.clone(); let handle = rt_handle.spawn(async move { - // Delete config first so UI can reflect removal immediately even if stop is blocked. - mcp_service - .config_service() - .delete_server_config(&server_id_owned) - .await?; - - // Best-effort async cleanup: slow startups may hold process write lock for a long time. - // Retry stop with short timeout, without blocking the delete operation completion. - let cleanup_service = mcp_service.clone(); - let cleanup_server_id = server_id_owned.clone(); - tokio::spawn(async move { - for attempt in 1..=20 { - let stop_result = tokio::time::timeout( - Duration::from_millis(250), - cleanup_service - .server_manager() - .stop_server(&cleanup_server_id), - ) - .await; - - match stop_result { - Ok(Ok(())) => return, - Ok(Err(bitfun_core::util::errors::BitFunError::NotFound(_))) => return, - Ok(Err(e)) => { - tracing::debug!( - "Best-effort MCP stop failed: id={} attempt={} error={}", - cleanup_server_id, - attempt, - e - ); - } - Err(_) => { - tracing::debug!( - "Best-effort MCP stop timed out: id={} attempt={}", - cleanup_server_id, - attempt - ); - } - } - - tokio::time::sleep(Duration::from_millis(250)).await; - } - - tracing::warn!( - "Best-effort MCP stop exhausted retries: id={}", - cleanup_server_id - ); - }); - - Ok::<(), bitfun_core::util::errors::BitFunError>(()) + agent + .delete_mcp_server(server_id) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) }); - self.pending_mcp_tasks.push(PendingMcpTask::Delete { server_id: task_server_id, handle, }); } - /// Open MCP config file in system editor or show its path - fn open_mcp_config(&self, chat_state: &mut ChatState) { - match bitfun_core::infrastructure::try_get_path_manager_arc() { - Ok(path_manager) => { - let config_file = path_manager.app_config_file(); - chat_state.add_system_message(format!( - "MCP servers are configured in:\n {}\n\n\ - Edit the \"mcp_servers\" section. Example (Cursor format):\n\ - {{\n \"mcp_servers\": {{\n \"mcpServers\": {{\n \ - \"my-server\": {{\n \"type\": \"stdio\",\n \ - \"command\": \"npx\",\n \"args\": [\"-y\", \"@modelcontextprotocol/server-xxx\"]\n \ - }}\n }}\n }}\n}}", - config_file.display() - )); - } - Err(_) => { - chat_state.add_system_message( - "Could not determine config file path. Check ~/.config/bitfun/config/app.json" - .to_string(), - ); - } + fn open_mcp_config(&self, chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle) { + let config_path = tokio::task::block_in_place(|| { + rt_handle + .block_on(self.agent.list_mcp_servers()) + .ok() + .and_then(|response| response.config_path) + }); + match config_path { + Some(config_path) => chat_state.add_system_message(format!( + "MCP servers are configured in:\n {config_path}\n\nEdit the \"mcp_servers\" section." + )), + None => chat_state.add_system_message( + "The MCP configuration path is unavailable from this Host.".to_string(), + ), } } } #[cfg(test)] -mod external_mcp_timeout_tests { - use super::external_mcp_timeout_detail; - use bitfun_product_domains::external_sources::ExternalMcpTimeouts; +mod mcp_terminal_tests { + use super::*; #[test] - fn external_mcp_timeout_detail_lists_only_explicit_phases() { - let detail = external_mcp_timeout_detail(&ExternalMcpTimeouts { - startup_ms: Some(1_000), - catalog_ms: None, - execution_ms: Some(30_000), - }) - .expect("explicit timeouts should be visible"); + fn mcp_summary_text_is_terminal_safe_and_bounded() { + let item = mcp_item_from_summary(McpServerSummary { + id: "server-id".to_string(), + name: "unsafe\nname".to_string(), + server_type: "local".to_string(), + status: "Running\u{202e}".to_string(), + tool_count: 1, + source_label: "source\rlabel".to_string(), + external: false, + detail: "x".repeat(600), + action: McpServerAction::ReadOnly { + reason: "reason\ttext".to_string(), + }, + }); - assert_eq!(detail, "timeouts: startup 1000 ms, execution 30000 ms"); - assert!(external_mcp_timeout_detail(&ExternalMcpTimeouts::default()).is_none()); + assert_eq!(item.name, "unsafe\\nname"); + assert_eq!(item.status, "Running\\u{202e}"); + assert_eq!(item.source_label, "source\\rlabel"); + assert_eq!(item.detail.chars().count(), 513); + assert!(item.detail.ends_with('…')); + assert!(matches!( + item.action, + McpItemAction::ReadOnly { ref reason } if reason == "reason\\ttext" + )); + } + + #[test] + fn shared_add_does_not_report_runtime_started() { + // In Shared TUI mode the add mutates this CLI process's local MCP + // compatibility owner, not the already-running Shared Runtime Host, + // so the completion message must not claim the server "started" for + // that Runtime and must state the local scope. + let shared = mcp_add_completion_message("srv", true); + assert!(shared.contains("local compatibility owner")); + assert!(shared.contains("Shared Runtime Host is not reconfigured")); + assert!(!shared.contains("added and started")); + + // Embedded mode owns the runtime, so "added and started" stays accurate. + assert_eq!( + mcp_add_completion_message("srv", false), + "MCP server 'srv' added and started" + ); } } diff --git a/src/apps/cli/src/modes/chat/native_hooks.rs b/src/apps/cli/src/modes/chat/native_hooks.rs index 9b94c67d5f..0950ec189e 100644 --- a/src/apps/cli/src/modes/chat/native_hooks.rs +++ b/src/apps/cli/src/modes/chat/native_hooks.rs @@ -80,7 +80,7 @@ fn render_native_hook_overview(overview: &NativeHookOverview) -> String { file.scope, if file.loaded { "loaded" } else { "not loaded" }, if file.exists { "present" } else { "missing" }, - crate::plugin_diagnostics::escape_terminal_text(&file.path.to_string_lossy()), + crate::plugin_diagnostics::escape_terminal_text(&file.location), )); } } @@ -101,9 +101,9 @@ fn render_native_hook_overview(overview: &NativeHookOverview) -> String { let mut current_event = ""; for rule in overview.rules.iter().take(MAX_TUI_NATIVE_HOOK_RULES) { if rule.event != current_event { - current_event = rule.event; + current_event = rule.event.as_str(); lines.push(String::new()); - lines.push(crate::plugin_diagnostics::escape_terminal_text(rule.event)); + lines.push(crate::plugin_diagnostics::escape_terminal_text(&rule.event)); } lines.push(native_hook_rule_line(rule)); for handler in rule @@ -114,7 +114,7 @@ fn render_native_hook_overview(overview: &NativeHookOverview) -> String { lines.push(format!( " - {} [timeout {}s{}]", truncate_hook_command(&crate::plugin_diagnostics::escape_terminal_text( - &handler.command, + &handler.command_summary, )), handler.timeout_seconds, match handler.status_message.as_deref() { diff --git a/src/apps/cli/src/modes/chat/provider_models.rs b/src/apps/cli/src/modes/chat/provider_models.rs index 215cfa0c43..1fa3d5671f 100644 --- a/src/apps/cli/src/modes/chat/provider_models.rs +++ b/src/apps/cli/src/modes/chat/provider_models.rs @@ -1,5 +1,5 @@ impl ChatMode { - /// Handle provider selection result (step 1 → step 2) + /// Handle provider selection result (step 1 to step 2). fn handle_provider_selection(&self, selection: ProviderSelection, chat_view: &mut ChatView) { match selection { ProviderSelection::Provider(template) => { @@ -11,13 +11,10 @@ impl ChatMode { &default_model, ); } - ProviderSelection::Custom => { - chat_view.show_model_config_form_custom(); - } + ProviderSelection::Custom => chat_view.show_model_config_form_custom(), } } - /// Save new model to global config fn save_new_model( &self, result: ModelFormResult, @@ -32,105 +29,31 @@ impl ChatMode { .unwrap_or_default() .as_millis() ); - - // Parse custom headers JSON if provided - let custom_headers: Option> = - if result.custom_headers.is_empty() { - None - } else { - serde_json::from_str(&result.custom_headers).ok() - }; - - let custom_request_body: Option = if result.custom_request_body.is_empty() { - None - } else { - Some(result.custom_request_body.clone()) + let request = AddModelRequest { + model: result.to_mutation(model_id.clone()), + make_primary_if_empty: true, }; - - let model_config = bitfun_core::service::config::AIModelConfig { - id: model_id.clone(), - name: result.name.clone(), - provider: result.provider_format.clone(), - model_name: result.model_name.clone(), - base_url: result.base_url.clone(), - api_key: result.api_key.clone(), - context_window: Some(result.context_window), - max_tokens: Some(result.max_tokens), - enabled: true, - reasoning: result.reasoning.clone(), - inline_think_in_text: result.inline_think_in_text, - skip_ssl_verify: result.skip_ssl_verify, - custom_headers, - custom_headers_mode: if result.custom_headers_mode.is_empty() - || result.custom_headers_mode == "merge" - { - None - } else { - Some(result.custom_headers_mode.clone()) - }, - custom_request_body, - ..Default::default() - }; - - let success = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let config_service = match GlobalConfigManager::get_service().await { - Ok(s) => s, - Err(e) => { - tracing::error!("Failed to get config service: {}", e); - return false; - } - }; - - if let Err(e) = config_service.add_ai_model(model_config).await { - tracing::error!("Failed to add AI model: {}", e); - return false; - } - - // Auto-set as primary model if no primary model exists - match config_service - .get_config::(None) - .await - { - Ok(global_config) => { - let has_primary = global_config - .ai - .default_models - .primary - .as_ref() - .map(|p| !p.is_empty()) - .unwrap_or(false); - if !has_primary { - if let Err(e) = config_service - .set_config("ai.default_models.primary", &model_id) - .await - { - tracing::warn!("Failed to auto-set primary model: {}", e); - } else { - tracing::info!("Auto-set primary model: {}", model_id); - } - } - } - Err(e) => { - tracing::warn!("Failed to read config for auto-primary: {}", e); - } - } - - true - }) - }); - - if success { - chat_view.set_status(Some(format!("Model added: {}", result.name))); - chat_state.current_model_name = format!("{} / {}", result.model_name, result.name); - tracing::info!("Added new AI model: {} ({})", model_id, result.model_name); - crate::account_sync::notify_local_settings_changed(); - } else { - chat_view.set_status(Some("Failed to add model".to_string())); + let outcome = + tokio::task::block_in_place(|| rt_handle.block_on(self.agent.add_model(request))); + + match outcome { + Ok(_) => { + chat_view.set_status(Some(format!("Model added: {}", result.name))); + chat_state.current_model_name = format!("{} / {}", result.model_name, result.name); + tracing::info!("Added new AI model: {} ({})", model_id, result.model_name); + let _ = tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.settings_sync_local_changed()) + }); + } + Err(error) => { + tracing::error!("Failed to add AI model: {error}"); + chat_view.set_status(Some(format!("Failed to add model: {error}"))); + } } } - /// Fetch full model config and open the edit form + /// The read projection contains only editable non-secret fields. Existing + /// secrets stay write-only and are preserved when the edit form is blank. fn edit_model( &self, selected: &ModelItem, @@ -138,56 +61,22 @@ impl ChatMode { rt_handle: &tokio::runtime::Handle, ) { let model_id = selected.id.clone(); - let result = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let config_service = GlobalConfigManager::get_service().await.ok()?; - let models: Vec = - config_service.get_ai_models().await.ok()?; - let model = models.into_iter().find(|m| m.id == model_id)?; - let reasoning_preset_options = self - .agent - .model_catalog() - .await - .ok() - .and_then(|catalog| catalog.reasoning_presets_by_model.get(&model.id).cloned()) - .unwrap_or_default(); - Some((model, reasoning_preset_options)) - }) + let outcome = tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.get_model(model_id.clone())) }); - match result { - Some((model, reasoning_preset_options)) => { - let form_data = ModelFormResult { - editing_model_id: Some(model.id.clone()), - name: model.name, - model_name: model.model_name, - base_url: model.base_url, - api_key: model.api_key, - provider_format: model.provider.clone(), - context_window: model.context_window.unwrap_or(128000), - max_tokens: model.max_tokens.unwrap_or(8192), - reasoning_preset_options, - reasoning: model.reasoning, - inline_think_in_text: model.inline_think_in_text, - skip_ssl_verify: model.skip_ssl_verify, - custom_headers: model - .custom_headers - .map(|h| serde_json::to_string(&h).unwrap_or_default()) - .unwrap_or_default(), - custom_headers_mode: model - .custom_headers_mode - .unwrap_or_else(|| "merge".to_string()), - custom_request_body: model.custom_request_body.unwrap_or_default(), - }; - chat_view.show_model_config_form_for_edit(&model.id, &form_data); + match outcome { + Ok(response) => { + let form_data = ModelFormResult::from_projection(response.model); + chat_view.show_model_config_form_for_edit(&model_id, &form_data); } - None => { - chat_view.set_status(Some("Failed to load model configuration".to_string())); + Err(error) => { + tracing::error!("Failed to load model configuration: {error}"); + chat_view.set_status(Some(format!("Failed to load model configuration: {error}"))); } } } - /// Update an existing model in global config fn update_existing_model( &self, result: ModelFormResult, @@ -195,78 +84,29 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - let model_id = match &result.editing_model_id { - Some(id) => id.clone(), - None => return, - }; - - let custom_headers: Option> = - if result.custom_headers.is_empty() { - None - } else { - serde_json::from_str(&result.custom_headers).ok() - }; - - let custom_request_body: Option = if result.custom_request_body.is_empty() { - None - } else { - Some(result.custom_request_body.clone()) + let Some(model_id) = result.editing_model_id.clone() else { + return; }; - - let model_config = bitfun_core::service::config::AIModelConfig { - id: model_id.clone(), - name: result.name.clone(), - provider: result.provider_format.clone(), - model_name: result.model_name.clone(), - base_url: result.base_url.clone(), - api_key: result.api_key.clone(), - context_window: Some(result.context_window), - max_tokens: Some(result.max_tokens), - enabled: true, - reasoning: result.reasoning.clone(), - inline_think_in_text: result.inline_think_in_text, - skip_ssl_verify: result.skip_ssl_verify, - custom_headers, - custom_headers_mode: if result.custom_headers_mode.is_empty() - || result.custom_headers_mode == "merge" - { - None - } else { - Some(result.custom_headers_mode.clone()) - }, - custom_request_body, - ..Default::default() + let request = UpdateModelRequest { + model_id: model_id.clone(), + model: result.to_mutation(model_id.clone()), }; - - let success = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let config_service = match GlobalConfigManager::get_service().await { - Ok(s) => s, - Err(e) => { - tracing::error!("Failed to get config service: {}", e); - return false; - } - }; - - if let Err(e) = config_service - .update_ai_model(&model_id, model_config) - .await - { - tracing::error!("Failed to update AI model: {}", e); - return false; - } - - true - }) - }); - - if success { - chat_view.set_status(Some(format!("Model updated: {}", result.name))); - chat_state.current_model_name = format!("{} / {}", result.model_name, result.name); - tracing::info!("Updated AI model: {}", model_id); - crate::account_sync::notify_local_settings_changed(); - } else { - chat_view.set_status(Some("Failed to update model".to_string())); + let outcome = + tokio::task::block_in_place(|| rt_handle.block_on(self.agent.update_model(request))); + + match outcome { + Ok(_) => { + chat_view.set_status(Some(format!("Model updated: {}", result.name))); + chat_state.current_model_name = format!("{} / {}", result.model_name, result.name); + tracing::info!("Updated AI model: {model_id}"); + let _ = tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.settings_sync_local_changed()) + }); + } + Err(error) => { + tracing::error!("Failed to update AI model: {error}"); + chat_view.set_status(Some(format!("Failed to update model: {error}"))); + } } } } diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index bc1ac3847a..db85947a20 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -443,7 +443,6 @@ impl ChatMode { state.apply_workspace_binding(workspace_binding); (session_id, state, Vec::new()) }; - chat_state.set_worktree_control_available(!self.agent.is_shared()); self.auto_approve_ask_override = None; self.agent .set_approval_policy(crate::runtime::approval::CliApprovalPolicy::Ask); @@ -454,65 +453,72 @@ impl ChatMode { self.workspace = chat_state.workspace.clone(); self.refresh_workspace_git_status(&mut chat_state, &rt_handle); - let mut external_source_rx = None; + // Apply model override (--model flag): update the session model. + // The backend validates the ID; an invalid ID logs a warning and + // falls back to the default model. + if let Some(ref model_override) = self.model_id { + let trimmed = model_override.trim(); + let sid = chat_state.core_session_id.clone(); + let mid = trimmed.to_string(); + let agent = self.agent.clone(); + if let Err(e) = tokio::task::block_in_place(|| { + rt_handle.block_on(async { agent.update_session_model(&sid, &mid).await }) + }) { + tracing::warn!("Failed to apply model override '{mid}': {e}"); + eprintln!("Warning: Model '{mid}' not found. Using default model."); + } + } + if self.agent.is_shared() { chat_view.set_status(Some(format!( "{SHARED_TUI_CHAT_STATUS} {SHARED_TUI_EMBEDDED_HANDOFF}" ))); - } else { - let external_workspace = self.agent.workspace_path_buf(); - let (initial_external_sources, updates, conflict_preferences) = - tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let updates = - subscribe_external_source_updates(Some(&external_workspace)).await; - let snapshot = - external_source_snapshot(Some(&external_workspace), false).await; - let preferences = external_source_conflict_choices().await.map(Into::into); - (snapshot, updates.ok(), preferences) - }) - }); - external_source_rx = updates; - match conflict_preferences { - Ok(preferences) => self.replace_external_conflict_preferences(preferences), - Err(error) => { - tracing::warn!("External source preferences are unavailable: {}", error) - } - } - match initial_external_sources { - Ok(snapshot) => { - let (available, restricted) = external_command_counts(&snapshot); - let pending_conflicts = snapshot - .command_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - .count(); - let tool_notice = self.take_external_tool_notice(&snapshot); - let agent_notice = self.take_external_agent_notice(&snapshot); - self.update_external_source_view(&mut chat_view, &snapshot); - self.external_source_snapshot = Some(snapshot.clone()); - if snapshot.discovery_pending { - chat_view.set_status(Some( - "Checking compatible content from external AI applications".to_string(), - )); - } else if tool_notice.is_some() || agent_notice.is_some() { - chat_view.set_status(Some( - [tool_notice, agent_notice] - .into_iter() - .flatten() - .collect::>() - .join("; "), - )); - } else if available + restricted > 0 || pending_conflicts > 0 { - chat_view.set_status(Some(format!( + } + let agent = self.agent.clone(); + let (initial_external_sources, updates) = tokio::task::block_in_place(|| { + let updates = agent.subscribe_external_source_updates().ok(); + let snapshot = rt_handle.block_on(agent.external_source_snapshot(false)); + (snapshot, updates) + }); + let mut external_source_rx = updates; + match initial_external_sources { + Ok(response) => { + self.replace_external_conflict_preferences(response.preferences.into()); + let snapshot = response.snapshot; + let (available, restricted) = external_command_counts(&snapshot); + let pending_conflicts = snapshot + .command_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .count(); + let tool_notice = self.take_external_tool_notice(&snapshot); + let agent_notice = self.take_external_agent_notice(&snapshot); + self.update_external_source_view(&mut chat_view, &snapshot); + self.external_source_snapshot = Some(snapshot.clone()); + if snapshot.discovery_pending { + chat_view.set_status(Some( + "Checking compatible content from external AI applications".to_string(), + )); + } else if tool_notice.is_some() || agent_notice.is_some() { + chat_view.set_status(Some( + [tool_notice, agent_notice] + .into_iter() + .flatten() + .collect::>() + .join("; "), + )); + } else if available + restricted > 0 || pending_conflicts > 0 { + chat_view.set_status(Some(format!( "External sources: {available} commands available, {restricted} restricted, {pending_conflicts} need a choice" ))); - } - } - Err(error) => { - tracing::warn!("External source discovery is unavailable: {}", error); } } + Err(error) => { + tracing::warn!( + error_code = error.code.as_str(), + "External source discovery is unavailable" + ); + } } // Load current model name for display @@ -747,7 +753,12 @@ impl ChatMode { let mut latest = None; for _ in 0..4 { match receiver.try_recv() { - Ok(snapshot) => latest = Some(snapshot), + Ok((workspace_path, snapshot)) + if workspace_path == self.agent.workspace_path_string() => + { + latest = Some(snapshot) + } + Ok(_) => continue, Err(TryRecvError::Lagged(_)) => continue, Err(TryRecvError::Empty) => break, Err(TryRecvError::Closed) => { @@ -762,14 +773,22 @@ impl ChatMode { .as_ref() .is_some_and(|previous| previous.discovery_pending) && !snapshot.discovery_pending; - let preferences = tokio::task::block_in_place(|| { - rt_handle - .block_on(external_source_conflict_choices()) - .map(Into::into) + let response = tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.external_source_snapshot(false)) }); - if let Ok(preferences) = preferences { - self.replace_external_conflict_preferences(preferences); - } + let snapshot = match response { + Ok(response) => { + self.replace_external_conflict_preferences(response.preferences.into()); + response.snapshot + } + Err(error) => { + tracing::warn!( + error_code = error.code.as_str(), + "External source event snapshot recovery failed" + ); + snapshot + } + }; let tool_notice = self.take_external_tool_notice(&snapshot); let agent_notice = self.take_external_agent_notice(&snapshot); self.update_external_source_view(&mut chat_view, &snapshot); @@ -809,8 +828,7 @@ impl ChatMode { } if chat_view.login_form_visible() { - self.refresh_account_panel_live(&mut chat_view); - if crate::account_sync::sync_in_flight() { + if self.refresh_account_panel_live(&mut chat_view) { needs_redraw = true; } } diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index f86be11290..d9048ee44f 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -92,20 +92,6 @@ fn apply_agent_mode_feedback( } } -fn usage_report_metadata(report: &SessionUsageReport) -> Result { - let usage_report = serde_json::to_value(report) - .map_err(|error| anyhow!("Failed to serialize usage report: {error}"))?; - Ok(serde_json::json!({ - "localCommandKind": "usage_report", - "reportId": report.report_id, - "schemaVersion": report.schema_version, - "generatedAt": report.generated_at, - "modelVisible": false, - "usageReport": usage_report, - "usageReportStatus": "completed", - })) -} - fn apply_model_selection_feedback( chat_state: &mut ChatState, selected_display_name: &str, @@ -212,14 +198,21 @@ fn apply_session_model_migration( impl ChatMode { fn logout(&self, chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle) { - let logged_in = - tokio::task::block_in_place(|| rt_handle.block_on(crate::account::is_logged_in())); - if !logged_in { - chat_state.add_system_message("Not logged in.".to_string()); - return; + let snapshot = + tokio::task::block_in_place(|| rt_handle.block_on(self.agent.account_snapshot())); + match snapshot { + Ok(snapshot) if !snapshot.logged_in => { + chat_state.add_system_message("Not logged in.".to_string()); + return; + } + Err(error) => { + chat_state.add_system_message(format!("Logout failed: {error}")); + return; + } + Ok(_) => {} } - match tokio::task::block_in_place(|| rt_handle.block_on(crate::account::logout())) { - Ok(()) => chat_state.add_system_message("Logged out.".to_string()), + match tokio::task::block_in_place(|| rt_handle.block_on(self.agent.account_logout())) { + Ok(_) => chat_state.add_system_message("Logged out.".to_string()), Err(error) => chat_state.add_system_message(format!("Logout failed: {error}")), } } @@ -245,6 +238,17 @@ impl ChatMode { .or_else(|| Some(self.agent.workspace_path_string())); let agent = self.agent.clone(); + /* + * Rendered into the conversation view and nowhere else. A report about + * a session is not an event in it, and this used to write one as a + * `local_command` Turn as well — which the desktop then loaded from + * disk and gave a numbered slot in its Turn rail, because the ordinals + * come from the backend catalog and the catalog counts what is stored. + * + * `add_assistant_message` is already the UI-only path: `turn_id: None`, + * never persisted, never in model context. In a terminal the scrollback + * is the record, so nothing here needs to replace what is being removed. + */ let report_result: Result = tokio::task::block_in_place(|| { let session_id = session_id.clone(); @@ -255,39 +259,21 @@ impl ChatMode { .filter(|path| !path.trim().is_empty()) .ok_or_else(|| anyhow!("Workspace path is required for usage reports"))?; - let report = agent + agent .generate_session_usage_report(AgentSessionUsageRequest { - session_id: session_id.clone(), + session_id, workspace_path: Some(workspace_path), remote_connection_id: None, remote_ssh_host: None, include_hidden_subagents: true, }) - .await?; - - let markdown = render_usage_report_markdown(&report); - let generated_at = u64::try_from(report.generated_at).unwrap_or_default(); - let metadata = usage_report_metadata(&report)?; - agent - .record_completed_local_command_turn(AgentLocalCommandTurnRecordRequest { - session_id, - content: markdown, - turn_id: Some(format!("local-usage-{}", report.report_id)), - timestamp_ms: Some(generated_at), - metadata: metadata.as_object().cloned().ok_or_else(|| { - anyhow!("Usage report metadata must be an object") - })?, - }) - .await?; - - Ok(report) + .await }) }); match report_result { Ok(report) => { - let markdown = render_usage_report_markdown(&report); - chat_state.add_assistant_message(markdown); + chat_state.add_assistant_message(render_usage_report_markdown(&report)); chat_view.set_status(Some("Usage report added to conversation".to_string())); } Err(error) => { @@ -462,52 +448,19 @@ impl ChatMode { let session_model_id = chat_state.current_model_id.clone(); let result: Option = tokio::task::block_in_place(|| { rt_handle.block_on(async { - let config_service = GlobalConfigManager::get_service().await.ok()?; - let models: Vec = - config_service.get_ai_models().await.ok()?; - let global_config: bitfun_core::service::config::GlobalConfig = - config_service.get_config(None).await.ok()?; - - let model_id = crate::model_selection::resolve_session_model_display_id( - &global_config.ai, + let catalog = self.agent.list_models().await.ok()?; + let model_id = crate::model_selection::resolve_tui_model_id( + &catalog, session_model_id.as_deref(), )?; - - fn provider_display_name( - model: &bitfun_core::service::config::AIModelConfig, - ) -> String { - let raw_name = model.name.trim(); - let model_name = model.model_name.trim(); - if !raw_name.is_empty() && !model_name.is_empty() { - let dashed_suffix = format!(" - {}", model_name); - let slash_suffix = format!("/{}", model_name); - if let Some(provider) = raw_name.strip_suffix(&dashed_suffix) { - return provider.trim().to_string(); - } - if let Some(provider) = raw_name.strip_suffix(&slash_suffix) { - return provider.trim().to_string(); - } - } - if raw_name.is_empty() { - model.provider.clone() - } else { - raw_name.to_string() - } - } - - fn model_display_name( - model: &bitfun_core::service::config::AIModelConfig, - ) -> String { - format!("{} / {}", model.model_name, provider_display_name(model)) - } - - let model_name = models - .iter() - .find(|model| model.id == model_id) - .map(model_display_name) - .unwrap_or_else(|| model_id.clone()); - - Some(model_name) + Some( + catalog + .models + .iter() + .find(|model| model.id == model_id) + .map(crate::model_selection::tui_model_display_name) + .unwrap_or(model_id), + ) }) }); @@ -527,35 +480,22 @@ impl ChatMode { ) { let result = tokio::task::block_in_place(|| { rt_handle.block_on(async { - let config_service = match GlobalConfigManager::get_service().await { - Ok(s) => s, - Err(e) => { - tracing::error!("Failed to get config service: {}", e); - return None; - } - }; - - let models: Vec = - config_service.get_ai_models().await.ok()?; - let global_config: bitfun_core::service::config::GlobalConfig = - config_service.get_config(None).await.ok()?; - let current_model_id = crate::model_selection::resolve_session_model_display_id( - &global_config.ai, + let catalog = self.agent.list_models().await.ok()?; + let current_model_id = crate::model_selection::resolve_tui_model_id( + &catalog, chat_state.current_model_id.as_deref(), ); - - // Convert to ModelItem list (only enabled models) - let model_items: Vec = models + let model_items: Vec = catalog + .models .into_iter() - .filter(|m| m.enabled) - .map(|m| ModelItem { - id: m.id, - name: m.name, - provider: m.provider, - model_name: m.model_name, + .filter(|model| model.enabled) + .map(|model| ModelItem { + id: model.id, + name: model.name, + provider: model.provider, + model_name: model.model_name, }) .collect(); - Some((model_items, current_model_id)) }) }); @@ -655,20 +595,12 @@ impl ChatMode { chat_state.is_processing, self.pending_session_operation.is_some(), ); - if self.agent.is_shared() { - chat_view.show_agent_modes_only( - agent_items, - Some(self.agent_type.clone()), - allow_mode_switch, - ); - } else { - chat_view.show_agent_selector( - agent_items, - Some(self.agent_type.clone()), - true, - allow_mode_switch, - ); - } + chat_view.show_agent_selector( + agent_items, + Some(self.agent_type.clone()), + !self.agent.is_shared(), + allow_mode_switch, + ); } fn handle_agent_selector_action( @@ -904,10 +836,7 @@ fn session_update_unavailable_message(setting_name: &str, is_processing: bool) - #[cfg(test)] mod usage_metadata_tests { - use super::{ - session_update_allowed, session_update_unavailable_message, usage_report_metadata, - SessionUsageReport, - }; + use super::{session_update_allowed, session_update_unavailable_message}; #[test] fn session_update_is_rechecked_when_an_idle_popup_outlives_turn_start() { @@ -925,21 +854,4 @@ mod usage_metadata_tests { "Agent mode cannot be changed during the current turn." ); } - - #[test] - fn usage_metadata_preserves_the_existing_tui_transcript_schema() { - let mut report = SessionUsageReport::partial_unavailable("session-1", 1_778_347_200_000); - report.report_id = "usage-session-1-1778347200000".to_string(); - - let metadata = usage_report_metadata(&report).expect("usage metadata"); - - assert_eq!(metadata["localCommandKind"], "usage_report"); - assert_eq!(metadata["reportId"], report.report_id); - assert_eq!(metadata["schemaVersion"], report.schema_version); - assert_eq!(metadata["generatedAt"], report.generated_at); - assert_eq!(metadata["modelVisible"], false); - assert_eq!(metadata["usageReportStatus"], "completed"); - assert_eq!(metadata["usageReport"]["sessionId"], "session-1"); - assert_eq!(metadata.as_object().map(serde_json::Map::len), Some(7)); - } } diff --git a/src/apps/cli/src/modes/chat/sessions.rs b/src/apps/cli/src/modes/chat/sessions.rs index 90e81f20cd..d49e1ffff3 100644 --- a/src/apps/cli/src/modes/chat/sessions.rs +++ b/src/apps/cli/src/modes/chat/sessions.rs @@ -53,7 +53,6 @@ impl ChatMode { chat_view.activate_session_composer(&source_session_id, &new_session_id); *session_id = new_session_id.clone(); *chat_state = new_state; - chat_state.set_worktree_control_available(!self.agent.is_shared()); self.agent_type = restored_agent_type; self.workspace = chat_state.workspace.clone(); self.refresh_workspace_git_status(chat_state, rt_handle); @@ -123,7 +122,6 @@ impl ChatMode { chat_view.activate_session_composer(&previous_session_id, new_session_id); *session_id = new_session_id.to_string(); *chat_state = new_state; - chat_state.set_worktree_control_available(!self.agent.is_shared()); self.agent_type = restored_agent_type; self.workspace = chat_state.workspace.clone(); self.refresh_workspace_git_status(chat_state, rt_handle); @@ -179,7 +177,6 @@ impl ChatMode { chat_view.activate_session_composer(&previous_session_id, &new_session_id); *session_id = new_session_id; *chat_state = new_state; - chat_state.set_worktree_control_available(!self.agent.is_shared()); self.workspace = chat_state.workspace.clone(); self.refresh_workspace_git_status(chat_state, rt_handle); self.auto_approve_ask_override = None; diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index c090b470e3..966650120d 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -11,7 +11,7 @@ mod tests { command_route, consume_selected_native_command_once, context_compression_tool_event, extension_command_help_request, external_agent_attention, external_agent_diagnostic_lines, external_agent_pending_notice_key, external_agent_result_is_stale, - external_agent_review_text, external_command_projections, external_control_review_text, + external_agent_review_text, external_command_projections, external_control_status_text, external_hook_help_text, external_integration_policy_lines, external_operation_error_status, external_tool_mutation_result_label, external_tool_pending_notice_key, external_tool_result_is_stale, external_tool_review_text, @@ -41,25 +41,38 @@ mod tests { use crate::ui::chat::ChatView; use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; use crate::ui::theme::Theme; - use bitfun_core::external_hooks::ExternalHookCatalogSnapshotV1; - use bitfun_core::external_sources::{ - native_prompt_command_conflict_key, ExternalSourceAssetKind, ExternalSourceCatalogSnapshot, - ExternalSourceControlSnapshotV1, ExternalSourceDiagnostic, - ExternalSourceDiagnosticSeverity, ExternalSourceOperationError, - ExternalSourceOperationErrorCode, ExternalSubagentActivationState, + use bitfun_app_server_protocol::hook::{ + NativeHookFileSummary as NativeHookFileView, + NativeHookHandlerSummary as NativeHookHandlerView, NativeHookOverview, + NativeHookRuleSummary as NativeHookRuleView, + }; + use bitfun_events::{AgenticEvent, ToolEventData}; + use bitfun_product_domains::external_hook_catalog::{ + ExternalHookCatalogEntry, ExternalHookCatalogSnapshotV1, ExternalHookHandlerKind, + ExternalHookMatcherSummary, ExternalHookNativeActivation, ExternalHookProjectionStatus, + }; + use bitfun_product_domains::external_source_control::ExternalSourceControlSnapshotV1; + use bitfun_product_domains::external_sources::{ + native_prompt_command_conflict_key, ExternalSourceAssetKind, + ExternalSourceCatalogSnapshot as RawExternalSourceCatalogSnapshot, + ExternalSourceDiagnostic, ExternalSourceDiagnosticSeverity, ExternalSourceOperationError, + ExternalSourceOperationErrorCode, + ExternalSourcePublicSnapshot as ExternalSourceCatalogSnapshot, ExternalSourceScope, ExternalToolActivationState, }; - - use bitfun_core::native_hooks::{ - NativeHookFileView, NativeHookHandlerView, NativeHookOverview, NativeHookRuleView, + use bitfun_product_domains::external_subagents::{ + ExternalSubagentActivationState, ExternalSubagentModelBindingTarget, }; - use bitfun_events::{AgenticEvent, ToolEventData}; - use bitfun_product_domains::external_sources::ExternalSourceScope; - use bitfun_product_domains::external_subagents::ExternalSubagentModelBindingTarget; use bitfun_runtime_ports::AgentContextReloadTarget; use crossterm::event::Event; use std::collections::{BTreeMap, BTreeSet}; + fn public_external_source_snapshot(value: serde_json::Value) -> ExternalSourceCatalogSnapshot { + let snapshot: RawExternalSourceCatalogSnapshot = + serde_json::from_value(value).expect("parse raw external source test snapshot"); + snapshot.into() + } + #[test] fn explicit_same_id_agent_selection_rebinds_through_the_runtime_owner() { let source = include_str!("selection.rs").replace("\r\n", "\n"); @@ -150,21 +163,25 @@ mod tests { ExternalControlUiAction::SetSafeMode(false) ); assert_eq!( - parse_external_control_action("source disable opencode.commands:project").unwrap(), + parse_external_control_action("disable 1").unwrap(), ExternalControlUiAction::SetSourceEnabled { - source_key: "opencode.commands:project".to_string(), + source_index: 0, enabled: false, } ); assert_eq!( - parse_external_control_action("source enable opencode.commands:project").unwrap(), + parse_external_control_action("enable 2").unwrap(), ExternalControlUiAction::SetSourceEnabled { - source_key: "opencode.commands:project".to_string(), + source_index: 1, enabled: true, } ); assert!(parse_external_control_action("safe-mode toggle").is_err()); assert!(parse_external_control_action("enable-everything").is_err()); + assert!(parse_external_control_action("review").is_err()); + let usage = parse_external_control_action("unknown").unwrap_err(); + assert!(!usage.contains("safe-mode")); + assert!(!usage.contains("review")); } #[test] @@ -210,16 +227,36 @@ mod tests { })) .unwrap(); - let text = external_control_review_text(&control); - assert!(text.contains("Safe Mode: on")); - assert!(text.contains("Generation: 9")); - assert!(text.contains("Execution domain: local-user")); - assert!(text.contains("New external Tool, Agent, and MCP calls are blocked")); - assert!(text.contains("restarting the Host turns it off")); - assert!(text.contains("Source opencode.commands:project")); - assert!(text.contains("source disable ")); - assert!(text.contains("Tools: 2 items, 1 review, 0 conflicts, inactive")); - assert!(text.contains("/extensions safe-mode off")); + let text = external_control_status_text(&control); + assert!(text.contains("Extensions")); + assert!(text.contains("1. OpenCode project commands - Available")); + assert!(text.contains("Disable: /extensions disable 1")); + assert!(text.contains("Refresh: /extensions refresh")); + assert!(text.contains("External access is paused. Resume: /extensions safe-mode off")); + for hidden in [ + "Generation", + "Execution domain", + "opencode.commands:project", + "review", + "items", + "conflicts", + "", + ] { + assert!(!text.contains(hidden), "leaked {hidden}:\n{text}"); + } + + let mut read_only = control.clone(); + read_only.host_capabilities.can_manage_sources = false; + let read_only_text = external_control_status_text(&read_only); + assert!(read_only_text.contains("This connection can only show extension status.")); + assert!(!read_only_text.contains("/extensions disable 1")); + + let mut permission_needed = control.clone(); + permission_needed.sources[0].effective_status = + bitfun_product_domains::external_source_control::ExternalSourceEffectiveStatus::ReviewRequired; + let permission_text = external_control_status_text(&permission_needed); + assert!(permission_text.contains("Needs permission")); + assert!(permission_text.contains("Manage permissions: /tools, /agent, /mcp, or /hooks")); } #[test] @@ -261,17 +298,17 @@ mod tests { })) .unwrap(); - let text = external_control_review_text(&control); - assert!(text.contains("Tools: 0 items, 0 review, 0 conflicts, inactive, support: partial")); - assert!(text.contains("Issues")); - assert!(text.contains("[external_tool.runtime_unavailable]")); - assert!(text.contains("Recovery")); + let text = external_control_status_text(&control); + assert!(text.contains("No extensions found.")); + assert!(!text.contains("External access is paused")); + assert!(text.contains("Needs attention")); + assert!(!text.contains("external_tool.runtime_unavailable")); assert!(text.contains("/extensions refresh")); assert!(text.contains("install or repair the required runtime")); } fn external_tool_review_snapshot() -> ExternalSourceCatalogSnapshot { - serde_json::from_value(serde_json::json!({ + public_external_source_snapshot(serde_json::json!({ "generation": 3, "discoveryPending": false, "sources": [{ @@ -482,7 +519,6 @@ mod tests { "source": { "providerId": "opencode.tools", "sourceId": "project" } }] })) - .unwrap() } #[test] @@ -880,26 +916,26 @@ mod tests { project_hooks_enabled: false, files: vec![ NativeHookFileView { - scope: "user", - path: std::path::PathBuf::from("/home/u/.config/bitfun/config/hooks.json"), + scope: "user".to_string(), + location: "/config/hooks.json".to_string(), exists: true, loaded: true, }, NativeHookFileView { - scope: "project", - path: std::path::PathBuf::from("/ws/.bitfun/config/hooks.json"), + scope: "project".to_string(), + location: "/.bitfun/config/hooks.json".to_string(), exists: true, loaded: false, }, ], rules: vec![NativeHookRuleView { - event: "PreToolUse", + event: "PreToolUse".to_string(), matcher: "Bash".to_string(), matcher_is_valid: true, - scope: "user", - source: "/home/u/.config/bitfun/config/hooks.json".to_string(), + scope: "user".to_string(), handlers: vec![NativeHookHandlerView { - command: "jq -r '.tool_input.command' >> ~/log".to_string(), + command_summary: "jq -r '.tool_input.command' >> ~/log".to_string(), + command_truncated: false, timeout_seconds: 600, status_message: None, }], @@ -1051,21 +1087,17 @@ mod tests { })) .unwrap(); snapshot.entries = (0..105) - .map( - |index| bitfun_core::external_hooks::ExternalHookCatalogEntry { - stable_key: format!("test-{index}"), - source: snapshot.sources[0].key.clone(), - native_event: format!("Event{index}"), - matcher: bitfun_core::external_hooks::ExternalHookMatcherSummary::Any, - handler_kind: bitfun_core::external_hooks::ExternalHookHandlerKind::Command, - projection_status: - bitfun_core::external_hooks::ExternalHookProjectionStatus::NativeOnly, - native_activation: - bitfun_core::external_hooks::ExternalHookNativeActivation::Unknown, - mapping: None, - content_version: format!("entry-v{index}"), - }, - ) + .map(|index| ExternalHookCatalogEntry { + stable_key: format!("test-{index}"), + source: snapshot.sources[0].key.clone(), + native_event: format!("Event{index}"), + matcher: ExternalHookMatcherSummary::Any, + handler_kind: ExternalHookHandlerKind::Command, + projection_status: ExternalHookProjectionStatus::NativeOnly, + native_activation: ExternalHookNativeActivation::Unknown, + mapping: None, + content_version: format!("entry-v{index}"), + }) .collect(); let text = render_external_hook_catalog(&snapshot); @@ -1076,7 +1108,7 @@ mod tests { #[test] fn unresolved_provider_conflicts_expose_explicit_cli_choices() { - let snapshot: ExternalSourceCatalogSnapshot = serde_json::from_value(serde_json::json!({ + let snapshot = public_external_source_snapshot(serde_json::json!({ "generation": 1, "discoveryPending": false, "sources": [ @@ -1140,8 +1172,7 @@ mod tests { } ] }] - })) - .unwrap(); + })); let projections = external_command_projections(&snapshot, &BTreeMap::new()); @@ -2186,13 +2217,16 @@ mod tests { } #[test] - fn shared_chat_status_separates_session_selection_from_management() { + fn shared_chat_status_describes_local_compatibility_management() { assert!(SHARED_TUI_CHAT_STATUS.contains("current Session Agent mode")); - assert!(SHARED_TUI_CHAT_STATUS.contains("current Session model")); + assert!(!SHARED_TUI_CHAT_STATUS.contains("current Session model")); assert!(SHARED_TUI_CHAT_STATUS.contains("current Session name")); assert!(SHARED_TUI_CHAT_STATUS.contains("/reload [skills|instructions]")); - assert!(SHARED_TUI_CHAT_STATUS.contains("Agent/Subagent management")); - assert!(SHARED_TUI_CHAT_STATUS.contains("model management remains Embedded")); + assert!(SHARED_TUI_CHAT_STATUS.contains("Model, Skill, Subagent, and MCP management")); + assert!(SHARED_TUI_CHAT_STATUS.contains("local compatibility owner")); + assert!(SHARED_TUI_CHAT_STATUS + .contains("do not reconfigure an already-running Shared Runtime Host")); + assert!(SHARED_TUI_CHAT_STATUS.contains("other management remain Embedded")); } #[test] @@ -2218,7 +2252,7 @@ mod tests { assert!(help.contains("Command Palette")); } fn external_agent_review_snapshot() -> ExternalSourceCatalogSnapshot { - serde_json::from_value(serde_json::json!({ + public_external_source_snapshot(serde_json::json!({ "generation": 9, "discoveryPending": false, "sources": [], @@ -2294,7 +2328,6 @@ mod tests { }], "pendingSubagentApprovals": ["external_subagent:opencode:review:v1"] })) - .unwrap() } #[test] diff --git a/src/apps/cli/src/modes/chat/worktree.rs b/src/apps/cli/src/modes/chat/worktree.rs index 30d453d1b9..3e577e3b7f 100644 --- a/src/apps/cli/src/modes/chat/worktree.rs +++ b/src/apps/cli/src/modes/chat/worktree.rs @@ -1,7 +1,3 @@ -use bitfun_core::service::git::GitService; -use bitfun_core::service::worktree::{WorktreeService, WorktreeSessionBindingRequest}; -use bitfun_runtime_ports::AgentSessionWorkspaceBinding; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum WorktreeCommand { Toggle, @@ -26,27 +22,27 @@ impl ChatMode { rt_handle: &tokio::runtime::Handle, ) { let Some(workspace_path) = chat_state.workspace.clone() else { + chat_state.set_worktree_control_available(false); chat_state.set_git_repository_status(false, None); return; }; let repository = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let repository = GitService::resolve_worktree_repository(&workspace_path).await?; - GitService::get_repository_basic(repository.query_path).await - }) + rt_handle.block_on( + self.agent + .worktree_repository_status(workspace_path.clone()), + ) }); match repository { Ok(repository) => { - chat_state.set_git_repository_status(true, Some(repository.current_branch)); + chat_state.set_worktree_control_available(repository.is_repository); + chat_state + .set_git_repository_status(repository.is_repository, repository.current_branch); } Err(error) => { + chat_state.set_worktree_control_available(false); chat_state.set_git_repository_status(false, None); - tracing::debug!( - "Git repository status is unavailable for workspace {}: {}", - workspace_path, - error - ); + tracing::debug!("Worktree repository status is unavailable: {}", error); } } } @@ -88,33 +84,21 @@ impl ChatMode { })); let project_workspace_path = chat_state.project_workspace_path().map(str::to_string); let result = tokio::task::block_in_place(|| { - rt_handle.block_on(WorktreeService::bind_session( - WorktreeSessionBindingRequest { - request_id: uuid::Uuid::new_v4().to_string(), - session_id: chat_state.core_session_id.clone(), + if enabled { + rt_handle.block_on(self.agent.worktree_bind_session( + chat_state.core_session_id.clone(), project_workspace_path, - enabled, - }, - )) + )) + } else { + rt_handle.block_on(self.agent.worktree_release_session( + chat_state.core_session_id.clone(), + project_workspace_path, + )) + } }) - .map_err(|error| { - format!( - "Worktree isolation could not be prepared ({}): {}", - error.code.as_str(), - error.message - ) - })?; - - let previous_binding = chat_state.workspace_binding.as_ref(); - let binding = AgentSessionWorkspaceBinding { - workspace_id: result.workspace_id, - workspace_path: result.workspace_path, - project_workspace_path: Some(result.project_workspace_path), - execution_target: Some(result.execution_target), - remote_connection_id: previous_binding - .and_then(|binding| binding.remote_connection_id.clone()), - remote_ssh_host: previous_binding.and_then(|binding| binding.remote_ssh_host.clone()), - }; + .map_err(|error| format!("Worktree isolation could not be prepared: {error}"))?; + + let binding = result.workspace_binding; self.agent.set_workspace_binding(&binding); chat_state.apply_workspace_binding(binding); chat_state.set_worktree_isolation_requested(None); @@ -161,10 +145,8 @@ impl ChatMode { chat_view.set_status(Some(action.unavailable_message(state))); return Ok(None); } - if self.agent.is_shared() { - let message = format!( - "Worktree isolation is unavailable in Shared TUI preview. {SHARED_TUI_EMBEDDED_HANDOFF}." - ); + if !chat_state.worktree_control_available() { + let message = "Worktree isolation is unavailable for the current workspace".to_string(); chat_view.set_status(Some(message.clone())); chat_state.add_system_message(message); return Ok(None); diff --git a/src/apps/cli/src/peer_host/bootstrap.rs b/src/apps/cli/src/peer_host/bootstrap.rs index 81856d809a..d073e01491 100644 --- a/src/apps/cli/src/peer_host/bootstrap.rs +++ b/src/apps/cli/src/peer_host/bootstrap.rs @@ -40,6 +40,8 @@ pub(crate) async fn ensure_peer_host_ready(runtime: &CliRuntimeContext) -> Resul agent_runtime: runtime.agent_runtime().clone(), local_workspace_snapshot: runtime.local_workspace_snapshot().clone(), compatibility: runtime.compatibility().clone(), + account_runtime: runtime.account_runtime().clone(), + account_routing: runtime.account_routing().clone(), turns: PeerTurnTracker::new(), workspace_service, filesystem_service, diff --git a/src/apps/cli/src/peer_host/commands/config.rs b/src/apps/cli/src/peer_host/commands/config.rs index a5b82f2a20..f771ccbd48 100644 --- a/src/apps/cli/src/peer_host/commands/config.rs +++ b/src/apps/cli/src/peer_host/commands/config.rs @@ -8,6 +8,7 @@ use bitfun_core::service::config::get_global_config_service; use bitfun_core::util::errors::BitFunError; use crate::peer_host::args::{optional_bool, request_value}; +use crate::peer_host::state::PeerHostState; fn is_expected_config_path_not_found(error: &BitFunError, path: Option<&str>) -> bool { match (error, path) { @@ -86,7 +87,7 @@ pub(crate) async fn get_configs(args: &Value) -> Result { Ok(json!(configs)) } -pub(crate) async fn set_config(args: &Value) -> Result { +pub(crate) async fn set_config(state: &PeerHostState, args: &Value) -> Result { let request = request_value(args); let path = request .get("path") @@ -110,7 +111,7 @@ pub(crate) async fn set_config(args: &Value) -> Result { // Config changed on this host via a peer controller — schedule the cloud // push so other same-account devices converge. - crate::account_sync::notify_local_settings_changed(); + state.account_runtime.notify_local_settings_changed(); Ok(json!("Configuration set successfully")) } diff --git a/src/apps/cli/src/peer_host/commands/external_sources.rs b/src/apps/cli/src/peer_host/commands/external_sources.rs index 07c9ff545a..0a335ad12b 100644 --- a/src/apps/cli/src/peer_host/commands/external_sources.rs +++ b/src/apps/cli/src/peer_host/commands/external_sources.rs @@ -6,12 +6,14 @@ use bitfun_core::external_sources::{ apply_external_source_control_action, choose_external_mcp_conflict, choose_external_subagent_conflict, external_source_snapshot, get_external_source_control_snapshot, set_external_mcp_server_decision, - set_external_prompt_command_conflict_choice, set_external_source_enabled, - set_external_subagent_activation, set_external_subagent_model_binding, + set_external_mcp_servers_enabled, set_external_prompt_command_conflict_choice, + set_external_source_enabled, set_external_subagent_activation, + set_external_subagent_model_binding, set_external_subagents_enabled, set_external_tool_conflict_choice, set_external_tool_target_decision, - update_external_integration_policy, ExternalIntegrationPolicyMutation, - ExternalSourceControlRequestV1, ExternalSourceHostCapabilities, ExternalSourceOperationError, - ExternalSourceOperationErrorCode, ExternalSourceOperationResult, ExternalSourcePublicSnapshot, + set_external_tool_targets_enabled, update_external_integration_policy, + ExternalIntegrationPolicyMutation, ExternalSourceControlRequestV1, + ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode, + ExternalSourceOperationResult, ExternalSourcePublicSnapshot, ExternalSubagentModelBindingTarget, }; use serde_json::Value; @@ -60,6 +62,28 @@ fn required_u64(request: &Value, key: &str) -> ExternalSourceOperationResult ExternalSourceOperationResult> { + let decisions = request + .get("decisions") + .and_then(Value::as_array) + .ok_or_else(|| { + ExternalSourceOperationError::invalid_request("Missing or invalid 'decisions'") + })?; + decisions + .iter() + .map(|decision| { + Ok(( + required_string(decision, first_key)?, + required_string(decision, second_key)?, + )) + }) + .collect() +} + fn model_binding_target_field( request: &Value, key: &str, @@ -221,6 +245,16 @@ async fn dispatch_inner( ) .await } + "set_external_tool_targets_enabled_command" => { + set_external_tool_targets_enabled( + workspace, + decision_pairs(request, "approvalKey", "decisionKey")?, + required_bool(request, "enabled")?, + required_u64(request, "expectedCatalogGeneration")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } "set_external_tool_conflict_choice_command" => { set_external_tool_conflict_choice( workspace, @@ -241,6 +275,16 @@ async fn dispatch_inner( ) .await } + "set_external_subagents_enabled_command" => { + set_external_subagents_enabled( + workspace, + decision_pairs(request, "candidateId", "decisionKey")?, + required_bool(request, "enabled")?, + required_u64(request, "expectedSubagentGeneration")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } "set_external_subagent_model_binding_command" => { set_external_subagent_model_binding( workspace, @@ -273,6 +317,16 @@ async fn dispatch_inner( ) .await } + "set_external_mcp_servers_enabled_command" => { + set_external_mcp_servers_enabled( + workspace, + decision_pairs(request, "candidateId", "decisionKey")?, + required_bool(request, "enabled")?, + required_u64(request, "expectedMcpGeneration")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } "choose_external_mcp_conflict_command" => { choose_external_mcp_conflict( workspace, @@ -395,4 +449,22 @@ mod tests { ExternalSourceOperationErrorCode::InvalidRequest ); } + + #[test] + fn peer_bulk_decisions_preserve_the_reviewed_identity_pairs() { + let request = serde_json::json!({ + "decisions": [ + { "candidateId": "agent-a", "decisionKey": "decision-a" }, + { "candidateId": "agent-b", "decisionKey": "decision-b" } + ] + }); + + assert_eq!( + decision_pairs(&request, "candidateId", "decisionKey").unwrap(), + vec![ + ("agent-a".to_string(), "decision-a".to_string()), + ("agent-b".to_string(), "decision-b".to_string()), + ] + ); + } } diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index 56a103d09f..8ef9bdd754 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -36,7 +36,7 @@ pub(crate) async fn dispatch( "reload_config" => workspace::reload_config().await, "get_config" => config::get_config(args).await, "get_configs" => config::get_configs(args).await, - "set_config" => config::set_config(args).await, + "set_config" => config::set_config(state, args).await, "get_agent_profile_config" => config::get_agent_profile_config(args).await, "get_agent_profile_configs" => config::get_agent_profile_configs().await, "get_external_source_snapshot" @@ -46,11 +46,14 @@ pub(crate) async fn dispatch( | "set_external_source_enabled_command" | "set_external_source_conflict_choice_command" | "set_external_tool_target_decision_command" + | "set_external_tool_targets_enabled_command" | "set_external_tool_conflict_choice_command" | "set_external_subagent_activation_command" + | "set_external_subagents_enabled_command" | "set_external_subagent_model_binding_command" | "choose_external_subagent_conflict_command" | "set_external_mcp_server_decision_command" + | "set_external_mcp_servers_enabled_command" | "choose_external_mcp_conflict_command" | "update_external_integration_policy_command" => { external_sources::dispatch(command, args, state).await diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 4e6770864c..b4c702e67b 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -96,8 +96,18 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_list_jobs", "dispatch_answer", "dispatch_append", + "dispatch_continue", "dispatch_load_transcript", "dispatch_save_transcript", + "speech_list_models", + "speech_download_model", + "speech_cancel_model_download", + "speech_delete_model", + "speech_verify_model", + "speech_start_input_session", + "speech_append_audio_chunk", + "speech_finish_input_session", + "speech_cancel_input_session", ]; /// Desktop IDE surfaces that CLI Peer Host does not implement. @@ -158,10 +168,30 @@ mod tests { "dispatch_list_jobs", "dispatch_answer", "dispatch_append", + "dispatch_continue", "dispatch_load_transcript", "dispatch_save_transcript", ] { assert!(is_local_only_command(command), "{command}"); } } + + /// The controller-side FE deny list is an optimization, not the boundary. + /// An older or non-FE controller still reaches this host. + #[test] + fn speech_capture_stays_on_the_controller_device() { + for command in [ + "speech_list_models", + "speech_download_model", + "speech_cancel_model_download", + "speech_delete_model", + "speech_verify_model", + "speech_start_input_session", + "speech_append_audio_chunk", + "speech_finish_input_session", + "speech_cancel_input_session", + ] { + assert!(is_local_only_command(command), "{command}"); + } + } } diff --git a/src/apps/cli/src/peer_host/fanout.rs b/src/apps/cli/src/peer_host/fanout.rs index 3fa826358e..d1a15b76e1 100644 --- a/src/apps/cli/src/peer_host/fanout.rs +++ b/src/apps/cli/src/peer_host/fanout.rs @@ -408,7 +408,9 @@ async fn handle_agentic_event(state: &PeerHostState, event: AgenticEvent) -> Res return Err("no attached Peer controller can receive Agent events".to_string()); } let generation = state.turns.current_event_stream_generation()?; - let owner = crate::account::capture_peer_fanout_owner() + let owner = state + .account_routing + .capture_peer_fanout_owner() .await .map_err(|error| format!("Peer event routing owner unavailable: {error}"))?; enqueue_peer_device_event( @@ -546,8 +548,14 @@ pub(crate) async fn fanout_peer_device_event(event: String, payload: serde_json: let inherits_routing_lease = inherited_owner.is_some(); let owner = match inherited_owner { Some(owner) => owner, - None => match crate::account::capture_peer_fanout_owner().await { - Ok(owner) => owner, + None => match super::state::peer_host_state().map(|state| state.account_routing.clone()) { + Ok(routing) => match routing.capture_peer_fanout_owner().await { + Ok(owner) => owner, + Err(error) => { + tracing::debug!("Peer event fanout skipped before enqueue: {error}"); + return; + } + }, Err(error) => { tracing::debug!("Peer event fanout skipped before enqueue: {error}"); return; diff --git a/src/apps/cli/src/peer_host/state.rs b/src/apps/cli/src/peer_host/state.rs index 25209d6b23..8e0cad1f11 100644 --- a/src/apps/cli/src/peer_host/state.rs +++ b/src/apps/cli/src/peer_host/state.rs @@ -901,6 +901,9 @@ pub(crate) struct PeerHostState { pub(crate) agent_runtime: AgentRuntime, pub(crate) local_workspace_snapshot: Arc, pub(crate) compatibility: CoreAgentRuntimeCompatibility, + pub(crate) account_runtime: + Arc, + pub(crate) account_routing: Arc, pub(crate) turns: PeerTurnTracker, pub(crate) workspace_service: Arc, pub(crate) filesystem_service: Arc, diff --git a/src/apps/cli/src/runtime/approval.rs b/src/apps/cli/src/runtime/approval.rs index df6170f798..97175c7153 100644 --- a/src/apps/cli/src/runtime/approval.rs +++ b/src/apps/cli/src/runtime/approval.rs @@ -1,5 +1,7 @@ +use bitfun_agent_runtime::permission::PERMISSION_MODE_CONTEXT_KEY; use bitfun_agent_runtime::sdk::{PermissionRequest, AUTO_APPROVE_ASK_CONTEXT_KEY}; use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; +use bitfun_runtime_ports::PermissionMode; use serde_json::{Map, Value}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -37,6 +39,21 @@ pub(crate) fn approval_metadata(approval_policy: CliApprovalPolicy) -> Map, compatibility: CoreAgentRuntimeCompatibility, + account_runtime: Arc, + account_routing: Arc, _agent_event_queue_owner: CoreProductEventQueueOwner, services: RuntimeServices, product: CliProductRuntimeState, @@ -97,6 +101,7 @@ impl CliRuntimeContext { .context("Failed to build CLI Agent Runtime SDK")?; let compatibility = CoreAgentRuntimeCompatibility::build(agentic_system.coordinator.clone(), scheduler); + let account = build_account_runtime(compatibility.clone()); let local_workspace_snapshot = CoreLocalWorkspaceSnapshot::build(); debug_assert_eq!( @@ -114,6 +119,8 @@ impl CliRuntimeContext { agent_runtime, local_workspace_snapshot, compatibility, + account_runtime: account.runtime, + account_routing: account.routing, services, product, approval_policy, @@ -136,6 +143,14 @@ impl CliRuntimeContext { &self.compatibility } + pub(crate) fn account_runtime(&self) -> &Arc { + &self.account_runtime + } + + pub(crate) fn account_routing(&self) -> &Arc { + &self.account_routing + } + pub(crate) fn local_workspace_snapshot(&self) -> &Arc { &self.local_workspace_snapshot } diff --git a/src/apps/cli/src/self_update.rs b/src/apps/cli/src/self_update.rs index 2224dc8313..56e118a661 100644 --- a/src/apps/cli/src/self_update.rs +++ b/src/apps/cli/src/self_update.rs @@ -899,26 +899,33 @@ fn release_pubkey() -> Option<&'static str> { } /// Verify a Tauri-format `.sig` (base64 of a minisign signature file) over the -/// archive, using the base64-wrapped public key. +/// archive. The public key accepts both current raw Tauri values and the legacy +/// base64 wrapper. /// /// A checksum only proves the transfer was not corrupted: whoever serves the /// archive can serve a matching `.sha256`. A signature proves the bytes came /// from whoever holds the release key, which is what actually protects the /// third-party GitHub proxy and mirror paths. -fn verify_signature(archive: &[u8], signature_b64: &str, pubkey_b64: &str) -> Result<()> { +fn verify_signature(archive: &[u8], signature_b64: &str, pubkey: &str) -> Result<()> { use base64::Engine as _; - let decode = |value: &str, what: &str| -> Result { + + let public_key_text = if pubkey.trim().starts_with("untrusted comment:") { + pubkey.trim().to_owned() + } else { let bytes = base64::engine::general_purpose::STANDARD - .decode(value.trim().as_bytes()) - .with_context(|| format!("decode {what}"))?; - String::from_utf8(bytes).with_context(|| format!("decode {what} as UTF-8")) + .decode(pubkey.trim().as_bytes()) + .context("decode release public key")?; + String::from_utf8(bytes).context("decode release public key as UTF-8")? }; - - let public_key = minisign_verify::PublicKey::decode(&decode(pubkey_b64, "release public key")?) + let public_key = minisign_verify::PublicKey::decode(&public_key_text) .map_err(|error| anyhow!("invalid release public key: {error}"))?; - let signature = - minisign_verify::Signature::decode(&decode(signature_b64, "release signature")?) - .map_err(|error| anyhow!("invalid release signature: {error}"))?; + let signature_bytes = base64::engine::general_purpose::STANDARD + .decode(signature_b64.trim().as_bytes()) + .context("decode release signature")?; + let signature_text = + String::from_utf8(signature_bytes).context("decode release signature as UTF-8")?; + let signature = minisign_verify::Signature::decode(&signature_text) + .map_err(|error| anyhow!("invalid release signature: {error}"))?; public_key .verify(archive, &signature, false) .map_err(|error| anyhow!("release signature does not match the archive: {error}")) @@ -1128,7 +1135,7 @@ fn is_newer_version(candidate: &str, current: &str) -> bool { fn automatic_update_is_eligible() -> bool { if std::env::var_os("BITFUN_CLI_DISABLE_AUTO_UPDATE").is_some() - || env!("CARGO_PKG_VERSION").contains("-nightly.") + || !release_version_allows_automatic_update(env!("CARGO_PKG_VERSION")) { return false; } @@ -1137,6 +1144,10 @@ fn automatic_update_is_eligible() -> bool { .is_some_and(|path| current_platform_key().is_some() && !is_development_binary(&path)) } +fn release_version_allows_automatic_update(version: &str) -> bool { + !version.contains("-nightly.") && !version.contains("-beta.") +} + /// Share the CLI's own config directory so a relocated profile (E2E storage /// guard, non-default home) does not silently re-check on every launch. fn automatic_stamp_path() -> Option { @@ -1194,6 +1205,7 @@ fn restart_managed_daemon() { #[cfg(test)] mod tests { use super::*; + use base64::Engine as _; use sha2::{Digest, Sha256}; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -1446,6 +1458,15 @@ mod tests { assert!(!is_newer_version("0.2.12", "0.2.13")); } + #[test] + fn prerelease_cli_builds_do_not_use_the_stable_auto_update_feed() { + assert!(release_version_allows_automatic_update("0.2.14")); + assert!(!release_version_allows_automatic_update("0.2.14-beta.1")); + assert!(!release_version_allows_automatic_update( + "0.2.14-nightly.20260811" + )); + } + /// Fixture produced with the real `minisign` CLI, then wrapped the way /// Tauri wraps keys and signatures (base64 of the whole file), so this pins /// the exact on-disk format CI must emit. @@ -1457,6 +1478,14 @@ mod tests { fn release_signature_accepts_the_tauri_wire_format() { verify_signature(FIXTURE_DATA, FIXTURE_SIGNATURE, FIXTURE_PUBKEY) .expect("minisign signature in Tauri's base64 wrapper must verify"); + let raw_pubkey = String::from_utf8( + base64::engine::general_purpose::STANDARD + .decode(FIXTURE_PUBKEY) + .expect("decode fixture public key"), + ) + .expect("fixture public key is UTF-8"); + verify_signature(FIXTURE_DATA, FIXTURE_SIGNATURE, &raw_pubkey) + .expect("raw minisign public key must verify"); } #[test] diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 3c2cad6a38..a5af4e4b6c 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -593,12 +593,6 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { .remove(&request.tool_id); Ok(RuntimeIpcOperationResult::Unit) } - RuntimeIpcOperation::RecordLocalCommandTurn { request } => self - .runtime - .record_completed_local_command_turn(request) - .await - .map(|record| RuntimeIpcOperationResult::LocalCommandTurnRecorded { record }) - .map_err(runtime_ipc_error), } } diff --git a/src/apps/cli/src/shared_tui_backend.rs b/src/apps/cli/src/shared_tui_backend.rs index f880ce3160..8c7d3871cf 100644 --- a/src/apps/cli/src/shared_tui_backend.rs +++ b/src/apps/cli/src/shared_tui_backend.rs @@ -1,9 +1,9 @@ //! CLI Host compatibility adapter from the private Shared Runtime IPC to TUI v2. -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; -use crate::tui_backend::{TuiBackend, TuiBackendError}; +use crate::tui_backend::{TuiBackend, TuiBackendError, TuiBackendErrorKind}; use async_trait::async_trait; use bitfun_agent_runtime_ipc::{ RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcClientEvent, RuntimeIpcErrorCode, @@ -12,7 +12,16 @@ use bitfun_agent_runtime_ipc::{ RuntimeSessionRenameRequest, RuntimeSessionRestoreRequest, RuntimeSessionState, RuntimeUserAnswersRequest, }; +use bitfun_app_server::management::{ + ACCOUNT_CAPABILITY, EXTERNAL_HOOKS_CAPABILITY, EXTERNAL_SOURCES_CAPABILITY, MODES_CAPABILITY, + NATIVE_HOOKS_CAPABILITY, SETTINGS_SYNC_CAPABILITY, WORKTREES_CAPABILITY, +}; +use bitfun_app_server::{ + AppManagementCapabilities, AppManagementError, AppManagementErrorKind, AppManagementService, +}; use bitfun_app_server_client::AppServerEvent; +use bitfun_app_server_protocol::account::*; +use bitfun_app_server_protocol::agent::*; use bitfun_app_server_protocol::app::{ CapabilityAvailability, CapabilityDescriptor, HealthResponse, HealthStatus, InitializeRequest, InitializeResponse, ServerInfo, TransportLimits, @@ -21,68 +30,50 @@ use bitfun_app_server_protocol::event::{ AgentEventNotification, EventCursor, EventStream, EventStreamState, EventStreamStateNotification, PermissionEventNotification, ResyncDirective, }; -use bitfun_app_server_protocol::tui::*; +use bitfun_app_server_protocol::external_source::*; +use bitfun_app_server_protocol::hook::*; +use bitfun_app_server_protocol::mcp::*; +use bitfun_app_server_protocol::model::*; +use bitfun_app_server_protocol::session::*; +use bitfun_app_server_protocol::skill::*; +use bitfun_app_server_protocol::subagent::*; +use bitfun_app_server_protocol::workspace::*; +use bitfun_app_server_protocol::worktree::*; use bitfun_app_server_protocol::{MIN_PROTOCOL_VERSION, PROTOCOL_VERSION}; use bitfun_runtime_ports::{ - AgentSessionCompactionResult, AgentSessionForkResult, AgentUserShellCommandResult, + AgentSessionCompactionResult, AgentSessionForkResult, AgentSessionWorkspaceBinding, + AgentUserShellCommandResult, }; use tokio::sync::broadcast; -use crate::agent::tui_client::{TuiAgentMode, TuiHostCapabilities}; - const EVENT_BUFFER: usize = 256; pub(crate) struct SharedTuiBackend { client: RuntimeIpcClient, + management: Arc, + local_management_scope: Arc, current_session_id: Arc>>, events: broadcast::Sender, } -pub(crate) struct SharedTuiHostCapabilities { - client: RuntimeIpcClient, -} - -impl SharedTuiHostCapabilities { - pub(crate) fn new(client: RuntimeIpcClient) -> Self { - Self { client } - } -} - -#[async_trait] -impl TuiHostCapabilities for SharedTuiHostCapabilities { - async fn available_agent_modes( - &self, - session_id: Option, - _workspace: std::path::PathBuf, - ) -> anyhow::Result> { - match self - .client - .request(RuntimeIpcOperation::ListAgentModes { session_id }) - .await? - { - RuntimeIpcOperationResult::AgentModes { modes } => Ok(modes - .into_iter() - .map(|mode| TuiAgentMode { - id: mode.id, - description: mode.description, - model_id: mode.model_id, - is_external: mode.is_external, - }) - .collect()), - other => Err(anyhow::anyhow!( - "Shared Runtime returned an unexpected mode catalog result: {other:?}" - )), - } - } -} - impl SharedTuiBackend { - pub(crate) fn new(client: RuntimeIpcClient) -> Self { + pub(crate) fn new(client: RuntimeIpcClient, management: Arc) -> Self { let (events, _) = broadcast::channel(EVENT_BUFFER); let connection_id = format!("shared-runtime-{}", uuid::Uuid::new_v4()); - spawn_event_bridge(client.subscribe_events(), events.clone(), connection_id); + spawn_event_bridge( + client.subscribe_events(), + events.clone(), + connection_id.clone(), + ); + spawn_external_source_event_bridge( + management.subscribe_external_source_updates(), + events.clone(), + connection_id, + ); Self { client, + management, + local_management_scope: Arc::new(AtomicBool::new(true)), current_session_id: Arc::new(Mutex::new(None)), events, } @@ -95,6 +86,13 @@ impl SharedTuiBackend { .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(session_id.into()); } + fn set_management_scope_from_binding(&self, binding: &AgentSessionWorkspaceBinding) { + self.local_management_scope.store( + binding.remote_connection_id.is_none() && binding.remote_ssh_host.is_none(), + Ordering::Relaxed, + ); + } + fn current_session(&self) -> Result { self.current_session_id .lock() @@ -112,6 +110,50 @@ impl SharedTuiBackend { .await .map_err(map_client_error) } + + fn management_service( + &self, + capability: &str, + ) -> Result<&AppManagementService, TuiBackendError> { + require_local_management_scope( + self.local_management_scope.load(Ordering::Relaxed), + capability, + )?; + match self.management.capabilities().availability(capability) { + Some(CapabilityAvailability::Available) => Ok(self.management.as_ref()), + Some(CapabilityAvailability::Unavailable { reason }) => Err(TuiBackendError { + message: reason.clone(), + outcome_unknown: false, + kind: TuiBackendErrorKind::Unsupported { + capability: capability.to_string(), + }, + }), + None => Err(TuiBackendError { + message: format!( + "{capability} is not declared by the App Server management service" + ), + outcome_unknown: false, + kind: TuiBackendErrorKind::Unsupported { + capability: capability.to_string(), + }, + }), + } + } +} + +fn require_local_management_scope(local: bool, capability: &str) -> Result<(), TuiBackendError> { + if local { + return Ok(()); + } + Err(TuiBackendError { + message: format!( + "{capability} is unavailable for a Remote workspace; the Shared CLI adapter does not fall back to its local management service" + ), + outcome_unknown: false, + kind: TuiBackendErrorKind::Unsupported { + capability: capability.to_string(), + }, + }) } #[async_trait] @@ -142,7 +184,7 @@ impl TuiBackend for SharedTuiBackend { name: "bitfun-shared-runtime-host-adapter".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), }, - tui_capabilities(), + tui_capabilities(&self.management.capabilities()), TransportLimits { max_frame_bytes: 16 * 1024 * 1024, event_buffer_capacity: EVENT_BUFFER as u32, @@ -154,6 +196,116 @@ impl TuiBackend for SharedTuiBackend { load_model_catalog().await } + async fn account_snapshot( + &self, + request: AccountSnapshotRequest, + ) -> Result { + self.management_service(ACCOUNT_CAPABILITY)? + .account_snapshot(request) + .await + .map_err(|error| map_management_error(ACCOUNT_CAPABILITY, error)) + } + + async fn account_login( + &self, + request: AccountLoginRequest, + ) -> Result { + self.management_service(ACCOUNT_CAPABILITY)? + .account_login(request) + .await + .map_err(|error| map_management_error(ACCOUNT_CAPABILITY, error)) + } + + async fn account_finalize_login( + &self, + request: AccountFinalizeLoginRequest, + ) -> Result { + self.management_service(ACCOUNT_CAPABILITY)? + .account_finalize_login(request) + .await + .map_err(|error| map_management_error(ACCOUNT_CAPABILITY, error)) + } + + async fn account_logout( + &self, + request: AccountLogoutRequest, + ) -> Result { + self.management_service(ACCOUNT_CAPABILITY)? + .account_logout(request) + .await + .map_err(|error| map_management_error(ACCOUNT_CAPABILITY, error)) + } + + async fn settings_sync_start( + &self, + request: SettingsSyncStartRequest, + ) -> Result { + self.management_service(SETTINGS_SYNC_CAPABILITY)? + .settings_sync_start(request) + .await + .map_err(|error| map_management_error(SETTINGS_SYNC_CAPABILITY, error)) + } + + async fn settings_sync_snapshot( + &self, + request: SettingsSyncSnapshotRequest, + ) -> Result { + self.management_service(SETTINGS_SYNC_CAPABILITY)? + .settings_sync_snapshot(request) + .await + .map_err(|error| map_management_error(SETTINGS_SYNC_CAPABILITY, error)) + } + + async fn settings_sync_cancel( + &self, + request: SettingsSyncCancelRequest, + ) -> Result { + self.management_service(SETTINGS_SYNC_CAPABILITY)? + .settings_sync_cancel(request) + .await + .map_err(|error| map_management_error(SETTINGS_SYNC_CAPABILITY, error)) + } + + async fn settings_sync_local_changed( + &self, + request: SettingsSyncLocalChangedRequest, + ) -> Result { + self.management_service(SETTINGS_SYNC_CAPABILITY)? + .settings_sync_local_changed(request) + .await + .map_err(|error| map_management_error(SETTINGS_SYNC_CAPABILITY, error)) + } + + async fn worktree_repository_status( + &self, + request: WorktreeRepositoryStatusRequest, + ) -> Result { + self.management_service(WORKTREES_CAPABILITY)? + .worktree_repository_status(request) + .await + .map_err(|error| map_management_error(WORKTREES_CAPABILITY, error)) + } + + async fn worktree_bind_session( + &self, + request: WorktreeBindSessionRequest, + ) -> Result { + self.management_service(WORKTREES_CAPABILITY)? + .worktree_bind_session(request) + .await + .map_err(|error| map_management_error(WORKTREES_CAPABILITY, error)) + } + + async fn worktree_release_session( + &self, + request: WorktreeReleaseSessionRequest, + ) -> Result { + self.management_service(WORKTREES_CAPABILITY)? + .worktree_release_session(request) + .await + .map_err(|error| map_management_error(WORKTREES_CAPABILITY, error)) + } + async fn health(&self) -> Result { self.client.health().await.map_err(map_client_error)?; Ok(HealthResponse { @@ -203,6 +355,7 @@ impl TuiBackend for SharedTuiBackend { pending_permissions, } => { self.set_current_session(requested_session_id); + self.set_management_scope_from_binding(&workspace_binding); Ok(SyncSessionResponse { session, state: map_session_state(state), @@ -225,6 +378,7 @@ impl TuiBackend for SharedTuiBackend { { RuntimeIpcOperationResult::SessionCreated { session } => { self.set_current_session(session.session_id.clone()); + self.local_management_scope.store(true, Ordering::Relaxed); Ok(CreateSessionResponse(session)) } other => Err(unexpected("create_session", other)), @@ -348,21 +502,6 @@ impl TuiBackend for SharedTuiBackend { Ok(SubmitUserAnswersResponse {}) } - async fn record_local_command_turn( - &self, - request: RecordLocalCommandTurnRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::RecordLocalCommandTurn { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::LocalCommandTurnRecorded { record } => { - Ok(RecordLocalCommandTurnResponse(record)) - } - other => Err(unexpected("record_local_command_turn", other)), - } - } - async fn respond_permission( &self, request: RespondPermissionRequest, @@ -601,6 +740,291 @@ impl TuiBackend for SharedTuiBackend { )?; Ok(UpdateSessionModeResponse {}) } + + async fn list_agent_modes( + &self, + _request: ListAgentModesRequest, + ) -> Result { + let session_id = self + .current_session_id + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + match self + .request(RuntimeIpcOperation::ListAgentModes { session_id }) + .await? + { + RuntimeIpcOperationResult::AgentModes { modes } => Ok(ListAgentModesResponse { + modes: modes + .into_iter() + .map(|mode| AgentModeSummary { + id: mode.id, + description: mode.description, + model_id: mode.model_id, + is_external: mode.is_external, + }) + .collect(), + }), + other => Err(unexpected("list_agent_modes", other)), + } + } + + async fn list_models(&self) -> Result { + self.management_service("tui.models")? + .list_models(ListModelsRequest {}) + .await + .map_err(|error| map_management_error("tui.models", error)) + } + + async fn get_model( + &self, + request: GetModelRequest, + ) -> Result { + self.management_service("tui.models")? + .get_model(request) + .await + .map_err(|error| map_management_error("tui.models", error)) + } + + async fn add_model( + &self, + request: AddModelRequest, + ) -> Result { + self.management_service("tui.models")? + .add_model(request) + .await + .map_err(|error| map_management_error("tui.models", error)) + } + + async fn update_model( + &self, + request: UpdateModelRequest, + ) -> Result { + self.management_service("tui.models")? + .update_model(request) + .await + .map_err(|error| map_management_error("tui.models", error)) + } + + async fn delete_model( + &self, + request: DeleteModelRequest, + ) -> Result { + self.management_service("tui.models")? + .delete_model(request) + .await + .map_err(|error| map_management_error("tui.models", error)) + } + + async fn set_model_default( + &self, + request: SetModelDefaultRequest, + ) -> Result { + self.management_service("tui.models")? + .set_model_default(request) + .await + .map_err(|error| map_management_error("tui.models", error)) + } + + async fn list_skills( + &self, + request: ListSkillsRequest, + ) -> Result { + self.management_service("tui.skills")? + .list_skills(request) + .await + .map_err(|error| map_management_error("tui.skills", error)) + } + + async fn set_skill_enabled( + &self, + request: SetSkillEnabledRequest, + ) -> Result { + self.management_service("tui.skills")? + .set_skill_enabled(request) + .await + .map_err(|error| map_management_error("tui.skills", error)) + } + + async fn list_subagents( + &self, + request: ListSubagentsRequest, + ) -> Result { + self.management_service("tui.subagents")? + .list_subagents(request) + .await + .map_err(|error| map_management_error("tui.subagents", error)) + } + + async fn set_subagent_enabled( + &self, + request: SetSubagentEnabledRequest, + ) -> Result { + self.management_service("tui.subagents")? + .set_subagent_enabled(request) + .await + .map_err(|error| map_management_error("tui.subagents", error)) + } + + async fn list_mcp_servers( + &self, + request: ListMcpServersRequest, + ) -> Result { + self.management_service("tui.mcp")? + .list_mcp_servers(request) + .await + .map_err(|error| map_management_error("tui.mcp", error)) + } + + async fn toggle_mcp_server( + &self, + request: ToggleMcpServerRequest, + ) -> Result { + self.management_service("tui.mcp")? + .toggle_mcp_server(request) + .await + .map_err(|error| map_management_error("tui.mcp", error)) + } + + async fn add_mcp_server( + &self, + request: AddMcpServerRequest, + ) -> Result { + self.management_service("tui.mcp")? + .add_mcp_server(request) + .await + .map_err(|error| map_management_error("tui.mcp", error)) + } + + async fn delete_mcp_server( + &self, + request: DeleteMcpServerRequest, + ) -> Result { + self.management_service("tui.mcp")? + .delete_mcp_server(request) + .await + .map_err(|error| map_management_error("tui.mcp", error)) + } + + async fn external_mcp_decision( + &self, + request: ExternalMcpDecisionRequest, + ) -> Result { + self.management_service("tui.mcp")? + .external_mcp_decision(request) + .await + .map_err(|error| map_management_error("tui.mcp", error)) + } + + async fn mcp_conflict_choice( + &self, + request: McpConflictChoiceRequest, + ) -> Result { + self.management_service("tui.mcp")? + .mcp_conflict_choice(request) + .await + .map_err(|error| map_management_error("tui.mcp", error)) + } + + async fn external_source_snapshot( + &self, + request: ExternalSourceSnapshotRequest, + ) -> Result { + self.management_service(EXTERNAL_SOURCES_CAPABILITY)? + .external_source_snapshot(request) + .await + .map_err(|error| map_management_error(EXTERNAL_SOURCES_CAPABILITY, error)) + } + + async fn external_source_control( + &self, + request: ExternalSourceControlRequest, + ) -> Result { + self.management_service(EXTERNAL_SOURCES_CAPABILITY)? + .external_source_control(request) + .await + .map_err(|error| map_management_error(EXTERNAL_SOURCES_CAPABILITY, error)) + } + + async fn external_source_review( + &self, + request: ExternalSourceReviewRequest, + ) -> Result { + self.management_service(EXTERNAL_SOURCES_CAPABILITY)? + .external_source_review(request) + .await + .map_err(|error| map_management_error(EXTERNAL_SOURCES_CAPABILITY, error)) + } + + async fn set_native_command_choice( + &self, + request: SetNativeCommandChoiceRequest, + ) -> Result { + self.management_service(EXTERNAL_SOURCES_CAPABILITY)? + .set_native_command_choice(request) + .await + .map_err(|error| map_management_error(EXTERNAL_SOURCES_CAPABILITY, error)) + } + + async fn expand_external_command( + &self, + request: ExpandExternalCommandRequest, + ) -> Result { + self.management_service(EXTERNAL_SOURCES_CAPABILITY)? + .expand_external_command(request) + .await + .map_err(|error| map_management_error(EXTERNAL_SOURCES_CAPABILITY, error)) + } + + async fn native_hook_overview( + &self, + request: NativeHookOverviewRequest, + ) -> Result { + self.management_service(NATIVE_HOOKS_CAPABILITY)? + .native_hook_overview(request) + .await + .map_err(|error| map_management_error(NATIVE_HOOKS_CAPABILITY, error)) + } + + async fn external_hook_snapshot( + &self, + request: ExternalHookSnapshotRequest, + ) -> Result { + self.management_service(EXTERNAL_HOOKS_CAPABILITY)? + .external_hook_snapshot(request) + .await + .map_err(|error| map_management_error(EXTERNAL_HOOKS_CAPABILITY, error)) + } + + async fn external_hook_plan( + &self, + request: ExternalHookPlanRequest, + ) -> Result { + self.management_service(EXTERNAL_HOOKS_CAPABILITY)? + .external_hook_plan(request) + .await + .map_err(|error| map_management_error(EXTERNAL_HOOKS_CAPABILITY, error)) + } + + async fn external_hook_apply( + &self, + request: ExternalHookApplyRequest, + ) -> Result { + self.management_service(EXTERNAL_HOOKS_CAPABILITY)? + .external_hook_apply(request) + .await + .map_err(|error| map_management_error(EXTERNAL_HOOKS_CAPABILITY, error)) + } + + async fn external_hook_mutate( + &self, + request: ExternalHookMutationRequest, + ) -> Result { + self.management_service(EXTERNAL_HOOKS_CAPABILITY)? + .external_hook_mutate(request) + .await + .map_err(|error| map_management_error(EXTERNAL_HOOKS_CAPABILITY, error)) + } } impl SharedTuiBackend { @@ -618,8 +1042,13 @@ impl SharedTuiBackend { }) .await? { - RuntimeIpcOperationResult::SessionForked { session, .. } => { + RuntimeIpcOperationResult::SessionForked { + session, + workspace_binding, + .. + } => { self.set_current_session(session.session_id.clone()); + self.set_management_scope_from_binding(&workspace_binding); Ok(ForkSessionResponse(AgentSessionForkResult { session_id: session.session_id, session_name: session.session_name, @@ -700,6 +1129,23 @@ fn backend_error(message: impl Into, outcome_unknown: bool) -> TuiBacken TuiBackendError { message: message.into(), outcome_unknown, + kind: TuiBackendErrorKind::Backend, + } +} + +fn map_management_error(capability: &str, error: AppManagementError) -> TuiBackendError { + let kind = match error.kind { + AppManagementErrorKind::Unsupported => TuiBackendErrorKind::Unsupported { + capability: capability.to_string(), + }, + AppManagementErrorKind::InvalidRequest + | AppManagementErrorKind::NotFound + | AppManagementErrorKind::Internal => TuiBackendErrorKind::Backend, + }; + TuiBackendError { + message: error.message, + outcome_unknown: false, + kind, } } @@ -728,8 +1174,8 @@ fn map_session_state(state: RuntimeSessionState) -> SessionRuntimeState { } } -fn tui_capabilities() -> Vec { - [ +fn tui_capabilities(management: &AppManagementCapabilities) -> Vec { + let mut capabilities = [ ( "agent", vec![ @@ -748,7 +1194,6 @@ fn tui_capabilities() -> Vec { "session", vec![ "session/sync", - "session/recordLocalCommandTurn", "session/rename", "session/updateModel", "session/updateMode", @@ -788,7 +1233,19 @@ fn tui_capabilities() -> Vec { availability: CapabilityAvailability::Available, methods: methods.into_iter().map(str::to_string).collect(), }) - .collect() + .collect::>(); + capabilities.push(CapabilityDescriptor { + id: "tui.modes".to_string(), + availability: CapabilityAvailability::Available, + methods: vec!["agent/listModes".to_string()], + }); + capabilities.extend( + management + .descriptors() + .into_iter() + .filter(|descriptor| descriptor.id != MODES_CAPABILITY), + ); + capabilities } fn spawn_event_bridge( @@ -853,6 +1310,57 @@ fn spawn_event_bridge( }); } +fn spawn_external_source_event_bridge( + mut source: broadcast::Receiver<( + String, + bitfun_product_domains::external_sources::ExternalSourcePublicSnapshot, + )>, + output: broadcast::Sender, + connection_id: String, +) { + tokio::spawn(async move { + let sequence = AtomicU64::new(0); + loop { + match source.recv().await { + Ok((workspace_path, snapshot)) => { + let _ = output.send(AppServerEvent::ExternalSource( + ExternalSourceEventNotification { + cursor: next_cursor( + &connection_id, + EventStream::ExternalSource, + &sequence, + ), + workspace_path, + snapshot, + }, + )); + } + Err(broadcast::error::RecvError::Lagged(missed)) => { + let _ = + output.send(AppServerEvent::StreamState(EventStreamStateNotification { + cursor: next_cursor( + &connection_id, + EventStream::ExternalSource, + &sequence, + ), + stream: EventStream::ExternalSource, + state: EventStreamState::Lagged, + missed: Some(missed), + resync: ResyncDirective { + method: "externalSource/snapshot".to_string(), + snapshot_available: true, + reason: Some( + "Shared external source event receiver lagged".to_string(), + ), + }, + })); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }); +} + fn next_cursor(connection_id: &str, stream: EventStream, sequence: &AtomicU64) -> EventCursor { EventCursor { connection_id: connection_id.to_string(), @@ -896,6 +1404,123 @@ mod tests { use super::*; use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; + #[test] + fn shared_management_capabilities_follow_the_local_management_service() { + let capabilities = tui_capabilities(&AppManagementCapabilities::available()); + for id in [ + "tui.models", + "tui.skills", + "tui.subagents", + "tui.mcp", + "tui.externalSources", + "tui.nativeHooks", + "tui.externalHooks", + ACCOUNT_CAPABILITY, + SETTINGS_SYNC_CAPABILITY, + WORKTREES_CAPABILITY, + ] { + let capability = capabilities + .iter() + .find(|capability| capability.id == id) + .expect("management capability should be declared"); + assert_eq!(capability.availability, CapabilityAvailability::Available); + assert!(!capability.methods.is_empty()); + } + } + + #[test] + fn shared_management_preserves_availability_and_error_kind() { + let mut management = AppManagementCapabilities::available(); + management.mcp = CapabilityAvailability::Unavailable { + reason: "local MCP compatibility owner unavailable".to_string(), + }; + let capabilities = tui_capabilities(&management); + let mcp = capabilities + .iter() + .find(|capability| capability.id == "tui.mcp") + .expect("MCP capability"); + assert!(matches!( + mcp.availability, + CapabilityAvailability::Unavailable { .. } + )); + + let error = map_management_error( + "tui.mcp", + AppManagementError::unsupported("MCP compatibility owner unavailable"), + ); + assert_eq!( + error.kind, + TuiBackendErrorKind::Unsupported { + capability: "tui.mcp".to_string() + } + ); + assert!(!error.outcome_unknown); + + let error = map_management_error( + "tui.models", + AppManagementError::invalid_request("invalid model mutation"), + ); + assert_eq!(error.kind, TuiBackendErrorKind::Backend); + assert!(!error.outcome_unknown); + } + + #[test] + fn remote_workspace_cannot_use_the_local_management_service() { + let remote_error = require_local_management_scope(false, "tui.models") + .expect_err("Remote workspace must not use the local service"); + assert_eq!( + remote_error.kind, + TuiBackendErrorKind::Unsupported { + capability: "tui.models".to_string() + } + ); + assert!(remote_error.message.contains("Remote workspace")); + assert!(remote_error.message.contains("does not fall back")); + + let external_error = require_local_management_scope(false, EXTERNAL_SOURCES_CAPABILITY) + .expect_err("Remote external sources must not use the local service"); + assert_eq!( + external_error.kind, + TuiBackendErrorKind::Unsupported { + capability: EXTERNAL_SOURCES_CAPABILITY.to_string() + } + ); + + for capability in [NATIVE_HOOKS_CAPABILITY, EXTERNAL_HOOKS_CAPABILITY] { + let hook_error = require_local_management_scope(false, capability) + .expect_err("Remote Hook management must not use the local service"); + assert_eq!( + hook_error.kind, + TuiBackendErrorKind::Unsupported { + capability: capability.to_string() + } + ); + assert!(hook_error.message.contains("does not fall back")); + } + + for capability in [ACCOUNT_CAPABILITY, SETTINGS_SYNC_CAPABILITY] { + let account_error = require_local_management_scope(false, capability) + .expect_err("Remote account management must not use the controller account"); + assert_eq!( + account_error.kind, + TuiBackendErrorKind::Unsupported { + capability: capability.to_string() + } + ); + assert!(account_error.message.contains("does not fall back")); + } + + let worktree_error = require_local_management_scope(false, WORKTREES_CAPABILITY) + .expect_err("Remote worktree management must not use the controller worktree owner"); + assert_eq!( + worktree_error.kind, + TuiBackendErrorKind::Unsupported { + capability: WORKTREES_CAPABILITY.to_string() + } + ); + assert!(worktree_error.message.contains("does not fall back")); + } + fn agent_event(text: &str) -> RuntimeIpcClientEvent { RuntimeIpcClientEvent::Runtime(RuntimeIpcEvent::Agent { session_id: "session-1".to_string(), diff --git a/src/apps/cli/src/tui_backend.rs b/src/apps/cli/src/tui_backend.rs index 734a5a5935..36f7ab618a 100644 --- a/src/apps/cli/src/tui_backend.rs +++ b/src/apps/cli/src/tui_backend.rs @@ -1,9 +1,20 @@ //! CLI-local App Server boundary for the interactive TUI. use async_trait::async_trait; -use bitfun_app_server_client::{AppServerClient, AppServerEvent, ClientError}; +use bitfun_app_server_client::{AppServerClient, AppServerEvent, ClientError, ProtocolError}; +use bitfun_app_server_protocol::account::*; +use bitfun_app_server_protocol::agent::*; use bitfun_app_server_protocol::app::{HealthResponse, InitializeRequest, InitializeResponse}; -use bitfun_app_server_protocol::tui::*; +use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; +use bitfun_app_server_protocol::external_source::*; +use bitfun_app_server_protocol::hook::*; +use bitfun_app_server_protocol::mcp::*; +use bitfun_app_server_protocol::model::*; +use bitfun_app_server_protocol::session::*; +use bitfun_app_server_protocol::skill::*; +use bitfun_app_server_protocol::subagent::*; +use bitfun_app_server_protocol::workspace::*; +use bitfun_app_server_protocol::worktree::*; #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[allow(dead_code)] @@ -21,6 +32,13 @@ pub(crate) trait TuiEffect { pub(crate) struct TuiBackendError { pub message: String, pub outcome_unknown: bool, + pub kind: TuiBackendErrorKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum TuiBackendErrorKind { + Backend, + Unsupported { capability: String }, } impl std::fmt::Display for TuiBackendError { @@ -44,6 +62,50 @@ pub(crate) trait TuiBackend: Send + Sync { fn subscribe_events(&self) -> tokio::sync::broadcast::Receiver; async fn model_catalog(&self) -> Result; + async fn account_snapshot( + &self, + request: AccountSnapshotRequest, + ) -> Result; + async fn account_login( + &self, + request: AccountLoginRequest, + ) -> Result; + async fn account_finalize_login( + &self, + request: AccountFinalizeLoginRequest, + ) -> Result; + async fn account_logout( + &self, + request: AccountLogoutRequest, + ) -> Result; + async fn settings_sync_start( + &self, + request: SettingsSyncStartRequest, + ) -> Result; + async fn settings_sync_snapshot( + &self, + request: SettingsSyncSnapshotRequest, + ) -> Result; + async fn settings_sync_cancel( + &self, + request: SettingsSyncCancelRequest, + ) -> Result; + async fn settings_sync_local_changed( + &self, + request: SettingsSyncLocalChangedRequest, + ) -> Result; + async fn worktree_repository_status( + &self, + request: WorktreeRepositoryStatusRequest, + ) -> Result; + async fn worktree_bind_session( + &self, + request: WorktreeBindSessionRequest, + ) -> Result; + async fn worktree_release_session( + &self, + request: WorktreeReleaseSessionRequest, + ) -> Result; async fn list_sessions( &self, @@ -85,10 +147,6 @@ pub(crate) trait TuiBackend: Send + Sync { &self, request: SubmitUserAnswersRequest, ) -> Result; - async fn record_local_command_turn( - &self, - request: RecordLocalCommandTurnRequest, - ) -> Result; async fn respond_permission( &self, request: RespondPermissionRequest, @@ -155,6 +213,112 @@ pub(crate) trait TuiBackend: Send + Sync { &self, request: UpdateSessionModeRequest, ) -> Result; + + async fn list_agent_modes( + &self, + request: ListAgentModesRequest, + ) -> Result; + async fn list_models(&self) -> Result; + async fn get_model( + &self, + request: GetModelRequest, + ) -> Result; + async fn add_model( + &self, + request: AddModelRequest, + ) -> Result; + async fn update_model( + &self, + request: UpdateModelRequest, + ) -> Result; + async fn delete_model( + &self, + request: DeleteModelRequest, + ) -> Result; + async fn set_model_default( + &self, + request: SetModelDefaultRequest, + ) -> Result; + async fn list_skills( + &self, + request: ListSkillsRequest, + ) -> Result; + async fn set_skill_enabled( + &self, + request: SetSkillEnabledRequest, + ) -> Result; + async fn list_subagents( + &self, + request: ListSubagentsRequest, + ) -> Result; + async fn set_subagent_enabled( + &self, + request: SetSubagentEnabledRequest, + ) -> Result; + async fn list_mcp_servers( + &self, + request: ListMcpServersRequest, + ) -> Result; + async fn toggle_mcp_server( + &self, + request: ToggleMcpServerRequest, + ) -> Result; + async fn add_mcp_server( + &self, + request: AddMcpServerRequest, + ) -> Result; + async fn delete_mcp_server( + &self, + request: DeleteMcpServerRequest, + ) -> Result; + async fn external_mcp_decision( + &self, + request: ExternalMcpDecisionRequest, + ) -> Result; + async fn mcp_conflict_choice( + &self, + request: McpConflictChoiceRequest, + ) -> Result; + async fn external_source_snapshot( + &self, + request: ExternalSourceSnapshotRequest, + ) -> Result; + async fn external_source_control( + &self, + request: ExternalSourceControlRequest, + ) -> Result; + async fn external_source_review( + &self, + request: ExternalSourceReviewRequest, + ) -> Result; + async fn set_native_command_choice( + &self, + request: SetNativeCommandChoiceRequest, + ) -> Result; + async fn expand_external_command( + &self, + request: ExpandExternalCommandRequest, + ) -> Result; + async fn native_hook_overview( + &self, + request: NativeHookOverviewRequest, + ) -> Result; + async fn external_hook_snapshot( + &self, + request: ExternalHookSnapshotRequest, + ) -> Result; + async fn external_hook_plan( + &self, + request: ExternalHookPlanRequest, + ) -> Result; + async fn external_hook_apply( + &self, + request: ExternalHookApplyRequest, + ) -> Result; + async fn external_hook_mutate( + &self, + request: ExternalHookMutationRequest, + ) -> Result; } pub(crate) struct AppServerTuiBackend { @@ -173,13 +337,7 @@ impl TuiBackend for AppServerTuiBackend { &self, request: InitializeRequest, ) -> Result { - self.client - .initialize(request) - .await - .map_err(|error| TuiBackendError { - message: error.to_string(), - outcome_unknown: false, - }) + map(self.client.initialize(request).await) } async fn health(&self) -> Result { @@ -194,6 +352,83 @@ impl TuiBackend for AppServerTuiBackend { self.client.subscribe_events() } + async fn account_snapshot( + &self, + request: AccountSnapshotRequest, + ) -> Result { + map(self.client.account_snapshot(request).await) + } + + async fn account_login( + &self, + request: AccountLoginRequest, + ) -> Result { + map_client(self.client.account_login(request).await) + } + + async fn account_finalize_login( + &self, + request: AccountFinalizeLoginRequest, + ) -> Result { + map_client(self.client.account_finalize_login(request).await) + } + + async fn account_logout( + &self, + request: AccountLogoutRequest, + ) -> Result { + map_client(self.client.account_logout(request).await) + } + + async fn settings_sync_start( + &self, + request: SettingsSyncStartRequest, + ) -> Result { + map_client(self.client.settings_sync_start(request).await) + } + + async fn settings_sync_snapshot( + &self, + request: SettingsSyncSnapshotRequest, + ) -> Result { + map(self.client.settings_sync_snapshot(request).await) + } + + async fn settings_sync_cancel( + &self, + request: SettingsSyncCancelRequest, + ) -> Result { + map_client(self.client.settings_sync_cancel(request).await) + } + + async fn settings_sync_local_changed( + &self, + request: SettingsSyncLocalChangedRequest, + ) -> Result { + map_client(self.client.settings_sync_local_changed(request).await) + } + + async fn worktree_repository_status( + &self, + request: WorktreeRepositoryStatusRequest, + ) -> Result { + map(self.client.worktree_repository_status(request).await) + } + + async fn worktree_bind_session( + &self, + request: WorktreeBindSessionRequest, + ) -> Result { + map_client(self.client.worktree_bind_session(request).await) + } + + async fn worktree_release_session( + &self, + request: WorktreeReleaseSessionRequest, + ) -> Result { + map_client(self.client.worktree_release_session(request).await) + } + async fn list_sessions( &self, request: ListSessionsRequest, @@ -264,13 +499,6 @@ impl TuiBackend for AppServerTuiBackend { map_client(self.client.submit_user_answers(request).await) } - async fn record_local_command_turn( - &self, - request: RecordLocalCommandTurnRequest, - ) -> Result { - map_client(self.client.record_local_command_turn(request).await) - } - async fn respond_permission( &self, request: RespondPermissionRequest, @@ -390,25 +618,285 @@ impl TuiBackend for AppServerTuiBackend { ) -> Result { map_client(self.client.update_session_mode(request).await) } + + async fn list_agent_modes( + &self, + request: ListAgentModesRequest, + ) -> Result { + map(self.client.list_agent_modes(request).await) + } + + async fn list_models(&self) -> Result { + map(self.client.list_models().await) + } + + async fn get_model( + &self, + request: GetModelRequest, + ) -> Result { + map(self.client.get_model(request).await) + } + + async fn add_model( + &self, + request: AddModelRequest, + ) -> Result { + map_client(self.client.add_model(request).await) + } + + async fn update_model( + &self, + request: UpdateModelRequest, + ) -> Result { + map_client(self.client.update_model(request).await) + } + + async fn delete_model( + &self, + request: DeleteModelRequest, + ) -> Result { + map_client(self.client.delete_model(request).await) + } + + async fn set_model_default( + &self, + request: SetModelDefaultRequest, + ) -> Result { + map_client(self.client.set_model_default(request).await) + } + + async fn list_skills( + &self, + request: ListSkillsRequest, + ) -> Result { + map(self.client.list_skills(request).await) + } + + async fn set_skill_enabled( + &self, + request: SetSkillEnabledRequest, + ) -> Result { + map_client(self.client.set_skill_enabled(request).await) + } + + async fn list_subagents( + &self, + request: ListSubagentsRequest, + ) -> Result { + map(self.client.list_subagents(request).await) + } + + async fn set_subagent_enabled( + &self, + request: SetSubagentEnabledRequest, + ) -> Result { + map_client(self.client.set_subagent_enabled(request).await) + } + + async fn list_mcp_servers( + &self, + request: ListMcpServersRequest, + ) -> Result { + map(self.client.list_mcp_servers(request).await) + } + + async fn toggle_mcp_server( + &self, + request: ToggleMcpServerRequest, + ) -> Result { + map_client(self.client.toggle_mcp_server(request).await) + } + + async fn add_mcp_server( + &self, + request: AddMcpServerRequest, + ) -> Result { + map_client(self.client.add_mcp_server(request).await) + } + + async fn delete_mcp_server( + &self, + request: DeleteMcpServerRequest, + ) -> Result { + map_client(self.client.delete_mcp_server(request).await) + } + + async fn external_mcp_decision( + &self, + request: ExternalMcpDecisionRequest, + ) -> Result { + map_client(self.client.external_mcp_decision(request).await) + } + + async fn mcp_conflict_choice( + &self, + request: McpConflictChoiceRequest, + ) -> Result { + map_client(self.client.mcp_conflict_choice(request).await) + } + + async fn external_source_snapshot( + &self, + request: ExternalSourceSnapshotRequest, + ) -> Result { + map(self.client.external_source_snapshot(request).await) + } + + async fn external_source_control( + &self, + request: ExternalSourceControlRequest, + ) -> Result { + map_client(self.client.external_source_control(request).await) + } + + async fn external_source_review( + &self, + request: ExternalSourceReviewRequest, + ) -> Result { + map_client(self.client.external_source_review(request).await) + } + + async fn set_native_command_choice( + &self, + request: SetNativeCommandChoiceRequest, + ) -> Result { + map_client(self.client.set_native_command_choice(request).await) + } + + async fn expand_external_command( + &self, + request: ExpandExternalCommandRequest, + ) -> Result { + map_client(self.client.expand_external_command(request).await) + } + + async fn native_hook_overview( + &self, + request: NativeHookOverviewRequest, + ) -> Result { + map(self.client.native_hook_overview(request).await) + } + + async fn external_hook_snapshot( + &self, + request: ExternalHookSnapshotRequest, + ) -> Result { + map(self.client.external_hook_snapshot(request).await) + } + + async fn external_hook_plan( + &self, + request: ExternalHookPlanRequest, + ) -> Result { + map(self.client.external_hook_plan(request).await) + } + + async fn external_hook_apply( + &self, + request: ExternalHookApplyRequest, + ) -> Result { + map_client(self.client.external_hook_apply(request).await) + } + + async fn external_hook_mutate( + &self, + request: ExternalHookMutationRequest, + ) -> Result { + map_client(self.client.external_hook_mutate(request).await) + } } -fn map(result: Result) -> Result { - result.map_err(|error| TuiBackendError { - message: error.to_string(), - outcome_unknown: false, - }) +fn map(result: Result) -> Result { + result.map_err(map_protocol_error) } fn map_client(result: Result) -> Result { - result.map_err(|error| TuiBackendError { - outcome_unknown: matches!(error, ClientError::Timeout(_)), - message: error.to_string(), + result.map_err(|error| match error { + ClientError::Protocol(error) => map_protocol_error(error), + ClientError::Timeout(data) => backend_error_from_data( + "App Server request timed out with unknown outcome".to_string(), + data, + ), }) } +fn map_protocol_error(error: ProtocolError) -> TuiBackendError { + let message = error.to_string(); + if i32::from(error.code) == -32601 { + return TuiBackendError { + message, + outcome_unknown: false, + kind: TuiBackendErrorKind::Unsupported { + capability: "appServer.method".to_string(), + }, + }; + } + if let Some(value) = error.data { + if let Ok(external) = serde_json::from_value::(value.clone()) { + let kind = match external.app.capability { + Some(capability) + if matches!(external.app.kind, AppServerErrorKind::Unsupported) => + { + TuiBackendErrorKind::Unsupported { capability } + } + _ => TuiBackendErrorKind::Backend, + }; + return TuiBackendError { + message: external.error.encode(), + outcome_unknown: external.app.outcome_unknown, + kind, + }; + } + if let Ok(worktree) = serde_json::from_value::(value.clone()) { + let kind = if matches!(worktree.app.kind, AppServerErrorKind::Unsupported) { + worktree + .app + .capability + .clone() + .map(|capability| TuiBackendErrorKind::Unsupported { capability }) + .unwrap_or(TuiBackendErrorKind::Backend) + } else { + TuiBackendErrorKind::Backend + }; + return TuiBackendError { + message: worktree.error.encode(), + outcome_unknown: worktree.app.outcome_unknown, + kind, + }; + } + if let Ok(data) = serde_json::from_value::(value) { + return backend_error_from_data(message, data); + } + } + TuiBackendError { + message, + outcome_unknown: false, + kind: TuiBackendErrorKind::Backend, + } +} + +fn backend_error_from_data(message: String, data: AppServerErrorData) -> TuiBackendError { + let kind = match (data.kind, data.capability) { + (AppServerErrorKind::Unsupported, Some(capability)) => { + TuiBackendErrorKind::Unsupported { capability } + } + _ => TuiBackendErrorKind::Backend, + }; + TuiBackendError { + message, + outcome_unknown: data.outcome_unknown, + kind, + } +} + #[cfg(test)] mod tests { - use super::{TuiEffect, TuiEffectRoute}; + use super::{map_protocol_error, TuiBackendErrorKind, TuiEffect, TuiEffectRoute}; + use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; + use bitfun_app_server_protocol::external_source::ExternalSourceErrorData; + use bitfun_product_domains::external_sources::{ + ExternalSourceOperationError, ExternalSourceOperationErrorCode, + }; struct LocalEffect; @@ -423,4 +911,82 @@ mod tests { assert_eq!(LocalEffect.route(), TuiEffectRoute::Local); assert_ne!(TuiEffectRoute::AppServer, TuiEffectRoute::HostCapability); } + + #[test] + fn method_not_found_is_treated_as_an_unsupported_host_method() { + let mapped = + map_protocol_error(bitfun_app_server_client::ProtocolError::method_not_found()); + assert!(matches!( + mapped.kind, + TuiBackendErrorKind::Unsupported { .. } + )); + assert!(!mapped.outcome_unknown); + } + + #[test] + fn protocol_unsupported_preserves_the_capability_id() { + let error = bitfun_app_server_client::ProtocolError::new( + AppServerErrorKind::Unsupported.json_rpc_code() as i32, + "not supported", + ) + .data( + serde_json::to_value(AppServerErrorData { + kind: AppServerErrorKind::Unsupported, + retryable: false, + outcome_unknown: false, + capability: Some("tui.models".to_string()), + request_id: None, + }) + .expect("serialize error data"), + ); + + let mapped = map_protocol_error(error); + assert_eq!( + mapped.kind, + TuiBackendErrorKind::Unsupported { + capability: "tui.models".to_string() + } + ); + assert!(!mapped.outcome_unknown); + } + + #[test] + fn external_source_protocol_error_preserves_domain_contract() { + let domain = ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::StaleRevision, + "The external source catalog changed", + false, + ) + .with_correlation_id("external-source-ref-5") + .with_default_recovery_actions(); + let error = bitfun_app_server_client::ProtocolError::new( + AppServerErrorKind::StaleRevision.json_rpc_code() as i32, + domain.detail.clone(), + ) + .data( + serde_json::to_value(ExternalSourceErrorData { + app: AppServerErrorData { + kind: AppServerErrorKind::StaleRevision, + retryable: domain.retryable, + outcome_unknown: false, + capability: Some("tui.externalSources".to_string()), + request_id: domain.correlation_id.clone(), + }, + error: domain.clone(), + }) + .expect("serialize external source error data"), + ); + + let mapped = map_protocol_error(error); + assert_eq!(mapped.kind, TuiBackendErrorKind::Backend); + assert!(!mapped.outcome_unknown); + let decoded = ExternalSourceOperationError::decode(&mapped.message) + .expect("decode mapped external source error"); + assert_eq!( + decoded.code, + ExternalSourceOperationErrorCode::StaleRevision + ); + assert_eq!(decoded.correlation_id, domain.correlation_id); + assert_eq!(decoded.recovery_actions, domain.recovery_actions); + } } diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index 786b1345f8..a9105dbf02 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -1,7 +1,7 @@ impl ChatView { pub(crate) fn show_prompt_command_shell_review( &mut self, - plan: bitfun_core::external_sources::PromptCommandShellReviewPlan, + plan: bitfun_product_domains::external_sources::PromptCommandShellReviewPlan, ) { self.prompt_command_shell_review = Some(crate::ui::prompt_command_shell_review::PromptCommandShellReviewPrompt::new(plan)); @@ -766,9 +766,9 @@ impl ChatView { pub(crate) fn show_account_panel( &mut self, - info: crate::account::AccountInfo, - devices: Vec, - sync_progress: crate::account_sync::SyncProgress, + info: bitfun_app_server_protocol::account::AccountInfo, + devices: Vec, + sync_progress: bitfun_app_server_protocol::account::SettingsSyncProgress, ) { self.login_form.show_account(info, devices, sync_progress); self.popup_stack.push(PopupType::LoginForm); @@ -781,8 +781,8 @@ impl ChatView { pub(crate) fn update_account_panel_progress( &mut self, - devices: Option>, - sync_progress: crate::account_sync::SyncProgress, + devices: Option>, + sync_progress: bitfun_app_server_protocol::account::SettingsSyncProgress, ) { self.login_form .update_account_progress(devices, sync_progress); diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index 7bffee62b0..2fe4abc5ba 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -68,7 +68,6 @@ const DEFAULT_ITEM_ORDER: &[&str] = &[ "mcp_servers", "extensions", "hooks", - "hooks_external", "login", "logout", "status", diff --git a/src/apps/cli/src/ui/login_form.rs b/src/apps/cli/src/ui/login_form.rs index 8584280bcc..22986598ef 100644 --- a/src/apps/cli/src/ui/login_form.rs +++ b/src/apps/cli/src/ui/login_form.rs @@ -12,9 +12,10 @@ use ratatui::{ Frame, }; -use crate::account::{AccountDevice, AccountInfo}; -use crate::account_sync::{sync_phase_label, SyncProgress, SyncStatus}; use crate::ui::theme::{StyleKind, Theme}; +use bitfun_app_server_protocol::account::{ + AccountDevice, AccountInfo, SettingsSyncProgress, SettingsSyncStatus, +}; /// Credentials collected by the login form. #[derive(Debug, Clone)] @@ -106,7 +107,7 @@ pub(crate) struct LoginFormState { account_focus: AccountFocus, account_info: Option, devices: Vec, - sync_progress: SyncProgress, + sync_progress: SettingsSyncProgress, } impl LoginFormState { @@ -127,7 +128,7 @@ impl LoginFormState { account_focus: AccountFocus::Close, account_info: None, devices: Vec::new(), - sync_progress: SyncProgress::default(), + sync_progress: SettingsSyncProgress::default(), } } @@ -169,7 +170,7 @@ impl LoginFormState { &mut self, info: AccountInfo, devices: Vec, - sync_progress: SyncProgress, + sync_progress: SettingsSyncProgress, ) { self.visible = true; self.mode = PanelMode::Account; @@ -184,7 +185,7 @@ impl LoginFormState { pub(crate) fn update_account_progress( &mut self, devices: Option>, - sync_progress: SyncProgress, + sync_progress: SettingsSyncProgress, ) { if let Some(devices) = devices { self.devices = devices; @@ -658,27 +659,31 @@ impl LoginFormState { let sync = &self.sync_progress; let sync_text = match sync.status { - SyncStatus::Idle => "Sync: idle".to_string(), - SyncStatus::Syncing => { + SettingsSyncStatus::Idle => "Sync: idle".to_string(), + SettingsSyncStatus::Syncing => { format!("Syncing: {} {}%", sync_phase_label(sync), sync.percent) } - SyncStatus::Done => format!( + SettingsSyncStatus::Done => format!( "Sync done — settings={} exported={}", sync.settings_synced, sync.sessions_exported ), - SyncStatus::Failed => format!( + SettingsSyncStatus::Failed => format!( "Sync failed: {}", sync.error.as_deref().unwrap_or("unknown error") ), + SettingsSyncStatus::Cancelled => "Sync cancelled".to_string(), }; let sync_style = match sync.status { - SyncStatus::Failed => theme.style(StyleKind::Error), - SyncStatus::Done => theme.style(StyleKind::Info), - SyncStatus::Syncing => theme.style(StyleKind::Primary), - SyncStatus::Idle => theme.style(StyleKind::Muted), + SettingsSyncStatus::Failed => theme.style(StyleKind::Error), + SettingsSyncStatus::Done => theme.style(StyleKind::Info), + SettingsSyncStatus::Syncing => theme.style(StyleKind::Primary), + SettingsSyncStatus::Idle => theme.style(StyleKind::Muted), + SettingsSyncStatus::Cancelled => theme.style(StyleKind::Muted), }; let bar_width = rows[1].width.saturating_sub(2) as usize; - let filled = if sync.status == SyncStatus::Syncing || sync.status == SyncStatus::Done { + let filled = if sync.status == SettingsSyncStatus::Syncing + || sync.status == SettingsSyncStatus::Done + { ((sync.percent as usize) * bar_width) / 100 } else { 0 @@ -891,6 +896,27 @@ impl LoginFormState { } } +fn sync_phase_label(progress: &SettingsSyncProgress) -> String { + match progress.phase.as_str() { + "uploading_settings" => "Uploading settings...".into(), + "downloading_settings" => "Downloading settings...".into(), + "applying_settings" => "Applying cloud settings...".into(), + "settings_done" => "Settings sync done".into(), + "listing_sessions" => "Listing local sessions...".into(), + "exporting_sessions" => { + if let (Some(current), Some(total)) = (progress.current, progress.total) { + format!("Uploading sessions ({current}/{total})...") + } else { + "Uploading sessions...".into() + } + } + "done" => format!("Sync complete (exported {})", progress.sessions_exported), + "starting" => "Starting sync...".into(), + other if other.is_empty() => "Sync".into(), + other => other.to_string(), + } +} + fn char_to_byte(s: &str, char_idx: usize) -> usize { s.char_indices() .nth(char_idx) diff --git a/src/apps/cli/src/ui/model_config_form.rs b/src/apps/cli/src/ui/model_config_form.rs index fff43b5bd4..1a108bd510 100644 --- a/src/apps/cli/src/ui/model_config_form.rs +++ b/src/apps/cli/src/ui/model_config_form.rs @@ -1,3 +1,4 @@ +use bitfun_app_server_protocol::model::{ModelEditProjection, ModelMutation, SecretUpdate}; /// Model configuration form dialog /// /// A multi-field input form for adding a new AI model configuration. @@ -60,6 +61,69 @@ fn reasoning_after_preset_selection( Some(reasoning) } +impl ModelFormResult { + pub(crate) fn from_projection(projection: ModelEditProjection) -> Self { + let model = projection.summary; + Self { + editing_model_id: Some(model.id), + name: model.name, + model_name: model.model_name, + base_url: model.base_url, + api_key: String::new(), + provider_format: model.provider, + context_window: model.context_window.unwrap_or(128_000), + max_tokens: model.max_tokens.unwrap_or(8_192), + reasoning_preset_options: projection.reasoning_preset_options, + reasoning: projection.reasoning, + inline_think_in_text: projection.inline_think_in_text, + skip_ssl_verify: projection.skip_ssl_verify, + custom_headers: String::new(), + custom_headers_mode: projection.custom_headers_mode, + custom_request_body: String::new(), + } + } + + pub(crate) fn to_mutation(&self, model_id: String) -> ModelMutation { + let editing = self.editing_model_id.is_some(); + let required_secret = |value: &str| { + if editing && value.is_empty() { + SecretUpdate::Preserve + } else { + SecretUpdate::Replace(value.to_string()) + } + }; + let optional_secret = |value: &str| { + if value.is_empty() { + if editing { + SecretUpdate::Preserve + } else { + SecretUpdate::Clear + } + } else { + SecretUpdate::Replace(value.to_string()) + } + }; + ModelMutation { + id: model_id, + name: self.name.clone(), + provider: self.provider_format.clone(), + model_name: self.model_name.clone(), + base_url: self.base_url.clone(), + api_key: Some(required_secret(&self.api_key)), + custom_headers: Some(optional_secret(&self.custom_headers)), + custom_request_body: Some(optional_secret(&self.custom_request_body)), + context_window: Some(self.context_window), + max_tokens: Some(self.max_tokens), + enabled: true, + reasoning: self.reasoning.clone(), + inline_think_in_text: self.inline_think_in_text, + skip_ssl_verify: self.skip_ssl_verify, + custom_headers_mode: (!self.custom_headers_mode.is_empty()) + .then(|| self.custom_headers_mode.clone()), + } + } +} + /// Action returned by the form #[derive(Debug, Clone)] pub(crate) enum ModelFormAction { @@ -453,7 +517,7 @@ impl ModelConfigFormState { if self.base_url.trim().is_empty() { return Some("Base URL is required".into()); } - if self.api_key.trim().is_empty() { + if self.editing_model_id.is_none() && self.api_key.trim().is_empty() { return Some("API Key is required".into()); } if self.context_window.trim().parse::().is_err() { @@ -871,6 +935,9 @@ impl ModelConfigFormState { FormField::Name => "Config Name *", FormField::ModelName => "Model Name *", FormField::BaseUrl => "Base URL *", + FormField::ApiKey if self.editing_model_id.is_some() => { + "API Key (leave blank to preserve)" + } FormField::ApiKey => "API Key *", FormField::ProviderFormat => "Provider Format", FormField::ContextWindow => "Context Window", @@ -1165,6 +1232,9 @@ impl ModelConfigFormState { FormField::Name => "e.g. My Model Config", FormField::ModelName => "e.g. gpt-4, claude-sonnet-4-5-20250929", FormField::BaseUrl => "https://api.example.com/v1/chat/completions", + FormField::ApiKey if self.editing_model_id.is_some() => { + "Leave blank to keep the configured key" + } FormField::ApiKey => "Enter your API key", FormField::ProviderFormat => "", FormField::ContextWindow => "128000", diff --git a/src/apps/cli/src/ui/prompt_command_shell_review.rs b/src/apps/cli/src/ui/prompt_command_shell_review.rs index 34dccb4a29..93ee8c324c 100644 --- a/src/apps/cli/src/ui/prompt_command_shell_review.rs +++ b/src/apps/cli/src/ui/prompt_command_shell_review.rs @@ -1,4 +1,4 @@ -use bitfun_core::external_sources::PromptCommandShellReviewPlan; +use bitfun_product_domains::external_sources::PromptCommandShellReviewPlan; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, @@ -161,7 +161,7 @@ impl PromptCommandShellReviewPrompt { #[cfg(test)] mod tests { use super::{PromptCommandShellReviewAction, PromptCommandShellReviewPrompt}; - use bitfun_core::external_sources::PromptCommandShellReviewPlan; + use bitfun_product_domains::external_sources::PromptCommandShellReviewPlan; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; fn plan(can_remember: bool) -> PromptCommandShellReviewPlan { diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 105d93c790..3c9f34dde4 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -19,7 +19,7 @@ use super::theme_selector::{ThemeItem, ThemeSelectorState}; use crate::actions::{ action_by_id, action_for_alias, removed_management_command_hint, ActionContext, ActionHandler, ActionSpec, ActionState, ResolvedKeymap, IMAGE_ATTACHMENTS_REQUIRE_MESSAGE, - SHARED_TUI_EMBEDDED_HANDOFF, SHARED_TUI_HELP_NOTE, + SHARED_TUI_HELP_NOTE, }; use crate::config::CliConfig; /// Startup page module @@ -30,6 +30,11 @@ use crate::config::CliConfig; /// - Model/Agent/Session/Skill/Subagent selector popups /// - Random tips use anyhow::Result; +use bitfun_app_server_protocol::model::{ + AddModelRequest, ModelDefaultSlot, SetModelDefaultRequest, UpdateModelRequest, +}; +use bitfun_app_server_protocol::skill::SkillSummary; +use bitfun_app_server_protocol::subagent::SubagentSummary; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ backend::Backend, @@ -42,20 +47,6 @@ use ratatui::{ use std::sync::Arc; use std::time::Duration; -use bitfun_core::agentic::agents::{ - get_agent_registry, AgentInfo, SubAgentSource, SubagentListScope, SubagentQueryContext, -}; -use bitfun_core::agentic::tools::implementations::skills::{ - mode_overrides::{ - load_project_mode_skills_document_local, save_project_mode_skills_document_local, - set_mode_skill_disabled_in_document, set_user_mode_skill_state, - }, - registry::SkillRegistry, - ModeSkillInfo, SkillInfo, -}; -use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; -use bitfun_core::service::config::GlobalConfigManager; - use crate::agent::tui_client::{TuiAgentClient, TuiAgentMode}; /// Types of popups that can be shown on the startup page @@ -206,7 +197,6 @@ pub(crate) struct StartupPage { // ── System context ── agent: Arc, - compatibility: Option, // ── State ── /// Selected agent type (can be changed via /agent or Tab) @@ -231,7 +221,6 @@ impl StartupPage { pub(crate) fn new( config: CliConfig, agent: Arc, - compatibility: Option, default_agent: String, workspace: Option, ) -> Self { @@ -293,7 +282,6 @@ impl StartupPage { login_form: LoginFormState::new(), theme_preview_original: None, agent, - compatibility, agent_type: default_agent, model_display_name: String::new(), selected_model_id: None, @@ -319,6 +307,16 @@ impl StartupPage { &self.agent_type } + /// Set a model ID override (from `--model` flag) for display and session + /// composition. The ID is validated when applied to the session; an invalid + /// ID logs a warning and falls back to the default model. + pub(crate) fn set_model_override(&mut self, model_id: Option) { + if model_id.is_some() { + self.selected_model_id = model_id; + } + self.load_current_model_name(); + } + /// Return the model explicitly selected for the new Session, if any. pub(crate) fn selected_model_id(&self) -> Option<&str> { self.selected_model_id.as_deref() @@ -1292,18 +1290,11 @@ impl StartupPage { } fn logout(&mut self) { - let logged_in = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(crate::account::is_logged_in()) - }); - if !logged_in { - self.status = Some("Not logged in.".to_string()); - return; - } self.status = Some( match tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(crate::account::logout()) + tokio::runtime::Handle::current().block_on(self.agent.account_logout()) }) { - Ok(()) => "Logged out.".to_string(), + Ok(_) => "Logged out.".to_string(), Err(error) => format!("Logout failed: {error}"), }, ); @@ -1369,53 +1360,52 @@ impl StartupPage { fn show_login_form(&mut self) { self.close_all_popups(); let logged_in = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(crate::account::is_logged_in()) - }); - if logged_in { - self.open_account_panel(); - } else { - self.login_form.show(); - } - } - - fn workspace_path_for_sync(&self) -> std::path::PathBuf { - self.workspace_path_buf() - } - - fn open_account_panel(&mut self) { - let (info, devices, progress) = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let info = crate::account::account_info().await; - let devices = crate::account::list_devices().await.unwrap_or_default(); - let progress = crate::account_sync::current_sync_progress().await; - (info, devices, progress) - }) + tokio::runtime::Handle::current().block_on(self.agent.account_snapshot()) }); - match info { - Ok(info) => self.login_form.show_account(info, devices, progress), - Err(e) => { - self.status = Some(format!("Failed to load account: {e}")); + match logged_in { + Ok(snapshot) if snapshot.logged_in => self.open_account_panel(snapshot), + Ok(_) => self.login_form.show(), + Err(error) => { self.login_form.show(); + self.login_form + .set_error(format!("Failed to load account: {error}")); } } } + fn open_account_panel( + &mut self, + snapshot: bitfun_app_server_protocol::account::AccountSnapshotResponse, + ) { + let Some(info) = snapshot.info else { + self.login_form.show(); + return; + }; + self.login_form + .show_account(info, snapshot.devices, snapshot.sync); + } + fn refresh_account_panel_live(&mut self) { if !self.login_form.is_visible() { return; } - let progress = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(crate::account_sync::current_sync_progress()) - }); + let Ok(progress) = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.agent.settings_sync_snapshot()) + }) else { + return; + }; + let progress = progress.progress; // Refresh devices occasionally while syncing / after done. let devices = if matches!( progress.status, - crate::account_sync::SyncStatus::Syncing | crate::account_sync::SyncStatus::Done + bitfun_app_server_protocol::account::SettingsSyncStatus::Syncing + | bitfun_app_server_protocol::account::SettingsSyncStatus::Done ) { tokio::task::block_in_place(|| { tokio::runtime::Handle::current() - .block_on(crate::account::list_devices()) + .block_on(self.agent.account_snapshot()) .ok() + .map(|snapshot| snapshot.devices) }) } else { None @@ -1424,16 +1414,19 @@ impl StartupPage { } fn start_sync_and_show_account(&mut self, is_first_login: bool) { - let Some(compatibility) = self.compatibility.clone() else { - self.open_account_panel(); - self.status = Some(format!( - "Account settings sync is unavailable in Shared TUI preview. {SHARED_TUI_EMBEDDED_HANDOFF}" - )); + let result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(self.agent.settings_sync_start(is_first_login)) + }); + if let Err(error) = result { + self.status = Some(format!("Account settings sync failed: {error}")); return; - }; - let workspace = self.workspace_path_for_sync(); - crate::account_sync::start_auto_sync_background(compatibility, is_first_login, workspace); - self.open_account_panel(); + } + if let Ok(snapshot) = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.agent.account_snapshot()) + }) { + self.open_account_panel(snapshot); + } self.status = Some(if is_first_login { "Sync started (use local / upload settings).".to_string() } else { @@ -1445,13 +1438,11 @@ impl StartupPage { match action { LoginFormAction::Submit(creds) => { let result = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on( - crate::account::login_with_credentials( - &creds.relay_url, - &creds.username, - &creds.password, - ), - ) + tokio::runtime::Handle::current().block_on(self.agent.account_login( + creds.relay_url, + creds.username, + creds.password, + )) }); match result { Ok(login) => { @@ -1469,47 +1460,61 @@ impl StartupPage { } } LoginFormAction::SyncUseLocal => { - if let Err(e) = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(crate::account::finalize_login_after_sync_choice()) - }) { - self.login_form - .set_error(format!("Finalize login failed: {e}")); - let _ = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(crate::account::logout()) - }); - self.login_form.show(); - return None; + let result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.agent.account_finalize_login( + bitfun_app_server_protocol::account::AccountSyncChoice::Local, + )) + }); + match result { + Ok(snapshot) => { + self.open_account_panel(snapshot); + self.status = + Some("Sync started (use local / upload settings).".to_string()); + } + Err(error) => { + self.login_form + .set_error(format!("Finalize login failed: {error}")); + let _ = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.agent.account_logout()) + }); + self.login_form.show(); + } } - self.start_sync_and_show_account(true); } LoginFormAction::SyncUseCloud => { - if let Err(e) = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(crate::account::finalize_login_after_sync_choice()) - }) { - self.login_form - .set_error(format!("Finalize login failed: {e}")); - let _ = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(crate::account::logout()) - }); - self.login_form.show(); - return None; + let result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.agent.account_finalize_login( + bitfun_app_server_protocol::account::AccountSyncChoice::Cloud, + )) + }); + match result { + Ok(snapshot) => { + self.open_account_panel(snapshot); + self.status = + Some("Sync started (use cloud / download settings).".to_string()); + } + Err(error) => { + self.login_form + .set_error(format!("Finalize login failed: {error}")); + let _ = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.agent.account_logout()) + }); + self.login_form.show(); + } } - self.start_sync_and_show_account(false); } LoginFormAction::SyncCancel => { let _ = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(crate::account::logout()) + tokio::runtime::Handle::current().block_on(self.agent.settings_sync_cancel()) }); self.login_form.show(); self.status = Some("Sync cancelled; logged out.".to_string()); } LoginFormAction::Logout => { match tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(crate::account::logout()) + tokio::runtime::Handle::current().block_on(self.agent.account_logout()) }) { - Ok(()) => { + Ok(_) => { self.login_form.show(); self.status = Some("Logged out.".to_string()); } @@ -1601,26 +1606,21 @@ impl StartupPage { let result = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { - let config_service = GlobalConfigManager::get_service().await.ok()?; - let models: Vec = - config_service.get_ai_models().await.ok()?; - let global_config: bitfun_core::service::config::GlobalConfig = - config_service.get_config(None).await.ok()?; - + let catalog = self.agent.list_models().await.ok()?; let current_model_id = resolve_startup_model_id( explicitly_selected_model_id, profile_model_id, - crate::model_selection::resolve_mode_model_id(&global_config.ai), + catalog.mode_default_model_id.clone(), ); - - let model_items: Vec = models + let model_items: Vec = catalog + .models .into_iter() - .filter(|m| m.enabled) - .map(|m| ModelItem { - id: m.id, - name: m.name, - provider: m.provider, - model_name: m.model_name, + .filter(|model| model.enabled) + .map(|model| ModelItem { + id: model.id, + name: model.name, + provider: model.provider, + model_name: model.model_name, }) .collect(); @@ -1650,16 +1650,15 @@ impl StartupPage { if !persist_shared_default { return true; } - let config_service = match GlobalConfigManager::get_service().await { - Ok(s) => s, - Err(_) => return false, - }; - - if let Err(e) = config_service - .set_config("ai.agent_model_defaults.mode", &selected_id) + if let Err(error) = self + .agent + .set_model_default(SetModelDefaultRequest { + slot: ModelDefaultSlot::Mode, + model_id: Some(selected_id.clone()), + }) .await { - tracing::error!("Failed to set future mode model: {}", e); + tracing::error!("Failed to set future mode model: {error}"); return false; } @@ -1672,7 +1671,10 @@ impl StartupPage { self.model_display_name = selected_display_name.clone(); self.status = Some(format!("Model switched to: {}", selected_display_name)); if persist_shared_default { - crate::account_sync::notify_local_settings_changed(); + let _ = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(self.agent.settings_sync_local_changed()) + }); } } else { self.status = Some("Failed to switch model".to_string()); @@ -1707,100 +1709,27 @@ impl StartupPage { .as_millis() ); - let custom_headers: Option> = - if result.custom_headers.is_empty() { - None - } else { - serde_json::from_str(&result.custom_headers).ok() - }; - - let custom_request_body: Option = if result.custom_request_body.is_empty() { - None - } else { - Some(result.custom_request_body.clone()) - }; - - let model_config = bitfun_core::service::config::AIModelConfig { - id: model_id.clone(), - name: result.name.clone(), - provider: result.provider_format.clone(), - model_name: result.model_name.clone(), - base_url: result.base_url.clone(), - api_key: result.api_key.clone(), - context_window: Some(result.context_window), - max_tokens: Some(result.max_tokens), - enabled: true, - reasoning: result.reasoning.clone(), - inline_think_in_text: result.inline_think_in_text, - skip_ssl_verify: result.skip_ssl_verify, - custom_headers, - custom_headers_mode: if result.custom_headers_mode.is_empty() - || result.custom_headers_mode == "merge" - { - None - } else { - Some(result.custom_headers_mode.clone()) - }, - custom_request_body, - ..Default::default() - }; - let result_name = result.name.clone(); let result_model_display = format!("{} / {}", result.model_name, result.name); + let request = AddModelRequest { + model: result.to_mutation(model_id.clone()), + make_primary_if_empty: true, + }; let success = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let config_service = match GlobalConfigManager::get_service().await { - Ok(s) => s, - Err(e) => { - tracing::error!("Failed to get config service: {}", e); - return false; - } - }; - - if let Err(e) = config_service.add_ai_model(model_config).await { - tracing::error!("Failed to add AI model: {}", e); - return false; - } - - // Auto-set as primary model if no primary model exists - match config_service - .get_config::(None) - .await - { - Ok(global_config) => { - let has_primary = global_config - .ai - .default_models - .primary - .as_ref() - .map(|p| !p.is_empty()) - .unwrap_or(false); - if !has_primary { - if let Err(e) = config_service - .set_config("ai.default_models.primary", &model_id) - .await - { - tracing::warn!("Failed to auto-set primary model: {}", e); - } else { - tracing::info!("Auto-set primary model: {}", model_id); - } - } - } - Err(e) => { - tracing::warn!("Failed to read config for auto-primary: {}", e); - } - } - - true - }) + tokio::runtime::Handle::current() + .block_on(self.agent.add_model(request)) + .map_err(|error| tracing::error!("Failed to add AI model: {error}")) + .is_ok() }); if success { self.model_display_name = result_model_display; self.status = Some(format!("Model added: {}", result_name)); tracing::info!("Added new AI model: {}", model_id); - crate::account_sync::notify_local_settings_changed(); + let _ = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.agent.settings_sync_local_changed()) + }); // Reload model name display self.load_current_model_name(); } else { @@ -1812,50 +1741,16 @@ impl StartupPage { fn edit_model(&mut self, selected: &ModelItem) { let model_id = selected.id.clone(); let result = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let config_service = GlobalConfigManager::get_service().await.ok()?; - let models: Vec = - config_service.get_ai_models().await.ok()?; - let model = models.into_iter().find(|m| m.id == model_id)?; - let reasoning_preset_options = self - .agent - .model_catalog() - .await - .ok() - .and_then(|catalog| catalog.reasoning_presets_by_model.get(&model.id).cloned()) - .unwrap_or_default(); - Some((model, reasoning_preset_options)) - }) + tokio::runtime::Handle::current().block_on(self.agent.get_model(model_id.clone())) }); match result { - Some((model, reasoning_preset_options)) => { - let form_data = ModelFormResult { - editing_model_id: Some(model.id.clone()), - name: model.name, - model_name: model.model_name, - base_url: model.base_url, - api_key: model.api_key, - provider_format: model.provider.clone(), - context_window: model.context_window.unwrap_or(128000), - max_tokens: model.max_tokens.unwrap_or(8192), - reasoning_preset_options, - reasoning: model.reasoning, - inline_think_in_text: model.inline_think_in_text, - skip_ssl_verify: model.skip_ssl_verify, - custom_headers: model - .custom_headers - .map(|h| serde_json::to_string(&h).unwrap_or_default()) - .unwrap_or_default(), - custom_headers_mode: model - .custom_headers_mode - .unwrap_or_else(|| "merge".to_string()), - custom_request_body: model.custom_request_body.unwrap_or_default(), - }; - self.model_config_form.show_for_edit(&model.id, &form_data); + Ok(response) => { + let form_data = ModelFormResult::from_projection(response.model); + self.model_config_form.show_for_edit(&model_id, &form_data); } - None => { - self.status = Some("Failed to load model configuration".to_string()); + Err(error) => { + self.status = Some(format!("Failed to load model configuration: {error}")); } } } @@ -1867,74 +1762,27 @@ impl StartupPage { None => return, }; - let custom_headers: Option> = - if result.custom_headers.is_empty() { - None - } else { - serde_json::from_str(&result.custom_headers).ok() - }; - - let custom_request_body: Option = if result.custom_request_body.is_empty() { - None - } else { - Some(result.custom_request_body.clone()) - }; - - let model_config = bitfun_core::service::config::AIModelConfig { - id: model_id.clone(), - name: result.name.clone(), - provider: result.provider_format.clone(), - model_name: result.model_name.clone(), - base_url: result.base_url.clone(), - api_key: result.api_key.clone(), - context_window: Some(result.context_window), - max_tokens: Some(result.max_tokens), - enabled: true, - reasoning: result.reasoning.clone(), - inline_think_in_text: result.inline_think_in_text, - skip_ssl_verify: result.skip_ssl_verify, - custom_headers, - custom_headers_mode: if result.custom_headers_mode.is_empty() - || result.custom_headers_mode == "merge" - { - None - } else { - Some(result.custom_headers_mode.clone()) - }, - custom_request_body, - ..Default::default() - }; - let result_name = result.name.clone(); let result_model_display = format!("{} / {}", result.model_name, result.name); + let request = UpdateModelRequest { + model_id: model_id.clone(), + model: result.to_mutation(model_id.clone()), + }; let success = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let config_service = match GlobalConfigManager::get_service().await { - Ok(s) => s, - Err(e) => { - tracing::error!("Failed to get config service: {}", e); - return false; - } - }; - - if let Err(e) = config_service - .update_ai_model(&model_id, model_config) - .await - { - tracing::error!("Failed to update AI model: {}", e); - return false; - } - - true - }) + tokio::runtime::Handle::current() + .block_on(self.agent.update_model(request)) + .map_err(|error| tracing::error!("Failed to update AI model: {error}")) + .is_ok() }); if success { self.model_display_name = result_model_display; self.status = Some(format!("Model updated: {}", result_name)); tracing::info!("Updated AI model: {}", model_id); - crate::account_sync::notify_local_settings_changed(); + let _ = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.agent.settings_sync_local_changed()) + }); self.load_current_model_name(); } else { self.status = Some("Failed to update model".to_string()); @@ -1965,13 +1813,8 @@ impl StartupPage { }) .collect(); - if self.agent.is_shared() { - self.agent_selector - .show_modes_only(agent_items, Some(self.agent_type.clone()), true); - } else { - self.agent_selector - .show(agent_items, Some(self.agent_type.clone()), false, true); - } + self.agent_selector + .show(agent_items, Some(self.agent_type.clone()), false, true); } fn handle_agent_selector_action(&mut self, action: AgentSelectorAction) { @@ -2113,18 +1956,16 @@ impl StartupPage { fn show_available_skill_list(&mut self) { let skills = tokio::task::block_in_place(|| { - let workspace = self.workspace_path_buf(); - let agent_type = self.agent_type.clone(); - tokio::runtime::Handle::current().block_on(async { - let registry = SkillRegistry::global(); - registry - .get_user_invocable_skills_for_workspace( - Some(workspace.as_path()), - Some(&agent_type), - ) - .await - }) + tokio::runtime::Handle::current() + .block_on(self.agent.list_skills(self.agent_type.clone(), false)) }); + let skills = match skills { + Ok(response) => response.skills, + Err(error) => { + self.status = Some(format!("Could not load skills: {error}")); + return; + } + }; if skills.is_empty() { self.status = Some(format!( @@ -2134,8 +1975,10 @@ impl StartupPage { return; } - let skill_items: Vec = - skills.into_iter().map(Self::skill_item_from_info).collect(); + let skill_items: Vec = skills + .into_iter() + .map(Self::skill_item_from_summary) + .collect(); if skill_items.is_empty() { self.status = Some("No skills found.".to_string()); @@ -2147,19 +1990,20 @@ impl StartupPage { fn show_skill_config_selector(&mut self) { let skills = tokio::task::block_in_place(|| { - let workspace = self.workspace_path_buf(); - let agent_type = self.agent_type.clone(); - tokio::runtime::Handle::current().block_on(async { - let registry = SkillRegistry::global(); - registry - .get_mode_skill_infos_for_workspace(Some(workspace.as_path()), &agent_type) - .await - }) + tokio::runtime::Handle::current() + .block_on(self.agent.list_skills(self.agent_type.clone(), true)) }); + let skills = match skills { + Ok(response) => response.skills, + Err(error) => { + self.status = Some(format!("Could not load skills: {error}")); + return; + } + }; let skill_items: Vec = skills .into_iter() - .map(Self::skill_item_from_mode_info) + .map(Self::skill_item_from_summary) .collect(); if skill_items.is_empty() { @@ -2186,49 +2030,21 @@ impl StartupPage { } fn set_skill_enabled(&mut self, selected: &SkillItem, enabled: bool) { - let workspace = self.workspace_path_buf(); let mode_id = self.agent_type.clone(); let skill = selected.clone(); - let result: Result<(), String> = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - match skill.level.as_str() { - "user" => { - set_user_mode_skill_state( - &mode_id, - &skill.key, - enabled, - skill.default_enabled, - ) - .await - .map_err(|error| error.to_string())?; - } - "project" => { - let mut document = load_project_mode_skills_document_local(&workspace) - .await - .map_err(|error| error.to_string())?; - set_mode_skill_disabled_in_document( - &mut document, - &mode_id, - &skill.key, - !enabled, - ) - .map_err(|error| error.to_string())?; - save_project_mode_skills_document_local(&workspace, &document) - .await - .map_err(|error| error.to_string())?; - } - other => { - return Err(format!("Unsupported skill level '{}'", other)); - } - } - - Ok(()) - }) + let result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.agent.set_skill_enabled( + mode_id, + skill.key, + enabled, + skill.default_enabled, + skill.level, + )) }); self.status = Some(match result { - Ok(()) => format!( + Ok(_) => format!( "Skill '{}' {} for mode '{}'.", selected.name, if enabled { "enabled" } else { "disabled" }, @@ -2238,60 +2054,40 @@ impl StartupPage { }); } - fn skill_item_from_info(info: SkillInfo) -> SkillItem { + fn skill_item_from_summary(info: SkillSummary) -> SkillItem { SkillItem { key: info.key, name: info.name, description: info.description, - level: info.level.as_str().to_string(), - source_slot: info.source_slot, - source_label: info.source_label, - enabled: true, - selected_for_runtime: true, - default_enabled: true, + level: info.level, + source_slot: info.source_slot.unwrap_or_default(), + source_label: info.source_label.unwrap_or_default(), + enabled: info.enabled, + selected_for_runtime: info.selected_for_runtime, + default_enabled: info.default_enabled, is_shadowed: info.is_shadowed, shadowed_by_key: info.shadowed_by_key, argument_hint: info.argument_hint, } } - fn skill_item_from_mode_info(info: ModeSkillInfo) -> SkillItem { - SkillItem { - key: info.skill.key, - name: info.skill.name, - description: info.skill.description, - level: info.skill.level.as_str().to_string(), - source_slot: info.skill.source_slot, - source_label: info.skill.source_label, - enabled: info.effective_enabled, - selected_for_runtime: info.selected_for_runtime, - default_enabled: info.default_enabled, - is_shadowed: info.skill.is_shadowed, - shadowed_by_key: info.skill.shadowed_by_key, - argument_hint: info.skill.argument_hint, - } - } - fn show_subagent_selector(&mut self) { self.push_current_popup_to_stack(); self.subagent_selector.show_menu(); } fn show_available_subagent_list(&mut self) { - let registry = get_agent_registry(); let subagents = tokio::task::block_in_place(|| { - let workspace = self.workspace_path_buf(); - let agent_type = self.agent_type.clone(); - tokio::runtime::Handle::current().block_on(registry.get_subagents_for_query( - &SubagentQueryContext { - parent_agent_type: Some(&agent_type), - workspace_root: Some(workspace.as_path()), - list_scope: SubagentListScope::TaskVisible, - include_disabled: false, - external_sources_supported: false, - }, - )) + tokio::runtime::Handle::current() + .block_on(self.agent.list_subagents(self.agent_type.clone(), false)) }); + let subagents = match subagents { + Ok(response) => response.subagents, + Err(error) => { + self.status = Some(format!("Could not load subagents: {error}")); + return; + } + }; if subagents.is_empty() { self.status = Some(format!( @@ -2303,7 +2099,7 @@ impl StartupPage { let subagent_items: Vec = subagents .into_iter() - .map(Self::subagent_item_from_info) + .map(Self::subagent_item_from_summary) .collect(); if subagent_items.is_empty() { @@ -2315,25 +2111,21 @@ impl StartupPage { } fn show_subagent_config_selector(&mut self) { - let registry = get_agent_registry(); let subagents = tokio::task::block_in_place(|| { - let workspace = self.workspace_path_buf(); - let agent_type = self.agent_type.clone(); - tokio::runtime::Handle::current().block_on(registry.get_subagents_for_query( - &SubagentQueryContext { - parent_agent_type: Some(&agent_type), - workspace_root: Some(workspace.as_path()), - list_scope: SubagentListScope::RegistryManagement, - include_disabled: true, - external_sources_supported: false, - }, - )) + tokio::runtime::Handle::current() + .block_on(self.agent.list_subagents(self.agent_type.clone(), true)) }); - - let subagent_items: Vec = subagents + let response = match subagents { + Ok(response) => response, + Err(error) => { + self.status = Some(format!("Could not load subagents: {error}")); + return; + } + }; + let subagent_items: Vec = response + .subagents .into_iter() - .filter(|info| info.subagent_source != Some(SubAgentSource::External)) - .map(Self::subagent_item_from_info) + .map(Self::subagent_item_from_summary) .collect(); if subagent_items.is_empty() { @@ -2363,27 +2155,19 @@ impl StartupPage { } fn set_subagent_enabled(&mut self, selected: &SubagentItem, enabled: bool) { - let registry = get_agent_registry(); - let workspace = self.workspace_path_buf(); let mode_id = self.agent_type.clone(); let subagent = selected.clone(); - let result: Result<(), String> = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - registry - .update_subagent_override( - &mode_id, - &subagent.id, - enabled, - Some(workspace.as_path()), - ) - .await - .map_err(|error| error.to_string()) - }) + let result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.agent.set_subagent_enabled( + mode_id, + subagent.id, + enabled, + )) }); self.status = Some(match result { - Ok(()) => format!( + Ok(_) => format!( "Subagent '{}' {} for mode '{}'.", selected.name, if enabled { "enabled" } else { "disabled" }, @@ -2393,23 +2177,14 @@ impl StartupPage { }); } - fn subagent_item_from_info(info: AgentInfo) -> SubagentItem { - let source = match info.subagent_source { - Some(SubAgentSource::Builtin) => "builtin", - Some(SubAgentSource::Project) => "project", - Some(SubAgentSource::User) => "user", - Some(SubAgentSource::External) => "external", - None => "builtin", - } - .to_string(); - + fn subagent_item_from_summary(info: SubagentSummary) -> SubagentItem { SubagentItem { key: info.key, id: info.id, name: info.name, description: info.description, - source, - enabled: info.effective_enabled, + source: info.source, + enabled: info.enabled, } } @@ -2516,50 +2291,17 @@ impl StartupPage { let profile_model_id = self.selected_agent_mode().and_then(|mode| mode.model_id); let result: Option = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { - let config_service = GlobalConfigManager::get_service().await.ok()?; - let models: Vec = - config_service.get_ai_models().await.ok()?; - let global_config: bitfun_core::service::config::GlobalConfig = - config_service.get_config(None).await.ok()?; - + let catalog = self.agent.list_models().await.ok()?; let model_id = resolve_startup_model_id( explicitly_selected_model_id, profile_model_id, - crate::model_selection::resolve_mode_model_id(&global_config.ai), + catalog.mode_default_model_id.clone(), )?; - - fn provider_display_name( - model: &bitfun_core::service::config::AIModelConfig, - ) -> String { - let raw_name = model.name.trim(); - let model_name = model.model_name.trim(); - if !raw_name.is_empty() && !model_name.is_empty() { - let dashed_suffix = format!(" - {}", model_name); - let slash_suffix = format!("/{}", model_name); - if let Some(provider) = raw_name.strip_suffix(&dashed_suffix) { - return provider.trim().to_string(); - } - if let Some(provider) = raw_name.strip_suffix(&slash_suffix) { - return provider.trim().to_string(); - } - } - if raw_name.is_empty() { - model.provider.clone() - } else { - raw_name.to_string() - } - } - - fn model_display_name( - model: &bitfun_core::service::config::AIModelConfig, - ) -> String { - format!("{} / {}", model.model_name, provider_display_name(model)) - } - - models + catalog + .models .iter() .find(|model| model.id == model_id) - .map(model_display_name) + .map(crate::model_selection::tui_model_display_name) }) }); diff --git a/src/apps/cli/src/ui/syntax_highlight.rs b/src/apps/cli/src/ui/syntax_highlight.rs index 1652fea129..1d2e7de5f6 100644 --- a/src/apps/cli/src/ui/syntax_highlight.rs +++ b/src/apps/cli/src/ui/syntax_highlight.rs @@ -1,7 +1,7 @@ /// Syntax highlighting module for TUI /// -/// Uses `syntect` for syntax analysis and `syntect-tui` to convert -/// highlighted output into ratatui `Span`s. +/// Uses `syntect` for syntax analysis and converts highlighted output directly +/// into ratatui `Span`s. use once_cell::sync::Lazy; use ratatui::{ style::Style, diff --git a/src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs b/src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs index 48862de237..6c6bba56da 100644 --- a/src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs +++ b/src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs @@ -367,6 +367,48 @@ fn stream_json_patch_success_emits_one_success_terminal() { ); } +#[test] +fn stream_json_malformed_sse_retries_then_completes() { + let server = MockOpenAiServer::malformed_sse_then_immediate(); + let environment = CliTestEnvironment::new(); + environment.configure_mock_model(server.base_url()); + let mut command = environment.std_command(); + command.args([ + "exec", + "exercise malformed provider stream retry", + "--output-format", + "stream-json", + ]); + let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); + server.assert_chat_completion_requests(2); + + let stdout = stdout(&output); + assert!(output.status.success(), "{}\n{stdout}", stderr(&output)); + let events = jsonl_events(&stdout); + assert!( + events.iter().any(|value| { + value["event"]["type"] == "TextChunk" + && value["event"]["text"] + .as_str() + .is_some_and(|text| text.contains(STREAM_COMPLETED_MARKER)) + }), + "retried model stream did not complete: {stdout}" + ); + assert_eq!( + events + .iter() + .filter(|value| is_terminal_event(value)) + .count(), + 1, + "retried stream must emit exactly one terminal envelope: {stdout}" + ); + assert_eq!( + events.last().expect("retried stream terminal event")["event"]["type"], + "DialogTurnCompleted", + "retried stream terminal must be last: {stdout}" + ); +} + #[test] fn stream_json_provider_http_403_emits_one_error_terminal() { let server = MockOpenAiServer::http_403("provider authorization denied"); @@ -380,7 +422,7 @@ fn stream_json_provider_http_403_emits_one_error_terminal() { "stream-json", ]); let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); - server.assert_chat_completion_requests(1); + server.assert_chat_completion_requests(10); let stdout = stdout(&output); assert!(!output.status.success(), "{stdout}"); @@ -443,7 +485,7 @@ fn stream_json_provider_and_patch_failures_publish_one_final_classification() { &output_target, ]); let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); - server.assert_chat_completion_requests(1); + server.assert_chat_completion_requests(10); let stdout = stdout(&output); let stderr = stderr(&output); @@ -481,7 +523,7 @@ fn stream_json_provider_and_patch_failures_publish_one_final_classification() { } #[test] -fn stream_json_disconnect_then_permanent_retry_failure_emits_one_error_terminal() { +fn stream_json_disconnect_then_exhausted_retry_failure_emits_one_error_terminal() { let server = MockOpenAiServer::disconnect_then_http_403(); let environment = CliTestEnvironment::new(); environment.configure_mock_model(server.base_url()); @@ -493,7 +535,7 @@ fn stream_json_disconnect_then_permanent_retry_failure_emits_one_error_terminal( "stream-json", ]); let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); - server.assert_chat_completion_requests(2); + server.assert_chat_completion_requests(10); let stdout = stdout(&output); assert!(!output.status.success(), "{stdout}"); diff --git a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs index 8d992d631f..da2cb83a06 100644 --- a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs +++ b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs @@ -141,7 +141,10 @@ fn doctor_rejects_incomplete_e2e_storage_roots() { #[test] fn remaining_cli_local_persistence_stays_behind_explicit_owner_boundaries() { - const ACCOUNT_SYNC: &str = include_str!("../../src/account_sync.rs"); + const ACCOUNT_ADAPTER: &str = include_str!("../../src/account.rs"); + const ACCOUNT_RUNTIME: &str = include_str!( + "../../../../crates/assembly/core/src/service/remote_connect/account_runtime.rs" + ); const STARTUP_PAGE: &str = include_str!("../../src/ui/startup.rs"); const PEER_BOOTSTRAP: &str = include_str!("../../src/peer_host/bootstrap.rs"); const PEER_STATE: &str = include_str!("../../src/peer_host/state.rs"); @@ -151,7 +154,7 @@ fn remaining_cli_local_persistence_stays_behind_explicit_owner_boundaries() { include_str!("../../../../crates/assembly/core/src/product_runtime/runtime_services.rs"); for (path, source) in [ - ("account_sync.rs", ACCOUNT_SYNC), + ("account.rs", ACCOUNT_ADAPTER), ("ui/startup.rs", STARTUP_PAGE), ("peer_host/bootstrap.rs", PEER_BOOTSTRAP), ("peer_host/state.rs", PEER_STATE), @@ -165,12 +168,19 @@ fn remaining_cli_local_persistence_stays_behind_explicit_owner_boundaries() { } assert!( - ACCOUNT_SYNC.contains("CoreAgentRuntimeCompatibility"), - "account sync must receive the narrow Core compatibility facade" + ACCOUNT_RUNTIME.contains("pub struct AccountRuntime") + && ACCOUNT_ADAPTER.contains("impl AccountRuntimeHost for CliAccountRoutingHost") + && ACCOUNT_ADAPTER.contains("impl AccountSessionBackupPort"), + "account state must live in the shared owner while CLI keeps narrow Host adapters" ); assert!( - STARTUP_PAGE.contains("CoreAgentRuntimeCompatibility"), - "startup must pass the initialized Core compatibility facade to account sync" + STARTUP_PAGE.contains("self.agent.account_snapshot()") + && STARTUP_PAGE.contains("self.agent.account_login(") + && STARTUP_PAGE.contains("self.agent.account_finalize_login(") + && STARTUP_PAGE.contains("self.agent.settings_sync_start(") + && STARTUP_PAGE.contains("self.agent.settings_sync_snapshot()") + && STARTUP_PAGE.contains("self.agent.settings_sync_cancel()"), + "startup account and settings-sync operations must use the typed TUI client" ); assert!( !CORE_RUNTIME_SERVICES.contains("pub fn persistence_manager"), @@ -200,6 +210,26 @@ fn remaining_cli_local_persistence_stays_behind_explicit_owner_boundaries() { ); } +#[test] +fn embedded_account_management_adapts_the_shared_runtime_directly() { + const EMBEDDED_APP_SERVER: &str = include_str!("../../src/embedded_app_server.rs"); + const CLI_MAIN: &str = include_str!("../../src/main.rs"); + const MANAGEMENT: &str = + include_str!("../../../../crates/interfaces/app-server/src/management.rs"); + const MANAGEMENT_SERVICE: &str = + include_str!("../../../../crates/interfaces/app-server/src/management/service.rs"); + + assert!( + EMBEDDED_APP_SERVER.contains("runtime.account_runtime().clone()") + && MANAGEMENT_SERVICE.contains("Option>") + && MANAGEMENT_SERVICE.contains("login_with_credentials") + && !MANAGEMENT.contains("AccountManagementHost") + && !CLI_MAIN.contains("mod tui_account_management") + && !CLI_MAIN.contains("mod account_sync"), + "Embedded account management must adapt AccountRuntime without a management Host trait" + ); +} + #[test] fn peer_session_control_and_usage_persistence_use_runtime_sdk() { const PEER_SESSION_COMMANDS: &str = include_str!("../../src/peer_host/commands/session.rs"); @@ -219,10 +249,19 @@ fn peer_session_control_and_usage_persistence_use_runtime_sdk() { "Peer Host session control must route {sdk_operation} through the Runtime SDK" ); } - assert!( - CHAT_SELECTION.contains("record_completed_local_command_turn") - && !CHAT_SELECTION.contains("append_completed_local_command_turn"), - "TUI usage persistence must use the fixed-semantics Runtime SDK port" + // Inverted, along with the behaviour it described. `/usage` renders into + // the conversation view and writes nothing: a report about a session is not + // an event in it, and the Turn this used to persist was loaded back by the + // desktop and given a numbered slot in its Turn rail. `add_assistant_message` + // is the UI-only path — `turn_id: None`, never persisted — and in a terminal + // the scrollback is the record. + // + // Source text only. This says the call is absent, not that nothing persists; + // a behavioural guarantee would have to come from the runtime port's own + // tests. + assert!( + !CHAT_SELECTION.contains("record_completed_local_command_turn"), + "/usage must not write a local_command Turn: it renders into the conversation view and persists nothing" ); for removed_compatibility_method in [ @@ -277,8 +316,8 @@ fn interactive_tui_session_client_uses_only_the_app_server_boundary() { TUI_BACKEND.contains("pub(crate) trait TuiBackend") && TUI_BACKEND.contains("AppServerClient") && !TUI_BACKEND.contains("bitfun_agent_runtime") - && !TUI_BACKEND.contains("bitfun_core::") - && TUI_CLIENT.contains("use crate::tui_backend::{TuiBackend, TuiBackendError};"), + && !TUI_BACKEND.contains("use bitfun_core::") + && TUI_CLIENT.contains("use crate::tui_backend::{TuiBackend, TuiBackendError"), "TuiBackend must remain CLI-local and depend only on App Server client contracts" ); for backend_operation in [ @@ -320,14 +359,13 @@ fn chat_context_reload_uses_the_same_tui_backend_as_session_operations() { } #[test] -fn tui_client_covers_interactive_permission_and_local_turn_operations() { +fn tui_client_covers_interactive_permission_operations() { const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); for sdk_operation in [ "subscribe_permission_requests", "pending_permission_requests", "respond_permission", - "record_completed_local_command_turn", ] { assert!( TUI_CLIENT.contains(sdk_operation), @@ -398,18 +436,13 @@ fn interactive_tui_agent_operations_stay_behind_app_server_backend() { && SHARED_RUNTIME.contains(".update_session_model(request)"), "Shared model updates must reuse the Runtime port through the private IPC adapter" ); - let shared_command_path = CHAT_COMMANDS - .split_once("fn handle_command(") - .expect("handle_command") - .1; assert!( - shared_command_path - .find("if self.agent.is_shared()") - .unwrap_or(usize::MAX) - < shared_command_path - .find("external_source_conflict_choices") - .expect("external source call"), - "Shared slash commands must branch before initializing Embedded external-source owners" + TUI_CLIENT.contains(".external_source_snapshot(ExternalSourceSnapshotRequest") + && TUI_CLIENT.contains(".external_source_control(ExternalSourceControlRequest") + && TUI_CLIENT.contains(".external_source_review(ExternalSourceReviewRequest") + && CHAT_COMMANDS.contains("self.agent.external_source_snapshot(false)") + && !CHAT_COMMANDS.contains("bitfun_core::external_sources"), + "TUI external-source controllers must route reads and mutations through the typed backend" ); assert!( CHAT_COMMANDS.matches("if self.agent.is_shared()").count() >= 3 @@ -424,6 +457,161 @@ fn interactive_tui_agent_operations_stay_behind_app_server_backend() { ); } +#[test] +fn interactive_tui_hook_management_stays_behind_the_typed_backend() { + const CHAT_HOOKS: &str = include_str!("../../src/modes/chat/external_hooks.rs"); + const CHAT_NATIVE_HOOKS: &str = include_str!("../../src/modes/chat/native_hooks.rs"); + const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); + const SHARED_TUI_BACKEND: &str = include_str!("../../src/shared_tui_backend.rs"); + + for operation in [ + "external_hook_snapshot", + "external_hook_plan", + "external_hook_apply", + "external_hook_mutate", + "native_hook_overview", + ] { + assert!( + TUI_CLIENT.contains(operation) && CHAT_HOOKS.contains(&format!(".{operation}(")), + "TUI Hook operation {operation} must route through TuiAgentClient" + ); + } + for direct_owner in [ + "bitfun_core::external_hooks", + "bitfun_core::native_hooks", + "bitfun_core::external_hook_import", + "crate::hook_import::mutate", + ] { + assert!( + !CHAT_HOOKS.contains(direct_owner) && !CHAT_NATIVE_HOOKS.contains(direct_owner), + "TUI Hook controllers must not reference {direct_owner}" + ); + } + assert!( + CHAT_HOOKS.contains("expected_revision") + && SHARED_TUI_BACKEND.contains("NATIVE_HOOKS_CAPABILITY") + && SHARED_TUI_BACKEND.contains("EXTERNAL_HOOKS_CAPABILITY") + && SHARED_TUI_BACKEND.contains("does not fall back"), + "Hook mutations must preserve stale-revision fencing and remote fail-closed routing" + ); + assert!( + !CHAT_HOOKS.contains("post_call_hooks") + && !CHAT_NATIVE_HOOKS.contains("post_call_hooks") + && !TUI_CLIENT.contains("post_call_hooks"), + "compiled-in post-call Hooks must not enter the TUI management API" + ); +} + +#[test] +fn interactive_tui_worktrees_stay_behind_the_typed_backend() { + const WORKTREE_CONTROLLER: &str = include_str!("../../src/modes/chat/worktree.rs"); + const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); + const TUI_BACKEND: &str = include_str!("../../src/tui_backend.rs"); + const SHARED_BACKEND: &str = include_str!("../../src/shared_tui_backend.rs"); + const WORKTREE_MANAGEMENT: &str = + include_str!("../../../../crates/interfaces/app-server/src/management/worktree.rs"); + const EMBEDDED_APP_SERVER: &str = include_str!("../../src/embedded_app_server.rs"); + const CLI_MAIN: &str = include_str!("../../src/main.rs"); + + for direct_owner in [ + "GitService", + "WorktreeService", + "WorktreeSessionBindingRequest", + "bitfun_core::", + "self.agent.is_shared()", + ] { + assert!( + !WORKTREE_CONTROLLER.contains(direct_owner), + "Worktree controller must not reference {direct_owner}" + ); + } + for operation in [ + "worktree_repository_status", + "worktree_bind_session", + "worktree_release_session", + ] { + assert!( + WORKTREE_CONTROLLER.contains(operation) + && TUI_CLIENT.contains(operation) + && TUI_BACKEND.contains(operation) + && SHARED_BACKEND.contains(operation), + "Worktree operation {operation} must stay behind the typed TUI backend" + ); + } + assert!( + WORKTREE_MANAGEMENT.contains("WorktreeService::bind_session") + && EMBEDDED_APP_SERVER.contains("load_for_local_host") + && !EMBEDDED_APP_SERVER.contains("LocalWorktreeManagement") + && !EMBEDDED_APP_SERVER.contains("tui_worktree_management"), + "the Embedded Host must enable the App Server's built-in local Worktree management" + ); + assert!( + SHARED_BACKEND.contains("WORKTREES_CAPABILITY") + && SHARED_BACKEND.contains("does not fall back") + && CLI_MAIN.contains("AppManagementService::load().await?"), + "Shared Worktree management must fail closed" + ); +} + +#[test] +fn phase4_tui_management_boundaries_have_zero_legacy_owner_budget() { + const CHAT_ACCOUNT: &str = include_str!("../../src/modes/chat/account.rs"); + const CHAT_HOOKS: &str = include_str!("../../src/modes/chat/external_hooks.rs"); + const CHAT_HOOK_REVIEW: &str = include_str!("../../src/modes/chat/external_review.rs"); + const CHAT_PROVIDER_MODELS: &str = include_str!("../../src/modes/chat/provider_models.rs"); + const CHAT_WORKTREE: &str = include_str!("../../src/modes/chat/worktree.rs"); + const STARTUP: &str = include_str!("../../src/ui/startup.rs"); + const BOUNDARY_RULES: &str = + include_str!("../../../../../scripts/core-boundaries/rules/tui-boundary-rules.mjs"); + + for (path, source, marker) in [ + ("chat/account.rs", CHAT_ACCOUNT, "crate::account::"), + ("chat/account.rs", CHAT_ACCOUNT, "crate::account_sync::"), + ("chat/external_hooks.rs", CHAT_HOOKS, "bitfun_core::"), + ("chat/external_review.rs", CHAT_HOOK_REVIEW, "bitfun_core::"), + ( + "chat/provider_models.rs", + CHAT_PROVIDER_MODELS, + "crate::account_sync::", + ), + ("chat/worktree.rs", CHAT_WORKTREE, "bitfun_core::"), + ("ui/startup.rs", STARTUP, "bitfun_core::"), + ("ui/startup.rs", STARTUP, "CoreAgentRuntimeCompatibility"), + ("ui/startup.rs", STARTUP, "crate::account::"), + ("ui/startup.rs", STARTUP, "crate::account_sync::"), + ] { + assert!( + !source.contains(marker), + "{path} must not reference {marker}" + ); + } + + for budget in [ + "'src/apps/cli/src/modes/chat/account.rs': {", + "'src/apps/cli/src/modes/chat/external_hooks.rs': { 'bitfun_core::': 0 }", + "'src/apps/cli/src/modes/chat/external_review.rs': { 'bitfun_core::': 0 }", + "'src/apps/cli/src/modes/chat/provider_models.rs': {", + "'src/apps/cli/src/modes/chat/worktree.rs': { 'bitfun_core::': 0 },", + "'src/apps/cli/src/ui/startup.rs': {", + ] { + assert!( + BOUNDARY_RULES.contains(budget), + "missing zero-budget rule: {budget}" + ); + } + for zero_budget in [ + "'crate::account::': 0", + "'crate::account_sync::': 0", + "'bitfun_core::': 0", + "CoreAgentRuntimeCompatibility: 0", + ] { + assert!( + BOUNDARY_RULES.contains(zero_budget), + "Phase 4 migrated owner budget must stay at zero: {zero_budget}" + ); + } +} + #[test] fn runtime_ownership_policy_is_assembled_once_in_core() { const SHARED_RUNTIME: &str = include_str!("../../src/shared_runtime.rs"); diff --git a/src/apps/cli/tests/support/mod.rs b/src/apps/cli/tests/support/mod.rs index b8c634b31c..03fcd71dc5 100644 --- a/src/apps/cli/tests/support/mod.rs +++ b/src/apps/cli/tests/support/mod.rs @@ -303,6 +303,7 @@ enum MockModelResponse { Gated, Http403 { reason: String }, DisconnectThenHttp403, + MalformedSseThenImmediate, } impl MockOpenAiServer { @@ -324,6 +325,10 @@ impl MockOpenAiServer { Self::spawn(MockModelResponse::DisconnectThenHttp403) } + pub(crate) fn malformed_sse_then_immediate() -> Self { + Self::spawn(MockModelResponse::MalformedSseThenImmediate) + } + pub(crate) fn base_url(&self) -> &str { &self.base_url } @@ -405,11 +410,14 @@ impl MockOpenAiServer { &disconnect_tx, ); attempt += 1; - if matches!( - response, - MockModelResponse::Http403 { .. } - | MockModelResponse::DisconnectThenHttp403 - ) { + let accepts_more_requests = + matches!( + response, + MockModelResponse::Http403 { .. } + | MockModelResponse::DisconnectThenHttp403 + ) || (matches!(response, MockModelResponse::MalformedSseThenImmediate) + && attempt < 2); + if accepts_more_requests { continue; } break; @@ -473,6 +481,13 @@ fn serve_model_response( ) .expect("write mock response headers"); + if matches!(response, MockModelResponse::MalformedSseThenImmediate) && attempt == 0 { + write_chunk(stream, b"data: not-json\n\n").expect("write malformed SSE frame"); + let _ = stream.write_all(b"0\r\n\r\n"); + let _ = stream.flush(); + return; + } + write_sse_chunk( stream, &json!({ @@ -579,7 +594,7 @@ fn write_http_403(stream: &mut TcpStream, reason: &str) -> std::io::Result<()> { .to_string(); write!( stream, - "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nRetry-After: 1\r\nConnection: close\r\n\r\n{body}", body.len() )?; stream.flush() diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 9ac1b7f6fa..6ee211984c 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -19,18 +19,18 @@ serde_json = { workspace = true } [dependencies] # Internal crates -bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = ["product-full"] } +bitfun-core = { path = "../../crates/assembly/core", features = ["product-full"] } bitfun-relay-service = { path = "../../crates/services/relay-service" } bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } -bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" } -bitfun-product-domains = { path = "../../crates/contracts/product-domains", default-features = false, features = ["appearance-market"] } -bitfun-services-integrations = { path = "../../crates/services/services-integrations", default-features = false, features = ["canvas-runtime", "miniapp-market", "speech"] } +bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports", features = ["agent-api", "permission", "workspace-ports"] } +bitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["appearance-market"] } +bitfun-services-integrations = { path = "../../crates/services/services-integrations", features = ["canvas-runtime", "miniapp-market", "speech"] } bitfun-core-types = { path = "../../crates/contracts/core-types" } -bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" } +bitfun-agent-tools = { path = "../../crates/execution/tool-contracts", features = ["element-token"] } bitfun-transport = { path = "../../crates/adapters/transport", features = ["tauri-adapter"] } bitfun-events = { path = "../../crates/contracts/events" } bitfun-webdriver = { path = "../../crates/adapters/webdriver" } -bitfun-acp = { path = "../../crates/interfaces/acp" } +bitfun-acp = { path = "../../crates/interfaces/acp", default-features = false, features = ["client"] } # Tauri tauri = { workspace = true } @@ -41,7 +41,6 @@ tauri-plugin-log = { workspace = true } tauri-plugin-autostart = { workspace = true } tauri-plugin-notification = { workspace = true } tauri-plugin-updater = { workspace = true } -tauri-plugin-global-shortcut = { workspace = true } tauri-plugin-single-instance = { workspace = true } tauri-plugin-window-state = { workspace = true } keepawake = { workspace = true } @@ -66,7 +65,7 @@ dark-light = { workspace = true } similar = { workspace = true } ignore = { workspace = true } urlencoding = { workspace = true } -reqwest = { workspace = true, features = ["rustls"] } +reqwest = { workspace = true, features = ["http2", "json", "query", "rustls", "stream"] } semver = { workspace = true } zip = { workspace = true } tar = { workspace = true } @@ -87,6 +86,9 @@ image = { workspace = true } resvg = { workspace = true } tempfile = { workspace = true } +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } + [target.'cfg(target_os = "macos")'.dependencies] bitflags = { workspace = true } core-foundation = { workspace = true } @@ -95,9 +97,9 @@ dispatch = { workspace = true } foreign-types = { workspace = true } libc = { workspace = true } objc2 = { workspace = true, features = ["exception"] } -objc2-foundation = { workspace = true } +objc2-foundation = { workspace = true, features = ["std", "NSArray", "NSData", "NSDictionary", "NSError", "NSString"] } objc2-app-kit = { workspace = true } -objc2-vision = { workspace = true } +objc2-vision = { workspace = true, features = ["std", "VNRecognizeTextRequest", "VNRequest", "VNObservation", "VNRequestHandler", "VNUtils", "VNTypes", "objc2-core-foundation"] } [target.'cfg(windows)'.dependencies] win32job = { workspace = true } diff --git a/src/apps/desktop/Info.plist b/src/apps/desktop/Info.plist new file mode 100644 index 0000000000..f455508c85 --- /dev/null +++ b/src/apps/desktop/Info.plist @@ -0,0 +1,8 @@ + + + + + NSMicrophoneUsageDescription + BitFun uses the microphone for voice input and local speech transcription. + + diff --git a/src/apps/desktop/build.rs b/src/apps/desktop/build.rs index 261851f6b6..8232623697 100644 --- a/src/apps/desktop/build.rs +++ b/src/apps/desktop/build.rs @@ -1,3 +1,11 @@ fn main() { + println!("cargo:rerun-if-env-changed=BITFUN_RELEASE_CHANNEL"); + println!("cargo:rerun-if-env-changed=BITFUN_UPDATER_PRIMARY_ENDPOINT"); + println!("cargo:rerun-if-env-changed=BITFUN_UPDATER_FALLBACK_ENDPOINT"); + // The Windows primary thread keeps the Tauri event loop and native window + // creation stack. Reserve the same headroom as the Tokio workers so a + // large debug invoke dispatcher cannot exhaust the default 1 MiB stack. + #[cfg(target_os = "windows")] + println!("cargo:rustc-link-arg-bins=/STACK:8388608"); tauri_build::build(); } diff --git a/src/apps/desktop/capabilities/default.json b/src/apps/desktop/capabilities/default.json index ff6229e4b7..2ecc97db95 100644 --- a/src/apps/desktop/capabilities/default.json +++ b/src/apps/desktop/capabilities/default.json @@ -105,11 +105,6 @@ "notification:allow-request-permission", "notification:allow-check-permissions", "notification:allow-permission-state", - "notification:allow-is-permission-granted", - "global-shortcut:default", - "global-shortcut:allow-register", - "global-shortcut:allow-unregister", - "global-shortcut:allow-unregister-all", - "global-shortcut:allow-is-registered" + "notification:allow-is-permission-granted" ] } diff --git a/src/apps/desktop/dmg/background.png b/src/apps/desktop/dmg/background.png new file mode 100644 index 0000000000..6b513100b3 Binary files /dev/null and b/src/apps/desktop/dmg/background.png differ diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 52d4ec3532..1c1f59b32c 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -17,8 +17,8 @@ use crate::startup_trace::DesktopStartupTrace; use bitfun_agent_runtime::deep_review::sanitize_focused_review_public_metadata; use bitfun_agent_runtime::sdk::{ AgentDialogSteerRequest, AgentDialogTurnExecution, AgentDialogTurnRequest, - AgentInputAttachment, AgentSessionCreateResult, AgentSessionModelSelection, - AgentSessionModeUpdateRequest, AgentSessionModelSelectionUpdateRequest, + AgentInputAttachment, AgentSessionCreateResult, AgentSessionModeUpdateRequest, + AgentSessionModelSelection, AgentSessionModelSelectionUpdateRequest, AgentSessionModelUpdateRequest, AgentSubmissionSource, AgentTurnCancellationRequest, DialogSteerOutcome, PermissionAuditRecord, PermissionGrant, PermissionGrantKey, PermissionReply, PermissionRequest, @@ -54,7 +54,7 @@ use bitfun_core::service::config::project_permission_store::{ use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_core::service::remote_ssh::workspace_state::resolve_workspace_session_identity; use bitfun_core::service::session::{ - DialogTurnData, SessionMemoryMode, SessionMetadata, SessionRelationship, + DialogTurnData, SessionContextUsage, SessionMemoryMode, SessionMetadata, SessionRelationship, SessionRelationshipKind, SessionTurnCatalog, SessionTurnWindowResponse, }; use bitfun_core::service::workspace::WorkspaceKind; @@ -65,7 +65,7 @@ use bitfun_core_types::{ WorktreeError, WorktreeErrorCode, }; use bitfun_product_domains::tool_permissions::PermissionRule; -use bitfun_runtime_ports::SessionTurnWindowRequest; +use bitfun_runtime_ports::{PermissionMode, SessionTurnWindowRequest}; const SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET: usize = 512 * 1024; const SESSION_VIEW_TOOL_RESULT_STRING_CHAR_LIMIT: usize = 16 * 1024; @@ -243,6 +243,31 @@ pub struct UpdateSessionModelRequest { pub include_internal: bool, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSessionPermissionModeRequest { + pub session_id: String, + /// `None` clears the session override so the session follows the + /// user-level default again, including later changes to that default. + #[serde(default)] + pub mode: Option, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, + #[serde(default)] + pub include_internal: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionPermissionModeResponse { + /// The session's own selection, or `null` when it follows the default. + pub mode: Option, +} + fn deserialize_present_nullable<'de, D, T>(deserializer: D) -> Result>, D::Error> where D: serde::Deserializer<'de>, @@ -502,6 +527,7 @@ pub struct RestoreSessionWithTurnsResponse { pub struct RestoreSessionViewResponse { pub session: SessionResponse, pub turns: Vec, + pub current_context_usage: Option, pub turn_catalog: SessionTurnCatalog, pub context_restore_state: String, pub is_partial: bool, @@ -725,6 +751,14 @@ pub struct SteerDialogTurnRequest { /// Original user text for UI rendering (defaults to `content`). #[serde(default)] pub display_content: Option, + /// Images attached to the steering message. Same shape the composer sends + /// when it starts a turn — a message keeps its attachments whether it is + /// submitted at a turn boundary or injected into a running turn. + #[serde(default)] + pub image_contexts: Option>, + /// Structured metadata carried with the steering message. + #[serde(default)] + pub user_message_metadata: Option, } #[derive(Debug, Serialize)] @@ -1798,6 +1832,78 @@ pub async fn update_session_model( .map_err(|error| format!("Failed to update session model: {}", error.into_message())) } +/// Sets the tool permission mode this session runs with. +/// +/// The mode is a per-session selector, so switching it in one conversation +/// leaves every other open session on its own selection. Passing no mode clears +/// the override and returns the session to the user-level default. +#[tauri::command] +pub async fn update_session_permission_mode( + runtime: State<'_, DesktopRuntimeContext>, + coordinator: State<'_, Arc>, + request: UpdateSessionPermissionModeRequest, +) -> Result { + let session_id = request.session_id.trim().to_string(); + if session_id.is_empty() { + return Err("session_id is required".to_string()); + } + let mode = match request.mode.as_deref().map(str::trim) { + None | Some("") => None, + Some(value) => Some( + PermissionMode::parse(value) + .ok_or_else(|| format!("unsupported permission mode: {value}"))?, + ), + }; + + ensure_session_loaded_for_selector_update( + runtime.inner(), + &session_id, + request.workspace_path, + request.remote_connection_id, + request.remote_ssh_host, + request.include_internal, + ) + .await?; + + coordinator + .get_session_manager() + .update_session_permission_mode(&session_id, mode) + .await + .map_err(|error| format!("Failed to update session permission mode: {error}"))?; + + Ok(SessionPermissionModeResponse { mode }) +} + +/// Reads the session's own permission mode selection. +/// +/// `null` means the session never chose one and follows the user-level default. +#[tauri::command] +pub async fn get_session_permission_mode( + runtime: State<'_, DesktopRuntimeContext>, + coordinator: State<'_, Arc>, + request: UpdateSessionPermissionModeRequest, +) -> Result { + let session_id = request.session_id.trim().to_string(); + if session_id.is_empty() { + return Err("session_id is required".to_string()); + } + ensure_session_loaded_for_selector_update( + runtime.inner(), + &session_id, + request.workspace_path, + request.remote_connection_id, + request.remote_ssh_host, + request.include_internal, + ) + .await?; + + Ok(SessionPermissionModeResponse { + mode: coordinator + .get_session_manager() + .session_permission_mode(&session_id), + }) +} + async fn ensure_session_loaded_for_selector_update( runtime: &DesktopRuntimeContext, session_id: &str, @@ -2651,13 +2757,24 @@ pub async fn steer_dialog_turn( dialog_turn_id, content, display_content, + image_contexts, + user_message_metadata, } = request; - let trimmed = content.trim(); - if trimmed.is_empty() { + let attachments: Vec = image_contexts + .unwrap_or_default() + .into_iter() + .map(desktop_image_attachment) + .collect(); + + // An image-only steering message is a real message; only a message with + // neither text nor attachments is empty. + if content.trim().is_empty() && attachments.is_empty() { return Err("Steering content cannot be empty".to_string()); } + let metadata = desktop_user_message_metadata(user_message_metadata); + let outcome = runtime .agent_runtime() .steer_dialog_turn(AgentDialogSteerRequest { @@ -2665,6 +2782,8 @@ pub async fn steer_dialog_turn( turn_id: dialog_turn_id, content, display_content, + attachments, + metadata, }) .await .map_err(|error| format!("Failed to steer dialog turn: {}", error.into_message()))?; @@ -3072,6 +3191,7 @@ pub async fn restore_session_view( .map_err(|error| format!("Failed to restore session view: {error}"))?; let session = restored.session; let mut turns = restored.turns; + let current_context_usage = restored.current_context_usage; let total_turn_count = restored.total_turn_count; let turn_catalog = restored.turn_catalog; let timings = restored.timings; @@ -3124,6 +3244,7 @@ pub async fn restore_session_view( Ok(RestoreSessionViewResponse { session: session_to_response_with_turn_count(session, total_turn_count), turns, + current_context_usage, turn_catalog, context_restore_state: "pending".to_string(), is_partial, diff --git a/src/apps/desktop/src/api/browser_control_api.rs b/src/apps/desktop/src/api/browser_control_api.rs index 1a7731a4d5..797be73d6b 100644 --- a/src/apps/desktop/src/api/browser_control_api.rs +++ b/src/apps/desktop/src/api/browser_control_api.rs @@ -18,6 +18,70 @@ fn default_cdp_port() -> u16 { DEFAULT_CDP_PORT } +/// Reattach to a browser that is already running with remote debugging on. +/// +/// The browser remembers the remote debugging preference across its own +/// restarts, and it keeps an approved connection grant for as long as it stays +/// running — but BitFun's connection registry lives in this process, so every +/// BitFun restart otherwise leaves Settings reporting "not connected" until +/// something asks for the browser. Reattaching here restores that connection +/// without the user having to click anything. +/// +/// Opt-in, because the grant does not survive a browser restart: after one, +/// reattaching raises an approval dialog before the user has asked for the +/// browser at all. +/// +/// This never starts a browser and never opens a settings page: when there is +/// no live endpoint to reattach to, it does nothing and leaves the on-demand +/// path to handle it. +pub fn init_on_startup() { + tokio::spawn(async { + if !auto_connect_on_startup_enabled().await { + return; + } + let Ok(kind) = selected_browser_kind().await else { + return; + }; + let Some(endpoint) = BrowserLauncher::user_profile_debug_endpoint(&kind) else { + return; + }; + if CdpClient::browser_connection_for_kind(DEFAULT_CDP_PORT, &kind) + .await + .is_some() + { + return; + } + // A denial or an approval timeout is an ordinary outcome here, not an + // error worth surfacing: the user never asked for this connection. + match CdpClient::connect_user_profile_browser( + DEFAULT_CDP_PORT, + endpoint.port, + &kind, + &endpoint.web_socket_url, + ) + .await + { + Ok(_) => log::info!("Reattached to the running {} profile on startup", kind), + Err(error) => log::info!( + "Could not reattach to the running {} profile on startup: {}", + kind, + error + ), + } + }); +} + +async fn auto_connect_on_startup_enabled() -> bool { + let Ok(service) = get_global_config_service().await else { + return false; + }; + service + .get_config::(None) + .await + .map(|config| config.ai.browser_control_auto_connect_on_startup) + .unwrap_or(false) +} + async fn selected_browser_kind() -> Result { let config = get_global_config_service() .await @@ -91,6 +155,12 @@ pub async fn browser_control_list_browsers() -> Result, pub port: u16, @@ -103,11 +173,48 @@ pub async fn browser_control_get_status( request: BrowserControlStatusRequest, ) -> Result { let port = request.port; - let available = BrowserLauncher::is_cdp_available(port).await; let configured_kind = selected_browser_kind().await?; + let default_cdp_supported = BrowserLauncher::supports_default_cdp(&configured_kind); + // Probe the live endpoint once and answer both questions from it: whether + // the persistent setting is on, and whether there is something to attach to + // right now. The probe is a file read plus a short local TCP connect, so it + // never prompts the browser the way attaching does. + let user_profile_endpoint = BrowserLauncher::user_profile_debug_endpoint(&configured_kind); + let default_cdp_enabled = default_cdp_supported + && (user_profile_endpoint.is_some() + || BrowserLauncher::is_default_cdp_enabled(&configured_kind)); + let user_profile_connection = + CdpClient::browser_connection_for_kind(port, &configured_kind).await; + let legacy_version = + if user_profile_connection.is_none() && BrowserLauncher::is_cdp_available(port).await { + CdpClient::get_version(port).await.ok() + } else { + None + }; + // Chrome and Edge share the logical 9222 slot in Settings. Do not report + // the selected browser as connected merely because the other one owns a + // legacy fixed-port endpoint left from an earlier selection. + let legacy_matches_selection = legacy_version.as_ref().is_some_and(|version| { + let detected = version + .browser + .as_deref() + .and_then(BrowserLauncher::browser_kind_from_cdp_version); + match &configured_kind { + BrowserKind::Chrome | BrowserKind::Edge => { + detected.map(|kind| kind == configured_kind).unwrap_or(true) + } + _ => true, + } + }); + let available = user_profile_connection.is_some() || legacy_matches_selection; + let browser_ready = available || user_profile_endpoint.is_some(); let (version, page_count, actual_kind) = if available { - let ver_info = CdpClient::get_version(port).await.ok(); + let ver_info = if let Some(connection) = &user_profile_connection { + connection.client.browser_version().await.ok() + } else { + legacy_version + }; let ver = ver_info.as_ref().and_then(|v| v.browser.clone()); // Identify the actual browser from CDP version response. let kind = ver @@ -116,15 +223,17 @@ pub async fn browser_control_get_status( .unwrap_or_else(|| configured_kind.clone()); // Only count targets of type "page" (real browser tabs), // not service workers, browser targets, etc. - let pages = CdpClient::list_pages(port) - .await - .ok() - .map(|p| { - p.iter() - .filter(|t| t.page_type.as_deref() == Some("page")) - .count() - }) - .unwrap_or(0); + let pages = if let Some(connection) = &user_profile_connection { + connection.client.browser_pages().await.ok() + } else { + CdpClient::list_pages(port).await.ok() + } + .map(|p| { + p.iter() + .filter(|t| t.page_type.as_deref() == Some("page")) + .count() + }) + .unwrap_or(0); (ver, pages, kind) } else { (None, 0, configured_kind) @@ -132,6 +241,9 @@ pub async fn browser_control_get_status( Ok(BrowserControlStatusResponse { cdp_available: available, + default_cdp_supported, + default_cdp_enabled, + browser_ready, browser_kind: actual_kind.to_string(), browser_version: version, port, @@ -153,6 +265,10 @@ pub struct BrowserControlLaunchResponse { pub status: String, pub message: Option, pub browser_kind: String, + /// Remote debugging settings URL, sent when the user has to open it + /// themselves because the platform cannot open a `chrome://` URL for them. + #[serde(skip_serializing_if = "Option::is_none")] + pub setup_url: Option, } fn to_launch_response(kind: &BrowserKind, result: LaunchResult) -> BrowserControlLaunchResponse { @@ -162,18 +278,47 @@ fn to_launch_response(kind: &BrowserKind, result: LaunchResult) -> BrowserContro status: "already_connected".into(), message: None, browser_kind: kind.to_string(), + setup_url: None, }, LaunchResult::Launched => BrowserControlLaunchResponse { success: true, status: "launched".into(), message: None, browser_kind: kind.to_string(), + setup_url: None, + }, + LaunchResult::UserProfileReady { .. } => BrowserControlLaunchResponse { + success: false, + status: "user_profile_ready".into(), + message: None, + browser_kind: kind.to_string(), + setup_url: None, + }, + LaunchResult::UserProfileSetupRequired { + instructions, + setup_url, + opened, + .. + } => BrowserControlLaunchResponse { + success: false, + // The two cases need different guidance: one asks the user to + // finish on a page that is already in front of them, the other + // asks them to open that page first. + status: if opened { + "requires_user_profile_setup".into() + } else { + "requires_manual_user_profile_setup".into() + }, + message: Some(instructions), + browser_kind: kind.to_string(), + setup_url: Some(setup_url), }, LaunchResult::LaunchedButCdpNotReady { message, .. } => BrowserControlLaunchResponse { success: false, status: "cdp_not_ready".into(), message: Some(message), browser_kind: kind.to_string(), + setup_url: None, }, LaunchResult::BrowserRunningWithoutCdp { instructions, .. } => { BrowserControlLaunchResponse { @@ -181,11 +326,47 @@ fn to_launch_response(kind: &BrowserKind, result: LaunchResult) -> BrowserContro status: "needs_restart".into(), message: Some(instructions), browser_kind: kind.to_string(), + setup_url: None, } } } } +async fn complete_launch( + kind: &BrowserKind, + logical_port: u16, + result: LaunchResult, +) -> Result { + match result { + LaunchResult::UserProfileReady { endpoint } => { + let connection = CdpClient::connect_user_profile_browser( + logical_port, + endpoint.port, + kind, + &endpoint.web_socket_url, + ) + .await; + if let Err(error) = connection { + return Ok(BrowserControlLaunchResponse { + success: false, + status: "user_profile_connection_failed".into(), + message: Some(error.to_string()), + browser_kind: kind.to_string(), + setup_url: None, + }); + } + Ok(BrowserControlLaunchResponse { + success: true, + status: "connected_user_profile".into(), + message: None, + browser_kind: kind.to_string(), + setup_url: None, + }) + } + other => Ok(to_launch_response(kind, other)), + } +} + /// Launch the user's default browser with CDP debug port. #[tauri::command] pub async fn browser_control_launch( @@ -194,11 +375,64 @@ pub async fn browser_control_launch( let port = request.port; let kind = selected_browser_kind().await?; + if CdpClient::browser_connection_for_kind(port, &kind) + .await + .is_some() + { + return Ok(to_launch_response(&kind, LaunchResult::AlreadyConnected)); + } + + // The logical port is shared across browser choices. Drop only the lookup + // entry when the user switches browsers; any already-attached page session + // keeps its transport alive, but new actions cannot accidentally reuse it. + if CdpClient::browser_connection(port).await.is_some() { + CdpClient::remove_browser_connection(port).await; + } + let result = BrowserLauncher::launch_with_cdp(&kind, port) .await .map_err(|e| e.to_string())?; - Ok(to_launch_response(&kind, result)) + complete_launch(&kind, port, result).await +} + +/// Open the selected browser's persistent guarded-CDP setting and wait for the +/// user-owned consent toggle. Once enabled, immediately request and retain the +/// real-profile connection so the Settings action is one continuous flow. +#[tauri::command] +pub async fn browser_control_enable_default_cdp( + request: BrowserControlLaunchRequest, +) -> Result { + let port = request.port; + let kind = selected_browser_kind().await?; + + if !BrowserLauncher::supports_default_cdp(&kind) { + return Ok(BrowserControlLaunchResponse { + success: false, + status: "default_cdp_unsupported".into(), + message: Some(format!( + "{} does not expose a supported persistent guarded-CDP setting", + kind + )), + browser_kind: kind.to_string(), + setup_url: None, + }); + } + + if CdpClient::browser_connection_for_kind(port, &kind) + .await + .is_some() + { + return Ok(to_launch_response(&kind, LaunchResult::AlreadyConnected)); + } + if CdpClient::browser_connection(port).await.is_some() { + CdpClient::remove_browser_connection(port).await; + } + + let result = BrowserLauncher::enable_default_cdp(&kind, port) + .await + .map_err(|e| e.to_string())?; + complete_launch(&kind, port, result).await } /// Restart the user's default browser with CDP debug port enabled. @@ -213,19 +447,5 @@ pub async fn browser_control_restart_with_cdp( .await .map_err(|e| e.to_string())?; - Ok(to_launch_response(&kind, result)) -} - -/// Create a macOS .app wrapper for the browser with CDP enabled. -#[tauri::command] -pub async fn browser_control_create_launcher() -> Result { - #[cfg(target_os = "macos")] - { - let kind = selected_browser_kind().await?; - BrowserLauncher::create_cdp_launcher_app(&kind, DEFAULT_CDP_PORT).map_err(|e| e.to_string()) - } - #[cfg(not(target_os = "macos"))] - { - Err("CDP launcher app creation is only supported on macOS".into()) - } + complete_launch(&kind, port, result).await } diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 8cf602f812..2f11db7f69 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -1156,9 +1156,14 @@ pub async fn initialize_ai(state: State<'_, AppState>) -> Result let ai_config = bitfun_core::util::types::AIConfig::try_from(model_config.clone()) .map_err(|e| format!("Failed to convert AI configuration: {}", e))?; + let proxy_config = if global_config.ai.proxy.enabled { + Some(global_config.ai.proxy.clone()) + } else { + None + }; let ai_client = bitfun_core::infrastructure::ai::AIClient::new_with_runtime_options( ai_config, - None, + proxy_config, stream_options, ); @@ -1193,16 +1198,26 @@ async fn create_transient_ai_client_for_config( let mut ai_config: bitfun_core::util::types::AIConfig = model_config .try_into() .map_err(|e| format!("Failed to convert configuration: {}", e))?; - - bitfun_core::infrastructure::ai::client_factory::apply_subscription_auth(&auth, &mut ai_config) - .await - .map_err(|e| format!("Failed to resolve subscription auth: {}", e))?; + let skip_ssl_verify = ai_config.skip_ssl_verify; let proxy_config = if global_config.ai.proxy.enabled { Some(global_config.ai.proxy.clone()) } else { None }; + let subscription_options = + bitfun_core::infrastructure::subscription_auth::SubscriptionHttpOptions::new( + proxy_config.clone(), + skip_ssl_verify, + ); + + bitfun_core::infrastructure::ai::client_factory::apply_subscription_auth_with_options( + &auth, + &mut ai_config, + &subscription_options, + ) + .await + .map_err(|e| format!("Failed to resolve subscription auth: {}", e))?; Ok( bitfun_core::infrastructure::ai::AIClient::new_with_runtime_options( @@ -5157,6 +5172,36 @@ pub async fn get_ai_model_catalog() -> Result bitfun_core_types::ReasoningCatalogProjection { + bitfun_core::project_ai_model_reasoning_catalog(request).await +} + +#[tauri::command] +pub async fn get_models_dev_catalog_status() -> bitfun_core_types::ModelsDevCatalogStatus { + bitfun_core::get_models_dev_catalog_status().await +} + +#[tauri::command] +pub async fn refresh_models_dev_catalog_now( +) -> Result { + bitfun_core::refresh_models_dev_catalog_now().await +} + +#[tauri::command] +pub async fn reveal_models_dev_cache_directory() -> Result<(), String> { + let status = bitfun_core::get_models_dev_catalog_status().await; + let cache_path = std::path::PathBuf::from(&status.cache_path); + let directory = cache_path + .parent() + .ok_or_else(|| "Models.dev cache directory is unavailable".to_string())?; + std::fs::create_dir_all(directory) + .map_err(|error| format!("Failed to create models.dev cache directory: {error}"))?; + reveal_local_path_in_explorer(directory, &directory.to_string_lossy()) +} + #[derive(Debug, Deserialize)] pub struct IdeControlResultRequest { pub request_id: String, @@ -5208,6 +5253,22 @@ pub struct SubscriptionLoginRequest { pub session_id: String, } +async fn configured_ai_proxy( + state: &State<'_, AppState>, +) -> Result, String> { + let global_config: bitfun_core::service::config::GlobalConfig = state + .config_service + .get_config(None) + .await + .map_err(|e| format!("Failed to get configuration: {}", e))?; + + Ok(global_config + .ai + .proxy + .enabled + .then_some(global_config.ai.proxy)) +} + #[tauri::command] pub async fn list_subscription_accounts( ) -> Result, String> { @@ -5216,11 +5277,18 @@ pub async fn list_subscription_accounts( #[tauri::command] pub async fn start_subscription_login( + state: State<'_, AppState>, request: SubscriptionLoginRequest, ) -> Result { - bitfun_core::infrastructure::subscription_auth::start_login( + let proxy_config = configured_ai_proxy(&state).await?; + let options = bitfun_core::infrastructure::subscription_auth::SubscriptionHttpOptions::new( + proxy_config, + false, + ); + bitfun_core::infrastructure::subscription_auth::start_login_with_options( request.provider, request.session_id, + options, ) .await .map_err(|e| format!("Failed to start subscription login: {e:#}")) @@ -5259,9 +5327,18 @@ pub async fn logout_subscription_account( #[tauri::command] pub async fn refresh_subscription_account( + state: State<'_, AppState>, request: SubscriptionProviderRequest, ) -> Result { - bitfun_core::infrastructure::subscription_auth::refresh_account(request.provider) - .await - .map_err(|e| format!("Failed to refresh subscription account: {e:#}")) + let proxy_config = configured_ai_proxy(&state).await?; + let options = bitfun_core::infrastructure::subscription_auth::SubscriptionHttpOptions::new( + proxy_config, + false, + ); + bitfun_core::infrastructure::subscription_auth::refresh_account_with_options( + request.provider, + &options, + ) + .await + .map_err(|e| format!("Failed to refresh subscription account: {e:#}")) } diff --git a/src/apps/desktop/src/api/config_api.rs b/src/apps/desktop/src/api/config_api.rs index 8485f94ad3..c2f71aabf8 100644 --- a/src/apps/desktop/src/api/config_api.rs +++ b/src/apps/desktop/src/api/config_api.rs @@ -2,6 +2,7 @@ use crate::api::app_state::AppState; use crate::startup_trace::DesktopStartupTrace; +use bitfun_core::service::config::{SaveCloudSpeechConfigRequest, SaveCloudSpeechConfigResult}; use bitfun_core::util::errors::BitFunError; use log::{error, info}; use serde::{Deserialize, Serialize}; @@ -224,6 +225,31 @@ pub async fn set_config( result } +#[tauri::command] +pub async fn save_cloud_speech_config( + state: State<'_, AppState>, + request: SaveCloudSpeechConfigRequest, +) -> Result { + match state.config_service.save_cloud_speech_config(request).await { + Ok(result) => { + state.ai_client_factory.invalidate_cache(); + crate::api::remote_connect_api::notify_settings_changed(); + info!( + "Cloud speech configuration saved atomically: model_id={}, created={}", + result.model_id, result.created + ); + Ok(result) + } + Err(error) => { + error!("Failed to save cloud speech configuration: {}", error); + Err(format!( + "Failed to save cloud speech configuration: {}", + error + )) + } + } +} + #[tauri::command] pub async fn reset_config( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/api/event_coalescer.rs b/src/apps/desktop/src/api/event_coalescer.rs new file mode 100644 index 0000000000..6cec99e775 --- /dev/null +++ b/src/apps/desktop/src/api/event_coalescer.rs @@ -0,0 +1,707 @@ +//! Time-window coalescing of streamed text chunks before transport emit. +//! +//! The agent stream emits one `TextChunk` / `ThinkingChunk` event per provider +//! chunk ([`bitfun_events::AgenticEvent`]). Forwarding every chunk to the +//! WebView costs one Tauri IPC message (JSON serialization, WebView2 boundary +//! crossing, JS parse + dispatch) and, when peer devices are attached, one +//! end-to-end encrypted relay message. This module merges chunks of the same +//! stream (session / turn / round / attempt / contentType) within a short +//! window so the frontend still receives content-equivalent events at a +//! fraction of the message rate. +//! +//! Semantics: +//! - Text chunks accumulate by appending; thinking chunks append and OR their +//! `is_end` flag. +//! - A non-chunk event flushes all pending merged events first, then passes +//! through unchanged, so text always precedes completion / error / +//! cancellation for the same stream. +//! - Nothing is dropped: the merged payload is identical to what the frontend +//! would have accumulated itself. +//! - Buffering is per stream, so concurrently streaming sessions do not flush +//! each other's pending text. +//! - Merged events are delivered in first-arrival order of their streams, so +//! the original FIFO sequence is preserved: for the same stream the producer +//! emits thinking chunks before text chunks, and `flush` therefore emits the +//! merged thinking event before the merged text event. + +use bitfun_events::AgenticEvent; +use std::collections::HashMap; +use std::time::Duration; + +/// Decide which flush deadline to keep after a batch of queued events has been +/// drained by the event loop. +/// +/// Pure scheduling decision so the arm/keep/clear rules of the 50ms coalescing +/// window are unit-testable without a live tokio task: +/// - Buffered chunks and no running deadline: arm `now + window`. +/// - Buffered chunks and a running deadline: keep the original deadline so the +/// window is not extended by a steady chunk stream. +/// - Nothing buffered: no deadline. +pub fn next_flush_deadline( + pending: bool, + deadline: Option, + now: tokio::time::Instant, + window: Duration, +) -> Option { + if pending { + Some(deadline.unwrap_or(now + window)) + } else { + None + } +} + +/// Maximum time a streamed chunk waits in the coalescer before being emitted +/// as a merged event. +pub const TEXT_CHUNK_COALESCE_WINDOW_MS: u64 = 50; + +// --------------------------------------------------------------------------- +// Rate-adaptive window +// --------------------------------------------------------------------------- +// +// The window grows with the measured stream rate so that fast streams merge +// more chunks per message (their latency is hidden by the frontend typewriter +// backlog) while slow streams keep a small window (their latency is directly +// visible as boundary stalls). The window is a throttle, not a debounce: it is +// fixed at arm time and never extended by a steady stream. + +/// Smallest window, used for slow streams (thinking phases, low-throughput +/// models). Keeps first-char latency and boundary stalls minimal. +pub const WINDOW_MIN_MS: u64 = 30; + +/// Largest window, reached only by fast streams (body text peaks). Bounds the +/// worst-case text delivery delay and the crash-loss window. +pub const WINDOW_MAX_MS: u64 = 100; + +/// Window used when the measured rate equals `WINDOW_REF_CPS`; matches the +/// previous fixed 50ms behavior at the measured median rate of a typical +/// streaming session, so average-speed streams see no regression. +pub const WINDOW_BASE_MS: u64 = 50; + +/// Reference stream rate (chars/sec) at which the window equals +/// `WINDOW_BASE_MS`. Calibrated to the measured median rate of real sessions +/// (~87 tokens/sec of Chinese text at ~0.92 chars/token). +pub const WINDOW_REF_CPS: f64 = 80.0; + +/// EMA smoothing factor applied to the measured instant rate. +const RATE_EMA_ALPHA: f64 = 0.7; + +/// A window longer than this resets the rate estimate instead of blending it; +/// used to forget the previous stream's rate after an idle gap. +const RATE_EMA_RESET_MS: u128 = 1000; + +/// Map a measured stream rate (chars/sec) to the coalescing window. +/// +/// Linear in the rate, clamped to `[WINDOW_MIN_MS, WINDOW_MAX_MS]`: +/// `window = base * (rate / ref)`. +pub fn next_window(rate_cps: f64) -> Duration { + let window_ms = WINDOW_BASE_MS as f64 * (rate_cps.max(0.0) / WINDOW_REF_CPS); + Duration::from_millis(window_ms.clamp(WINDOW_MIN_MS as f64, WINDOW_MAX_MS as f64) as u64) +} + +/// Blend a freshly measured stream rate into the EMA estimate. +/// +/// `flushed_chars` is the content emitted by one window flush and `elapsed` +/// the duration of that window. A long window (idle gap, stream restart) +/// resets the estimate to the instant rate instead of blending. +pub fn update_rate_ema(previous: f64, flushed_chars: usize, elapsed: Duration) -> f64 { + let elapsed_ms = elapsed.as_millis().max(1) as f64; + let instant_cps = flushed_chars as f64 * 1000.0 / elapsed_ms; + if elapsed.as_millis() > RATE_EMA_RESET_MS { + instant_cps + } else { + RATE_EMA_ALPHA * instant_cps + (1.0 - RATE_EMA_ALPHA) * previous + } +} + +/// Initial EMA value: the reference rate, so the very first window of a +/// session behaves exactly like the previous fixed 50ms window. +pub const INITIAL_RATE_EMA_CPS: f64 = WINDOW_REF_CPS; + +/// Stable merge key for one streaming text/thinking stream. +type ChunkStreamKey = (String, String, String, String, bool); + +fn resolve_attempt_token(attempt_id: &Option, attempt_index: Option) -> String { + if let Some(id) = attempt_id { + if !id.is_empty() { + return id.clone(); + } + } + match attempt_index { + Some(index) => format!("idx-{index}"), + None => "none".to_string(), + } +} + +enum PendingChunk { + Text { + session_id: String, + turn_id: String, + round_id: String, + attempt_id: Option, + attempt_index: Option, + text: String, + }, + Thinking { + session_id: String, + turn_id: String, + round_id: String, + attempt_id: Option, + attempt_index: Option, + content: String, + is_end: bool, + }, +} + +impl PendingChunk { + fn into_event(self) -> AgenticEvent { + match self { + PendingChunk::Text { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + text, + } => AgenticEvent::TextChunk { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + text, + }, + PendingChunk::Thinking { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + content, + is_end, + } => AgenticEvent::ThinkingChunk { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + content, + is_end, + }, + } + } +} + +/// Coalesces streamed text/thinking chunks within a short time window. +pub struct TextChunkCoalescer { + pending: HashMap, + /// First-arrival order of the buffered stream keys. Kept in sync with + /// `pending` (a key is pushed exactly when its entry is inserted) so that + /// `flush` reproduces the producer's FIFO sequence instead of reordering + /// streams by key. + order: Vec, + /// Total content characters buffered since the last flush. Used by the + /// caller to measure the stream rate for the adaptive window. + buffered_chars: usize, +} + +impl Default for TextChunkCoalescer { + fn default() -> Self { + Self::new() + } +} + +impl TextChunkCoalescer { + pub fn new() -> Self { + Self { + pending: HashMap::new(), + order: Vec::new(), + buffered_chars: 0, + } + } + + /// Whether the coalescer currently holds at least one buffered chunk. + pub fn is_pending(&self) -> bool { + !self.pending.is_empty() + } + + /// Content characters buffered since the last flush (0 once flushed). + pub fn buffered_chars(&self) -> usize { + self.buffered_chars + } + + /// Feed one event and return the events that must be delivered immediately. + /// + /// Text/thinking chunks of the same stream are buffered (an empty vector is + /// returned); a chunk of a different stream is buffered independently. Any + /// non-chunk event first flushes all pending merged events, then passes + /// through unchanged. + pub fn push(&mut self, event: AgenticEvent) -> Vec { + match event { + AgenticEvent::TextChunk { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + text, + } => { + let key = ( + session_id.clone(), + turn_id.clone(), + round_id.clone(), + resolve_attempt_token(&attempt_id, attempt_index), + false, + ); + match self.pending.get_mut(&key) { + Some(PendingChunk::Text { text: pending, .. }) => { + pending.push_str(&text); + self.buffered_chars += text.chars().count(); + Vec::new() + } + _ => { + let len = text.chars().count(); + self.pending.insert( + key.clone(), + PendingChunk::Text { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + text, + }, + ); + self.order.push(key); + self.buffered_chars += len; + Vec::new() + } + } + } + AgenticEvent::ThinkingChunk { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + content, + is_end, + } => { + let key = ( + session_id.clone(), + turn_id.clone(), + round_id.clone(), + resolve_attempt_token(&attempt_id, attempt_index), + true, + ); + match self.pending.get_mut(&key) { + Some(PendingChunk::Thinking { + content: pending, + is_end: pending_is_end, + .. + }) => { + pending.push_str(&content); + *pending_is_end |= is_end; + self.buffered_chars += content.chars().count(); + Vec::new() + } + _ => { + let len = content.chars().count(); + self.pending.insert( + key.clone(), + PendingChunk::Thinking { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + content, + is_end, + }, + ); + self.order.push(key); + self.buffered_chars += len; + Vec::new() + } + } + } + other => { + let mut events = self.flush(); + events.push(other); + events + } + } + } + + /// Emit all buffered chunks as merged events and clear the buffer. + /// + /// Merged events are emitted in first-arrival order of their streams, which + /// restores the FIFO sequence the frontend relied on: for the same stream + /// the producer emits thinking chunks before text chunks, so the merged + /// thinking event (with its OR'd `is_end`) precedes the merged text event + /// even though they buffer under separate keys. + pub fn flush(&mut self) -> Vec { + let mut events = Vec::with_capacity(self.order.len()); + for key in self.order.drain(..) { + if let Some(chunk) = self.pending.remove(&key) { + events.push(chunk.into_event()); + } + } + self.buffered_chars = 0; + events + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_chunk( + session_id: &str, + turn_id: &str, + round_id: &str, + attempt_id: Option<&str>, + attempt_index: Option, + text: &str, + ) -> AgenticEvent { + AgenticEvent::TextChunk { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + round_id: round_id.to_string(), + attempt_id: attempt_id.map(str::to_string), + attempt_index, + text: text.to_string(), + } + } + + fn thinking_chunk( + session_id: &str, + turn_id: &str, + round_id: &str, + attempt_id: Option<&str>, + attempt_index: Option, + content: &str, + is_end: bool, + ) -> AgenticEvent { + AgenticEvent::ThinkingChunk { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + round_id: round_id.to_string(), + attempt_id: attempt_id.map(str::to_string), + attempt_index, + content: content.to_string(), + is_end, + } + } + + #[test] + fn merges_same_stream_text_chunks() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, Some(1), "hello ")) + .is_empty()); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, Some(1), "world")) + .is_empty()); + + let events = coalescer.flush(); + assert_eq!(events.len(), 1); + match &events[0] { + AgenticEvent::TextChunk { text, .. } => assert_eq!(text, "hello world"), + other => panic!("expected TextChunk, got {other:?}"), + } + } + + #[test] + fn merges_same_stream_thinking_chunks_and_ors_is_end() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(thinking_chunk("s", "t", "r", None, None, "think ", false)) + .is_empty()); + assert!(coalescer + .push(thinking_chunk("s", "t", "r", None, None, "more", false)) + .is_empty()); + assert!(coalescer + .push(thinking_chunk("s", "t", "r", None, None, "", true)) + .is_empty()); + + let events = coalescer.flush(); + assert_eq!(events.len(), 1); + match &events[0] { + AgenticEvent::ThinkingChunk { + content, is_end, .. + } => { + assert_eq!(content, "think more"); + assert!(is_end); + } + other => panic!("expected ThinkingChunk, got {other:?}"), + } + } + + #[test] + fn keeps_text_and_thinking_streams_separate() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(thinking_chunk("s", "t", "r", None, None, "think", false)) + .is_empty()); + assert!(coalescer + .push(thinking_chunk("s", "t", "r", None, None, "", true)) + .is_empty()); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "answer ")) + .is_empty()); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "text")) + .is_empty()); + + let events = coalescer.flush(); + assert_eq!(events.len(), 2); + // Same-stream delivery must follow the producer's FIFO order: the + // merged thinking event (with its OR'd is_end) precedes the merged + // text event. Regression guard for the flush-order reversal where + // text was emitted before thinking. + match &events[0] { + AgenticEvent::ThinkingChunk { + content, is_end, .. + } => { + assert_eq!(content, "think"); + assert!(is_end); + } + other => panic!("expected ThinkingChunk first, got {other:?}"), + } + match &events[1] { + AgenticEvent::TextChunk { text, .. } => assert_eq!(text, "answer text"), + other => panic!("expected TextChunk second, got {other:?}"), + } + } + + #[test] + fn flush_preserves_first_arrival_order_across_streams() { + let mut coalescer = TextChunkCoalescer::new(); + // The "z" stream starts buffering before the "a" stream; flush must + // follow arrival order, not lexicographic key order. + assert!(coalescer + .push(text_chunk("s", "t", "z", None, None, "z-first")) + .is_empty()); + assert!(coalescer + .push(text_chunk("s", "t", "a", None, None, "a-second")) + .is_empty()); + + let events = coalescer.flush(); + assert_eq!(events.len(), 2); + assert!(matches!(&events[0], AgenticEvent::TextChunk { round_id, .. } if round_id == "z")); + assert!(matches!(&events[1], AgenticEvent::TextChunk { round_id, .. } if round_id == "a")); + } + + #[test] + fn different_stream_chunks_are_buffered_independently() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(text_chunk("s1", "t", "r", None, None, "a")) + .is_empty()); + assert!(coalescer + .push(text_chunk("s2", "t", "r", None, None, "b")) + .is_empty()); + + let events = coalescer.flush(); + assert_eq!(events.len(), 2); + } + + #[test] + fn non_chunk_event_flushes_pending_text_first() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "final ")) + .is_empty()); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "words")) + .is_empty()); + + let events = coalescer.push(AgenticEvent::DialogTurnCompleted { + session_id: "s".to_string(), + turn_id: "t".to_string(), + total_rounds: 1, + total_tools: 0, + duration_ms: 10, + partial_recovery_reason: None, + success: Some(true), + finish_reason: Some("stop".to_string()), + has_final_response: Some(true), + }); + + assert_eq!(events.len(), 2); + assert!( + matches!(&events[0], AgenticEvent::TextChunk { text, .. } if text == "final words") + ); + assert!(matches!( + &events[1], + AgenticEvent::DialogTurnCompleted { .. } + )); + assert!(!coalescer.is_pending()); + } + + #[test] + fn flush_clears_buffer() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "x")) + .is_empty()); + assert_eq!(coalescer.flush().len(), 1); + assert!(coalescer.flush().is_empty()); + assert!(!coalescer.is_pending()); + } + + #[test] + fn preserves_attempt_identity_on_merged_event() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(text_chunk("s", "t", "r", Some("attempt-7"), Some(3), "a")) + .is_empty()); + assert!(coalescer + .push(text_chunk("s", "t", "r", Some("attempt-7"), Some(3), "b")) + .is_empty()); + + let events = coalescer.flush(); + match &events[0] { + AgenticEvent::TextChunk { + attempt_id, + attempt_index, + text, + .. + } => { + assert_eq!(attempt_id.as_deref(), Some("attempt-7")); + assert_eq!(*attempt_index, Some(3)); + assert_eq!(text, "ab"); + } + other => panic!("expected TextChunk, got {other:?}"), + } + } + + #[test] + fn arms_deadline_when_pending_without_one() { + let now = tokio::time::Instant::now(); + let window = Duration::from_millis(TEXT_CHUNK_COALESCE_WINDOW_MS); + assert_eq!( + next_flush_deadline(true, None, now, window), + Some(now + window) + ); + } + + #[test] + fn keeps_existing_deadline_when_pending() { + let now = tokio::time::Instant::now(); + let window = Duration::from_millis(TEXT_CHUNK_COALESCE_WINDOW_MS); + let existing = now + Duration::from_millis(10); + assert_eq!( + next_flush_deadline(true, Some(existing), now, window), + Some(existing) + ); + } + + #[test] + fn clears_deadline_when_buffer_drained() { + let now = tokio::time::Instant::now(); + let window = Duration::from_millis(TEXT_CHUNK_COALESCE_WINDOW_MS); + let existing = now + Duration::from_millis(10); + assert_eq!( + next_flush_deadline(false, Some(existing), now, window), + None + ); + assert_eq!(next_flush_deadline(false, None, now, window), None); + } + + #[test] + fn window_is_base_at_reference_rate() { + assert_eq!( + next_window(WINDOW_REF_CPS), + Duration::from_millis(WINDOW_BASE_MS) + ); + } + + #[test] + fn window_grows_with_rate_and_clamps() { + // Slow stream: clamped to the minimum (smaller than the fixed 50ms). + assert_eq!(next_window(0.0), Duration::from_millis(WINDOW_MIN_MS)); + assert_eq!(next_window(10.0), Duration::from_millis(WINDOW_MIN_MS)); + // Double the reference rate -> double the window (within the cap). + assert_eq!( + next_window(WINDOW_REF_CPS * 2.0), + Duration::from_millis(100) + ); + // Fast stream: clamped to the maximum. + assert_eq!( + next_window(WINDOW_REF_CPS * 10.0), + Duration::from_millis(WINDOW_MAX_MS) + ); + // Negative rates are treated as zero. + assert_eq!(next_window(-5.0), Duration::from_millis(WINDOW_MIN_MS)); + } + + #[test] + fn rate_ema_blends_instant_rate() { + // 40 chars flushed over a 50ms window -> 800 chars/sec instant. + let blended = update_rate_ema(INITIAL_RATE_EMA_CPS, 40, Duration::from_millis(50)); + let expected = 0.7 * 800.0 + 0.3 * INITIAL_RATE_EMA_CPS; + assert!((blended - expected).abs() < 1e-9); + } + + #[test] + fn rate_ema_resets_after_idle_gap() { + // A window longer than the reset threshold replaces the estimate with + // the instant rate instead of blending (stream restart). + let reset = update_rate_ema(INITIAL_RATE_EMA_CPS, 80, Duration::from_millis(1000)); + assert!((reset - 80.0).abs() < 1e-9); + } + + #[test] + fn buffered_chars_tracks_pending_content() { + let mut coalescer = TextChunkCoalescer::new(); + assert_eq!(coalescer.buffered_chars(), 0); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "abcd")) + .is_empty()); + assert_eq!(coalescer.buffered_chars(), 4); + // Merging into the same stream accumulates. + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "ef")) + .is_empty()); + assert_eq!(coalescer.buffered_chars(), 6); + // Thinking content counts too. + assert!(coalescer + .push(thinking_chunk("s", "t", "r", None, None, "xyz", false)) + .is_empty()); + assert_eq!(coalescer.buffered_chars(), 9); + // Flush drains and resets the counter. + assert_eq!(coalescer.flush().len(), 2); + assert_eq!(coalescer.buffered_chars(), 0); + } + + #[test] + fn buffered_chars_counts_unicode_not_bytes() { + let mut coalescer = TextChunkCoalescer::new(); + // "中文" is 2 Unicode characters but 6 UTF-8 bytes. + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "中文")) + .is_empty()); + assert_eq!(coalescer.buffered_chars(), 2); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "a")) + .is_empty()); + assert_eq!(coalescer.buffered_chars(), 3); + assert!(coalescer + .push(thinking_chunk("s", "t", "r", None, None, "世界", false)) + .is_empty()); + assert_eq!(coalescer.buffered_chars(), 5); + assert_eq!(coalescer.flush().len(), 2); + assert_eq!(coalescer.buffered_chars(), 0); + } + + #[test] + fn rate_ema_resets_after_long_idle_gap() { + // A window longer than the reset threshold must replace the estimate + // with the instant rate. Use a previous estimate that differs from the + // instant rate so the reset is observable. + let reset = update_rate_ema(160.0, 80, Duration::from_millis(2000)); + // 80 chars over 2000 ms -> 40 chars/sec; reset should discard the old 160. + assert!((reset - 40.0).abs() < 1e-9); + } +} diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index d1bf91b2c5..9288eb1f25 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -1,15 +1,18 @@ //! Desktop host API for ecosystem-neutral external AI application sources. use bitfun_core::external_sources::{ - apply_external_source_control_action, choose_external_mcp_conflict, - choose_external_subagent_conflict, expand_external_prompt_command, - external_source_location_for_host_action, external_source_snapshot, + acknowledge_external_ecosystems, apply_external_source_control_action, + choose_external_mcp_conflict, choose_external_subagent_conflict, + expand_external_prompt_command, external_source_location_for_host_action, + external_source_snapshot, get_external_source_control_snapshot as core_get_external_source_control_snapshot, native_prompt_command_conflicts, set_external_mcp_server_decision, - set_external_prompt_command_conflict_choice, set_external_source_enabled, - set_external_subagent_activation, set_external_subagent_model_binding, + set_external_mcp_servers_enabled, set_external_prompt_command_conflict_choice, + set_external_source_enabled, set_external_subagent_activation, + set_external_subagent_model_binding, set_external_subagents_enabled, set_external_tool_conflict_choice, set_external_tool_target_decision, - set_native_prompt_command_conflict_choice, update_external_integration_policy, + set_external_tool_targets_enabled, set_native_prompt_command_conflict_choice, + unacknowledged_external_ecosystems, update_external_integration_policy, workspace_reference_snapshot, ExternalIntegrationPolicyMutation, ExternalSourceControlRequestV1, ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourceOperationResult, ExternalSourcePublicSnapshot, @@ -73,6 +76,25 @@ pub struct RevealExternalSourceLocationRequest { pub source_key: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalEcosystemAwarenessRequest { + pub workspace_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEcosystemAwarenessResponse { + pub unacknowledged_ecosystem_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AcknowledgeExternalEcosystemsRequest { + pub workspace_path: Option, + pub ecosystem_ids: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UpdateExternalIntegrationPolicyRequest { @@ -133,6 +155,23 @@ pub struct SetExternalToolTargetDecisionRequest { pub expected_preference_revision: u64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalToolDecisionRef { + pub approval_key: String, + pub decision_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetExternalToolTargetsEnabledRequest { + pub workspace_path: Option, + pub decisions: Vec, + pub enabled: bool, + pub expected_catalog_generation: u64, + pub expected_preference_revision: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalToolConflictChoiceRequest { @@ -153,6 +192,23 @@ pub struct SetExternalSubagentActivationRequest { pub decision_key: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalCandidateDecisionRef { + pub candidate_id: String, + pub decision_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetExternalSubagentsEnabledRequest { + pub workspace_path: Option, + pub decisions: Vec, + pub enabled: bool, + pub expected_subagent_generation: u64, + pub expected_preference_revision: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SetExternalSubagentModelBindingRequest { @@ -186,6 +242,16 @@ pub struct SetExternalMcpServerDecisionRequest { pub expected_preference_revision: u64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetExternalMcpServersEnabledRequest { + pub workspace_path: Option, + pub decisions: Vec, + pub enabled: bool, + pub expected_mcp_generation: u64, + pub expected_preference_revision: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ChooseExternalMcpConflictRequest { @@ -407,6 +473,37 @@ pub async fn apply_external_source_control_action_command( apply_external_source_control_action(workspace, request.control).await } +/// External applications discovered on this host that the user has never been +/// told about. Surfaces use it to show a low-key "something new" affordance. +#[tauri::command] +pub async fn get_external_ecosystem_awareness_command( + request: ExternalEcosystemAwarenessRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + unacknowledged_external_ecosystems(workspace) + .await + .map( + |unacknowledged_ecosystem_ids| ExternalEcosystemAwarenessResponse { + unacknowledged_ecosystem_ids, + }, + ) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + +/// Records that the user has seen these external applications. +/// +/// This only clears the "new application" hint. It grants nothing, so it takes +/// no expected preference revision and leaves approvals and policy untouched. +#[tauri::command] +pub async fn acknowledge_external_ecosystems_command( + request: AcknowledgeExternalEcosystemsRequest, +) -> ExternalSourceOperationResult<()> { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + acknowledge_external_ecosystems(workspace, request.ecosystem_ids) + .await + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + #[tauri::command] pub async fn set_external_source_enabled_command( request: SetExternalSourceEnabledRequest, @@ -501,6 +598,27 @@ pub async fn set_external_tool_target_decision_command( .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } +#[tauri::command] +pub async fn set_external_tool_targets_enabled_command( + request: SetExternalToolTargetsEnabledRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + set_external_tool_targets_enabled( + workspace, + request + .decisions + .into_iter() + .map(|decision| (decision.approval_key, decision.decision_key)) + .collect(), + request.enabled, + request.expected_catalog_generation, + request.expected_preference_revision, + ) + .await + .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + #[tauri::command] pub async fn set_external_tool_conflict_choice_command( request: SetExternalToolConflictChoiceRequest, @@ -535,6 +653,27 @@ pub async fn set_external_subagent_activation_command( .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } +#[tauri::command] +pub async fn set_external_subagents_enabled_command( + request: SetExternalSubagentsEnabledRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + set_external_subagents_enabled( + workspace, + request + .decisions + .into_iter() + .map(|decision| (decision.candidate_id, decision.decision_key)) + .collect(), + request.enabled, + request.expected_subagent_generation, + request.expected_preference_revision, + ) + .await + .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + #[tauri::command] pub async fn set_external_subagent_model_binding_command( request: SetExternalSubagentModelBindingRequest, @@ -588,6 +727,27 @@ pub async fn set_external_mcp_server_decision_command( .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } +#[tauri::command] +pub async fn set_external_mcp_servers_enabled_command( + request: SetExternalMcpServersEnabledRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + set_external_mcp_servers_enabled( + workspace, + request + .decisions + .into_iter() + .map(|decision| (decision.candidate_id, decision.decision_key)) + .collect(), + request.enabled, + request.expected_mcp_generation, + request.expected_preference_revision, + ) + .await + .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + #[tauri::command] pub async fn choose_external_mcp_conflict_command( request: ChooseExternalMcpConflictRequest, diff --git a/src/apps/desktop/src/api/lsp_workspace_api.rs b/src/apps/desktop/src/api/lsp_workspace_api.rs index bf13e53e77..b16f154fb2 100644 --- a/src/apps/desktop/src/api/lsp_workspace_api.rs +++ b/src/apps/desktop/src/api/lsp_workspace_api.rs @@ -6,12 +6,11 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -use bitfun_core::infrastructure::events::TransportEmitter; use bitfun_core::service::lsp::types::CompletionItem; use bitfun_core::service::lsp::{ close_workspace, get_workspace_manager, open_workspace_with_emitter, ServerState, }; -use bitfun_transport::TauriTransportAdapter; +use bitfun_transport::{TauriTransportAdapter, TransportEmitter}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src/apps/desktop/src/api/miniapp_export_api.rs b/src/apps/desktop/src/api/miniapp_export_api.rs index 2dab358524..91457c8b3e 100644 --- a/src/apps/desktop/src/api/miniapp_export_api.rs +++ b/src/apps/desktop/src/api/miniapp_export_api.rs @@ -19,6 +19,7 @@ const RENDER_TIMEOUT_MS: u64 = 30_000; const RENDER_SETTLE_MS: u64 = 900; /// Reused hidden host — one window, navigate per slide (avoids create/close flash per page). const EXPORT_HOST_LABEL: &str = "miniapp-slide-export-host"; +const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -41,6 +42,13 @@ fn wrap_slide_html(html: &str, width: u32, height: u32) -> String { ) } +fn utf8_html_bytes(html: &str) -> Vec { + let mut bytes = Vec::with_capacity(UTF8_BOM.len() + html.len()); + bytes.extend_from_slice(UTF8_BOM); + bytes.extend_from_slice(html.as_bytes()); + bytes +} + /// Write slide HTML to app cache and return a `file://` URL for the export webview. fn file_url_for_export_html( app: &AppHandle, @@ -54,7 +62,10 @@ fn file_url_for_export_html( std::fs::create_dir_all(&export_dir) .map_err(|error| format!("Failed to create export cache dir: {error}"))?; let file_path = export_dir.join(format!("slide-{}.html", Uuid::new_v4())); - std::fs::write(&file_path, html) + // Sanitized slide documents may intentionally omit author-provided meta + // tags. The BOM makes the file encoding unambiguous before a hidden + // WebView renders it to PDF or PNG. + std::fs::write(&file_path, utf8_html_bytes(html)) .map_err(|error| format!("Failed to write export HTML: {error}"))?; let url = tauri::Url::from_file_path(&file_path) .map_err(|_| "Failed to build file URL for export webview".to_string())?; @@ -164,3 +175,31 @@ pub async fn miniapp_render_slide_page( other => Err(format!("Unsupported slide render format: {other}")), } } + +#[cfg(test)] +mod tests { + use super::{utf8_html_bytes, wrap_slide_html, UTF8_BOM}; + + #[test] + fn export_html_bytes_are_utf8_even_when_full_document_has_no_charset_meta() { + let document = wrap_slide_html( + "架构说明中文 · café", + 1280, + 720, + ); + assert!(!document.to_ascii_lowercase().contains("charset=")); + + let bytes = utf8_html_bytes(&document); + assert!(bytes.starts_with(UTF8_BOM)); + assert_eq!( + std::str::from_utf8(&bytes[UTF8_BOM.len()..]).expect("HTML should remain valid UTF-8"), + document + ); + } + + #[test] + fn fragment_wrapper_keeps_its_explicit_utf8_charset() { + let document = wrap_slide_html("
中文
", 1280, 720); + assert!(document.contains("")); + } +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index f11a5ef845..6751d5d506 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -22,6 +22,7 @@ pub mod dispatch_api; pub(crate) mod dispatch_host; pub mod dto; pub mod editor_ai_api; +pub mod event_coalescer; pub mod external_hooks_api; pub mod external_sources_api; pub mod git_agent_api; diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index bac4f20251..22ef022627 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -119,6 +119,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_list_jobs", "dispatch_answer", "dispatch_append", + "dispatch_continue", "dispatch_load_transcript", "dispatch_save_transcript", // One-click relay deploy SSHes from the controller to a user host @@ -129,6 +130,16 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "relay_deploy_cancel", "relay_deploy_register", "relay_deploy_verify", + // Speech capture and model files belong to the machine the user speaks at. + "speech_list_models", + "speech_download_model", + "speech_cancel_model_download", + "speech_delete_model", + "speech_verify_model", + "speech_start_input_session", + "speech_append_audio_chunk", + "speech_finish_input_session", + "speech_cancel_input_session", ]; static PENDING: OnceLock>>> = @@ -476,6 +487,30 @@ mod tests { } } + /// The controller-side FE deny list is an optimization, not the boundary. + /// A controller on an older build (or a non-FE controller) still reaches + /// this host, so every controller-owned command must be refused here too. + #[test] + fn controller_owned_capture_and_dispatch_commands_are_refused_on_the_peer() { + for command in [ + // Capture and model files belong to the machine the user speaks at. + "speech_list_models", + "speech_download_model", + "speech_cancel_model_download", + "speech_delete_model", + "speech_verify_model", + "speech_start_input_session", + "speech_append_audio_chunk", + "speech_finish_input_session", + "speech_cancel_input_session", + // Same controller-owned observer/credential family as the other + // dispatch verbs already denied here. + "dispatch_continue", + ] { + assert!(is_local_only_command(command), "{command}"); + } + } + #[test] fn only_the_final_detach_drains_peer_permission_requests() { let mut state = control_state(&["controller-a", "controller-b"], &["request-1"]); diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 564b163a3e..25e9dcddc1 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -22,7 +22,7 @@ use bitfun_core::service::remote_connect::{ bot::{self, weixin, BotConfig}, lan, session_store, sync_state, AccountClient, AccountPairingVerification, AccountSession, ConnectionMethod, ConnectionResult, DelegatedIdentityAuthorization, DeviceIdentity, - PairingState, RemoteConnectConfig, RemoteConnectService, + PairingState, ProvisionedDeviceAuthorization, RemoteConnectConfig, RemoteConnectService, }; use bitfun_core::service::session::{DialogTurnData, SessionMetadata}; use bitfun_core::service::workspace::{get_global_workspace_service, WorkspaceKind}; @@ -82,6 +82,17 @@ static ACCOUNT_CONTEXT_TRANSITIONS: AtomicUsize = AtomicUsize::new(0); static DEVICE_ROUTING_LIFECYCLE_LOCK: tokio::sync::RwLock<()> = tokio::sync::RwLock::const_new(()); static DEVICE_ROUTING_CONNECTION_ID: AtomicU64 = AtomicU64::new(0); +/// Ceiling on device RPCs executing at once. +/// +/// RPCs run off the routing loop rather than on it, so without a bound a phone +/// that fans out a screenful of `list_sessions` would put all of them on the +/// webview bridge at once. The bound exists to keep that burst from crowding +/// out the next device's first request, not because concurrency is unsafe: +/// each RPC holds its own routing lease and answers its own correlation id. +const MAX_CONCURRENT_DEVICE_RPCS: usize = 8; +static DEVICE_RPC_SLOTS: tokio::sync::Semaphore = + tokio::sync::Semaphore::const_new(MAX_CONCURRENT_DEVICE_RPCS); + #[derive(Clone, Debug, Eq, PartialEq)] struct DeviceRoutingOwner { account_generation: u64, @@ -902,6 +913,7 @@ pub(crate) async fn provision_dispatch_account_device( &session, &identity.device_id, &identity.device_name, + "desktop", uuid::Uuid::new_v4(), ) .await @@ -1220,6 +1232,62 @@ async fn register_delegated_identity_providers() { }) .await; + // Room-channel provider that adds a keyboard-less device (a watch) to + // this account. Same lease discipline as delegation above; the errors + // are returned rather than swallowed because a provisioning failure is + // shown to someone standing there waiting for it. + let account_context = get_account_context().clone(); + service + .set_peer_device_provisioner(move |device_id, device_name, request_id| { + let account_context = account_context.clone(); + Box::pin(async move { + // Minted by the device being provisioned so a retry anywhere + // along the chain replays one idempotent relay request. + let request_id = uuid::Uuid::parse_str(&request_id) + .map_err(|_| "Request id must be a UUID".to_string())?; + let generation = account_context_generation(); + if !account_context_is_current(generation) { + return Err("Desktop account changed; try again".to_string()); + } + let account_lease = lock_account_sync(generation) + .await + .map_err(|_| "Desktop account changed; try again".to_string())?; + let context = account_context + .read() + .await + .clone() + .ok_or_else(|| "Desktop is not logged into a BitFun account".to_string())?; + if !account_context_matches(generation, &context.session.token).await { + return Err("Desktop account changed; try again".to_string()); + } + let provisioned = AccountClient::new() + .provision_device_token( + &context.relay_url, + &context.session, + &device_id, + &device_name, + "watch", + request_id, + ) + .await + .map_err(|e| { + log::warn!("Provision device token failed: {e}"); + format!("Could not add the device to your account: {e}") + })?; + if !account_context_matches(generation, &context.session.token).await { + return Err("Desktop account changed; try again".to_string()); + } + Ok(ProvisionedDeviceAuthorization::with_host_lease( + provisioned.token, + provisioned.user_id, + context.session.master_key, + provisioned.device_id, + account_lease, + )) + }) + }) + .await; + // Account-mode mobile pairing: QR prefill + password verification. register_account_pairing_context(service).await; @@ -2826,7 +2894,10 @@ pub async fn account_connect_devices() -> Result, String> } } Ok(cmd) if source_device_id == "rpc" => { - let Some(_routing_effect) = + // The lease is taken here, on the loop, so a + // retiring loop still notices it has been + // replaced and stops reading events at once. + let Some(routing_effect) = lock_current_device_routing(&event_owner).await else { break 'routing_events; @@ -2837,34 +2908,56 @@ pub async fn account_connect_devices() -> Result, String> log::info!( "RPC request received from relay: corr={correlation_id}" ); - let execution = execute_local_remote_command(&cmd).await; - if !device_routing_owner_is_current(&event_owner).await { - break 'routing_events; - } - match execution { - Ok(resp_value) => { - send_rpc_envelope( - &event_owner, - &event_session, - &correlation_id, - resp_value, - ) - .await; + // Spawned rather than awaited. Most commands + // are answered by the webview, which can take + // up to DEFAULT_INVOKE_TIMEOUT (120s) to reply; + // awaiting here meant one slow command stalled + // every device behind it, so a `ping` from the + // watch could take 40s to come back for no + // reason of its own. Each RPC carries its own + // correlation id, so nothing about the reply + // path depends on them finishing in order. + let rpc_owner = event_owner.clone(); + let rpc_session = event_session.clone(); + tokio::spawn(async move { + // Held for the whole call: teardown takes + // the write lease, so an in-flight RPC now + // keeps the connection from being replaced + // out from under its own reply. + let _routing_effect = routing_effect; + let Ok(_slot) = DEVICE_RPC_SLOTS.acquire().await else { + return; + }; + let execution = execute_local_remote_command(&cmd).await; + // Returning drops this reply only. The loop + // re-checks ownership at the top of every + // iteration, so a stale connection is still + // retired there — just not from in here. + if !device_routing_owner_is_current(&rpc_owner).await { + return; } - Err(e) => { - log::warn!("RPC: execute command failed: {e}"); - send_rpc_error( - &event_owner, - &event_session, - &correlation_id, - format!("RPC execute failed: {e}"), - ) - .await; + match execution { + Ok(resp_value) => { + send_rpc_envelope( + &rpc_owner, + &rpc_session, + &correlation_id, + resp_value, + ) + .await; + } + Err(e) => { + log::warn!("RPC: execute command failed: {e}"); + send_rpc_error( + &rpc_owner, + &rpc_session, + &correlation_id, + format!("RPC execute failed: {e}"), + ) + .await; + } } - } - if !device_routing_owner_is_current(&event_owner).await { - break 'routing_events; - } + }); } Ok(cmd) => { let _ = cmd; diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 2d772df151..2bed249162 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -201,6 +201,14 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "apply_external_source_control_action_command", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "get_external_ecosystem_awareness_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "acknowledge_external_ecosystems_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ("apply_patch", RemoteWorkspacePolicy::LegacyUnaudited), ( "archive_all_sessions", @@ -208,7 +216,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("archive_session", RemoteWorkspacePolicy::LegacyUnaudited), ( - "browser_control_create_launcher", + "browser_control_enable_default_cdp", RemoteWorkspacePolicy::LocalOnly, ), ( @@ -517,6 +525,14 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "get_ai_model_catalog", RemoteWorkspacePolicy::WorkspaceAgnostic, ), + ( + "project_ai_model_reasoning_catalog", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "get_models_dev_catalog_status", + RemoteWorkspacePolicy::LocalOnly, + ), ( "get_all_modified_files", RemoteWorkspacePolicy::LegacyUnaudited, @@ -709,6 +725,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::LegacyUnaudited, ), ("get_session_lineage", RemoteWorkspacePolicy::RemoteRouted), + ( + "get_session_permission_mode", + RemoteWorkspacePolicy::RemoteRouted, + ), ("get_session_files", RemoteWorkspacePolicy::LegacyUnaudited), ( "get_session_operations", @@ -1364,14 +1384,18 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ("read_file_content", RemoteWorkspacePolicy::LegacyUnaudited), ("read_mcp_resource", RemoteWorkspacePolicy::LegacyUnaudited), ("record_file_change", RemoteWorkspacePolicy::LegacyUnaudited), - ( - "record_local_command_turn", - RemoteWorkspacePolicy::RemoteRouted, - ), ( "refresh_model_client", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "refresh_models_dev_catalog_now", + RemoteWorkspacePolicy::LocalOnly, + ), + ( + "reveal_models_dev_cache_directory", + RemoteWorkspacePolicy::LocalOnly, + ), ( "refresh_subscription_account", RemoteWorkspacePolicy::LocalOnly, @@ -1638,6 +1662,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::LegacyUnaudited, ), ("save_canvas_state", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "save_cloud_speech_config", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ( "save_git_repo_history", RemoteWorkspacePolicy::LegacyUnaudited, @@ -1713,6 +1741,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "set_external_mcp_server_decision_command", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "set_external_mcp_servers_enabled_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "set_external_source_conflict_choice_command", RemoteWorkspacePolicy::RemoteUnsupported, @@ -1733,10 +1765,18 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "set_external_tool_target_decision_command", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "set_external_tool_targets_enabled_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "set_external_subagent_activation_command", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "set_external_subagents_enabled_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "set_external_subagent_model_binding_command", RemoteWorkspacePolicy::RemoteUnsupported, @@ -1956,6 +1996,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("update_miniapp", RemoteWorkspacePolicy::LegacyUnaudited), ("update_session_mode", RemoteWorkspacePolicy::RemoteRouted), + ( + "update_session_permission_mode", + RemoteWorkspacePolicy::RemoteRouted, + ), ( "update_session_model", RemoteWorkspacePolicy::LegacyUnaudited, diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index abacb34899..17623bea85 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -12,7 +12,7 @@ use bitfun_core::agentic::persistence::{SessionBranchResult, SessionMetadataPage use bitfun_core::service::remote_ssh::normalize_remote_workspace_path; use bitfun_core::service::session::{ DialogTurnData, SessionKind, SessionMetadata, SessionStatus, SessionTranscriptExport, - SessionTranscriptExportOptions, SessionTurnCatalog, + SessionTranscriptExportOptions, }; use bitfun_core::service::session_usage::SessionUsageReport; use bitfun_core::service::workspace::WorkspaceKind; @@ -89,25 +89,6 @@ pub struct SaveSessionTurnRequest { pub remote_ssh_host: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RecordLocalCommandTurnRequest { - pub turn_data: DialogTurnData, - pub workspace_path: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_connection_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_ssh_host: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RecordLocalCommandTurnResponse { - pub turn_id: String, - pub storage_turn_index: usize, - pub total_turn_count: usize, - pub turn_catalog: SessionTurnCatalog, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SaveSessionMetadataRequest { pub metadata: SessionMetadata, @@ -502,36 +483,6 @@ pub async fn save_session_turn( Ok(()) } -#[tauri::command] -pub async fn record_local_command_turn( - request: RecordLocalCommandTurnRequest, - runtime: State<'_, DesktopRuntimeContext>, -) -> Result { - let recorded = runtime - .session_application() - .record_local_command_turn( - desktop_session_scope( - request.workspace_path.clone(), - request.remote_connection_id, - request.remote_ssh_host, - ), - &request.turn_data, - ) - .await - .map_err(|error| format!("Failed to record local command turn: {error}"))?; - - crate::api::remote_connect_api::notify_session_changed( - &request.turn_data.session_id, - &request.workspace_path, - ); - Ok(RecordLocalCommandTurnResponse { - turn_id: recorded.turn_id, - storage_turn_index: recorded.storage_turn_index, - total_turn_count: recorded.total_turn_count, - turn_catalog: recorded.turn_catalog, - }) -} - #[tauri::command] pub async fn save_session_metadata( request: SaveSessionMetadataRequest, diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index 990e1c10f4..975c77b4d4 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -16,9 +16,14 @@ const UPDATE_PROGRESS_EVENT: &str = "bitfun-update-progress"; /// Updater origins, in configured (fallback) order. Kept in step with /// `scripts/desktop-tauri-build.mjs`, which bakes the same pair into the bundle. -const GITHUB_UPDATER_ENDPOINT: &str = - "https://github.com/GCWing/BitFun/releases/latest/download/latest.json"; -const OPENBITFUN_UPDATER_ENDPOINT: &str = "https://openbitfun.com/release/latest.json"; +const GITHUB_UPDATER_ENDPOINT: &str = match option_env!("BITFUN_UPDATER_PRIMARY_ENDPOINT") { + Some(endpoint) => endpoint, + None => "https://github.com/GCWing/BitFun/releases/latest/download/latest.json", +}; +const OPENBITFUN_UPDATER_ENDPOINT: &str = match option_env!("BITFUN_UPDATER_FALLBACK_ENDPOINT") { + Some(endpoint) => endpoint, + None => "https://openbitfun.com/release/latest.json", +}; /// Throughput probe settings, matching the CLI updater and the relay deploy /// script (`src/apps/cli/src/self_update.rs`, diff --git a/src/apps/desktop/src/computer_use/desktop_host/mod.rs b/src/apps/desktop/src/computer_use/desktop_host/mod.rs index 929303c55b..c3fcd69a68 100644 --- a/src/apps/desktop/src/computer_use/desktop_host/mod.rs +++ b/src/apps/desktop/src/computer_use/desktop_host/mod.rs @@ -36,6 +36,86 @@ const STALE_CAPTURE_TOOL_MESSAGE: &str = "Computer use refused: call **`screensh static SCREENSHOT_ID_COUNTER: AtomicU64 = AtomicU64::new(1); +/// How long `open_app` waits for a freshly activated app to show up in +/// LaunchServices before giving up on resolving its pid. +#[cfg(target_os = "macos")] +const OPEN_APP_SETTLE_MS: u64 = 3_000; +/// How long `open_app` waits for the app to put a window on screen. Cold +/// Electron launches routinely need several seconds; reporting `window_count: +/// 0` too early would send the agent down a false "app is broken" path. +#[cfg(target_os = "macos")] +const OPEN_APP_WINDOW_WAIT_MS: u64 = 8_000; +#[cfg(target_os = "macos")] +const OPEN_APP_POLL_INTERVAL_MS: u64 = 150; + +/// How long an `open_app` AppleScript may run before it is killed. +/// +/// `activate` sends an AppleEvent to the target app and waits for it to answer. +/// A hung or busy app simply does not answer, and macOS's default AppleEvent +/// timeout is **120 seconds** — during which `open_app` occupies a blocking +/// thread and the agent has no idea anything is wrong. An app that has not +/// acknowledged activation in a few seconds is not going to. +#[cfg(target_os = "macos")] +const OSASCRIPT_TIMEOUT_MS: u64 = 10_000; + +/// Run `osascript -e @@ -709,17 +724,6 @@ ${createWidgetAppearanceStaticShellCss()} return false; } return true; - }, - onNodeAdded: function (node) { - if ( - node && - node.nodeType === 1 && - node.tagName !== 'SCRIPT' && - node.tagName !== 'STYLE' - ) { - node.style.animation = 'bitfunWidgetFadeIn 0.18s ease both'; - } - return node; } }); } else { @@ -1044,7 +1048,7 @@ export const GenerativeWidgetFrame: React.FC = ({