diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7324baa1d..87f50fe0e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -138,9 +138,41 @@ jobs:
cargo test --locked -p terminal-core background_only_binding_is_owned_by_the_session -- --test-threads=1
# ── Rust: build check ─────────────────────────────────────────────
+ # Cargo-deny gate: advisories + licenses + sources (+ bans at warn level).
+ # Kept on Linux only - the license/source graph is OS-independent, so a
+ # single check covers the whole workspace without triplicating the run.
+ cargo-deny:
+ name: Cargo Deny (advisories + licenses)
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v5
+
+ - uses: dtolnay/rust-toolchain@stable
+
+ - uses: swatinem/rust-cache@v2
+ with:
+ shared-key: "cargo-deny-v1"
+ cache-bin: false
+
+ - name: Install cargo-deny
+ run: cargo install cargo-deny --locked --version 0.20.2
+
+ - name: Deny advisories
+ run: cargo deny check advisories
+
+ - name: Deny licenses
+ run: cargo deny check licenses
+
+ - name: Deny sources
+ run: cargo deny check sources
+
rust-build-check:
name: Rust Build Check (${{ matrix.os }})
runs-on: ${{ matrix.os }}
+ # Rust workspace check + 6 test groups across 3 OS; generous but bounded
+ # so a wedged dependency/network cannot burn the full 6h default.
+ timeout-minutes: 60
env:
# Keep the workspace check plus desktop test profiles within hosted-runner disk limits.
CARGO_INCREMENTAL: "0"
@@ -197,13 +229,62 @@ jobs:
- uses: swatinem/rust-cache@v2
with:
- shared-key: "ci-check-v3-${{ runner.os }}-no-cargo-bin-v1"
+ shared-key: "ci-check-v4-${{ runner.os }}-no-cargo-bin-v1-sherpa-native-v1"
cache-bin: false
+ # sherpa-onnx-sys stores downloaded native archives beside Cargo's
+ # normal artifacts. Preserve that directory with the build-script
+ # fingerprints that reference it, or restored test builds cannot link.
+ cache-directories: |
+ target/sherpa-onnx-prebuilt
# 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' }}
+ # sherpa-onnx-sys downloads its native static libraries from GitHub
+ # Releases at build time. Hosted runners intermittently fail that
+ # download (network/rate limits), so pre-fetch and extract the archive
+ # here, then point SHERPA_ONNX_LIB_DIR at the extracted lib directory.
+ # The build script uses that variable directly without any cache/rerun
+ # logic, so both `cargo check` and `cargo test` link against the same
+ # pre-fetched libraries. Windows is skipped: its archive is already
+ # cached in the rust-cache path and the previous failures were Linux and
+ # macOS only.
+ - name: Pre-download sherpa-onnx native libraries
+ if: runner.os != 'Windows'
+ shell: bash
+ env:
+ SHERPA_VERSION: "1.13.4"
+ run: |
+ set -euo pipefail
+ case "${{ runner.os }}" in
+ Linux)
+ archive="sherpa-onnx-v${SHERPA_VERSION}-linux-x64-static-lib.tar.bz2"
+ ;;
+ macOS)
+ archive="sherpa-onnx-v${SHERPA_VERSION}-osx-arm64-static-lib.tar.bz2"
+ ;;
+ *)
+ exit 0
+ ;;
+ esac
+ mkdir -p "$RUNNER_TEMP/sherpa-onnx-libs"
+ archive_path="$RUNNER_TEMP/sherpa-onnx-libs/$archive"
+ if [ ! -f "$archive_path" ]; then
+ curl -fL --retry 5 --retry-all-errors \
+ "https://github.com/k2-fsa/sherpa-onnx/releases/download/v${SHERPA_VERSION}/${archive}" \
+ -o "$archive_path"
+ fi
+ lib_dir="$RUNNER_TEMP/sherpa-onnx-libs/lib"
+ if [ ! -d "$lib_dir" ]; then
+ tar -xjf "$archive_path" -C "$RUNNER_TEMP/sherpa-onnx-libs"
+ # The archive extracts to a versioned directory; its lib/ is the
+ # native library directory the build script expects.
+ lib_dir="$(find "$RUNNER_TEMP/sherpa-onnx-libs" -maxdepth 2 -type d -name lib | head -n 1)"
+ fi
+ test -n "$lib_dir" && test -f "$lib_dir/libsherpa-onnx-c-api.a"
+ echo "SHERPA_ONNX_LIB_DIR=$lib_dir" >> "$GITHUB_ENV"
+
- name: Check compilation
run: cargo check --locked --workspace
@@ -253,6 +334,9 @@ jobs:
frontend-build:
name: Frontend Build
runs-on: ubuntu-latest
+ # Full web-ui test + build + i18n audits; bounded so a stuck install or
+ # test cannot burn the full 6h default.
+ timeout-minutes: 40
env:
NODE_OPTIONS: --max-old-space-size=6144
steps:
diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml
index 32a861682..6d81c87ca 100644
--- a/.github/workflows/desktop-package.yml
+++ b/.github/workflows/desktop-package.yml
@@ -34,6 +34,7 @@ jobs:
prepare:
name: Prepare
runs-on: ubuntu-latest
+ timeout-minutes: 10
outputs:
version: ${{ steps.meta.outputs.version }}
release_tag: ${{ steps.meta.outputs.release_tag }}
@@ -95,10 +96,13 @@ jobs:
runs-on: ${{ matrix.platform.os }}
needs: prepare
if: needs.prepare.outputs.relay_image_only != 'true'
+ # Release packaging: full release-profile build + bundles per platform.
+ # 6h default is far too long for a wedged job; cap it at 120m.
+ timeout-minutes: 120
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
+ TAURI_UPDATER_ENDPOINT: https://github.com/${{ github.repository }}/releases/latest/download/latest.json
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
@@ -274,6 +278,7 @@ jobs:
publish-relay-image:
name: Publish Relay Server Image
needs: [prepare, linux-binaries]
+ timeout-minutes: 60
if: >-
always() &&
(needs.prepare.outputs.upload_to_release == 'true' ||
@@ -285,7 +290,7 @@ jobs:
contents: write
packages: write
env:
- IMAGE: ghcr.io/gcwing/bitfun-relay-server
+ IMAGE: ghcr.io/${{ github.repository_owner }}/bitfun-relay-server
steps:
- name: Checkout
@@ -311,7 +316,7 @@ jobs:
set -euo pipefail
mkdir -p linux-release-assets
gh release download "${RELEASE_TAG}" \
- --repo GCWing/BitFun \
+ --repo "${{ github.repository }}" \
--dir linux-release-assets \
--pattern 'bitfun-relay-server-*.tar.gz' \
--pattern 'bitfun-relay-server-*.tar.gz.sha256'
@@ -359,7 +364,7 @@ jobs:
if [[ "${IMAGE_ONLY}" == "true" ]]; then
# Backfilling an older release must not roll the floating tag
# backwards. GitHub's latest endpoint excludes prereleases.
- latest_release="$(gh api repos/GCWing/BitFun/releases/latest --jq .tag_name)"
+ latest_release="$(gh api repos/${{ github.repository }}/releases/latest --jq .tag_name)"
if [[ "${RELEASE_TAG}" == "${latest_release}" ]]; then
echo "${IMAGE}:latest"
fi
@@ -465,6 +470,7 @@ jobs:
upload-release-assets:
name: Upload Release Assets
needs: [prepare, package, linux-binaries, publish-relay-image]
+ timeout-minutes: 30
if: >-
always() &&
needs.prepare.outputs.upload_to_release == 'true' &&
@@ -520,6 +526,7 @@ jobs:
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
@@ -537,7 +544,7 @@ jobs:
--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}"
@@ -555,7 +562,7 @@ jobs:
--assets-dir linux-release-assets \
--version "${{ needs.prepare.outputs.version }}" \
--tag "${{ needs.prepare.outputs.release_tag }}" \
- --repo "GCWing/BitFun" \
+ --repo "${{ github.repository }}" \
--out linux-release-assets/linux-binaries.json
# The Tauri bundler signs the five updater artifacts during `tauri build`,
@@ -587,36 +594,47 @@ jobs:
# can fetch a key for is not verifiable.
printf '%s' "${BITFUN_SIGNING_PUBKEY}" | base64 -d >release-assets/minisign.pub
+ - name: Stage uniquely named release assets
+ 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: Upload to release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.prepare.outputs.release_tag }}
generate_release_notes: true
- files: |
- release-updater-assets/*
- release-manual-assets/*.exe
- release-manual-assets/*.exe.sig
- release-assets/**/*.AppImage
- release-assets/**/*.deb
- release-assets/**/*.dmg
- release-assets/**/*.rpm
- 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 \
@@ -628,7 +646,7 @@ jobs:
- name: Verify published Linux binaries manifest
run: |
curl -fsSL --retry 5 --retry-delay 3 \
- "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/linux-binaries.json" \
+ "https://github.com/${{ github.repository }}/releases/download/${{ needs.prepare.outputs.release_tag }}/linux-binaries.json" \
-o linux-binaries.published.json
test "$(jq -r '.version' linux-binaries.published.json)" = "${{ needs.prepare.outputs.version }}"
while IFS= read -r cli_url; do
@@ -639,13 +657,13 @@ jobs:
- name: Verify published Relay image descriptor
run: |
curl -fsSL --retry 5 --retry-delay 3 \
- "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/relay-image.json" \
+ "https://github.com/${{ github.repository }}/releases/download/${{ needs.prepare.outputs.release_tag }}/relay-image.json" \
-o relay-image.published.json
test "$(jq -r '.tag' relay-image.published.json)" = "${{ needs.prepare.outputs.release_tag }}"
- test "$(jq -r '.image' relay-image.published.json)" = "ghcr.io/gcwing/bitfun-relay-server"
+ test "$(jq -r '.image' relay-image.published.json)" = "ghcr.io/${{ github.repository_owner }}/bitfun-relay-server"
jq -e '.digest | test("^sha256:[0-9a-f]{64}$")' relay-image.published.json >/dev/null
curl -fsSL --retry 5 --retry-delay 3 \
- "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/relay-image.json.sig" \
+ "https://github.com/${{ github.repository }}/releases/download/${{ needs.prepare.outputs.release_tag }}/relay-image.json.sig" \
-o /dev/null
# Nudge the openbitfun.com mirror to sync now instead of on its next
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 8c6b44a65..2af1483c0 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -162,6 +162,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
diff --git a/.gitignore b/.gitignore
index 95806da18..329b666a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,11 +23,44 @@ dist-ssr
# Build outputs - Rust/Tauri
target/
**/target/
+.target/
/.targets/
+# Local evidence working dir (fully ignored; intermediate artifacts go to
+# outside the repo)
+target2/
# The deployable Rust services use the workspace lockfile for reproducible
# container builds.
!Cargo.lock
+# Work artifacts that must never enter the repo (S-37/S-56 hygiene):
+# recon/fix/verify/report/sediment intermediates, sync records, ws-check data.
+/docs/plans/RECON-*
+/docs/plans/FIX-*
+/docs/plans/VERIFY-*
+/docs/plans/REPORT-*
+/docs/plans/SEDIMENT-*
+/docs/plans/sync-record-*
+/docs/plans/recon-*
+/docs/plans/fix-*
+/docs/plans/doc-governance-report-*
+/docs/plans/pr-final-*
+/docs/plans/ws-check-*
+/docs/plans/del-*.json
+/docs/plans/侦查-*
+/docs/plans/核对-*
+/docs/plans/核查-*
+/docs/plans/*.log
+/docs/plans/*.json
+/docs/plans/*.cjs
+
+# Local customization docs stay out of the repo (S-56 sanitize)
+
+/docs/功能文档/
+/交接文档-现状与决议.md
+/fix-dualfeed-停止回报.md
+/docs/plans/review-upstream-sync-*
+/docs/features/agent-hot-reload.md
+
# Monaco Editor - copied from node_modules
public/monaco-editor/
src/web-ui/public/monaco-editor/
@@ -91,5 +124,9 @@ external/
/.bitfun/search/flashgrep-index/
.agents/
/.flashgrep-index-engine/
+/src/apps/desktop/.bitfun/search/flashgrep-index/
+/target/debug/.bitfun/search/flashgrep-index/
.design/
+__pycache__/
+*.pyc
diff --git a/AGENTS-CN.md b/AGENTS-CN.md
index 093cce57d..597aed1bb 100644
--- a/AGENTS-CN.md
+++ b/AGENTS-CN.md
@@ -128,13 +128,53 @@ 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,而不是默认假设。
+
+改动说明中要写清楚在哪些远程场景下验证过。只跑本地测试不能作为远程行为的证据。
### Agent loop 行为
diff --git a/AGENTS.md b/AGENTS.md
index 3494a3094..906325923 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -145,14 +145,66 @@ 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.
### Agent loop behavior
diff --git a/BitFun-Installer/package-lock.json b/BitFun-Installer/package-lock.json
index 6ed36d14f..08ffb33d5 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 bd0037687..7f9d1759c 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 ecf66ffbc..55d8d2525 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/Cargo.lock b/Cargo.lock
index a2e1ab33e..f11c798f3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -759,7 +759,7 @@ dependencies = [
[[package]]
name = "bitfun-acp"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -778,15 +778,16 @@ dependencies = [
"tokio",
"tokio-util",
"uuid",
+ "which 8.0.5",
]
[[package]]
name = "bitfun-agent-content"
-version = "0.2.16"
+version = "0.2.17"
[[package]]
name = "bitfun-agent-runtime"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"async-trait",
"bitfun-agent-stream",
@@ -812,7 +813,7 @@ dependencies = [
[[package]]
name = "bitfun-agent-runtime-ipc"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"async-trait",
"bitfun-events",
@@ -832,7 +833,7 @@ dependencies = [
[[package]]
name = "bitfun-agent-stream"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"async-trait",
@@ -852,7 +853,7 @@ dependencies = [
[[package]]
name = "bitfun-agent-tools"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"async-trait",
"bitfun-core-types",
@@ -865,7 +866,7 @@ dependencies = [
[[package]]
name = "bitfun-ai-adapters"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"apple-native-keyring-store",
@@ -899,7 +900,7 @@ dependencies = [
[[package]]
name = "bitfun-app-server"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -924,7 +925,7 @@ dependencies = [
[[package]]
name = "bitfun-app-server-client"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -935,7 +936,7 @@ dependencies = [
[[package]]
name = "bitfun-app-server-protocol"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"agent-client-protocol",
"bitfun-core-types",
@@ -949,7 +950,7 @@ dependencies = [
[[package]]
name = "bitfun-claude-code-adapter"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"bitfun-product-domains",
"bitfun-services-core",
@@ -968,7 +969,7 @@ dependencies = [
[[package]]
name = "bitfun-cli"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"arboard",
@@ -1030,7 +1031,7 @@ dependencies = [
[[package]]
name = "bitfun-codex-adapter"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"bitfun-product-domains",
"bitfun-services-core",
@@ -1047,7 +1048,7 @@ dependencies = [
[[package]]
name = "bitfun-core"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"async-trait",
@@ -1092,6 +1093,7 @@ dependencies = [
"log",
"md5",
"notify",
+ "rand 0.8.7",
"regex",
"reqwest",
"rusqlite",
@@ -1118,7 +1120,7 @@ dependencies = [
[[package]]
name = "bitfun-core-types"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"serde",
"serde_json",
@@ -1127,7 +1129,7 @@ dependencies = [
[[package]]
name = "bitfun-desktop"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -1212,7 +1214,7 @@ dependencies = [
[[package]]
name = "bitfun-events"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"async-trait",
@@ -1226,7 +1228,7 @@ dependencies = [
[[package]]
name = "bitfun-external-sources"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"bitfun-product-domains",
"futures",
@@ -1236,7 +1238,7 @@ dependencies = [
[[package]]
name = "bitfun-harness"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"async-trait",
"thiserror 2.0.19",
@@ -1245,7 +1247,7 @@ dependencies = [
[[package]]
name = "bitfun-miniapp-market-server"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"axum",
@@ -1257,7 +1259,7 @@ dependencies = [
[[package]]
name = "bitfun-miniapp-market-service"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"axum",
@@ -1288,7 +1290,7 @@ dependencies = [
[[package]]
name = "bitfun-opencode-adapter"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"async-trait",
"bitfun-plugin-runtime-client",
@@ -1316,7 +1318,7 @@ dependencies = [
[[package]]
name = "bitfun-page-function-runtime"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"rquickjs",
"serde",
@@ -1327,7 +1329,7 @@ dependencies = [
[[package]]
name = "bitfun-plugin-runtime-client"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"async-trait",
"bitfun-runtime-ports",
@@ -1336,7 +1338,7 @@ dependencies = [
[[package]]
name = "bitfun-product-capabilities"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"async-trait",
"bitfun-agent-runtime",
@@ -1349,7 +1351,7 @@ dependencies = [
[[package]]
name = "bitfun-product-domains"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"dirs 6.0.0",
"hex",
@@ -1366,7 +1368,7 @@ dependencies = [
[[package]]
name = "bitfun-relay-server"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"axum",
@@ -1383,7 +1385,7 @@ dependencies = [
[[package]]
name = "bitfun-relay-service"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"aes-gcm",
"anyhow",
@@ -1412,7 +1414,7 @@ dependencies = [
[[package]]
name = "bitfun-runtime-ports"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"async-trait",
@@ -1427,7 +1429,7 @@ dependencies = [
[[package]]
name = "bitfun-runtime-services"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"async-trait",
@@ -1442,7 +1444,7 @@ dependencies = [
[[package]]
name = "bitfun-sdk-host"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"async-trait",
"bitfun-agent-runtime",
@@ -1460,7 +1462,7 @@ dependencies = [
[[package]]
name = "bitfun-sdk-host-app"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"async-trait",
@@ -1479,7 +1481,7 @@ dependencies = [
[[package]]
name = "bitfun-server"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -1505,7 +1507,7 @@ dependencies = [
[[package]]
name = "bitfun-services-core"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"async-trait",
@@ -1514,9 +1516,11 @@ dependencies = [
"bitfun-events",
"bitfun-runtime-ports",
"chrono",
+ "dashmap",
"dunce",
"filetime",
"fs2",
+ "futures",
"git2",
"globset",
"ignore",
@@ -1541,7 +1545,7 @@ dependencies = [
[[package]]
name = "bitfun-services-integrations"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"aes",
"aes-gcm",
@@ -1565,8 +1569,12 @@ dependencies = [
"futures",
"futures-util",
"git2",
+ "globset",
+ "grep-regex",
+ "grep-searcher",
"hex",
"hostname",
+ "ignore",
"image 0.25.10",
"keyring-core",
"libc",
@@ -1616,7 +1624,7 @@ dependencies = [
[[package]]
name = "bitfun-skin-market-server"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"axum",
@@ -1628,7 +1636,7 @@ dependencies = [
[[package]]
name = "bitfun-skin-market-service"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"axum",
@@ -1657,7 +1665,7 @@ dependencies = [
[[package]]
name = "bitfun-static-hook-support"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"bitfun-product-domains",
"bitfun-services-core",
@@ -1670,7 +1678,7 @@ dependencies = [
[[package]]
name = "bitfun-tool-call-jsonrepair"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"serde",
"serde_json",
@@ -1678,11 +1686,11 @@ dependencies = [
[[package]]
name = "bitfun-tool-packs"
-version = "0.2.16"
+version = "0.2.17"
[[package]]
name = "bitfun-transport"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"async-trait",
@@ -1694,7 +1702,7 @@ dependencies = [
[[package]]
name = "bitfun-webdriver"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"axum",
@@ -2156,9 +2164,9 @@ dependencies = [
[[package]]
name = "clang-sys"
-version = "1.8.1"
+version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
+checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
dependencies = [
"glob",
"libc",
@@ -10647,7 +10655,7 @@ dependencies = [
[[package]]
name = "terminal-core"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anyhow",
"async-trait",
@@ -11078,7 +11086,7 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tool-runtime"
-version = "0.2.16"
+version = "0.2.17"
dependencies = [
"anydoc",
"bitfun-agent-tools",
diff --git a/Cargo.toml b/Cargo.toml
index 6d57caa7e..69db77145 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -54,9 +54,10 @@ resolver = "2"
# Shared package metadata — single source of truth for version
[workspace.package]
-version = "0.2.16" # x-release-please-version
+version = "0.2.17" # x-release-please-version
authors = ["BitFun Team"]
edition = "2021"
+license = "MIT"
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "warn"
@@ -133,7 +134,7 @@ encoding_rs = "0.8.35"
url = "2"
# HTTP client
-reqwest = { version = "0.13.4", default-features = false, features = ["http2", "json", "stream", "multipart", "query", "form"] }
+reqwest = { version = "0.13.4", default-features = false }
semver = "1.0"
# Debug Log HTTP Server
diff --git a/deny.toml b/deny.toml
new file mode 100644
index 000000000..d4f9d0a8b
--- /dev/null
+++ b/deny.toml
@@ -0,0 +1,102 @@
+# =============================================================================
+# cargo-deny configuration (schema: cargo-deny 0.20.x)
+# License compliance + dependency review + vulnerability gate
+# =============================================================================
+# Reference: https://embarkstudios.github.io/cargo-deny/
+# Note: 0.20 moved [advisories] lint levels to Scope values and renamed
+# [bans] wildcard-predicates -> wildcards. See
+# https://github.com/EmbarkStudios/cargo-deny/blob/0.20.2/src/advisories/cfg.rs
+# and src/bans/cfg.rs for the exact accepted keys.
+
+[advisories]
+# 0.20: vulnerability/notice are deprecated (removed in the 0.14 line) and
+# severity-threshold is deprecated (PR#611); keeping them aborts parsing.
+# unmaintained/unsound are Scope values: "all" | "workspace" | "transitive" | "none".
+# The pre-0.20 "warn" policy is preserved as "none" (warn, never block CI).
+unmaintained = "none"
+unsound = "all"
+# Whether to error on crates yanked from crates.io (LintLevel). The previous
+# `ignore-yanked = false` ("never ignore yanked crates") maps to warn-level.
+yanked = "warn"
+# Known advisories in the upstream dependency tree, each registered with an
+# explicit reason. New advisories NOT listed here fail the gate. The pinned
+# upstream versions cannot be upgraded without breaking API semantics:
+# - russh 0.45 (^0.45) -> fixed 0.60.3 is a breaking API jump
+# - glib 0.18 / quick-xml 0.28-0.30 / memmap2 0.7-0.8 are pinned by the
+# Linux desktop stack (tauri 2.11 / screenshots / enigo)
+# - rsa 0.9.10 (RUSTSEC-2023-0071) has no fixed release
+ignore = [
+ { id = "RUSTSEC-2026-0154", reason = "russh 0.45 pinned by ^0.45 in Cargo.toml; fixed version 0.60.3 is a breaking API upgrade" },
+ { id = "RUSTSEC-2026-0153", reason = "russh-cryptovec ships with pinned russh 0.45; same upgrade constraint" },
+ { id = "RUSTSEC-2023-0071", reason = "rsa 0.9.10 via russh-keys; no fixed release exists for this advisory" },
+ { id = "RUSTSEC-2024-0429", reason = "glib 0.18 pinned by tauri 2.11 Linux GTK stack; fix requires glib 0.20 (gtk-rs major bump)" },
+ { id = "RUSTSEC-2026-0194", reason = "quick-xml 0.28/0.30 are build-time deps of the Linux desktop stack (xcb/wayland); fix 0.41 is a breaking jump" },
+ { id = "RUSTSEC-2026-0195", reason = "quick-xml NsReader OOM advisory; same quick-xml version constraint as RUSTSEC-2026-0194" },
+ { id = "RUSTSEC-2026-0186", reason = "memmap2 0.7/0.8 pinned by screenshots/enigo on Linux desktop; fix 0.9.11 is a breaking jump" },
+ { id = "RUSTSEC-2026-0187", reason = "lopdf 0.41 pinned by anydoc 0.1.6 (document conversion); fix 0.42 is a breaking jump" },
+ { id = "RUSTSEC-2026-0002", reason = "lru 0.12 pinned by ratatui 0.29 (CLI TUI); fix 0.16.3 is a breaking jump" },
+]
+
+[bans]
+# Ban specific crates
+# multiple-versions is warn (not deny): the current upstream dependency tree
+# legitimately contains 110+ duplicate crate versions (tauri/webview2/mozilla
+# stacks). Deny would make the gate permanently red and CI unusable; warn keeps
+# the duplicates visible without blocking.
+multiple-versions = "warn"
+# wildcards is warn (not deny): dozens of internal path dependencies use "*"
+# version requirements upstream. Deny would block the whole workspace; warn
+# keeps them visible.
+wildcards = "warn"
+deny = []
+# skip list - allow multiple versions for some crates (usually unavoidable via transitive deps)
+skip = []
+# skip-tree - allow multiple versions for an entire subtree rooted at a crate
+# (none currently: the previous tokio-util/aws-sdk-s3 entries no longer match
+# the dependency graph and only produced unmatched-skip-root warnings)
+skip-tree = []
+
+[licenses]
+# 0.20 removed copyleft/allow-osi-fsf-free/default/deny (PR#611); the allow
+# list below is now the single source of truth for what is permitted.
+# Ignore license checks for private workspace crates that are never published
+# (bitfun-*/terminal-* don't declare a license field upstream); third-party
+# dependencies are still fully checked.
+private = { ignore = true }
+# Allowed licenses
+allow = [
+ "MIT",
+ "Apache-2.0",
+ "Apache-2.0 WITH LLVM-exception",
+ "BSD-2-Clause",
+ "BSD-3-Clause",
+ "ISC",
+ "Unicode-3.0",
+ "Unlicense",
+ "CC0-1.0",
+ "Zlib",
+ "MPL-2.0",
+ # Permissive OSI/FSF licenses in the current dependency tree (cargo deny
+ # rejects anything not explicitly listed):
+ "BSL-1.0", # clipboard-win, error-code (OSI + FSF free)
+ "UPL-1.0", # readability-js (OSI + FSF free)
+ "CDLA-Permissive-2.0", # webpki-root-certs, webpki-roots (permissive data license)
+]
+confidence-threshold = 0.8
+# Exceptions - crates with explicit license approval. Keep only per-crate
+# licenses that are NOT in the global allow list (none currently).
+exceptions = []
+
+[sources]
+# Allowed crate sources (0.20: allow-registry is an array of registry URLs;
+# allow-git-registry was removed, use allow-git for git sources)
+unknown-registry = "deny"
+unknown-git = "deny"
+allow-registry = [
+ "https://github.com/rust-lang/crates.io-index",
+]
+# Git dependency allowlist
+allow-git = [
+ # tauri git dependency pinned in Cargo.lock (pre-0.20 `allow-git-registry`)
+ "https://github.com/tauri-apps/tauri.git",
+]
diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md
index f55b70844..bb8c5c521 100644
--- a/docs/architecture/agent-runtime-deployment-design.md
+++ b/docs/architecture/agent-runtime-deployment-design.md
@@ -65,14 +65,14 @@ flowchart TB
| 范围 | 当前状态 |
|---|---|
| Embedded Desktop GUI | 继续使用 Desktop 事件投影和 Tauri adapter;按实际打开的本机 workspace 延迟取得并持有 Embedded ownership,不增加后台进程;目标迁入同进程私有 App Server |
-| Embedded interactive TUI | 已组装同进程私有 App Server,通过 in-memory transport、`AppServerClient` 和 `AppServerTuiBackend` 完成当前核心聊天与 Session 路径;剩余管理面继续迁移 |
+| Embedded interactive TUI | 已组装同进程私有 App Server,通过 in-memory transport、`AppServerClient` 和 `AppServerTuiBackend` 完成当前核心聊天、Session 与 Phase 3/4 管理面路径 |
| 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;Model、Skill、Subagent 和 MCP 管理由 Shared CLI Host 显式装配 App Server 的具体 `AppManagementService` 保留,默认仍是 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;Model、Skill、Subagent、MCP、External Source V1 和 Hook 管理由 Shared CLI Host 显式装配 App Server 的具体 `AppManagementService` 保留;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。
@@ -519,7 +519,7 @@ Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理
- 当前入口使用第 1.1 节列出的 adapter;若第 1.2 节目标通过评审并迁移完成,Desktop GUI、Web UI 和交互式 TUI 才统一使用 App Server。
- Client、窗口、Session 或 workspace 数量不会自动等量增加 Runtime 或 Plugin Host 进程。
- 当前 Shared Runtime IPC 是第一方 TUI 的 private compatibility transport,不成为公开 SDK、Remote、Peer、HTTP 或浏览器协议;是否由 App Server Shared transport 替换仍待评审。
-- Shared TUI 的 Model、Skill、Subagent 和 MCP 管理暂由 CLI Host 显式装配的 App Server `AppManagementService` 承接;这不扩展 v17,不改变 Shared Runtime 对 Session/chat 的权威性,也不能用于 Remote workspace 的控制端本机回退。MCP service 的进程状态和 tool registry 只属于当前 CLI 进程,不即时重配已经运行的 Shared Runtime Host;跨进程 MCP 管理需要单独的同步/restart contract。
+- Shared TUI 的 Model、Skill、Subagent、MCP、External Source V1 和 Hook 管理暂由 CLI Host 显式装配的 App Server `AppManagementService` 承接;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;只有交互式 TUI 的显式 `--shared` 选择 Shared。互斥按 `workspace + product` 生效,不再按入口名称缩窄。
- Account/session cloud sync 仍使用既有 Core compatibility 边界,不属于 Shared Runtime 支持。
- Remote workspace 的文件、凭据、进程和 Runtime 位于目标执行域,禁止静默回落本机。
diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md
index e767ebfd0..9c9c5b28d 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/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md
index f08fd8621..a4492d158 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:
-> **实现状态:部分交付。** 交互式 TUI 已通过 Host 返回的 V2 快照提供应用级状态、连接、断开、暂不使用和分页批量确认;Embedded 连接旧 Host 时回退既有 V1 只读状态,未接线的 Shared Runtime 明确不支持且绝不回退到控制进程本地执行。任务相关 `action-required` 与非交互 CLI 结果仍未交付。
-
-- `/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 5b5bde622..f02b9484b 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 3d55dfb88..000000000
--- a/docs/architecture/extensions/external-ai-app-connection-experience-design.md
+++ /dev/null
@@ -1,551 +0,0 @@
-# 外部 AI 应用连接与管理详细设计
-
-本文定义“外部 AI 应用”在 Desktop Settings、交互式 TUI 和非交互 CLI 中的应用级连接与管理体验。稳定架构、归属模块和运行视图见[外部 AI 工作内容架构](external-ai-work-sources-design.md),实施顺序见[外部 AI 应用连接体验执行计划](../../plans/external-ai-app-connection-experience-plan.md)。
-
-本文只描述交互、应用级读模型、动作语义和宿主投影,不重定义生态解析、能力归属、执行权限或插件运行时。
-
-> **实现状态:部分交付。** 当前分支保留严格 V1 兼容路径,并已交付独立 V2 应用快照、作用域化连接偏好与迁移、分页批量确认、Desktop/Peer 投影,以及 Desktop Settings 和交互式 TUI 消费。App Server 只在真实注入 management owner 的宿主中暴露这些方法;Shared Runtime 与通用 Server 不伪装支持。任务相关 `action-required`、非交互 CLI 结果和 `HookManagementSnapshot` 仍是后续工作。Hook 管理继续沿用独立 owner 和现有安全审核契约,其产品入口与展示规则见第 5.5、7.1 和 8.3 节。
-
-## 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 首页
-
-首页沿用现有 `ConfigPageLayout` 的 760px 正文最大宽度。接入设置不能藏在详情或“高级设置”深层,默认页面只保留:
-
-1. 一个应用级总开关,控制是否使用外部 AI 应用能力;
-2. 一个推荐模式入口,默认由产品安全策略自动完成发现、连接和低风险能力加载;
-3. “需要确认”入口,仅在确有少量不能安全自动决定的事项时显示数量;
-4. 一个可展开的应用/能力树,供用户查看结果或覆盖单项决定;
-5. Safe Mode,仅在生效或当前宿主可操作时显著展示。
-
-首页不平铺 Tool、Subagent、MCP、Hook、来源路径、冲突、完整诊断、scope 和兼容参数。应用树默认折叠,只在用户希望检查单项状态时展开。
-
-默认路径的目标不是让用户逐项配置,而是由产品完成大多数决定:只读发现自动执行;成熟适配中安全上限内的低风险声明式内容按推荐策略自动连接和加载;已有等价决定静默复用;普通更新静默同步。只有可执行内容首次信任、实质权限扩大、无法自动选择的真实冲突,或宿主安全策略要求时才进入“需要确认”。
-
-用户处理全部待确认事项应在一个页面或弹层内完成:默认应用共享推荐集合,提供“使用推荐设置”和“暂不启用”少量主操作;可展开树只用于查看和调整例外项。正常流程不要求用户理解能力分类、来源文件、作用域或诊断码。
-
-应用行与能力节点用于解释自动化结果,而不是要求用户逐项决策。顶层只显示应用名、整体状态和简短结果;展开后才显示能力类别与单项状态。没有问题的应用不展示操作按钮,连接、断开或单项覆盖通过同一树内的上下文操作完成。
-
-### 5.2 应用树与逐级披露
-
-默认页面不再要求用户进入独立应用详情才能接入或查看结果。应用树按外部应用分组;每个折叠行只包含展开箭头、应用名称、已启用能力图标和应用总开关:
-
-- 打开应用总开关等价于“使用推荐设置”,系统自动应用安全且无歧义的决定,不启动配置向导;
-- 关闭应用总开关撤下该应用贡献的运行能力,但不修改外部配置、不删除已保存决定,也不影响其他应用;
-- 只显示已启用能力类型的紧凑图标,未启用类型不占位;图标全名、数量和状态原因通过 tooltip 或无障碍标签提供;
-- 应用或能力下存在待确认项时只显示圆点/计数,不重复放置确认按钮或说明段落;
-- 展开应用后显示能力类型及类型开关,再展开能力类型才显示单项覆盖;单项明细不是正常使用的前置步骤。
-
-正常状态下不显示“管理连接”、恢复或确认文案。低频作用域覆盖、来源路径、完整诊断、兼容说明和恢复动作进入高级设置。Safe Mode 生效时仍必须显著显示,不能因折叠隐藏。
-
-### 5.3 单一确认入口
-
-Tool、Subagent、MCP、Hook 和无法自动决定的真实冲突进入同一个确认页面或弹层,不使用连续弹窗,也不要求按应用重复提交。顶部仅在存在真实待确认项时显示一个带数量的入口;应用树只负责定位相关应用和能力。
-
-确认页默认只显示总数、简短风险摘要和共享推荐集合,并提供“使用推荐设置”和“暂不启用”两个主要决定。用户展开分类或单项后,才展示名称、来源、路径、命令、环境变量名、网络目标、冲突和行为变化。敏感值、完整 prompt、完整 URL query 和未经脱敏的绝对路径不进入公共快照。
-
-系统应把确认项压缩到最少:只读发现、低风险声明式能力、行为等价更新、已有有效决定和无歧义优先级自动完成。首次可执行内容信任、实质权限扩大、无法安全自动消解的冲突及组织策略要求才需要确认。待确认项在决定前不运行,但同应用其他安全能力继续生效。
-
-提交不要求客户端读取全部分页:`review_id` 绑定同一不可变确认计划,`selection_baseline` 只能是共享推荐集合或空集合,`selection_overrides` 只携带与基线不同的稳定项目引用和选择结果。服务端从同代权威计划还原完整选择,依次应用基线和改动项,再校验作用域、偏好版本、发现代次、决策键、行为版本、安全上限和最大选择数。计划过期或引用不属于该计划时整批拒绝,不能把不同页面或不同代次拼接。
-
-批量语义:
-
-- stale revision、无效 generation 或宿主能力整体不兼容时,整个请求不应用;
-- owner 允许逐项业务拒绝时,响应返回逐项结果;宿主只把成功项标为已启用;
-- “整个请求不应用”只保证分派前的 identity、revision 和 generation 预检;开始分派后若 owner 状态并发变化,可以同时返回已应用项和类型化 stale/failed 项,不承诺跨 owner 回滚;
-- 未知结果不能假定成功;
-- 失败项保留可行动原因与恢复动作。
-
-### 5.4 高级设置
-
-以下内容后置到可展开树或高级设置:全局/项目 scope、生态与能力覆盖、物理来源、完整诊断、配置位置、Safe Mode 恢复和兼容说明。高级设置不是正常接入的前置入口,也不能重复承载应用总开关或待确认主操作。
-
-### 5.5 扩展能力与 Hook
-
-External AI Apps 是 GUI 中查看外部应用及其能力的唯一 Settings 一级入口。Hook 与 Command、Skill、Agent、Tool、MCP 等并列,是扩展能力类型之一;不能把 Hook 提升为与外部应用并列的产品域,也不能为了统一界面创建承载所有能力载荷的通用 Extension 对象。
-
-应用树中的能力节点把 Hook 与其他类型并列展示。折叠应用行只显示已启用的 Hook 图标;展开后显示已启用数量和待确认标记,再展开 Hook 节点才列出原生、已导入和可审核单项。Hook 摘要至少区分已启用、待确认、需要更新、部分兼容和异常;技术性的“已发现/可导入/已导入”只在单项详情中表达,不作为顶层用户状态。
-
-Hook 节点同时覆盖:
-
-1. BitFun 用户级和项目级原生 Hook;
-2. 已导入并由 BitFun 管理的外部 Hook;
-3. Claude Code、Codex 等外部应用中可审核导入的 Hook;
-4. Hook 总开关、项目 Hook 开关、配置位置和兼容说明,这些内容作为类型专属高级控制呈现。
-
-该节点优先用图标、开关和计数回答当前哪些 Hook 会运行、来自哪里以及是否需要确认;解释文字进入 tooltip、无障碍标签或按需详情。完整命令、依赖、matcher 和诊断只随单项展开。Settings 不再单列 Agent Hooks 一级入口;既有深链可兼容导航到 External AI Apps 并展开对应 Hook 节点,但不能继续形成第二套管理页面。
-
-Hook 数据仍由 `native_hooks`、`external_hooks` catalog 和 `external_hook_import` 各自拥有。External AI Apps 只消费应用与能力摘要,不复制可执行载荷、不修改外部来源文件,也不取代 Hook 的精确计划审核、revision fencing、行为版本和运行开关。
-
-加载遵循渐进披露:默认页只读取轻量摘要;用户展开应用、能力节点,或已有 Hook 相关真实待办时才读取对应详情。不能为了默认页精确计数而预加载命令和依赖;摘要不足时使用图标状态而不是触发无界扫描。
-
-### 5.6 当前 V1 修复切片
-
-在组合 `HookManagementSnapshot` 尚未交付时,Desktop 仍必须保证既有 Hook 管理能力可达,但不能为此恢复第二个 Settings 一级入口或复制一套 Hook 数据 owner。当前 V1 修复切片采用以下过渡边界:
-
-1. `External AI Apps` 保持唯一一级入口;页面内提供一个按需展开的 Hook 专属区域,复用现有 `app.hooks` 配置、`external_hooks` catalog 和 `external_hook_import` 操作。区域未展开时不读取 Hook 详情;旧 `hooks` 深链进入本页并自动展开、聚焦该区域。
-2. Hook 区域只做现有 owner 的组合呈现,不创建新的后端协议、不复制可执行载荷,也不把现有 Hook 操作改接到 external-source policy。后续组合快照交付时替换读模型,不改变现有写操作的 owner。
-3. 应用总开关从 `custom` 关闭时只把当前 mode 设为 `disabled`,不清除当前作用域的 capability overrides;重新打开时,存在保留 override 的应用恢复为 `custom`,否则进入 `recommended`。显式重置会清除 override,因此仍返回推荐模式。
-4. 应用树的能力状态展示生效权限,而不是连接计划中的推荐权限。`auto`、`ask_before_use`、`discover_only` 和 `disabled` 必须有不同且准确的用户文案;推荐值只用于产生默认决定,不能冒充当前状态。
-5. 首次加载且没有可展示快照时,页面显示可访问的错误提示和带文字的重试操作,并暂不展示依赖快照的应用树与高级设置。已有 last-valid 快照时继续展示该快照,同时标记降级状态,不能因刷新失败把现有内容替换为空白页。
-6. 新增或调整的 appearance part 必须在同一 DOM 节点声明所属 component,并与 appearance 注册表一一对应;不能通过放宽审计或保留不存在的 part 让检查通过。
-
-该切片必须用生产路径组件测试覆盖旧深链到 Hook 区域、Hook owner API 可达、`custom -> disabled -> custom` 权威快照往返、四种生效权限文案、无快照错误恢复和 last-valid 降级显示,并通过 appearance contract audit。它不实现完整 Hook 摘要计数、跨宿主通知决定或新的共享连接协议。
-
-## 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 等价的批量确认语义:共享推荐集合、高风险默认不选、可展开技术详情并调整。能力管理沿用竞品与 BitFun 已建立的直达命令:`/hooks`、`/tools`、`/agent` 和 `/mcp` 都是完整专项入口,不要求用户先进入 `/extensions`,也不新增 `/extensions hooks` 一类层级。GUI 的应用/能力导航不能被强加为 TUI 命令心智。
-
-`/hooks` 同时展示 BitFun 用户级和项目级 Hook、已导入的外部 Hook,以及 Claude Code、Codex 等应用中可审核的 Hook 来源,并提供现有审核、导入、更新、启停和移除操作。`/hooks_external` 与 `/hooks-external` 仅保留解析兼容,不进入推荐帮助和补全。TUI 与 GUI 使用同一后端派生状态和安全操作,但各自保留适合表面的布局。
-
-首次发现只显示一次非阻塞摘要;无关待办不阻塞聊天输入。
-
-### 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、显示摘要、推荐与安全上限
-```
-
-首次打开确认页时,请求不带 cursor 和 expected generations;若后台发现已在首页快照后完成,Host 可以返回当前只读确认计划,并以响应中的 `review_id` 和 generations 作为后续翻页与提交的唯一基准。偏好版本、执行域、工作区和目标作用域仍必须完全匹配。首次响应之后,分页游标严格绑定作用域、`review_id`、偏好版本和发现代次;任一事实变化都返回过期并重新读取,不能把旧页与新页拼接。详细页通过稳定项目引用关联现有 Tool、Subagent、MCP 和冲突投影;总量继续服从现有归属模块上限,完整提示词、命令正文、凭据和可执行载荷不进入分页响应。
-
-状态和主操作由共享归属模块派生;React、TUI、Peer 和 Server 不重复实现优先级规则。
-
-### 8.3 Hook 管理读模型与通知决定
-
-Hook owner 应提供一个面向产品表面的版本化组合读模型,供 External AI Apps 和 TUI `/hooks` 使用。它组合原生 Hook 概览、外部 Hook catalog 与导入摘要,但不成为新的数据 owner:
-
-```text
-HookManagementSnapshot
- schema_version / revision
- native
- enabled / project_hooks_enabled
- configured_count / active_count / issue_count
- applications[]
- ecosystem_id / display_name
- discovered_count / importable_count
- imported_count / enabled_count
- update_count / unsupported_count / issue_count
- attention_state
- imports[]
- existing import summaries
- notice?
- notice_key / attention_reason / acknowledged
-```
-
-摘要不得携带完整命令、环境变量值、依赖文件正文、matcher 正文或其他执行载荷。能力专属操作继续调用现有 Hook API;写操作成功后返回或重新读取权威快照,客户端不能长期维护乐观派生状态。现有 `ExternalHookImportSnapshotV1` 能直接表达的字段应复用,不能复制一套同构导入协议。
-
-首次发现圆点的已查看事实必须由事实所在 Host 持久化,不能只写 React `localStorage`。最小决定键包含 execution domain、可选 workspace scope、ecosystem、notice kind 和行为或摘要版本。`acknowledged` 只消除低侵入提示,不代表信任、导入或允许运行;安全决定仍由现有计划指纹、revision、行为版本和运行开关控制。相同行为版本跨重启不重复提示,只有新审核、实质权限扩大、已激活 Hook 失效或真实冲突才能产生新 notice。
-
-冲突由能力 owner 在默认状态确实不能共存、用户正在启用冲突项,或外部变化使既有决定失效时产生。仅发现多个候选或数量变化不构成冲突,也不触发打断式确认。
-
-任务依赖通过执行路径单独返回,不进入可轮询、可持久化的应用快照:
-
-```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` 不提交用户决定或运行能力写动作,直接承担版本探测,不再增加单独的版本信息接口。首次激活对应 owner 时允许执行可重入偏好迁移并启动既有后台发现;当前 V2 偏好读取不得重复写回。确认分页只读取已激活 owner 的不可变结果,不能冷启动服务或发现:
-
-- 新宿主返回严格的 V2 快照和 `host_capabilities`;客户端校验成功后,才可读取分页确认项或发送 V2 写操作;
-- 旧宿主对 V2 快照返回传输层 method-not-found 时,客户端回退显示 V1 来源/能力管理并禁用 V2 写操作;
-- “升级宿主”由新客户端根据 method-not-found 本地投影,不能向旧宿主发送未知 V2 动作,也不能要求旧宿主返回 V1 不认识的恢复类型;
-- 旧客户端只调用原 V1 接口,因此新宿主必须继续生成严格 V1 响应;V1/V2 快照不得拼接成混合数据结构;
-- 数据结构不匹配、宿主身份变化或重连后,所有未完成 V2 写操作和分页游标失效并重新读取快照。
-
-兼容测试必须覆盖旧客户端 → 新宿主、新客户端 → 旧宿主、V2 同代成功、未知数据结构/枚举安全拒绝,以及重连后旧响应不能覆盖新执行域或工作区作用域。
-
-### 8.2 性能与演进约束
-
-- 应用快照和确认分页必须从当前不可变发现结果派生;首次快照可触发既有 owner 的迁移和后台发现,确认分页不得重新扫描文件、冷启动 owner、启动外部进程或持有偏好写锁。
-- 首页只返回摘要,确认页每页最多 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
-
-- 默认页只呈现总状态、单一确认入口、刷新和按应用分组的可展开树;
-- 应用折叠行只显示名称、已启用能力图标和应用总开关,解释文字进入 tooltip 或按需详情;
-- 打开应用自动应用推荐设置,不启动逐项配置流程;关闭应用撤下其运行能力且不修改外部文件;
-- 应用树把 Hook 与其他扩展能力并列,逐级展开后同时覆盖 BitFun 原生和外部来源;
-- Settings 不再单列 Agent Hooks 一级入口,旧深链导航到 External AI Apps 并展开 Hook 节点;
-- 所有例外通过一个确认入口一次处理,只有首次可执行信任、权限扩大和真实冲突进入确认;
-- 首次发现圆点由 Host 持久化去重,同一行为版本跨重启不重复;
-- 批量默认选择与共享推荐一致,待确认单项不阻塞其他安全能力;
-- 技术详情默认折叠;
-- 过期读取或写操作不覆盖新状态;
-- 现有 Safe Mode、审批、冲突、诊断和脱敏测试保持通过;
-- type-check、i18n 和主题治理通过。
-
-### 13.3 TUI 与非交互 CLI
-
-- GUI/TUI 对同一 fixture 的应用状态、默认策略和数量一致;
-- `/extensions review` 提交同一批量决定;
-- `/hooks` 完整显示并管理 BitFun 原生、已导入和可审核的外部 Hook,且不新增 `/extensions hooks`;
-- `/hooks_external` 与 `/hooks-external` 仅保留解析兼容,非交互 `bitfun hooks` 契约保持稳定;
-- 提示去重跨进程和宿主生效;
-- 无关待办不阻塞交互;
-- 非交互仅在当前任务受影响时返回 `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 f222f4d54..ecf60b044 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/platform-portability-design.md b/docs/architecture/platform-portability-design.md
index 44761b996..0b4b3294e 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 ed904873d..9c171546b 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)、
@@ -638,8 +636,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 分支行为。
@@ -761,6 +759,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 +817,10 @@ 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 偶然出现 |
+| 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 +837,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 8e836364d..fb608230e 100644
--- a/docs/architecture/rust-build-dependency-boundaries.md
+++ b/docs/architecture/rust-build-dependency-boundaries.md
@@ -54,6 +54,26 @@ Cargo 会统一同一 package 在依赖图中的 feature;workspace dependency
入口应选择真实需要的 owner feature;`product-full` 只能描述确实需要完整产品装配的兼容入口,不能作为尚未完成 feature/owner 分解时的占位解法。缩小某个产品的 capability 集合时必须从实际 construction/command path 反推,并保留行为等价或明确 unsupported-state 测试。
+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;
@@ -62,11 +82,11 @@ Cargo 会统一同一 package 在依赖图中的 feature;workspace dependency
- 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 平台证书验证承载时,才以明确行为证据评审替换方案,而不是重新叠加第二后端。
diff --git a/docs/performance/01-compile-performance.md b/docs/performance/01-compile-performance.md
index b5e036b18..509ed20b2 100644
--- a/docs/performance/01-compile-performance.md
+++ b/docs/performance/01-compile-performance.md
@@ -1,87 +1,140 @@
# BitFun 编译与依赖治理计划
-> 最近核实:2026-08-04
+> 最近核实:2026-08-10
>
-> 快照基线:`gcwing/main@061024fb2` 加权限规划 owner 迁移
+> 实现复核基线:`gcwing/main@734e5b05f`
+>
+> 性能 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` 边界;收益不足则停止 |
-| 依赖多版本不能按数量批量清理 | 只处理仓库能控制、行为等价且能缩小真实构建图的版本路径 |
-
-权限 owner 的长期边界和功能不变量见
-[Agent Runtime 服务设计](../architecture/agent-runtime-services-design.md)。这里不重复维护行为规格。
+| 服务测试链接拓扑已收敛 | Services 两个 crate 的集成 target 总数从 33 降到 25;选中的 `local-storage`、MCP、基础 SSH 闭包从 16 个集成 executable 降到 8 个 |
+| 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 |
+| 完整产品行为和闭包保持 | `product-full` 显式组合全部 owner,Windows normal/build 闭包保持 570;CLI 保持 649。ACP 只退出未选择或未使用的隐含能力,累计在 Windows/macOS/Linux 分别减少 12/15/24 |
+| Installer 删除未使用的直接能力 | 独立 manifest 的直接 dependency 从 18 降到 10,Windows normal/build 闭包减少 6;不把 Installer 并入根 workspace,本 PR 按要求不提交其生成 lockfile |
+| focused test 仍保持精确 | 同 owner、feature、平台和进程语义的源文件进入分组 target;使用 `--test ::` 运行单模块 |
-## 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 服务层测试拓扑
+
+本轮只合并 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 |
-| 路径 | 当前快照 | 判断 |
-|---|---:|---|
-| `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 | 已完成收敛;平台和进程边界继续独立 |
+Windows、Cargo 1.97.1 的同机独立 `CARGO_TARGET_DIR` A/B 如下。冷构建、无变更重跑和
+单叶文件 mtime 触发各测一次;“owner 重建”在依赖已热后对 owner package 执行三轮
+clean/rebuild,表中为均值。时间是方向性证据,不是硬阈值。
-节点数来自同一 Windows 环境下的 `cargo tree --locked` 相对统计,不是实际耗时,也不是跨平台阈值。
-权限纯策略路径理论上少进入约 371 个节点;产品构建闭包没有变化。
+| 闭包 | 冷构建前→后 | 无变更前→后 | 单叶变更前→后 | 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,不计入本轮收益。
+
+可重复确认的产物变化如下;`test executable` 包含每个 crate 的 lib test harness,因此比 integration
+target 多 1。PDB 大小会随工具链变化,只比较同次 A/B:
+
+| 闭包 | 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 |
### 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` 上,本轮继续把两个重型能力从 Core 基线改为弱
+modifier。计数先移除 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` |
+
+本轮没有新增 crate 或第三方 dependency。收益来自两类现有重闭包退出窄入口:`anydoc` 及其
+文档解析/压缩依赖,以及订阅凭据的 keyring/加密/本地存储依赖。完整产品 package 集合不变,
+因此这里只报告依赖图收敛,不宣称 `product-full` wall-clock 提速。
+
+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` 与实现复核基线保持一致,package 记录不增加;Installer 自己生成的
+`BitFun-Installer/src-tauri/Cargo.lock` 本 PR 不提交。
+
| 状态 | 范围 | 处理结论 |
|---|---|---|
-| 已稳定 | 根 `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 Agent Runtime capability、文档转换与订阅认证 modifier、Installer 未使用直接依赖 | 以真实入口 closure 收敛,不建立新的产品 umbrella,也不扩大根依赖宇宙 |
+| 当前不动 | 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 +146,40 @@
| 可复现解析 | 根 lockfile 已提交,普通 CI 使用 `--locked`;build.rs 输出已排序 |
| CI 拓扑 | Rust job 不再等待完整前端构建,自建 Tauri 检查所需资源目录 |
| 依赖收敛 | Desktop 直接 image 版本和 Reqwest TLS 双栈已治理 |
+| Agent Runtime 闭包 | Core 基线不再暗带具体 capability;完整产品和 CLI 显式保持原能力,ACP 退出未选择闭包 |
+| 重型可选能力 | 文档转换和本地订阅凭据由弱 modifier 细化已有 runtime owner;Core 基线和 App Server 退出未消费闭包 |
+| Installer 闭包 | 删除 8 个未使用直接 dependency;独立 workspace 和发布生命周期不变,本 PR 不提交其生成 lockfile |
| Agent Runtime 测试 | 28 个 integration executable 已收敛为 5 个职责/平台 target |
+| Services 测试 | 两个服务 crate 使用显式 target;选中闭包少 8 个 integration executable,进程/feature/external-system 边界保持独立 |
-内置 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。需要真实 CI 样本或上游条件成熟后,按以下顺序重新核实:
-这是下一 PR 的推荐范围,也是唯一需要优先设计的核心入口改造。
-
-先回答:
-
-- 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 9fc57e62b..000000000
--- a/docs/plans/external-ai-app-connection-experience-plan.md
+++ /dev/null
@@ -1,637 +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)为准。
-
-> **实现状态:分阶段交付。** 当前分支已完成共享 V2 应用契约、产品默认、旧偏好迁移、分页批量确认、Desktop/Peer/App Server 薄适配,以及 Desktop Settings 和交互式 TUI 的纵向切片。Web 在旧 Host 上保持严格 V1 只读回退;交互式 TUI 只在 Embedded 旧 Host 上回退 V1,未接线的 Shared Runtime 明确不支持且不会改在控制进程本地执行。通用 Server 尚未绑定可信 workspace owner,任务相关 `action-required`、非交互 CLI 结果、组合 Hook 摘要和完整跨宿主回归仍按本计划后续阶段推进,不能据此宣称支持。
-
-## 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 快照不提交用户决定或运行能力写动作并直接用于能力探测;首次 owner 激活仍可执行可重入迁移和既有后台发现;
-- 所有 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`:首次无 cursor/no-generation 打开允许 Host 在后台发现刚完成时返回当前只读计划,客户端从该响应接续;其余分页游标严格绑定执行域、工作区作用域、`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 同代的新快照。
-
-这里的零应用保证止于分派前预检。分派开始后若某个 owner 的事实并发变化,响应可以同时包含已应用项与类型化 stale/failed 项;本阶段不增加跨 owner 事务或回滚管理器,也不宣称批量业务执行原子化。
-
-### 测试优先顺序
-
-- 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 数据结构,并携带宿主读写能力;首次 owner 激活可执行可重入迁移和既有后台发现,确认分页不得冷启动 owner。旧宿主的传输层 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. 首页使用现有 `ConfigPageLayout` 的 760px 单列阅读轴:标题、应用列表、高级设置。真实的任务相关待办通过就地提示或状态变化处理,不把无法归属的系统诊断聚合成首页数量。
-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 8d7f9f088..dbd5dfc96 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-3 已完成当前定义的边界、协议基础、核心聊天和配置管理迁移;Phase 4 尚未开始,Phase 5 目标待评审。
+> 状态:Phase 0-4 已完成当前定义的边界、协议基础、核心聊天、配置管理和外部集成接口迁移;Phase 5 Shared App Server 目标待评审。
>
-> 当前状态基线:2026-08-06。一次性的运行证据保留在对应 PR/Actions 记录中;本文不绑定会因 rebase 失效的提交 SHA。
+> 当前状态基线:2026-08-09。一次性的运行证据保留在对应 PR/Actions 记录中;本文不绑定会因 rebase 失效的提交 SHA。
>
> 本文只记录当前差距、阶段和完成证据。稳定架构约束见相邻架构文档;Phase 0 的历史盘点已失效,不再作为当前能力清单。
@@ -58,7 +58,9 @@ Shared TUI (--shared)
两条路径统一的是 TUI 可见的行为端口。Shared compatibility adapter 会把 Runtime IPC 的结果和事件映射为 `TuiBackend` 使用的类型,但它没有运行 `BitfunAppServer`,也不是 Shared App Server transport。
-Phase 3 已将 Mode/Model、Skill、Subagent 和 MCP 管理面迁移到 `TuiBackend` 的 owner-specific typed API。具体 DTO/owner 适配由 App Server 的 `AppManagementService` 持有,并由 Host 显式装配;Embedded TUI 经 App Server 访问既有 owner。Shared 的 Session/chat/mode authority 继续映射 v17,Model、Skill、Subagent 和 MCP 则由 `SharedTuiBackend` 委托同一个具体 management service。该兼容路径保留迁移前的本机同用户产品行为,不扩展 v17 wire,也不适用于 Remote workspace。Phase 4 的 Hook、外部来源、Account、Settings Sync 和 Worktree 管理面仍可能通过 Host 中的 compatibility 路径完成,它们是当前剩余差距,不能据 Phase 3 完成状态宣称整个 TUI 已解耦。
+Phase 3 已将 Mode/Model、Skill、Subagent 和 MCP 管理面迁移到 `TuiBackend` 的 owner-specific typed API。Phase 4 进一步迁移了 External Source、native/external Hook、Account、Settings Sync 和 Worktree 管理面。具体 DTO/owner 适配由 App Server 的 `AppManagementService` 持有,并由 Host 显式装配;Embedded TUI 经 App Server 访问既有 owner,TUI controller 不再直接访问这些 compatibility owner。
+
+Shared 的 Session/chat/mode authority 继续映射 v17;Host 实际提供的本机管理 capability 由 `SharedTuiBackend` 委托同一个具体 management service。当前 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 完成表示接口边界已迁移,不表示所有 deployment 的 capability 完全相同。Phase 4 之后新增的 External Application V2 控制面目前只在 Embedded App Server 接线,Shared Runtime 明确 unsupported,不重新打开 Phase 4 的旧 owner 直连预算。
### 2.2 Proposed target
@@ -132,10 +134,10 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll
| Mode/Model 管理 | Embedded 经 typed mode catalog 和 model list/get/add/update/delete/default API;read DTO 只含 secret configured metadata,mutation 使用 preserve/replace/clear | Phase 3 已完成;Shared mode catalog 来自 Runtime Host,model 管理由 Host 装配的 App Server management service 转发,Session model mutation 仍由 v17 owner 提交 |
| Skill/Subagent | TUI 经 typed list/toggle API 消费 visible/manageable read model;App Server management service 委托既有 registry owner | Phase 3 已完成;Embedded 与 Shared 共用具体 service,Shared capability 明确属于本机 CLI compatibility scope |
| MCP | 当前 TUI 用例经 typed catalog/status/toggle/add/delete/external decision/conflict API;read projection 与 Debug 输出不暴露凭据 | Phase 3 已完成当前定义;Shared 通过当前 CLI 进程的本地 MCP compatibility service 保留迁移前管理行为。该 service 的 MCP 进程状态和 tool registry 不会即时重配已经运行的 Shared Runtime Host;要取得 Host 侧新状态仍需显式的同步/restart contract,不能把本地 toggle 描述成 v17 远端控制 |
-| 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 |
+| External Source/Tool/Command/Agent | TUI 经 typed snapshot/control/review、conflict choice、command expansion 和事件接口消费既有 owner;后续 External Application V2 snapshot/review/action 已在 Embedded 接线 | Phase 4 当前定义已完成;Shared 保留 V1 本机 compatibility,V2 明确 unsupported,Remote 不回落本机 |
+| Hooks | TUI 经 typed native overview 与 external snapshot/plan/apply/mutate API 消费既有 owner | Phase 4 已完成;native user hooks、compiled-in `post_call_hooks` 和 external hook catalog 继续分离,Remote 明确 unsupported |
+| 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 尚未迁移为 App Server Host | Host allowlist、身份/作用域、真实 limits 与平台 capability provider |
### 3.4 本地保留
@@ -183,7 +185,7 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll
| 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 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、Worktree 管理面经 typed backend;remote 不回落本机 | owner/remote/security contract tests | 未开始 | - |
+| 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:Shared App Server | Shared Host 达到 v17 治理等价,opt-in 双栈验证完成,并有回滚与删除证据 | 跨 transport parity、故障、性能和安全测试 | 未开始,目标待评审 | - |
### 5.1 Phase 0-2 已交付摘要
@@ -221,6 +223,8 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll
目标:迁移外部来源、Hook、Account、Settings Sync 和 Worktree 管理面。
+状态:已完成当前定义。
+
完成条件:
- mutation 有 identity/revision、stale、取消和 audit 语义。
@@ -228,6 +232,14 @@ 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` 调用这些用例。Phase 4 涉及的 `bitfun_core`、account/account-sync compatibility marker 已从 controller 文件移除,对应 Core boundary budget 固定为零。
+- 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 5 的评审门槛。
+
### 5.4 Phase 5
Phase 5 不以“删除 v17”为起点。建议顺序:
@@ -255,7 +267,7 @@ 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) 中。Phase 3 已运行上列 protocol、client、server、Runtime IPC、CLI binary 和 Core boundary focused 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 行为等价场景
@@ -274,7 +286,7 @@ Shared App Server 实现后,同一 fixture 必须增加 Embedded App Server、
只有同时满足以下条件,才能宣布 TUI/App Server 解耦完成:
-1. Phase 3/4 管理面已迁移,或从产品范围明确移除。
+1. Phase 3/4 当前定义的管理面已迁移;后续新增 capability 也不得绕过 `TuiBackend` 或恢复旧 owner 直连。
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 默认值。
diff --git a/package-lock.json b/package-lock.json
index 5b4884153..cbf6d89c9 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 d37c05c7e..1a3cbf06f 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"
@@ -42,6 +42,7 @@
"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",
@@ -90,6 +91,8 @@
"installer:build:only": "pnpm --dir BitFun-Installer run installer:build:only",
"installer:build:only:fast": "pnpm --dir BitFun-Installer run installer:build:only:fast",
"installer:dev": "pnpm --dir BitFun-Installer run installer:dev",
+ "package:windows:assets": "node scripts/package-windows-assets.mjs",
+ "package:windows:test": "node --test scripts/package-windows-assets.test.mjs",
"cli:dev": "node scripts/cli-product.mjs dev",
"cli:build": "node scripts/cli-product.mjs build",
"cli:install": "node scripts/install-cli.mjs",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 769d91a40..8d4bf8f1e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -960,89 +960,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -1333,36 +1349,42 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
+ libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
@@ -1459,66 +1481,79 @@ packages:
resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.57.1':
resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==}
cpu: [arm]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.57.1':
resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.57.1':
resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.57.1':
resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==}
cpu: [loong64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.57.1':
resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==}
cpu: [loong64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.57.1':
resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.57.1':
resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==}
cpu: [ppc64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.57.1':
resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.57.1':
resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.57.1':
resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.57.1':
resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.57.1':
resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-openbsd-x64@4.57.1':
resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==}
@@ -1598,30 +1633,35 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.10.0':
resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.10.0':
resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.10.0':
resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.10.0':
resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.10.0':
resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==}
@@ -3453,12 +3493,12 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
- deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+ deprecated: Glob versions prior to v9 are no longer supported
glob@8.1.0:
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
engines: {node: '>=12'}
- deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+ deprecated: Glob versions prior to v9 are no longer supported
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
@@ -5290,7 +5330,6 @@ packages:
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
- deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
uuid@11.1.0:
diff --git a/scripts/audit-appearance-contracts.mjs b/scripts/audit-appearance-contracts.mjs
index 8c4d85fd2..7f2d9e10d 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/cargo-target-gc.mjs b/scripts/cargo-target-gc.mjs
index 8494cf597..09dca555e 100644
--- a/scripts/cargo-target-gc.mjs
+++ b/scripts/cargo-target-gc.mjs
@@ -117,6 +117,47 @@ export function selectStaleByMtime(entries, keep) {
return sorted.slice(keep).map((entry) => entry.path);
}
+/**
+ * Keep the newest `keep` entries per crate by mtime, but refuse to delete any
+ * entry whose session is still active elsewhere (d8-P2-6): another worktree
+ * compiling the same crate on a different branch may own the newest
+ * s-*-working root. `isActive` is injected so the decision is testable.
+ */
+export function selectStaleByMtimeWithLiveness(entries, keep, isActive = defaultRootActive) {
+ if (keep < 1) {
+ throw new Error('keep must be >= 1');
+ }
+ if (entries.length <= keep) {
+ return [];
+ }
+ const sorted = [...entries].sort((a, b) => b.mtimeMs - a.mtimeMs);
+ return sorted
+ .slice(keep)
+ .filter((entry) => !isActive(entry.path));
+}
+
+function defaultRootActive(path) {
+ return false;
+}
+
+function rootHasActiveSession(rootPath) {
+ // An incremental root is "active" when it contains a s-*-working session
+ // that is still compiling (fresh mtime). The default keep of 1 protects the
+ // newest root; a concurrent worktree reusing the same CARGO_TARGET_DIR
+ // creates its own s-*-working subdir inside a *different* crate root, so we
+ // additionally keep any root whose newest s-*-working is younger than a
+ // short grace window — even if it is not the newest root by directory mtime
+ // (d8-P2-6).
+ const workingDirs = listDirs(rootPath)
+ .filter((name) => name.endsWith('-working'))
+ .map((name) => join(rootPath, name));
+ if (workingDirs.length === 0) {
+ return false;
+ }
+ const newest = Math.max(...workingDirs.map((p) => safeStatMtimeMs(p)));
+ return Date.now() - newest < 60_000;
+}
+
export function planIncrementalPrune(incrementalDir, { keepSessions = 1 } = {}) {
const toDelete = [];
const groups = new Map();
@@ -133,7 +174,16 @@ export function planIncrementalPrune(incrementalDir, { keepSessions = 1 } = {})
}
for (const entries of groups.values()) {
- toDelete.push(...selectStaleByMtime(entries, 1));
+ // Keep 2 roots per crate instead of 1 so a concurrent worktree compiling
+ // the same crate keeps its incremental root even when its directory mtime
+ // is older than the newest one here (d8-P2-6). Roots with a live
+ // s-*-working session are additionally protected below.
+ const stale = selectStaleByMtime(entries, 2);
+ for (const path of stale) {
+ if (!rootHasActiveSession(path)) {
+ toDelete.push(path);
+ }
+ }
}
const keptRoots = listDirs(incrementalDir)
@@ -257,6 +307,14 @@ export function planFingerprintPrune(
export function planDepsOrphanPrune(depsDir, keptHashes) {
const toDelete = [];
+ // Conservative guard (d8-P2-5): when the fingerprint plan produced no kept
+ // hashes at all (e.g. .fingerprint was cleared/corrupted externally), every
+ // deps artifact would otherwise match the orphan rule and the whole cache
+ // would be deleted, forcing a full rebuild. Treat the empty set as "unknown
+ // state, keep everything".
+ if (!keptHashes || keptHashes.size === 0) {
+ return toDelete;
+ }
for (const name of listFiles(depsDir)) {
const hash = extractDepsArtifactHash(name);
if (!hash) {
@@ -278,6 +336,10 @@ export function planDepsOrphanPrune(depsDir, keptHashes) {
export function planBuildOrphanPrune(buildDir, keptHashes) {
const toDelete = [];
+ // Same conservative guard as planDepsOrphanPrune (d8-P2-5).
+ if (!keptHashes || keptHashes.size === 0) {
+ return toDelete;
+ }
for (const name of listDirs(buildDir)) {
const split = splitFingerprintDir(name);
if (split && !keptHashes.has(split.hash)) {
@@ -314,12 +376,20 @@ function sleepMs(ms) {
export function isCompilerBusy({ exec = execFileSync, platform = process.platform } = {}) {
try {
if (platform === 'win32') {
- const out = exec(
- 'cmd.exe',
- ['/d', '/s', '/c', 'tasklist /FI "IMAGENAME eq cargo.exe" & tasklist /FI "IMAGENAME eq rustc.exe"'],
+ // Pass each /FI filter as a single argument. Routing the whole command
+ // through cmd.exe /c re-splits the quoted filter, so tasklist receives
+ // `eq` as a standalone option and fails with `无效参数/选项 - 'eq'`.
+ const cargo = exec(
+ 'tasklist',
+ ['/FI', 'IMAGENAME eq cargo.exe', '/NH'],
{ encoding: 'utf8' }
);
- return /\bcargo\.exe\b/i.test(out) || /\brustc\.exe\b/i.test(out);
+ const rustc = exec(
+ 'tasklist',
+ ['/FI', 'IMAGENAME eq rustc.exe', '/NH'],
+ { encoding: 'utf8' }
+ );
+ return /\bcargo\.exe\b/i.test(cargo) || /\brustc\.exe\b/i.test(rustc);
}
const cargo = exec('pgrep', ['-x', 'cargo'], { encoding: 'utf8' }).trim();
if (cargo) {
@@ -496,7 +566,13 @@ export function runCargoTargetGc(options = {}) {
}
export function parseGcArgs(argv) {
- const args = { profile: 'debug', triple: null, dryRun: undefined, help: false };
+ const args = {
+ profile: 'debug',
+ triple: null,
+ dryRun: undefined,
+ fingerprintMinAgeHours: undefined,
+ help: false,
+ };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--help' || arg === '-h') {
@@ -513,16 +589,34 @@ export function parseGcArgs(argv) {
i += 1;
} else if (arg.startsWith('--target=')) {
args.triple = arg.slice('--target='.length);
+ } else if (arg === '--min-age-hours') {
+ const value = Number(argv[i + 1]);
+ if (Number.isFinite(value) && value >= 0) {
+ args.fingerprintMinAgeHours = value;
+ }
+ i += 1;
+ } else if (arg.startsWith('--min-age-hours=')) {
+ const value = Number(arg.slice('--min-age-hours='.length));
+ if (Number.isFinite(value) && value >= 0) {
+ args.fingerprintMinAgeHours = value;
+ }
}
}
return args;
}
function printHelp() {
- console.log(`Usage: node scripts/cargo-target-gc.mjs [--profile debug] [--target TRIPLE] [--dry-run]
+ console.log(`Usage: node scripts/cargo-target-gc.mjs [--profile debug] [--target TRIPLE] [--min-age-hours HOURS] [--dry-run]
Prune stale Cargo incremental / fingerprint / deps caches for one profile.
+Options:
+ --profile profile dir under target (default debug)
+ --target target triple subdir (default none)
+ --min-age-hours fingerprint minimum age before pruning
+ (default 24, env BITFUN_TARGET_GC_MIN_AGE_HOURS)
+ --dry-run report only, do not delete
+
Environment:
BITFUN_TARGET_GC=0 disable
BITFUN_TARGET_GC_DRY_RUN=1 dry-run
@@ -581,6 +675,7 @@ if (isMain) {
profile: args.profile,
triple: args.triple,
dryRun: args.dryRun,
+ fingerprintMinAgeHours: args.fingerprintMinAgeHours,
});
process.exit(result.skipped && result.reason === 'error' ? 1 : 0);
}
diff --git a/scripts/cargo-target-gc.test.mjs b/scripts/cargo-target-gc.test.mjs
index 6529380c6..88b8859ee 100644
--- a/scripts/cargo-target-gc.test.mjs
+++ b/scripts/cargo-target-gc.test.mjs
@@ -11,6 +11,8 @@ import {
profileFromTauriBuildArgs,
runCargoTargetGc,
selectStaleByMtime,
+ planBuildOrphanPrune,
+ planDepsOrphanPrune,
splitFingerprintDir,
splitIncrementalCrateDir,
targetFromTauriBuildArgs,
@@ -156,7 +158,10 @@ test('collectGcPlan keeps distinct Cargo units while pruning stale generations',
const plan = collectGcPlan(profileDir, { now, fingerprintMinAgeMs: dayMs });
- assert.ok(plan.incremental.some((path) => path.endsWith('bitfun_core-oldhash1')));
+ // Keep-2 per crate (d8-P2-6): with two roots for the same crate the older
+ // one is retained as a concurrency buffer; the stale session inside the
+ // newest root is still pruned.
+ assert.ok(!plan.incremental.some((path) => path.endsWith('bitfun_core-oldhash1')));
assert.ok(
plan.incremental.some((path) =>
path.includes(`${join('bitfun_core-newhash2', 's-old-session')}`)
@@ -217,9 +222,12 @@ test('runCargoTargetGc prunes old generations and honors dry-run', () => {
logger: { info() {}, warn() {} },
});
assert.equal(dry.dryRun, true);
- assert.ok(dry.counts.total >= 2);
+ assert.ok(dry.counts.total >= 1);
assert.ok(existsSync(join(profileDir, 'incremental', 'bitfun_demo-old')));
+ // Keep-2 per crate (d8-P2-6): with only two roots for the same crate,
+ // neither is pruned — the older one is retained as a concurrency buffer
+ // for other worktrees sharing this target dir.
const live = runCargoTargetGc({
rootDir: root,
targetDir,
@@ -230,7 +238,7 @@ test('runCargoTargetGc prunes old generations and honors dry-run', () => {
logger: { info() {}, warn() {} },
});
assert.equal(live.skipped, false);
- assert.equal(existsSync(join(profileDir, 'incremental', 'bitfun_demo-old')), false);
+ assert.equal(existsSync(join(profileDir, 'incremental', 'bitfun_demo-old')), true);
assert.equal(existsSync(join(profileDir, 'incremental', 'bitfun_demo-new')), true);
assert.equal(
existsSync(join(profileDir, '.fingerprint', 'bitfun-demo-aaaaaaaaaaaaaaaa')),
@@ -250,6 +258,24 @@ test('runCargoTargetGc prunes old generations and honors dry-run', () => {
}
});
+test('planDepsOrphanPrune skips everything when keptHashes is empty (d8-P2-5)', () => {
+ const { root, cleanup } = fixtureRoot();
+ try {
+ const profileDir = join(root, 'target', 'debug');
+ touchFile(join(profileDir, 'deps', 'libbitfun_demo-aaaaaaaaaaaaaaaa.rlib'), Date.now());
+ touchDir(join(profileDir, 'deps', 'bitfun_demo-aaaaaaaaaaaaaaaa'), Date.now());
+
+ // Empty keptHashes (fingerprint plan produced nothing) must not nuke deps.
+ const deps = planDepsOrphanPrune(join(profileDir, 'deps'), new Set());
+ assert.equal(deps.length, 0);
+ const build = planBuildOrphanPrune(join(profileDir, 'build'), new Set());
+ assert.equal(build.length, 0);
+ assert.equal(existsSync(join(profileDir, 'deps', 'libbitfun_demo-aaaaaaaaaaaaaaaa.rlib')), true);
+ } finally {
+ cleanup();
+ }
+});
+
test('target busy detection scopes Cargo locks to the selected profile', () => {
const { root, cleanup } = fixtureRoot();
try {
@@ -324,3 +350,11 @@ test('tauri build argv helpers resolve profile and target', () => {
assert.equal(targetFromTauriBuildArgs(['--target', 'aarch64-apple-darwin']), 'aarch64-apple-darwin');
assert.equal(targetFromTauriBuildArgs([]), null);
});
+
+test('parseGcArgs supports --min-age-hours (d8-P2-7)', () => {
+ assert.equal(parseGcArgs([]).fingerprintMinAgeHours, undefined);
+ assert.equal(parseGcArgs(['--min-age-hours', '48']).fingerprintMinAgeHours, 48);
+ assert.equal(parseGcArgs(['--min-age-hours=12']).fingerprintMinAgeHours, 12);
+ // Non-numeric values are ignored, leaving the env/default in effect.
+ assert.equal(parseGcArgs(['--min-age-hours', 'abc']).fingerprintMinAgeHours, undefined);
+});
diff --git a/scripts/check-build-prereqs.mjs b/scripts/check-build-prereqs.mjs
index 10908401c..1d227b74e 100644
--- a/scripts/check-build-prereqs.mjs
+++ b/scripts/check-build-prereqs.mjs
@@ -23,7 +23,7 @@
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
-import { join, dirname } from 'node:path';
+import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -34,7 +34,7 @@ const FIX = process.argv.includes('--fix');
// --- Check logic (extracted for re-use and testing) ---
-function runChecks(rootDir) {
+export function runChecks(rootDir) {
const errors = [];
const warnings = [];
@@ -126,45 +126,50 @@ function runFixes(pendingFixes, rootDir) {
return allSucceeded;
}
-// --- Main ---
+// --- Main (only when run directly, not when imported as a module) ---
-const firstResult = runChecks(ROOT_DIR);
+const isDirectRun =
+ process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
-if (firstResult.errors.length === 0 && firstResult.warnings.length === 0) {
- console.log('Build prerequisite check passed.');
- process.exit(0);
-}
+if (isDirectRun) {
+ const firstResult = runChecks(ROOT_DIR);
+
+ if (firstResult.errors.length === 0 && firstResult.warnings.length === 0) {
+ console.log('Build prerequisite check passed.');
+ process.exit(0);
+ }
-reportResults(firstResult);
+ reportResults(firstResult);
-if (firstResult.errors.length > 0) {
- const pendingFixes = collectPendingFixes(firstResult.errors);
+ if (firstResult.errors.length > 0) {
+ const pendingFixes = collectPendingFixes(firstResult.errors);
- if (FIX && pendingFixes.length > 0) {
- console.log('Attempting fixes...\n');
- const allSucceeded = runFixes(pendingFixes, ROOT_DIR);
+ if (FIX && pendingFixes.length > 0) {
+ console.log('Attempting fixes...\n');
+ const allSucceeded = runFixes(pendingFixes, ROOT_DIR);
- if (allSucceeded) {
- console.log('Re-checking prerequisites...\n');
- const secondResult = runChecks(ROOT_DIR);
- reportResults(secondResult);
+ if (allSucceeded) {
+ console.log('Re-checking prerequisites...\n');
+ const secondResult = runChecks(ROOT_DIR);
+ reportResults(secondResult);
- if (secondResult.errors.length === 0) {
- console.log('All errors resolved after fix.');
- process.exit(0);
+ if (secondResult.errors.length === 0) {
+ console.log('All errors resolved after fix.');
+ process.exit(0);
+ }
+ console.error('Some errors remain after fix.');
+ process.exit(1);
}
- console.error('Some errors remain after fix.');
+ console.error('Some fix attempts failed. See errors above.');
process.exit(1);
}
- console.error('Some fix attempts failed. See errors above.');
+
+ console.error(
+ 'Run with --fix to attempt automatic fixes for missing prerequisites.',
+ );
process.exit(1);
}
- console.error(
- 'Run with --fix to attempt automatic fixes for missing prerequisites.',
- );
- process.exit(1);
+ // Only warnings, no errors
+ process.exit(0);
}
-
-// Only warnings, no errors
-process.exit(0);
diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs
index 62c59a768..7aa64e468 100644
--- a/scripts/check-core-boundaries.test.mjs
+++ b/scripts/check-core-boundaries.test.mjs
@@ -20,10 +20,16 @@ import {
} from './core-boundaries/cargo-dependency-boundaries.mjs';
import {
checkCliIntegrationTestTopology,
+ checkServicesCoreIntegrationTestTopology,
+ checkServicesIntegrationsIntegrationTestTopology,
cliIntegrationTestTargets,
validateExplicitIntegrationTestTopology,
} from './core-boundaries/explicit-test-topology.mjs';
import { crateLayoutRules } from './core-boundaries/rules/crate-layout.mjs';
+import {
+ coreClosedFeatureProfileRules,
+ coreProductFullFeatureAssemblyRule,
+} from './core-boundaries/rules/feature-rules.mjs';
const ENTRYPOINT = new URL('./check-core-boundaries.mjs', import.meta.url);
const MODULES = [
@@ -242,6 +248,13 @@ 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('runtime-services test support is absent from ordinary library builds', async () => {
const [manifest, library] = await Promise.all([
readFile(
@@ -373,6 +386,82 @@ test('product entrypoints must disable bitfun-core default features', () => {
assert.match(violations[0].message, /default-features = false/);
});
+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',
+ '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(
@@ -417,13 +506,87 @@ test('explicit product entrypoint bitfun-core feature selections pass', () => {
);
});
-test('ACP Core capability closure must retain its Canvas owner', () => {
+const ACP_REVIEWED_CORE_FEATURES = [
+ 'agent-runtime',
+ 'document-read',
+ 'subscription-auth',
+ 'deep-research',
+ 'lsp',
+ 'external-sources',
+ 'ssh-remote',
+ 'tools-basic',
+ 'tools-git',
+ 'tools-mcp',
+ 'tools-browser-web',
+ 'tools-computer-use',
+ 'tools-image-analysis',
+ 'tools-miniapp',
+ 'tools-canvas',
+ 'tools-agent-control',
+];
+
+const CLI_REVIEWED_CORE_FEATURES = [
+ ...ACP_REVIEWED_CORE_FEATURES,
+ 'remote-connect',
+ 'plugin-runtime',
+];
+
+const APP_SERVER_REVIEWED_CORE_FEATURES = [
+ 'external-sources',
+ 'git',
+ 'remote-connect',
+];
+
+test('App Server Core capability closure keeps its production Git owner', () => {
+ const core = packageAt('bitfun-core', 'src/crates/assembly/core/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 !== 'git'),
+ })],
+ );
+
+ const violations = findProductEntrypointCoreFeatureViolations(
+ [appServer, core],
+ { root: TEST_ROOT, crateLayoutRules },
+ );
+
+ assert.deepEqual(violations.map((violation) => violation.message), [
+ 'bitfun-app-server Core capability closure must include git',
+ ]);
+});
+
+test('App Server reviewed Core capability closure remains independently valid', () => {
+ const core = packageAt('bitfun-core', 'src/crates/assembly/core/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,
+ })],
+ );
+
+ assert.deepEqual(
+ findProductEntrypointCoreFeatureViolations(
+ [appServer, core],
+ { root: TEST_ROOT, crateLayoutRules },
+ ),
+ [],
+ );
+});
+
+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 +596,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 +605,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 +614,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 +625,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 +1021,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 +1267,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', [
@@ -1271,7 +1422,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 +1430,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 +1446,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 +1459,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 +1519,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 +1530,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 +1539,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 +1552,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 +1603,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 +2022,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,8 +2041,11 @@ 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('services-core capability profiles keep heavy owners out of the empty profile', async () => {
diff --git a/scripts/check-github-config.test.mjs b/scripts/check-github-config.test.mjs
index 35885c242..38bafe37f 100644
--- a/scripts/check-github-config.test.mjs
+++ b/scripts/check-github-config.test.mjs
@@ -251,6 +251,15 @@ 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'],
+ 'target/sherpa-onnx-prebuilt\n',
+ 'Rust CI must restore sherpa native libraries with the Cargo fingerprints that reference them',
+ );
+
const commandByStep = new Map(
rustJob.steps.map((step) => [step.name, step.run]),
);
@@ -275,3 +284,74 @@ 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 stagingIndex = steps.findIndex(
+ (step) => step.name === 'Stage uniquely named release assets',
+ );
+ const uploadIndex = steps.findIndex((step) => step.name === 'Upload to release');
+
+ assert.notEqual(stagingIndex, -1);
+ assert.notEqual(uploadIndex, -1);
+ 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/*');
+});
diff --git a/scripts/check-harmonyos-architecture.mjs b/scripts/check-harmonyos-architecture.mjs
new file mode 100644
index 000000000..2ccdeff49
--- /dev/null
+++ b/scripts/check-harmonyos-architecture.mjs
@@ -0,0 +1,341 @@
+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',
+ 'syncCloudAccount',
+ '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/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs
index 0c00d7ca8..e4c21b01e 100644
--- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs
+++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs
@@ -156,9 +156,7 @@ const SERVICES_CORE_TOKIO_FEATURES = new Map([
]);
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 effectiveTokioCapabilities(feature, featureGraph, visiting = new Set()) {
if (visiting.has(feature)) {
@@ -245,37 +243,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) {
@@ -359,6 +357,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;
@@ -514,6 +524,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];
@@ -539,6 +563,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)) {
@@ -559,12 +592,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,
@@ -776,44 +812,118 @@ export function findProductEntrypointCoreFeatureViolations(
const reviewedCoreFeatureClosures = new Map([
['bitfun-cli', [
'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-acp', [
'agent-runtime',
- 'canvas-runtime',
+ 'document-read',
+ 'subscription-auth',
+ 'deep-research',
+ 'lsp',
'external-sources',
'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-app-server', [
+ 'external-sources',
+ 'git',
+ 'remote-connect',
]],
]);
const acpActiveCoreFeatures = [
'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',
+ 'scheduled-jobs',
+ 'script-tool-runtime',
'ssh-remote',
+ '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 reviewedActiveCoreFeatureClosures = new Map([
- ['bitfun-cli', [...acpActiveCoreFeatures, 'plugin-runtime']],
+ ['bitfun-cli', [...acpActiveCoreFeatures, 'plugin-runtime', 'remote-connect']],
['bitfun-acp', acpActiveCoreFeatures],
+ ['bitfun-app-server', [
+ 'agent-runtime',
+ 'ai-adapter-runtime',
+ 'external-sources',
+ 'file-watch',
+ 'filesystem',
+ 'git',
+ '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 packageByManifest = new Map(
packages.map((pkg) => [normalizedPath(pkg.manifest_path), pkg]),
@@ -898,7 +1008,11 @@ export function findProductEntrypointCoreFeatureViolations(
);
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'],
+ ]).get(rootName) ?? rootName;
const packageStates = new Map();
const pending = [];
diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs
index fbd68a371..a9081ca1c 100644
--- a/scripts/core-boundaries/checker.mjs
+++ b/scripts/core-boundaries/checker.mjs
@@ -32,6 +32,7 @@ import { runManifestParserSelfTest } from './self-test.mjs';
import {
featureReferencesDependency,
featureReferencesFeature,
+ featureReferencesOptionalDependencyOwner,
unexpectedDependencyOwnerFeatures,
unexpectedReachableLocalFeatures,
} from './manifest-feature-helpers.mjs';
@@ -40,6 +41,7 @@ import {
agentRuntimeIntegrationTestTargets,
checkAgentRuntimeIntegrationTestTopology,
checkCliIntegrationTestTopology,
+ checkServiceIntegrationTestTopologies,
cliIntegrationTestTargets,
validateExplicitIntegrationTestTopology,
} from './explicit-test-topology.mjs';
@@ -544,7 +546,7 @@ function checkOptionalDependencyFeatureOwners(crateDir, rule) {
});
continue;
}
- if (!featureReferencesDependency(feature, dependency.depName)) {
+ if (!featureReferencesOptionalDependencyOwner(feature, dependency.depName)) {
failures.push({
path: manifestPath,
line: feature.line,
@@ -1122,6 +1124,7 @@ export function runCoreBoundaryCheck() {
failures.push(...checkCargoDependencyBoundariesSafely({ root: ROOT, crateLayoutRules }));
failures.push(...checkAgentRuntimeIntegrationTestTopology(ROOT));
failures.push(...checkCliIntegrationTestTopology(ROOT));
+ failures.push(...checkServiceIntegrationTestTopologies(ROOT));
for (const rule of forbiddenManifestDependencyRules) {
checkForbiddenManifestDependencyRule(rule);
diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs
index e43a0659c..9a157af1d 100644
--- a/scripts/core-boundaries/explicit-test-topology.mjs
+++ b/scripts/core-boundaries/explicit-test-topology.mjs
@@ -15,6 +15,37 @@ 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' },
+];
+
function parseExplicitTestTargets(manifestText) {
const targets = [];
let current = null;
@@ -66,7 +97,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 +117,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)) {
@@ -130,6 +421,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 +449,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 +469,7 @@ function checkExplicitIntegrationTestTopology(root, {
cratePath,
expectedTargets,
ignoredDirectories = [],
+ allowedLeafCfgLines = new Map(),
}) {
const crateDir = join(root, ...cratePath.split('/'));
const testsDir = join(crateDir, 'tests');
@@ -173,6 +477,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 +492,7 @@ function checkExplicitIntegrationTestTopology(root, {
join(testsDir, entry.name),
testsDir,
leafRustFiles,
+ leafSources,
ignoredDirectorySet,
);
}
@@ -199,6 +505,8 @@ function checkExplicitIntegrationTestTopology(root, {
topLevelRustFiles,
rootSources,
leafRustFiles,
+ leafSources,
+ allowedLeafCfgLines,
}).map((message) => ({ path: manifestPath, line: 1, message }));
}
@@ -216,3 +524,28 @@ 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 checkServiceIntegrationTestTopologies(root) {
+ return [
+ ...checkServicesCoreIntegrationTestTopology(root),
+ ...checkServicesIntegrationsIntegrationTestTopology(root),
+ ];
+}
diff --git a/scripts/core-boundaries/manifest-feature-helpers.mjs b/scripts/core-boundaries/manifest-feature-helpers.mjs
index 4445e2070..0dd50b252 100644
--- a/scripts/core-boundaries/manifest-feature-helpers.mjs
+++ b/scripts/core-boundaries/manifest-feature-helpers.mjs
@@ -10,6 +10,13 @@ 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));
}
@@ -17,7 +24,7 @@ export function featureReferencesFeature(feature, featureName) {
export function unexpectedDependencyOwnerFeatures(features, dependency) {
return [...features.entries()].filter(
([featureName, feature]) =>
- featureReferencesDependency(feature, dependency.depName)
+ featureReferencesOptionalDependencyOwner(feature, dependency.depName)
&& !dependency.ownerFeatures.includes(featureName),
);
}
diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs
index 1e0cdc078..7e69f9b97 100644
--- a/scripts/core-boundaries/rules/feature-rules.mjs
+++ b/scripts/core-boundaries/rules/feature-rules.mjs
@@ -58,7 +58,7 @@ export const optionalDependencyFeatureOwnerRules = [
reason:
'runtime-ports may expose product-domain permission ports only through the explicit permission contract slice',
dependencies: [
- { depName: 'bitfun-product-domains', ownerFeatures: ['permission'] },
+ { depName: 'bitfun-product-domains', ownerFeatures: ['permission', 'ts'] },
],
},
{
@@ -66,8 +66,11 @@ 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: '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-claude-code-adapter', ownerFeatures: ['external-sources'] },
@@ -77,46 +80,86 @@ 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'] },
- { depName: 'chrono-tz', ownerFeatures: ['agent-runtime'] },
- { depName: 'cron', ownerFeatures: ['agent-runtime'] },
+ {
+ 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: '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: 'fs2', ownerFeatures: ['agent-runtime'] },
- { depName: 'image', ownerFeatures: ['agent-runtime', 'tool-packs'] },
+ { 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'] },
+ // rand stays owned by agent-runtime: core warden Challenge-Poke Poisson
+ // scheduling compiles unconditionally under agent-runtime (customization
+ // over upstream, which moved rand to services-integrations).
+ { depName: 'rand', ownerFeatures: ['agent-runtime'] },
+ { depName: 'reqwest', ownerFeatures: ['mcp-runtime', 'tools-miniapp'] },
{ 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: 'tower-http', ownerFeatures: ['debug-log'] },
- { depName: 'tool-runtime', ownerFeatures: ['agent-runtime'] },
+ {
+ depName: 'tool-runtime',
+ ownerFeatures: ['agent-runtime', 'document-read', 'web-tools'],
+ },
],
},
{
@@ -209,6 +252,25 @@ export const coreProductFullFeatureAssemblyRule = {
featureName: 'product-full',
requiredFeatureRefs: [
'agent-runtime',
+ 'document-read',
+ 'subscription-auth',
+ '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,7 +290,7 @@ 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',
@@ -244,8 +306,6 @@ export const coreClosedFeatureProfileRules = [
'dep:bitfun-agent-content',
'dep:bitfun-agent-stream',
'dep:bitfun-harness',
- 'dep:chrono-tz',
- 'dep:cron',
'dep:dashmap',
'dep:filetime',
'dep:flate2',
@@ -254,41 +314,32 @@ export const coreClosedFeatureProfileRules = [
'dep:indexmap',
'dep:image',
'dep:md5',
- 'dep:reqwest',
- 'dep:semver',
+ // rand stays required: core warden Poisson scheduler compiles
+ // unconditionally under agent-runtime (customization over upstream).
+ 'dep:rand',
'dep:rusqlite',
'dep:similar',
- 'dep:tokio-tungstenite',
'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',
'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',
+ ],
+ 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',
@@ -298,11 +349,15 @@ export const coreClosedFeatureProfileRules = [
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 +371,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,
@@ -333,6 +391,9 @@ export const coreClosedFeatureProfileRules = [
requiredFeatureRefs: ['external-sources', 'dep:bitfun-plugin-runtime-client'],
allowedTransitiveFeatureRefs: [
'agent-runtime',
+ 'model-catalog',
+ 'mcp-runtime',
+ 'script-tool-runtime',
'file-watch',
'workspace-watch',
'ai-adapter-runtime',
@@ -344,11 +405,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,15 +421,279 @@ 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',
+ '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'],
+ 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-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:
@@ -502,7 +830,7 @@ export const coreClosedFeatureProfileRules = [
{
manifestPath: 'src/crates/assembly/core/Cargo.toml',
featureName: 'dispatch-store',
- requiredFeatureRefs: ['local-storage'],
+ requiredFeatureRefs: ['local-storage', 'bitfun-services-core/dispatch-workspace'],
exact: true,
reason: 'bitfun-core dispatch-store must expose only the durable dispatch index facade',
},
diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs
index c716b558b..d6eaab22e 100644
--- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs
+++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs
@@ -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:
diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs
index ad4378da9..1fe145be9 100644
--- a/scripts/core-boundaries/rules/source/public-api-rules.mjs
+++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs
@@ -829,8 +829,6 @@ export const externalSourceContractPublicApiEntries = [
export const externalSourceControlPublicApiEntries = [
'EXTERNAL_SOURCE_CONTROL_SCHEMA_V1',
- 'EXTERNAL_APPLICATION_SCHEMA_V2',
- 'EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS',
'ExternalSourceOperationStage',
'ExternalSourceRecoveryActionV1',
'ExternalSourceDiscoveryState',
@@ -846,39 +844,6 @@ export const externalSourceControlPublicApiEntries = [
'ExternalSourceSurfaceSnapshotV1',
'ExternalSourceControlActionV1',
'ExternalSourceControlRequestV1',
- 'ExternalApplicationTargetScopeV2',
- 'ExternalApplicationDesiredConnectionV2',
- 'ExternalApplicationUserDecisionV2',
- 'ExternalApplicationDiscoveryStateV2',
- 'ExternalApplicationConnectionStateV2',
- 'ExternalApplicationHealthV2',
- 'ExternalApplicationEffectiveStatusV2',
- 'ExternalApplicationPrimaryActionV2',
- 'derive_external_application_status_v2',
- 'ExternalApplicationDefaultConnectionPolicyV2',
- 'ExternalApplicationRiskLevelV2',
- 'ExternalApplicationSafetyCeilingV2',
- 'ExternalApplicationRecoveryActionV2',
- 'ExternalApplicationHostCapabilitiesV2',
- 'ExternalApplicationRiskSummaryV2',
- 'ExternalApplicationReviewItemKindV2',
- 'ExternalApplicationReviewItemRefV2',
- 'ExternalApplicationOwnerGenerationV2',
- 'ExternalApplicationReviewCategoryCountV2',
- 'ExternalApplicationReviewRecommendationSummaryV2',
- 'ExternalApplicationReviewSummaryV2',
- 'ExternalApplicationSummaryV2',
- 'ExternalApplicationSnapshotV2',
- 'ExternalApplicationReviewItemV2',
- 'ExternalApplicationReviewPageRequestV2',
- 'ExternalApplicationReviewPageV2',
- 'ExternalApplicationReviewSelectionBaselineV2',
- 'ExternalApplicationReviewSelectionOverrideV2',
- 'ExternalApplicationControlActionV2',
- 'ExternalApplicationControlRequestV2',
- 'ExternalApplicationOperationOutcomeV2',
- 'ExternalApplicationReviewItemResultV2',
- 'ExternalApplicationControlResultV2',
].map((symbol) =>
externalSourceControlEntry(
symbol,
@@ -1020,9 +985,6 @@ export const externalSourceCorePublicApiEntries = [
'EXTERNAL_SOURCE_CONTROL_SCHEMA_V1',
'get_external_source_control_snapshot',
'apply_external_source_control_action',
- 'get_external_application_snapshot_v2',
- 'get_external_application_review_page_v2',
- 'apply_external_application_action_v2',
].map((symbol) =>
externalSourceControlEntry(
symbol,
@@ -1120,16 +1082,16 @@ export const externalSourceCorePublicApiEntries = [
].map((symbol) => ({
symbol,
owner: 'bitfun-core external source composition facade',
- consumer: 'Desktop settings navigation and CLI/TUI external application entry points',
+ consumer: 'Desktop external-source host adapter and Web settings navigation',
verification:
- 'core acknowledgement persistence and execution-domain scoping tests, plus Desktop and TUI first-discovery hint tests',
- p0: 'first-discovery hint for external applications shared by GUI and TUI',
+ '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:
- 'both surfaces must derive "an external application the user has not seen" from one owner, otherwise GUI and TUI drift; awareness stays outside the preference-revision contract because it grants nothing and only suppresses a hint',
+ 'the Web settings notification must remain stable across refreshes and workspace changes without treating acknowledgement as permission or policy',
exit:
- 'remove once the versioned application-level read model owns notice state, together with its cross-surface deduplication tests',
+ 'remove only if Web settings no longer persists first-discovery awareness or a reviewed owner-scoped replacement preserves the same workspace isolation',
})),
...[
'ExternalToolActivationState',
diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs
index 774928a2d..8b4dc744d 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',
},
],
})),
@@ -230,7 +220,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: [
@@ -3925,21 +3915,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,9 +3954,9 @@ 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:
@@ -3969,18 +3975,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 +3988,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 +4007,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 +4059,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,
@@ -4100,24 +4100,44 @@ export const requiredContentRules = [
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\(feature = "agent-runtime"\)\]\s*pub mod remote_connect\b/s,
- message: 'Core Remote Connect product bridge must stay behind agent-runtime',
+ 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 = "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,
@@ -7208,6 +7228,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 +7521,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',
@@ -8394,8 +8422,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/self-test.mjs b/scripts/core-boundaries/self-test.mjs
index ff6465a51..2f4e8d9b3 100644
--- a/scripts/core-boundaries/self-test.mjs
+++ b/scripts/core-boundaries/self-test.mjs
@@ -80,11 +80,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 +152,35 @@ 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 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(
@@ -249,7 +335,7 @@ export function runManifestParserSelfTest({
'workspace-runtime',
'workspace-watch',
'product-capabilities',
- 'product-domains',
+ 'function-agents',
'tool-packs',
]) {
if (!coreProductFullFeatureAssemblyRule.requiredFeatureRefs.includes(featureName)) {
@@ -339,7 +425,7 @@ 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', ['local-storage', 'bitfun-services-core/dispatch-workspace']],
[coreManifest, 'filesystem', ['bitfun-services-core/filesystem']],
[coreManifest, 'local-storage', ['bitfun-services-core/local-storage']],
[coreManifest, 'process-runtime', ['bitfun-services-core/process-runtime']],
@@ -372,7 +458,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']],
@@ -882,45 +968,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',
);
@@ -1527,12 +1574,6 @@ export function runManifestParserSelfTest({
'ExternalSourceControlRequestV1',
'ExternalSourceOperationStage',
'ExternalSourceRecoveryActionV1',
- 'EXTERNAL_APPLICATION_SCHEMA_V2',
- 'ExternalApplicationSnapshotV2',
- 'ExternalApplicationReviewPageV2',
- 'ExternalApplicationControlActionV2',
- 'ExternalApplicationControlRequestV2',
- 'ExternalApplicationControlResultV2',
]) {
if (!externalSourceControlPublicApiRule?.allowedSymbolEntries.some(
(entry) => entry.symbol === requiredSymbol
@@ -1583,9 +1624,6 @@ export function runManifestParserSelfTest({
'ExternalSourceControlRequestV1',
'get_external_source_control_snapshot',
'apply_external_source_control_action',
- 'get_external_application_snapshot_v2',
- 'get_external_application_review_page_v2',
- 'apply_external_application_action_v2',
]) {
if (!externalSourceCorePublicApiRule?.allowedSymbolEntries.some(
(entry) => entry.symbol === requiredSymbol
@@ -3757,6 +3795,7 @@ export function runManifestParserSelfTest({
'StaticToolProviderFactory',
'create_registry_from_static_provider_entries',
'create_product_tool_registry_from_plan',
+ 'unavailable_feature_groups',
'materialize_tool',
'GetToolSpecTool',
],
@@ -3845,6 +3884,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',
@@ -4179,9 +4219,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'],
@@ -4202,13 +4246,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',
],
},
{
@@ -4218,8 +4262,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 24bf28cf3..7c9f99c7a 100644
--- a/scripts/desktop-tauri-build.mjs
+++ b/scripts/desktop-tauri-build.mjs
@@ -41,6 +41,27 @@ async function main() {
Object.assign(process.env, productBuildEnvironment(resolution));
console.log(`[product] ${resolution.assembly.member} ${resolution.assembly.assemblyDigest}`);
+ // L2-P2-2:构建前校验。tauri.conf.json 将 `../../mobile-web/dist` 映射为
+ // bundle 资源,dist 缺失会让 cargo check/tauri build 以令人困惑的
+ // "resource path doesn't exist" 失败(exit 101)。这里复用
+ // check-build-prereqs.mjs 的检查逻辑在真正启动构建前拦截缺失前置条件,
+ // 输出明确错误与修复命令(pnpm run prepare:mobile-web)。
+ const prereqs = await import('./check-build-prereqs.mjs');
+ const { errors: prereqErrors, warnings: prereqWarnings } = prereqs.runChecks(ROOT);
+ for (const warning of prereqWarnings) {
+ console.warn(`[build-prereq][WARN] ${warning.name}: ${warning.message}`);
+ }
+ if (prereqErrors.length > 0) {
+ console.error('Build prerequisite check failed before tauri build:\n');
+ for (const error of prereqErrors) {
+ console.error(` [FAIL] ${error.name}: ${error.message}`);
+ if (error.fix) {
+ console.error(` Fix: ${error.fix.join(' ')}`);
+ }
+ }
+ process.exit(1);
+ }
+
const flashgrepBinary = ensureFlashgrepBinary();
process.env.FLASHGREP_DAEMON_BIN = flashgrepBinary;
diff --git a/scripts/dev.cjs b/scripts/dev.cjs
index d218cb06b..791c89f39 100644
--- a/scripts/dev.cjs
+++ b/scripts/dev.cjs
@@ -286,25 +286,6 @@ async function waitForPort(port, hosts = DEV_SERVER_HOSTS, timeoutMs = 30000) {
throw new Error(`Port ${port} did not become ready within ${timeoutMs}ms`);
}
-async function runDesktopTargetGcBestEffort(profile = 'debug') {
- try {
- const { runGcBestEffort } = await import(
- pathToFileURL(path.join(__dirname, 'cargo-target-gc.mjs')).href
- );
- printInfo('Pruning stale Cargo target caches (keep latest only)');
- runGcBestEffort({
- rootDir: ROOT_DIR,
- profile,
- logger: {
- info: (message) => printInfo(message),
- warn: (message) => printError(message),
- },
- });
- } catch (error) {
- printError(`Target GC skipped: ${error.message || String(error)}`);
- }
-}
-
async function runDesktopTargetGc(profile = 'debug') {
try {
const { runGcBestEffort } = await import(
diff --git a/scripts/embed-server.py b/scripts/embed-server.py
new file mode 100644
index 000000000..75025e6fc
--- /dev/null
+++ b/scripts/embed-server.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+"""Local OpenAI-compatible embedding server for gbrain (port 8890).
+
+Model: Qdrant/bge-small-zh-v1.5 (ONNX, Dim=512) via onnxruntime + transformers
+tokenizer. No optimum dependency (optimum-onnxruntime has no py3.14 wheel).
+
+History (see .workbuddy/HANDBOOK.md):
+ uvicorn/FastAPI -> wedges after ~58 requests (async + ONNX blocking)
+ single-thread http.server -> queue timeouts under gbrain 20-way concurrency
+ ThreadingHTTPServer -> stable (200/200 concurrent test passed)
+"""
+
+import json
+import logging
+import os
+import sys
+import time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from threading import BoundedSemaphore
+
+import numpy as np
+import onnxruntime as ort
+from transformers import AutoTokenizer
+
+MODEL_DIR = os.environ.get(
+ "GBRAIN_EMBED_MODEL_DIR",
+ os.path.expanduser(
+ r"~/.cache/huggingface/hub/models--Qdrant--bge-small-zh-v1.5/snapshots/v1.5"
+ ),
+)
+MODEL_FILE = os.environ.get("GBRAIN_EMBED_MODEL_FILE", "model_optimized.onnx")
+HOST = os.environ.get("GBRAIN_EMBED_HOST", "127.0.0.1")
+PORT = int(os.environ.get("GBRAIN_EMBED_PORT", "8890"))
+MAX_TOKENS = 512
+MAX_REQUEST_BYTES = 2 * 1024 * 1024 # 2 MiB hard cap for request bodies (d8-P2-5)
+MAX_BATCH_SIZE = 128
+# Cap concurrent /v1/embeddings requests. ThreadingHTTPServer spawns a thread
+# per connection; without a bound a local process can open thousands of sockets
+# and exhaust threads/memory (d8-P1-5). 8 >= gbrain's 20-way concurrency is
+# sized below it; excess requests queue on the semaphore instead of stacking
+# threads.
+MAX_CONCURRENT_REQUESTS = 8
+_concurrency_gate = BoundedSemaphore(MAX_CONCURRENT_REQUESTS)
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="[embed-server] %(message)s",
+ stream=sys.stdout,
+)
+
+
+class EmbedServer:
+ def __init__(self):
+ logging.info("Loading BAAI/bge-small-zh-v1.5...")
+ t0 = time.time()
+ model_path = os.path.join(MODEL_DIR, MODEL_FILE)
+ try:
+ self.tokenizer = AutoTokenizer.from_pretrained(MODEL_DIR, local_files_only=True)
+ self.sess = ort.InferenceSession(
+ model_path,
+ providers=["CPUExecutionProvider"],
+ )
+ except Exception as e: # noqa: BLE001
+ # Friendly, actionable diagnostics instead of a bare traceback
+ # (d8-P2-3): the model path can be overridden via
+ # GBRAIN_EMBED_MODEL_DIR / GBRAIN_EMBED_MODEL_FILE.
+ logging.error(
+ "Failed to load embedding model.\n"
+ f" model dir : {MODEL_DIR}\n"
+ f" model file: {model_path}\n"
+ f" error : {e}\n"
+ "Fix: set GBRAIN_EMBED_MODEL_DIR (and GBRAIN_EMBED_MODEL_FILE if the\n"
+ "onnx file has a different name) to a local path containing the\n"
+ "tokenizer files + the onnx model, e.g.\n"
+ " $env:GBRAIN_EMBED_MODEL_DIR='C:/models/bge-small-zh-v1.5'\n"
+ " $env:GBRAIN_EMBED_MODEL_FILE='model_optimized.onnx'"
+ )
+ raise
+ self.input_names = [i.name for i in self.sess.get_inputs()]
+ self.dim = 512
+ logging.info(f"Model loaded. Dim={self.dim} ({(time.time() - t0):.1f}s)")
+
+ def _mean_pool(self, last_hidden, mask):
+ # mask must stay 2D for count; expanded copy only for weighting
+ m = mask.astype("float32")[..., np.newaxis] # (B, S, 1)
+ summed = (last_hidden * m).sum(1) # (B, D)
+ count = mask.astype("float32").sum(1).clip(min=1e-9)[..., np.newaxis] # (B, 1)
+ return summed / count
+
+ def embed(self, texts):
+ enc = self.tokenizer(
+ list(texts),
+ padding=True,
+ truncation=True,
+ max_length=MAX_TOKENS,
+ return_tensors="np",
+ )
+ feed = {}
+ for name in self.input_names:
+ if name in enc:
+ feed[name] = enc[name]
+ out = self.sess.run(None, feed)
+ # last_hidden_state is the first output
+ last_hidden = out[0]
+ mask = enc["attention_mask"]
+ pooled = self._mean_pool(last_hidden, mask).astype("float32")
+ pooled = pooled / np.linalg.norm(pooled, axis=1, keepdims=True).clip(min=1e-9)
+ # OpenAI format: each item's embedding is a flat list (no batch axis).
+ return pooled.tolist()
+
+
+class Handler(BaseHTTPRequestHandler):
+ server: "EmbedServerWrapper" # type: ignore
+ timeout = 60
+
+ def log_message(self, fmt, *args):
+ try:
+ msg = fmt % args
+ except Exception: # noqa: BLE001
+ msg = fmt
+ logging.info(f"{self.command} {self.path} HTTP/1.1 {msg}")
+
+ def do_GET(self):
+ if self.path == "/health":
+ body = b'{"status":"ok"}'
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ return
+ self.send_error(404)
+
+ def do_POST(self):
+ if self.path != "/v1/embeddings":
+ self.send_error(404)
+ return
+ try:
+ length = int(self.headers.get("Content-Length", 0))
+ except (TypeError, ValueError):
+ # Non-numeric Content-Length: 4xx instead of a broken connection
+ # (d8-P2-4).
+ self._json(400, {"error": {"message": "invalid Content-Length", "type": "invalid_request_error"}})
+ return
+ if length < 0 or length > MAX_REQUEST_BYTES:
+ self._json(413, {"error": {"message": f"request body too large (limit {MAX_REQUEST_BYTES} bytes)", "type": "invalid_request_error"}})
+ return
+ raw = self.rfile.read(length)
+ try:
+ req = json.loads(raw)
+ except json.JSONDecodeError:
+ self._json(400, {"error": {"message": "invalid JSON", "type": "invalid_request_error"}})
+ return
+ inp = req.get("input", "")
+ if isinstance(inp, str):
+ texts = [inp]
+ elif isinstance(inp, list):
+ if not inp:
+ # Explicit definition for an empty batch (d8-P2-2): refuse
+ # rather than feeding the tokenizer an empty batch.
+ self._json(400, {"error": {"message": "input list must not be empty", "type": "invalid_request_error"}})
+ return
+ if len(inp) > MAX_BATCH_SIZE:
+ self._json(400, {"error": {"message": f"input list too large (max {MAX_BATCH_SIZE} items)", "type": "invalid_request_error"}})
+ return
+ texts = [t if isinstance(t, str) else str(t) for t in inp]
+ else:
+ self._json(400, {"error": {"message": "input must be string or list", "type": "invalid_request_error"}})
+ return
+ try:
+ # Bound concurrent embedding work; excess requests wait on the
+ # semaphore instead of piling up threads (d8-P1-5).
+ with _concurrency_gate:
+ vectors = self.server.embedder.embed(texts)
+ except Exception as e: # noqa: BLE001
+ logging.error(f"embed failed: {e}")
+ self._json(500, {"error": {"message": str(e), "type": "server_error"}})
+ return
+ data = [{"object": "embedding", "index": i, "embedding": v} for i, v in enumerate(vectors)]
+ self._json(200, {"object": "list", "data": data, "model": req.get("model", "bge-small-zh-v1.5"),
+ "usage": {"prompt_tokens": 0, "total_tokens": 0}})
+
+ def _json(self, code, payload):
+ body = json.dumps(payload).encode("utf-8")
+ self.send_response(code)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+
+class EmbedServerWrapper(ThreadingHTTPServer):
+ daemon_threads = True
+
+ def __init__(self, server_address, handler_class):
+ self.embedder = EmbedServer()
+ super().__init__(server_address, handler_class)
+
+
+if __name__ == "__main__":
+ srv = EmbedServerWrapper((HOST, PORT), Handler)
+ logging.info(f"Embedding server running on http://{HOST}:{PORT}")
+ try:
+ srv.serve_forever()
+ except KeyboardInterrupt:
+ pass
diff --git a/scripts/package-windows-assets.mjs b/scripts/package-windows-assets.mjs
new file mode 100644
index 000000000..4afc5688d
--- /dev/null
+++ b/scripts/package-windows-assets.mjs
@@ -0,0 +1,261 @@
+#!/usr/bin/env node
+/**
+ * Windows release asset packager (三件套: installer exe + zip 便携版 + SHA256SUMS).
+ *
+ * Usage:
+ * node scripts/package-windows-assets.mjs \
+ * --installer \
+ * --app-release-dir \
+ * --version 0.2.16 \
+ * --out-dir release-assets
+ *
+ * Produces under --out-dir:
+ * BitFun__windows-x86_64-installer.exe (copied installer)
+ * BitFun__windows-x86_64-portable.zip (portable app: exe + runtime dirs)
+ * SHA256SUMS (sha256 of every asset)
+ *
+ * The portable zip mirrors the installer payload layout: the main app exe plus
+ * the runtime siblings the app needs at startup (mobile-web, resources,
+ * third-party, THIRD_PARTY_NOTICES.md). It is a no-install distribution.
+ *
+ * Windows-native: uses tar.exe bsdtar to create the zip (available on Windows
+ * 10+); falls back to PowerShell Compress-Archive if bsdtar is unavailable.
+ */
+import { createHash } from 'crypto';
+import {
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ writeFileSync,
+} from 'fs';
+import { basename, dirname, join, resolve as resolvePath } from 'path';
+import { fileURLToPath } from 'url';
+import { spawnSync } from 'child_process';
+
+if (isMain()) {
+ try {
+ const args = parseArgs(process.argv.slice(2));
+ await main(args);
+ } catch (error) {
+ process.exit(error?.exitCode ?? 1);
+ }
+}
+
+function isMain() {
+ // Under `node --test` the module is imported with a bare entry argv
+ // (argv[1] is the test runner's shim or undefined), so a pure argv
+ // comparison would treat imports as the CLI. Compare against the resolved
+ // module path instead.
+ if (!process.argv[1]) {
+ return false;
+ }
+ try {
+ return resolvePath(process.argv[1]) === resolvePath(fileURLToPath(import.meta.url));
+ } catch {
+ return false;
+ }
+}
+
+export async function main(argv = []) {
+ // Accept either raw CLI args (["--installer", ...]) or a parsed object
+ // ({ installer, ... }). The CLI passes raw argv; tests pass raw argv too,
+ // so normalize once here.
+ const parsed = Array.isArray(argv) ? parseArgs(argv) : argv;
+ const installerPath = requireArg(parsed, 'installer');
+ const appReleaseDir = requireArg(parsed, 'app-release-dir');
+ const version = requireArg(parsed, 'version');
+ const outDir = requireArg(parsed, 'out-dir');
+
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
+ fail(`Version is not safe for a release asset name: ${version}`);
+ }
+ if (!existsSync(installerPath)) {
+ fail(`Installer does not exist: ${installerPath}`);
+ }
+ if (!existsSync(appReleaseDir)) {
+ fail(`App release dir does not exist: ${appReleaseDir}`);
+ }
+
+ const exeName = 'bitfun-desktop.exe';
+ const exePath = join(appReleaseDir, exeName);
+ if (!existsSync(exePath)) {
+ fail(`Main app exe not found in release dir: ${exePath}`);
+ }
+
+ // Never rm -rf an unexpected path: --out-dir must be a directory that does
+ // not exist yet, or one whose contents are all old release assets (files or
+ // the SHA256SUMS manifest). Everything else is refused so a mistyped path
+ // like E:/ or src can never be recursively deleted (d8-P2-1).
+ if (existsSync(outDir)) {
+ assertSafeOutDir(outDir);
+ }
+
+ rmSync(outDir, { recursive: true, force: true });
+ mkdirSync(outDir, { recursive: true });
+
+ const baseName = `BitFun_${version}_windows-x86_64`;
+ const installerOut = join(outDir, `${baseName}-installer.exe`);
+ const zipOut = join(outDir, `${baseName}-portable.zip`);
+ const sumsOut = join(outDir, 'SHA256SUMS');
+
+ // 1. Copy installer exe.
+ copyFile(installerPath, installerOut);
+ log(`Copied installer: ${installerPath} -> ${installerOut}`);
+
+ // 2. Create portable zip from the app release dir.
+ // Only copy the runtime-relevant entries (mirrors build-installer.cjs payload
+ // selection, plus the notice file); exclude build metadata and debug symbols.
+ const portableEntries = collectPortableEntries(appReleaseDir, exeName);
+ log(`Portable zip will contain ${portableEntries.length} file(s) from ${appReleaseDir}`);
+ createZip(portableEntries, zipOut);
+
+ // 3. Write SHA256SUMS over every produced asset.
+ const assets = [installerOut, zipOut].sort();
+ const lines = assets
+ .map((file) => `${sha256File(file)} ${basename(file)}`)
+ .join('\n');
+ writeFileSync(sumsOut, `${lines}\n`);
+ log(`Wrote ${sumsOut}:`);
+ for (const line of lines.split('\n')) log(` ${line}`);
+
+ console.log(`\n[package-windows-assets] Done. Output in ${outDir}`);
+ console.log(` ${installerOut}`);
+ console.log(` ${zipOut}`);
+ console.log(` ${sumsOut}`);
+}
+
+export function collectPortableEntries(releaseDir, exeName) {
+ const entries = [];
+ const runtimeDirs = ['mobile-web', 'resources', 'third-party'];
+ for (const entry of readdirSync(releaseDir, { withFileTypes: true })) {
+ const src = join(releaseDir, entry.name);
+ if (entry.isFile()) {
+ if (entry.name === exeName) entries.push(src);
+ else if (entry.name === 'THIRD_PARTY_NOTICES.md') entries.push(src);
+ // .pdb / .d / .cargo-lock are build metadata, not runtime files.
+ } else if (entry.isDirectory() && runtimeDirs.includes(entry.name)) {
+ entries.push(src);
+ }
+ }
+ return entries;
+}
+
+function createZip(entries, zipPath) {
+ // Build a bsdtar include list of the source paths. tar.exe on Windows
+ // (C:\Windows\System32\tar.exe) uses libarchive and can write zip archives.
+ //
+ // IMPORTANT: entries are absolute paths; archives must store RELATIVE
+ // entry names rooted at the release directory, otherwise the zip unpacks
+ // into the full temp path nesting (Users/.../Temp/...) and the Windows
+ // portable distribution is unusable (d8-P1-1). bsdtar supports `-C `
+ // to chdir before reading entries, which stores the basenames relative to
+ // that directory. The Compress-Archive fallback passes `-Path` absolute
+ // paths whose file names are stored relative to the first component, so
+ // the two branches already produce the same relative layout.
+ const cwd = process.cwd();
+ let tar = spawnSync('tar', ['--version'], { encoding: 'utf8' });
+ if (tar.status === 0) {
+ const releaseDir = dirname(entries[0]);
+ const args = ['-a', '-c', '-f', zipPath];
+ for (const entry of entries) args.push('-C', releaseDir, basename(entry));
+ const result = spawnSync('tar', args, { stdio: 'inherit', encoding: 'utf8' });
+ if (result.status === 0) {
+ log(`Created zip via bsdtar: ${zipPath}`);
+ return;
+ }
+ log('bsdtar zip creation failed, falling back to Compress-Archive');
+ }
+ // Fallback: PowerShell Compress-Archive (slower, but always present).
+ // `-Path` with absolute paths stores entries relative to the leaf
+ // directory's parent (the release dir), matching the bsdtar layout.
+ const psScript = [
+ '$ErrorActionPreference = "Stop"',
+ `$dest = '${zipPath.replace(/'/g, "''")}'`,
+ 'if (Test-Path $dest) { Remove-Item $dest -Force }',
+ `$items = @(${entries
+ .map((entry) => `'${entry.replace(/'/g, "''")}'`)
+ .join(', ')})`,
+ 'Compress-Archive -Path $items -DestinationPath $dest -CompressionLevel Optimal',
+ ].join('; ');
+ const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', psScript], {
+ stdio: 'inherit',
+ encoding: 'utf8',
+ });
+ if (result.status !== 0) fail(`Failed to create zip: ${zipPath}`);
+ log(`Created zip via Compress-Archive: ${zipPath}`);
+}
+
+function sha256File(filePath) {
+ return createHash('sha256').update(readFileSync(filePath)).digest('hex');
+}
+
+function copyFile(src, dest) {
+ mkdirSync(join(dest, '..'), { recursive: true });
+ copyFileSync(src, dest);
+}
+
+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 log(message) {
+ console.log(`\x1b[36m[package-windows-assets]\x1b[0m ${message}`);
+}
+
+function fail(message) {
+ console.error(`\x1b[31m[package-windows-assets]\x1b[0m ${message}`);
+ // Throw instead of process.exit so the error is catchable by test runners;
+ // the CLI entry wraps main() and exits with a non-zero code.
+ const error = new Error(message);
+ error.exitCode = 1;
+ throw error;
+}
+
+/**
+ * Refuse to delete a directory that is not a prior release-asset output.
+ * Allowed contents: files whose names look like release assets
+ * (BitFun_*_windows-x86_64-* / SHA256SUMS) or their manifest, plus nothing
+ * else (no subdirectories). This protects against a mistyped --out-dir
+ * wiping an unrelated directory tree (d8-P2-1).
+ */
+function assertSafeOutDir(dir) {
+ let entries;
+ try {
+ entries = readdirSync(dir, { withFileTypes: true });
+ } catch (error) {
+ fail(`Cannot read --out-dir ${dir}: ${error.message || String(error)}`);
+ }
+ const assetName = /^BitFun_\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?_windows-x86_64-(installer\.exe|portable\.zip)$/;
+ const manifestName = /^SHA256SUMS$/;
+ for (const entry of entries) {
+ if (entry.isDirectory()) {
+ fail(
+ `Refusing to delete --out-dir ${dir}: contains subdirectory "${entry.name}" (not a release-assets output)`,
+ );
+ }
+ if (!assetName.test(entry.name) && !manifestName.test(entry.name)) {
+ fail(
+ `Refusing to delete --out-dir ${dir}: contains unexpected file "${entry.name}"`,
+ );
+ }
+ }
+}
diff --git a/scripts/package-windows-assets.test.mjs b/scripts/package-windows-assets.test.mjs
new file mode 100644
index 000000000..b713fd51c
--- /dev/null
+++ b/scripts/package-windows-assets.test.mjs
@@ -0,0 +1,122 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { collectPortableEntries, main } from './package-windows-assets.mjs';
+
+function makeFakeReleaseDir() {
+ const dir = mkdtempSync(join(tmpdir(), 'pwa-assets-'));
+ writeFileSync(join(dir, 'bitfun-desktop.exe'), 'fake exe bytes');
+ writeFileSync(join(dir, 'THIRD_PARTY_NOTICES.md'), '# notices');
+ writeFileSync(join(dir, 'bitfun_desktop.pdb'), 'debug symbols');
+ writeFileSync(join(dir, 'bitfun-desktop.d'), 'dep file');
+ writeFileSync(join(dir, '.cargo-lock'), '');
+ mkdirSync(join(dir, 'mobile-web', 'dist'), { recursive: true });
+ writeFileSync(join(dir, 'mobile-web', 'dist', 'index.html'), '');
+ mkdirSync(join(dir, 'resources'), { recursive: true });
+ writeFileSync(join(dir, 'resources', 'worker_host.js'), 'worker');
+ mkdirSync(join(dir, 'third-party', 'models.dev'), { recursive: true });
+ writeFileSync(join(dir, 'third-party', 'models.dev', 'LICENSE.txt'), 'license');
+ // Non-runtime build dirs that must be excluded.
+ mkdirSync(join(dir, 'deps'));
+ mkdirSync(join(dir, 'build'));
+ mkdirSync(join(dir, 'incremental'));
+ mkdirSync(join(dir, '.fingerprint'));
+ return dir;
+}
+
+test('collectPortableEntries includes exe, notice, and runtime dirs only', () => {
+ const dir = makeFakeReleaseDir();
+ try {
+ const entries = collectPortableEntries(dir, 'bitfun-desktop.exe');
+ const names = entries
+ .map((entry) => entry.replace(dir, '').replace(/\\/g, '/'))
+ .sort();
+ assert.deepEqual(names, [
+ '/THIRD_PARTY_NOTICES.md',
+ '/bitfun-desktop.exe',
+ '/mobile-web',
+ '/resources',
+ '/third-party',
+ ]);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('collectPortableEntries excludes pdb/d/cargo-lock/build dirs', () => {
+ const dir = makeFakeReleaseDir();
+ try {
+ const entries = collectPortableEntries(dir, 'bitfun-desktop.exe');
+ const flat = JSON.stringify(entries);
+ for (const excluded of ['bitfun_desktop.pdb', 'bitfun-desktop.d', '.cargo-lock', 'deps', 'build', 'incremental', '.fingerprint']) {
+ assert.ok(!flat.includes(excluded), `must exclude ${excluded}`);
+ }
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('collectPortableEntries returns empty for empty release dir', () => {
+ const dir = mkdtempSync(join(tmpdir(), 'pwa-empty-'));
+ try {
+ const entries = collectPortableEntries(dir, 'bitfun-desktop.exe');
+ assert.deepEqual(entries, []);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('main refuses to delete a non-release-asset out-dir (d8-P2-1)', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'pwa-outguard-'));
+ const outDir = join(root, 'danger');
+ mkdirSync(outDir, { recursive: true });
+ writeFileSync(join(outDir, 'keep.txt'), 'unrelated file');
+ const releaseDir = makeFakeReleaseDir();
+ const installer = join(root, 'bitfun-desktop-installer.exe');
+ writeFileSync(installer, 'installer bytes');
+ try {
+ await assert.rejects(
+ main([
+ '--installer', installer,
+ '--app-release-dir', releaseDir,
+ '--version', '0.2.16',
+ '--out-dir', outDir,
+ ]),
+ /contains unexpected file/,
+ );
+ // The unrelated file must survive.
+ assert.equal(readdirSync(outDir).includes('keep.txt'), true);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ rmSync(releaseDir, { recursive: true, force: true });
+ }
+});
+
+test('main allows re-using a clean release-assets out-dir (d8-P2-1)', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'pwa-reuse-'));
+ const outDir = join(root, 'assets');
+ mkdirSync(outDir, { recursive: true });
+ // Old assets from a previous run are allowed.
+ writeFileSync(join(outDir, 'BitFun_0.2.15_windows-x86_64-portable.zip'), 'old zip');
+ writeFileSync(join(outDir, 'SHA256SUMS'), 'old sums');
+ const releaseDir = makeFakeReleaseDir();
+ const installer = join(root, 'bitfun-desktop-installer.exe');
+ writeFileSync(installer, 'installer bytes');
+ try {
+ await main([
+ '--installer', installer,
+ '--app-release-dir', releaseDir,
+ '--version', '0.2.16',
+ '--out-dir', outDir,
+ ]);
+ // Old assets replaced by the new run.
+ assert.equal(readdirSync(outDir).includes('BitFun_0.2.15_windows-x86_64-portable.zip'), false);
+ assert.equal(readdirSync(outDir).some((n) => n.startsWith('BitFun_0.2.16_')), true);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ rmSync(releaseDir, { recursive: true, force: true });
+ }
+});
diff --git a/scripts/stage-github-release-assets.mjs b/scripts/stage-github-release-assets.mjs
new file mode 100644
index 000000000..307744a13
--- /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
index 17c4a162f..9eaaa1512 100644
--- a/scripts/tauri-release-manifest.test.mjs
+++ b/scripts/tauri-release-manifest.test.mjs
@@ -75,6 +75,49 @@ test('latest.json keeps the updater URL separate from the manual installer URL',
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,
diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml
index 0fdf26e35..b6a07afd6 100644
--- a/src/apps/cli/Cargo.toml
+++ b/src/apps/cli/Cargo.toml
@@ -1,4 +1,5 @@
[package]
+license.workspace = true
name = "bitfun-cli"
version.workspace = true
authors.workspace = true
@@ -32,10 +33,23 @@ path = "tests/terminal_process_contracts.rs"
# Internal crates
bitfun-core = { path = "../../crates/assembly/core", default-features = false, 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" }
@@ -108,7 +122,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/src/account.rs b/src/apps/cli/src/account.rs
index cfb5e7926..28ffd1dd3 100644
--- a/src/apps/cli/src/account.rs
+++ b/src/apps/cli/src/account.rs
@@ -1,1111 +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,
+pub(crate) struct CliAccountRuntimeParts {
+ pub(crate) runtime: Arc,
+ pub(crate) routing: Arc,
}
-/// 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 build_account_runtime(
+ compatibility: CoreAgentRuntimeCompatibility,
+) -> CliAccountRuntimeParts {
+ build_account_runtime_with_backup(Arc::new(CliAccountSessionBackupPort { compatibility }))
}
-pub(crate) fn account_context_is_current(generation: u64) -> bool {
- ACCOUNT_CONTEXT_TRANSITIONS.load(Ordering::Acquire) == 0
- && account_context_generation() == generation
+pub(crate) fn build_management_account_runtime() -> Arc {
+ build_account_runtime_with_backup(Arc::new(UnavailableSessionBackup)).runtime
}
-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
- }
-}
-
-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);
- }
+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 }
}
-struct AccountContextTransitionGuard {
- sync_guard: Option>,
- transition: Option,
- routing_guard: Option>,
- transition_guard: Option>,
-}
+struct UnavailableSessionBackup;
-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
+#[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"
+ ))
}
}
-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());
- }
-}
-
-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"));
- }
- 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