diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 32a861682b..27c5af5b03 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -520,6 +520,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 @@ -587,30 +588,41 @@ 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 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 8c6b44a652..2af1483c06 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/BitFun-Installer/package-lock.json b/BitFun-Installer/package-lock.json index 6ed36d14ff..08ffb33d52 100644 --- a/BitFun-Installer/package-lock.json +++ b/BitFun-Installer/package-lock.json @@ -1,12 +1,12 @@ { "name": "bitfun-installer", - "version": "0.2.16", + "version": "0.2.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bitfun-installer", - "version": "0.2.16", + "version": "0.2.17", "dependencies": { "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.6.0", diff --git a/BitFun-Installer/package.json b/BitFun-Installer/package.json index bd00376873..7f9d1759c0 100644 --- a/BitFun-Installer/package.json +++ b/BitFun-Installer/package.json @@ -1,6 +1,6 @@ { "name": "bitfun-installer", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "description": "BitFun Custom Installer - Modern branded installation experience", diff --git a/BitFun-Installer/src-tauri/Cargo.toml b/BitFun-Installer/src-tauri/Cargo.toml index ecf66ffbc8..55d8d2525a 100644 --- a/BitFun-Installer/src-tauri/Cargo.toml +++ b/BitFun-Installer/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitfun-installer" -version = "0.2.16" +version = "0.2.17" authors = ["BitFun Team"] edition = "2021" description = "BitFun Custom Installer - Modern branded installation experience" @@ -22,19 +22,11 @@ tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["full"] } -tokio-stream = "0.1" anyhow = "1.0" log = "0.4" dirs = "5.0" zip = "0.6" -flate2 = "1.0" -tar = "0.4" chrono = "0.4" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } -urlencoding = "2" -futures = "0.3" -eventsource-stream = "0.2" bitfun-ai-adapters = { path = "../../src/crates/adapters/ai-adapters" } [target.'cfg(windows)'.dependencies] diff --git a/Cargo.lock b/Cargo.lock index ffbf16a26c..ede08203e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -748,7 +748,7 @@ dependencies = [ [[package]] name = "bitfun-acp" -version = "0.2.16" +version = "0.2.17" dependencies = [ "agent-client-protocol", "async-trait", @@ -771,11 +771,11 @@ dependencies = [ [[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", @@ -801,7 +801,7 @@ dependencies = [ [[package]] name = "bitfun-agent-runtime-ipc" -version = "0.2.16" +version = "0.2.17" dependencies = [ "async-trait", "bitfun-events", @@ -821,7 +821,7 @@ dependencies = [ [[package]] name = "bitfun-agent-stream" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "async-trait", @@ -841,7 +841,7 @@ dependencies = [ [[package]] name = "bitfun-agent-tools" -version = "0.2.16" +version = "0.2.17" dependencies = [ "async-trait", "bitfun-core-types", @@ -854,7 +854,7 @@ dependencies = [ [[package]] name = "bitfun-ai-adapters" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "apple-native-keyring-store", @@ -888,7 +888,7 @@ dependencies = [ [[package]] name = "bitfun-app-server" -version = "0.2.16" +version = "0.2.17" dependencies = [ "agent-client-protocol", "anyhow", @@ -913,7 +913,7 @@ dependencies = [ [[package]] name = "bitfun-app-server-client" -version = "0.2.16" +version = "0.2.17" dependencies = [ "agent-client-protocol", "anyhow", @@ -924,7 +924,7 @@ dependencies = [ [[package]] name = "bitfun-app-server-protocol" -version = "0.2.16" +version = "0.2.17" dependencies = [ "agent-client-protocol", "bitfun-core-types", @@ -938,7 +938,7 @@ dependencies = [ [[package]] name = "bitfun-claude-code-adapter" -version = "0.2.16" +version = "0.2.17" dependencies = [ "bitfun-product-domains", "bitfun-services-core", @@ -957,7 +957,7 @@ dependencies = [ [[package]] name = "bitfun-cli" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "arboard", @@ -1018,7 +1018,7 @@ dependencies = [ [[package]] name = "bitfun-codex-adapter" -version = "0.2.16" +version = "0.2.17" dependencies = [ "bitfun-product-domains", "bitfun-services-core", @@ -1035,7 +1035,7 @@ dependencies = [ [[package]] name = "bitfun-core" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "async-trait", @@ -1096,6 +1096,7 @@ dependencies = [ "serde_yaml", "sha2", "similar", + "tauri", "tempfile", "terminal-core", "thiserror 2.0.19", @@ -1113,7 +1114,7 @@ dependencies = [ [[package]] name = "bitfun-core-types" -version = "0.2.16" +version = "0.2.17" dependencies = [ "serde", "serde_json", @@ -1122,7 +1123,7 @@ dependencies = [ [[package]] name = "bitfun-desktop" -version = "0.2.16" +version = "0.2.17" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1204,7 +1205,7 @@ dependencies = [ [[package]] name = "bitfun-events" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "async-trait", @@ -1218,7 +1219,7 @@ dependencies = [ [[package]] name = "bitfun-external-sources" -version = "0.2.16" +version = "0.2.17" dependencies = [ "bitfun-product-domains", "futures", @@ -1228,7 +1229,7 @@ dependencies = [ [[package]] name = "bitfun-harness" -version = "0.2.16" +version = "0.2.17" dependencies = [ "async-trait", "thiserror 2.0.19", @@ -1237,7 +1238,7 @@ dependencies = [ [[package]] name = "bitfun-miniapp-market-server" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "axum", @@ -1249,7 +1250,7 @@ dependencies = [ [[package]] name = "bitfun-miniapp-market-service" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "axum", @@ -1280,7 +1281,7 @@ dependencies = [ [[package]] name = "bitfun-opencode-adapter" -version = "0.2.16" +version = "0.2.17" dependencies = [ "async-trait", "bitfun-plugin-runtime-client", @@ -1308,7 +1309,7 @@ dependencies = [ [[package]] name = "bitfun-page-function-runtime" -version = "0.2.16" +version = "0.2.17" dependencies = [ "rquickjs", "serde", @@ -1319,7 +1320,7 @@ dependencies = [ [[package]] name = "bitfun-plugin-runtime-client" -version = "0.2.16" +version = "0.2.17" dependencies = [ "async-trait", "bitfun-runtime-ports", @@ -1328,7 +1329,7 @@ dependencies = [ [[package]] name = "bitfun-product-capabilities" -version = "0.2.16" +version = "0.2.17" dependencies = [ "async-trait", "bitfun-agent-runtime", @@ -1341,7 +1342,7 @@ dependencies = [ [[package]] name = "bitfun-product-domains" -version = "0.2.16" +version = "0.2.17" dependencies = [ "dirs 6.0.0", "hex", @@ -1358,7 +1359,7 @@ dependencies = [ [[package]] name = "bitfun-relay-server" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "axum", @@ -1375,7 +1376,7 @@ dependencies = [ [[package]] name = "bitfun-relay-service" -version = "0.2.16" +version = "0.2.17" dependencies = [ "aes-gcm", "anyhow", @@ -1404,7 +1405,7 @@ dependencies = [ [[package]] name = "bitfun-runtime-ports" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "async-trait", @@ -1419,7 +1420,7 @@ dependencies = [ [[package]] name = "bitfun-runtime-services" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "async-trait", @@ -1434,7 +1435,7 @@ dependencies = [ [[package]] name = "bitfun-sdk-host" -version = "0.2.16" +version = "0.2.17" dependencies = [ "async-trait", "bitfun-agent-runtime", @@ -1452,7 +1453,7 @@ dependencies = [ [[package]] name = "bitfun-sdk-host-app" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "async-trait", @@ -1471,7 +1472,7 @@ dependencies = [ [[package]] name = "bitfun-server" -version = "0.2.16" +version = "0.2.17" dependencies = [ "agent-client-protocol", "anyhow", @@ -1497,7 +1498,7 @@ dependencies = [ [[package]] name = "bitfun-services-core" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "async-trait", @@ -1533,7 +1534,7 @@ dependencies = [ [[package]] name = "bitfun-services-integrations" -version = "0.2.16" +version = "0.2.17" dependencies = [ "aes", "aes-gcm", @@ -1609,7 +1610,7 @@ dependencies = [ [[package]] name = "bitfun-skin-market-server" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "axum", @@ -1621,7 +1622,7 @@ dependencies = [ [[package]] name = "bitfun-skin-market-service" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "axum", @@ -1650,7 +1651,7 @@ dependencies = [ [[package]] name = "bitfun-static-hook-support" -version = "0.2.16" +version = "0.2.17" dependencies = [ "bitfun-product-domains", "bitfun-services-core", @@ -1663,7 +1664,7 @@ dependencies = [ [[package]] name = "bitfun-tool-call-jsonrepair" -version = "0.2.16" +version = "0.2.17" dependencies = [ "serde", "serde_json", @@ -1671,11 +1672,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", @@ -1687,7 +1688,7 @@ dependencies = [ [[package]] name = "bitfun-webdriver" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "axum", @@ -11256,7 +11257,7 @@ dependencies = [ [[package]] name = "terminal-core" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "async-trait", @@ -11677,7 +11678,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 a8ece3d3dc..2ecc9115a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ 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" diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index f55b70844f..bb8c5c5214 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 e767ebfd01..9c9c5b28d4 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -748,7 +748,8 @@ Rust Runtime SDK,不注册未实现的 `RuntimeServices` 能力,也不宣称 本地工作区快照 owner port;Peer Host 只用它完成本地工作区准备、会话文件清单、类型化统计和工作区文件回滚。 账号同步、富历史读取及 Peer Host/ACP 的其余维护等产品操作仍由 `assembly/core` 的单一兼容接口转发。 `doctor` 与 `health` 校验真实组装结果及必需注册完整性; -Core 的 Network、Git 和 MCP Catalog 当前仍含兼容 marker,因此该诊断不等于对这些外部服务做实时探活。 +Core 只为当前 feature closure 真正组装的 Network、Git、MCP Catalog 和 +Remote Workspace 注册 capability marker;该诊断仍不等于对外部服务做实时探活。 该切换仍是 `product-full` 兼容组装,不是完整 ToolPipeline owner 迁移。 协调器、调度器、持久化、工具管线和 Agentic Event Queue 仍由 Core 唯一持有。 diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index f08fd86217..a4492d158a 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -10,8 +10,6 @@ - 公开 Agent SDK:[`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md) - 产品定制:[`product-customization-blueprint.md`](product-customization-blueprint.md) - 外部 AI 工作来源:[`extensions/external-ai-work-sources-design.md`](extensions/external-ai-work-sources-design.md) -- 外部 AI 应用连接体验:[`extensions/external-ai-app-connection-experience-design.md`](extensions/external-ai-app-connection-experience-design.md) -- 外部 AI 应用连接执行计划:[`../plans/external-ai-app-connection-experience-plan.md`](../plans/external-ai-app-connection-experience-plan.md) - OpenCode 兼容矩阵:[`extensions/opencode-extension-compatibility.md`](extensions/opencode-extension-compatibility.md) - 插件 Runtime:[`extensions/plugin-runtime-design.md`](extensions/plugin-runtime-design.md) - Detached Dispatch:[`detached-task-dispatch.md`](detached-task-dispatch.md) @@ -146,7 +144,7 @@ SHELL composer - `stream-json` stdout 每行是一个完整 Agent event。 - 日志与诊断进入 stderr 或日志文件。 - 默认拒绝需要人工确认的操作;只有显式调用级策略可以自动批准。 -- 目标连接体验交付后,只有 Agent Runtime 沿现有事件流返回与当前执行域、工作区作用域、根会话和根轮次完全匹配的依赖结果时,CLI 才投影类型化 `action-required`;当前实现尚未提供该结果。子代理必须通过现有父子关系事件证明仍属于根依赖链,无关待办或后台子代理不得改变退出结果。 +- 非交互入口不等待人工确认,也不从全局外部来源状态推断特殊任务结果。能力不可用时返回普通失败;能够可靠归属到 Tool、Agent 或 MCP owner 时,错误只给出对应管理入口。 - 取消、事件失步、失败完成和 Patch 失败不能报告成功。 ## 5. TUI 内部边界 @@ -174,9 +172,10 @@ CLI-local 配置只保存终端形态偏好与调用入口设置。共享权限 CLI 通过 `DeliveryProfile::Cli` 消费经过校验的产品 Runtime parts。产品定义、Delivery Profile、Runtime Configuration 和 Capability Availability 是不同概念: -- 编译期由 CLI 显式选择 `agent-runtime`、`canvas-runtime`、`external-sources`、 - `plugin-runtime` 与 `ssh-remote` owner feature;这保持现有 CLI capability plan, - 但不继承 Desktop 后续加入 `product-full` 的能力。 +- 编译期由 CLI 显式选择 `agent-runtime` 生命周期基线、实际 service owner、 + `external-sources` / `plugin-runtime` / `ssh-remote` 和九组 `tools-*`;这保持现有 + CLI capability plan,但不再从 Core 基线暗带具体能力,也不继承 Desktop 后续加入 + `product-full` 的能力。 - 隐藏入口不证明后端依赖被移除。 - CLI 不读取 authoring product definition 作为运行时业务配置。 - 品牌、资源、数据 namespace、更新渠道和内置扩展由产品定制 owner 生成,CLI 只消费结果。 @@ -185,10 +184,8 @@ CLI 通过 `DeliveryProfile::Cli` 消费经过校验的产品 Runtime parts。 CLI 只消费 typed summary 与 typed action: -> **实现状态:部分交付。** 交互式 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 5b5bde6229..f02b9484bc 100644 --- a/docs/architecture/extensions/capability-runtime-integration-design.md +++ b/docs/architecture/extensions/capability-runtime-integration-design.md @@ -435,8 +435,8 @@ OpenCode,多语言协议与发布一致性参考 Copilot SDK。最终结构和 ## 10. 产品体验要求 -1. **不阻塞正常工作**:发现、准备、兼容检查和无关待确认项在后台进行;只有当前操作真正依赖待确认能力时返回 - 类型化 `action-required`。 +1. **不阻塞正常工作**:发现、准备、兼容检查和无关待确认项在后台进行;当前操作真正依赖不可用能力时,由该能力 owner + 返回普通失败并指向对应的权限或配置入口,不增加跨能力任务结果类型。 2. **能力状态可解释**:设置页、CLI 和 SDK 能看到来源范围、执行位置、外部宿主、native/degraded 状态、最终 Provider、权限 上限、最近错误和恢复动作;默认界面只显示需处理项和聚合摘要。 3. **不重复打扰**:同一来源/能力/候选内容摘要只询问一次;内部 `prepare/ready/activate` 阶段不逐层重复审批。 diff --git a/docs/architecture/extensions/external-ai-app-connection-experience-design.md b/docs/architecture/extensions/external-ai-app-connection-experience-design.md deleted file mode 100644 index 3d55dfb884..0000000000 --- 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 f222f4d54a..ecf60b044b 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -5,14 +5,13 @@ 适配器负责,本文不建立跨生态通用配置格式或脚本 SDK。BitFun 自身能力如何通过 MCP、Skill、Plugin、Hook、 SDK 或 Server 输出到外部宿主,以及内部能力组合、状态、事件和并发边界,见 [`capability-runtime-integration-design.md`](capability-runtime-integration-design.md);两条方向共用适用的身份事实和能力归属模块, -但不共用一个大一统 adapter 或状态模型。外部应用的 Settings/TUI 信息架构、默认连接、批量确认和提示去重见 -[`external-ai-app-connection-experience-design.md`](external-ai-app-connection-experience-design.md),对应实施顺序见 -[`../../plans/external-ai-app-connection-experience-plan.md`](../../plans/external-ai-app-connection-experience-plan.md)。 +但不共用一个大一统 adapter 或状态模型。Settings 和 TUI 只能把本文的来源与 integration policy 事实压缩为简短概览; +审批、冲突和可执行能力状态继续由 Tool、Agent、MCP、Hook 等真实 owner 负责。 本文同时记录当前可用端到端能力与目标架构。当前 BitFun 已具备通用外部来源目录、四条能力专属发现通道,并由 `ExternalSourceControlPlane` 负责 provider-neutral 调度、generation fencing 和故障隔离;`assembly/core` 的 `WorkspaceExternalSourceService` 负责产品级策略、偏好、聚合和运行装配,`contracts/product-domains` 提供版本化控制事实、固定动作与错误语义, -Desktop、交互式 TUI 和 Peer Host 只显示宿主所需状态,不再各自派生另一套状态机。Server 仓库中保留了只读 external-source dispatch helper,但当前 `/ws` 已直连 in-process App Server,external-source 方法尚未进入 App Server schema,生产请求会得到 `method_not_found`;因此不能把 Server 只读投影列为已交付。OpenCode Prompt Command +Desktop、交互式 TUI 和 Peer Host 只显示宿主所需状态,不再各自派生另一套状态机。App Server 已注册 external-source schema 与 handler;Embedded TUI 注入 management owner 后可以调用,通用 Server `/ws` 当前没有注入绑定可信工作区的 management owner,因此请求会得到类型化 `unsupported`,不能把通用 Server 只读投影列为已交付。OpenCode Prompt Command 适配器已接入本地用户全局/项目来源;Desktop 可查看、刷新、抑制和处理跨来源冲突,交互式 TUI(ChatMode)可列出并执行 Prompt Command;静态文件和经审阅的本地 shell 输出由共享归属模块完成装配。第二条端到端能力已让受支持的单文件 OpenCode `.js` standalone Tool 经静态 预览、来源/能力确认和同名冲突选择后进入现有 Tool Runtime;Desktop 与交互式 TUI(ChatMode)使用同一决策状态。第三条纵向 @@ -120,7 +119,7 @@ stale,界面替换为服务端返回的新 plan,并只保留“旧选择与 ### 3.1 首次发现 发现始终在后台进行。Desktop、交互式 TUI(ChatMode)和 Peer 控制界面消费事实所在 Host 的同一来源状态,但按 -宿主展示;Peer 控制界面只代理 Peer Host,不读取控制端同名来源。当前 Server `/ws` 尚未接入 external-source App Server 方法;未来只读 Web 入口必须先通过版本化 App Server schema 接入 Host 能力,不能由浏览器扫描来源: +宿主展示;Peer 控制界面只代理 Peer Host,不读取控制端同名来源。当前 Server `/ws` 已注册 external-source App Server 方法但未注入可信工作区 owner;未来只读 Web 入口必须先绑定 Host 持有的工作区范围,不能由浏览器提供任意路径或扫描来源: ```text 已发现 OpenCode 工作内容 @@ -162,8 +161,8 @@ stale,界面替换为服务端返回的新 plan,并只保留“旧选择与 Safe Mode 是执行域/工作区实例内的易失控制状态,不写入来源偏好,也不把来源伪装成 `disabled`。进入后继续发现和 展示 Command、Tool、Subagent 与 MCP,但立即撤下外部 Tool、Subagent 和 MCP 的新调用路由;Prompt Command 作为 静态模板继续可见。退出后基于当前来源版本重新协调,不能恢复已删除、已撤销或已过期审批的旧路由。 -GUI 和 TUI 都通过同一个 `SetSafeMode` 动作请求该变化,Peer Host 在事实所在 Host 执行;目标只读 Server 在完成 App Server 接线后通过 -`hostCapabilities` 明确拒绝变更,当前 Server external-source 方法仍是 `method_not_found`。所有偏好写操作携带 `expectedPreferenceRevision`,旧视图必须得到 `stale_revision` +GUI 和 TUI 都通过同一个 `SetSafeMode` 动作请求该变化,Peer Host 在事实所在 Host 执行;目标只读 Server 在注入绑定可信工作区的只读 owner 后通过 +`hostCapabilities` 明确拒绝变更,当前通用 Server 因没有 management owner 而返回类型化 `unsupported`。所有偏好写操作携带 `expectedPreferenceRevision`,旧视图必须得到 `stale_revision` 并重新读取,不能用界面本地状态覆盖并发进程的新决定。 ### 3.3 兼容来源与显式导入 @@ -299,7 +298,7 @@ generation lease 的模型绑定形态,不建立第二套 Agent Runtime。 Desktop、交互式 TUI 以及未来通过 Host 能力访问该状态的界面必须同时展示:来源请求、实际绑定、绑定方式和受影响候选数。 例如“来源请求 `sonnet`;当前工作区由用户绑定到 Primary(实际为已配置模型 X);影响 71 个 Agent”。用户可以选择其他 已配置模型、`primary`、`fast` 或保持相关候选禁用。界面不得把用户选择的替代模型描述成来源原始要求,也不得逐项重复确认 -同一绑定。目标只读 Server 在完成 App Server V1 前置切片后只投影脱敏状态,不获得写入能力;当前 Server 尚不能消费该投影。 +同一绑定。目标只读 Server 在注入绑定可信工作区的只读 management owner 后只投影脱敏状态,不获得写入能力;当前 App Server 方法已经注册,但通用 Server 尚未注入该 owner。 绑定目标的配置 ID 与 `model_runtime_binding_fingerprint` 进入既有激活审批 envelope。来源引用改变、绑定目标被删除或停用, 或者同一配置 ID 下的 provider、模型名、endpoint、认证来源及其他运行身份发生变化时,旧激活决定失效;进行中的调用继续 @@ -452,9 +451,9 @@ Theme、Keybind、完整插件清单,以及各生态新增的 managed/session/ ## 6. 架构与职责 -当前 V1 生产路径是 `Desktop/TUI/Peer Host adapter → WorkspaceExternalSourceService → ExternalSourceControlPlane → 能力专属 provider`,宿主消费 `ExternalSourceControlSnapshotV1`、目录和既有能力级动作。它没有应用级连接状态、统一批量确认计划或任务依赖结果;当前 Server App Server 也尚未暴露这条只读路径。以下 6.1-6.3 全部是连接体验交付后的目标视图,不能作为现状证据。 +当前生产路径是 `Desktop/TUI/Peer Host adapter → WorkspaceExternalSourceService → ExternalSourceControlPlane → 能力专属 provider`。宿主消费现有的 `ExternalSourceControlSnapshotV1`、公共目录、integration policy 和能力级动作。这里不再建立第二套应用连接状态、跨能力批量确认或任务依赖事件。 -### 6.1 目标逻辑视图 +### 6.1 逻辑视图 ```mermaid flowchart TB @@ -463,12 +462,11 @@ flowchart TB Ports["能力专属 provider 契约"] Discovery["ExternalSourceControlPlane\nprovider-neutral discovery"] ProductCoordinator["WorkspaceExternalSourceService\n产品级协调"] - Policy["产品能力事实 / 接入策略 / 安全上限"] + Policy["Integration policy / Safe Mode"] Store["现有原子偏好存储"] - AppView["版本化应用级读模型 + 批量确认计划"] - Catalog["公共 catalog + 能力专属详情"] + Catalog["来源控制 + 公共 catalog"] Owners["Command / Tool / Subagent / MCP / Config owner"] - Surfaces["Desktop / TUI / Peer / future read-only Server"] + Surfaces["Desktop / TUI / Peer"] Sources --- Adapters Adapters --- Ports @@ -476,35 +474,31 @@ flowchart TB Discovery --- ProductCoordinator Policy --- ProductCoordinator Store --- ProductCoordinator - ProductCoordinator --- AppView ProductCoordinator --- Catalog ProductCoordinator ---|窄 typed owner boundary| Owners Owners --- Catalog - AppView --- Surfaces Catalog --- Surfaces ``` -图中连线只表示目标稳定逻辑关系,不表示调用顺序或用户动作;连接、断开和批量确认时序只在 6.3 描述。目标中,发现、连接和加载是三个独立阶段:适配层只产生候选;现有 `ExternalSourceControlPlane` 继续只协调能力专属提供方的发现、期限、代次和故障隔离;现有 `WorkspaceExternalSourceService` 增加产品级协调职责,结合产品事实、作用域化用户决定和安全上限派生应用级连接状态与确认计划,并通过窄类型化端口请求真实能力归属模块加载或撤下。应用级读模型不携带可执行载荷,也不取代公共目录或能力专属 DTO。这里不新增第二个公开控制面类型,也不声称这些新增职责已经接线。 +图中连线表示稳定逻辑关系,不表示调用顺序。适配层只产生候选;`ExternalSourceControlPlane` 负责 provider discovery、期限、代次和故障隔离;`WorkspaceExternalSourceService` 组合 integration policy 与目录,并把真正的批准、冲突选择、加载和撤下交给能力 owner。Desktop 可以按生态对这些事实分组,但分组只属于展示,不是第二个业务状态或协议对象。 -### 6.2 目标开发视图 +### 6.2 开发视图 ```mermaid flowchart TB - ProductDomains["contracts/product-domains\n应用级状态、确认、类型化动作"] + ProductDomains["contracts/product-domains\nV1 来源、policy 与能力契约"] AssemblyExternal["assembly/external-sources\nprovider-neutral 协调器"] AssemblyCore["assembly/core\nWorkspaceExternalSourceService / 产品装配"] EcosystemAdapters["adapters/*\n生态解析与原生覆盖"] Services["services/*\n文件观察、原子存储、进程/网络"] CapabilityOwners["execution / services / core owners\nCommand、Tool、Subagent、MCP"] DesktopAdapter["apps/desktop\nTauri / Peer Host adapter"] - WebUi["web-ui\nOverview / Detail / Review / Advanced"] - Cli["apps/cli\n/extensions 与 action-required"] - Server["server / remote adapters\n目标能力约束与只读投影"] + WebUi["web-ui\n简短概览 / 能力专项设置"] + Cli["apps/cli\n/extensions /tools /agent /mcp /hooks"] WebUi --> DesktopAdapter DesktopAdapter --> AssemblyCore Cli --> AssemblyCore - Server --> AssemblyCore AssemblyCore --> AssemblyExternal AssemblyCore --> EcosystemAdapters AssemblyCore --> Services @@ -515,9 +509,9 @@ flowchart TB CapabilityOwners --> ProductDomains ``` -箭头表示目标编译期/模块依赖方指向被依赖方,不是运行时数据流。依赖方向继续遵守 interfaces/apps → assembly → adapters/services/execution → contracts;`assembly/external-sources` 只依赖 product-domain 契约,不反向依赖 Core、app 或具体生态 adapter。产品默认连接事实由 assembly 选择并通过稳定 contract 投影;React、TUI 和远端 adapter 不按生态 ID 重算默认值、应用状态或推荐集合。Server 节点只有在先把 V1 external-source 只读方法接入 App Server schema、handler/client translation 并通过 WebSocket round-trip 后才能进入这张目标图。 +箭头表示编译期依赖方指向被依赖方,不是运行时数据流。依赖方向继续遵守 interfaces/apps → assembly → adapters/services/execution → contracts;`assembly/external-sources` 只依赖 product-domain 契约,不反向依赖 Core、app 或具体生态 adapter。React 和 TUI 不复制审批、冲突或 capability owner 状态机。 -### 6.3 目标运行视图 +### 6.3 运行视图 ```mermaid sequenceDiagram @@ -525,34 +519,27 @@ sequenceDiagram participant Product as WorkspaceExternalSourceService participant Discovery as ExternalSourceControlPlane participant Adapter as Ecosystem Adapter - participant Policy as Product Policy + participant Policy as Integration Policy participant Owner as Capability Owner participant Store as Preference Store - Surface->>Product: 读取作用域化应用级 snapshot + Surface->>Product: 读取来源 control/catalog Product->>Discovery: 按 execution domain / workspace scope 刷新 Discovery->>Adapter: 只读发现候选 Adapter-->>Discovery: 来源、版本、风险摘要 Discovery-->>Product: 同代能力专属发现结果 - Product->>Policy: 计算默认连接、推荐集合与安全上限 - Policy-->>Product: OpenCode 可默认连接;其他生态只发现 - Product-->>Surface: 应用状态、主操作、review plan - Surface->>Product: ConnectApplication(scope, expected revision) - Product->>Store: 原子保存连接决定并推进权威 preference revision - Product->>Owner: 仅请求允许自动应用的低风险内容 - Owner-->>Product: 已启用 / 受限 / 失败结果 - Product-->>Surface: 连接完成摘要 - Surface->>Product: SubmitApplicationReview(scope, generations, decision keys) - Product->>Product: 重验身份、revision、generation 与 safety ceiling - Product->>Owner: 按能力类型提交批准项 - Owner-->>Product: 逐项权威结果 - Product->>Store: 原子保存有效决定 - Product-->>Surface: 同代 snapshot 与逐项结果 + Product->>Policy: 读取作用域化启停和 capability access + Product-->>Surface: 来源/应用简短概览 + Surface->>Product: 更新 integration policy 或来源启停 + Product->>Store: 校验 preference revision 后原子保存 + Surface->>Owner: 通过 /tools、/agent、/mcp 或 /hooks 处理精确对象 + Owner->>Owner: 重验 identity、version、scope 与 generation + Owner-->>Surface: 权威批准、拒绝、冲突或恢复结果 ``` -目标运行语义中,发现不会产生执行副作用。连接先在目标执行域和工作区作用域中持久化应用级决定,再只协调共享策略允许的低风险内容;批量确认仍分派到各能力归属模块,并在提交前重新校验身份、作用域、偏好版本、发现代次、决策键、行为版本、宿主能力、Safe Mode 和安全上限。 +发现不会产生执行副作用。启停只改变 integration policy 或来源状态;可执行内容在真正的能力 owner 中按精确对象确认。跨能力页面不能代替 owner,也不能批量扩大权限。 -### 6.4 现有能力与连接体验的边界 +### 6.4 现有能力边界 | 部分 | 负责 | 不能承担 | |---|---|---| @@ -563,8 +550,8 @@ sequenceDiagram | 文件观察服务 | 提供可订阅、去抖的文件变化事实 | 解释生态路径、决定优先级、提交业务状态。 | | 本地 JSON 存储服务 | 提供跨进程锁、锁内读改写和同卷原子替换;替换失败时保留旧文件 | 定义外部来源偏好 schema、冲突策略或生态语义。 | | `ExternalSourceControlPlane` | 四类来源分别刷新;同一 provider 同一时间只扫描一次;超时只影响该 provider;旧结果不能覆盖新刷新;确认最新结果后,再通知对应能力模块切换 | 按生态 ID 分支业务行为、把四类数据合并为通用资产、解析生态文件、直接提交配置、工具、权限或界面状态。 | -| `WorkspaceExternalSourceService` / 产品级协调 | 绑定执行域与工作区路由;组合产品事实、现有偏好和控制面发现结果;派生应用级投影;通过窄类型化端口分派连接、撤下和批量确认,并汇总归属模块的权威结果 | 复制提供方调度器、能力审批/冲突存储或 Runtime 归属;把无法撤下的能力宣称为已断开;成为新的公共跨生态执行 API。 | -| 版本化控制状态视图 | 根据 discovery/desired/review/runtime/support 事实生成一级状态;向宿主提供同一版本的 control/catalog、`hostCapabilities`、恢复动作和固定通用操作 | 保存第二份权威状态、携带 Prompt/凭据/可执行数据、替代能力专属审批和冲突 DTO,或让 GUI/TUI 自行推导生命周期。 | +| `WorkspaceExternalSourceService` / 产品级协调 | 绑定执行域与工作区路由;组合 integration policy、现有偏好和控制面发现结果;向能力 owner 提交窄类型化请求 | 复制提供方调度器、能力审批/冲突存储或 Runtime 归属;派生第二套应用连接状态;成为新的公共跨生态执行 API。 | +| 来源控制状态视图 | 根据 discovery、desired、owner decision、runtime 和 support 事实提供 control/catalog、`hostCapabilities`、恢复动作和固定通用操作 | 保存第二份权威状态、携带 Prompt/凭据/可执行数据、替代能力专属审批和冲突 DTO。 | | 界面状态 | 按使用范围、工作区或用户目录关系统一生成安全来源位置,清理诊断文本中的已知绝对路径,并按 `Source / Command / Tool / Subagent` 资源类型路由诊断 | 让 GUI/TUI 解析 provider 诊断码前缀、识别 `.opencode`、`.claude` 等私有目录结构,或接收原始用户/工作区路径。 | | 冲突解析 | 对独立 provider 或产品本地可执行能力的同名候选建立版本敏感内容摘要;未选择时不激活,选择后只在内容摘要不变时复用。现有 Skill 固定根顺序由 Skill 归属模块独立维护 | 用 adapter 优先级静默覆盖另一生态或本地可执行能力,或把选择写回外部文件。 | | 激活策略与能力归属模块 | 根据风险、用户选择、组织上限和执行位置决定自动应用、等待确认或限制 | 修改生态加载顺序或把策略拒绝伪装成解析失败。 | @@ -589,10 +576,7 @@ provider discovery 必须是可独立调度的 request/result,不在协调器 未来网络 provider 仍应实现协作式超时和取消, 但不改变目录、冲突或产品入口契约。 -控制请求保持闭合且类型化:严格 V1 只保留已有 `Refresh`、`SetSourceEnabled` 和 `SetSafeMode`;应用级连接体验在独立协商后的 V2 增加 -`ConnectApplication`、`DisconnectApplication`、`SetApplicationDeferred` 和 `SubmitApplicationReview`。批量 review 只封装 -一组带执行域、workspace route、能力类型、generation、decision key 和 behavior version 的选择,并由产品级协调 owner 分派给现有能力归属模块;它不能成为携带 -任意数据的通用执行 API。能力专属执行参数和调用时权限继续由各归属模块的类型明确契约承担。错误以 `code + stage + retryable + +控制请求保持闭合且类型化:现有来源 control 保留 `Refresh`、`SetSourceEnabled` 和 `SetSafeMode`,应用/生态启停复用 integration policy mutation。能力审批、冲突和执行参数继续由各归属模块的类型明确契约承担,不增加跨能力批量动作。错误以 `code + stage + retryable + correlationId/causationId + recoveryActions` 表达;`detail` 只用于有界诊断,界面和远端协议不得解析文本 决定控制流。日志只记录动作、阶段、关联 ID、错误类别和脱敏对象身份;产品打点可在同一结果上叠加,但不得反向改变状态。 @@ -602,9 +586,7 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 ## 7. 状态与提示规则 -本节定义底层来源/能力的正交生命周期状态;面向 Settings 首页和 TUI `/extensions` 的五种应用级摘要、优先级和主操作,统一见 -[外部 AI 应用连接与管理详细设计](external-ai-app-connection-experience-design.md#3-产品状态模型)。宿主不得把底层状态直接拼成第二套 -应用级规则,也不能用应用摘要替代底层事实。 +本节定义底层来源/能力的正交生命周期状态。Settings 首页和 TUI `/extensions` 可以隐藏不必要的技术细节并生成简短摘要,但不得建立第二套应用级状态规则,也不能用摘要替代底层事实。 | 用户状态 | 含义 | |---|---| @@ -625,8 +607,7 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 - 用户关闭、确认、断开连接或选择暂不使用后,同一内容/行为与风险摘要版本不再主动提示;普通数量变化只更新应用摘要。 - 再次主动提示仅限当前任务确实因待确认能力受阻或降级,或者已确认内容发生实质权限扩大。与当前任务无关的更新失败、来源删除和未连接应用变化只更新状态与恢复动作。 - 普通文件变化、多个同源错误和多项目全局更新按应用/来源聚合;详情进入设置页或 CLI 状态,每次重载最多产生一条摘要,不用 Toast 展示字段级错误。 -- 非交互入口只有在当前操作实际依赖待确认资产时才返回类型化 `action-required`;无关待办只进入结构化状态或 - `stderr` 摘要,不阻塞当前操作,也不自动批准。 +- 非交互入口不等待人工确认,也不从全局待办推断特殊任务结果;能力不可用时返回普通失败且不自动批准。 ## 8. 分阶段落地与验收 diff --git a/docs/architecture/platform-portability-design.md b/docs/architecture/platform-portability-design.md index 44761b9964..0b4b3294ec 100644 --- a/docs/architecture/platform-portability-design.md +++ b/docs/architecture/platform-portability-design.md @@ -93,8 +93,8 @@ Cargo package `bitfun-cli` 的 `aarch64-unknown-linux-ohos` 目标依赖解析 | 问题域 | 当前识别结果 | 主要风险 | 后续专题需要回答 | |---|---|---|---| -| 产品依赖闭包 | CLI 已显式选择 `agent-runtime`、`canvas-runtime`、`external-sources`、`plugin-runtime` 与 `ssh-remote` Core owner feature,不再继承 `product-full`;当前闭包仍主动保留 remote、browser、canvas、plugin、watch、Git、SQLite、PTY 等现有能力 | 无关平台依赖阻塞构建;为过编译而破坏共享 owner | 在不改变现有 CLI 规格的前提下,哪些 owner 还应继续拆分或针对目标平台隔离 | -| Rust 与依赖解析 | 仓库无根 `Cargo.lock`;Rust 1.94.1 探针先被要求 Rust 1.95 的 `oxc-browserslist`、`oxc_sourcemap` 阻塞 | 把通用 MSRV/解析问题误判为 OHOS 问题;构建不可复现 | 仓库认可的 Rust、依赖解析和构建基线 | +| 产品依赖闭包 | CLI 已显式选择 `agent-runtime` 生命周期基线、实际 service owner、external/plugin/SSH owner 和九组 `tools-*`,不再继承 `product-full`;当前 CLI 闭包仍主动保留 remote、browser、canvas、plugin、watch、Git、SQLite、PTY 等现有能力 | 无关平台依赖阻塞构建;为过编译而破坏共享 owner | 在不改变现有 CLI 规格的前提下,哪些 owner 还应继续拆分或针对目标平台隔离 | +| Rust 与依赖解析 | 当前仓库已有根 `Cargo.lock`;旧的 Rust 1.94.1 探针曾先被要求 Rust 1.95 的 `oxc-browserslist`、`oxc_sourcemap` 阻塞,必须在真正启动 OHOS 适配时按届时 lock 与工具链重跑 | 把通用 MSRV/解析问题误判为 OHOS 问题;使用过期解析结论 | 仓库认可的 Rust、依赖解析和构建基线 | | TUI/TTY | `ratatui/crossterm` 依赖 `mio`、rustix、signal-hook 和终端系统调用 | 能编译但 raw mode、输入、resize、信号或恢复不可用 | 真实系统终端支持范围与 TUI 退化边界 | | 剪贴板与语法高亮 | `arboard -> x11rb` 带入 X11;`syntect-tui` 重新带入 `onig_sys` | 桌面 Linux/C 原生依赖进入 OHOS 产物 | 这些能力是否必需,以及各自可维护的鸿蒙化路线 | | 进程与交互终端 | `portable-pty -> termios` 依赖 openpty、shell、信号、进程组和 `/dev` 语义 | 交互 shell、取消和子进程回收不成立 | OHOS 公开进程/PTY 能力与产品可接受的能力范围 | diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index ed904873d6..9c171546ba 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 8e836364da..8815f04bd4 100644 --- a/docs/architecture/rust-build-dependency-boundaries.md +++ b/docs/architecture/rust-build-dependency-boundaries.md @@ -54,6 +54,21 @@ 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 偶然补齐。 + +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; diff --git a/docs/performance/01-compile-performance.md b/docs/performance/01-compile-performance.md index b5e036b186..e84c31ea5d 100644 --- a/docs/performance/01-compile-performance.md +++ b/docs/performance/01-compile-performance.md @@ -1,87 +1,124 @@ # BitFun 编译与依赖治理计划 -> 最近核实:2026-08-04 +> 最近核实:2026-08-10 > -> 快照基线:`gcwing/main@061024fb2` 加权限规划 owner 迁移 +> 实现复核基线:`gcwing/main@e63084bc5` +> +> 性能 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 union | `bitfun-core/agent-runtime` 只保留生命周期和基础工具 owner;MCP、Remote Connect、Browser/Web、Git、模型目录等由产品入口显式组合,三平台 normal/build 闭包各减少 105–110 个版本化 package instance | +| 完整产品行为和闭包保持 | `product-full` 显式组合全部 owner,Windows normal/build 闭包保持 570;CLI 保持 649。ACP 只退出未选择的隐含能力,Windows/macOS/Linux 分别减少 10/13/22 | +| 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 | + +Windows、Cargo 1.97.1 的同机独立 `CARGO_TARGET_DIR` A/B 如下。冷构建、无变更重跑和 +单叶文件 mtime 触发各测一次;“owner 重建”在依赖已热后对 owner package 执行三轮 +clean/rebuild,表中为均值。时间是方向性证据,不是硬阈值。 -| 路径 | 当前快照 | 判断 | -|---|---:|---| -| `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 | 已完成收敛;平台和进程边界继续独立 | +| 闭包 | 冷构建前→后 | 无变更前→后 | 单叶变更前→后 | 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 | -节点数来自同一 Windows 环境下的 `cargo tree --locked` 相对统计,不是实际耗时,也不是跨平台阈值。 -权限纯策略路径理论上少进入约 371 个节点;产品构建闭包没有变化。 +这些单轮数据不支持“编译明显提速”的结论,也不足以把小幅差值与机器波动区分开。依赖编译仍占 +冷路径主导;分组后单叶变更会重链整个职责 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 | + +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、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 +130,39 @@ | 可复现解析 | 根 lockfile 已提交,普通 CI 使用 `--locked`;build.rs 输出已排序 | | CI 拓扑 | Rust job 不再等待完整前端构建,自建 Tauri 检查所需资源目录 | | 依赖收敛 | Desktop 直接 image 版本和 Reqwest TLS 双栈已治理 | +| Agent Runtime 闭包 | Core 基线不再暗带具体 capability;完整产品和 CLI 显式保持原能力,ACP 退出未选择闭包 | +| 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 的推荐范围,也是唯一需要优先设计的核心入口改造。 - -先回答: - -- App Server 与 Server 的真实 construction、command 和 schema 路径分别消费哪些 Core owner? -- Server 对未实现能力应保持什么 typed unsupported 行为? -- 哪些能力由 Server 直接消费,哪些只是经 App Server 间接带入? - -实现边界: +本轮之后先观察,不立即再开同类“小修补”PR。需要真实 CI 样本或上游条件成熟后,按以下顺序重新核实: -- 只替换一个端到端 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 9fc57e62bd..0000000000 --- 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 8d7f9f0886..dbd5dfc96d 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 5b48841532..cbf6d89c91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "BitFun", - "version": "0.2.16", + "version": "0.2.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "BitFun", - "version": "0.2.16", + "version": "0.2.17", "hasInstallScript": true, "dependencies": { "jszip": "^3.10.1", diff --git a/package.json b/package.json index 0118fbeb61..0db6d92f86 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", diff --git a/scripts/audit-appearance-contracts.mjs b/scripts/audit-appearance-contracts.mjs index 8c4d85fd21..7f2d9e10d3 100644 --- a/scripts/audit-appearance-contracts.mjs +++ b/scripts/audit-appearance-contracts.mjs @@ -80,10 +80,15 @@ if (fs.existsSync(retiredOwnershipFile)) { failures.push(`${relative(retiredOwnershipFile)}: directory-level Appearance source ownership is forbidden`); } +const ignoredWalkDirectories = new Set(['node_modules', 'dist', 'build']); + function walk(directory) { return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => { const absolute = path.join(directory, entry.name); - if (entry.isDirectory()) return walk(absolute); + if (entry.isDirectory()) { + if (ignoredWalkDirectories.has(entry.name)) return []; + return walk(absolute); + } return /\.(?:css|scss|ts|tsx)$/.test(entry.name) ? [absolute] : []; }); } @@ -91,7 +96,10 @@ function walk(directory) { function walkContractSources(directory) { return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => { const absolute = path.join(directory, entry.name); - if (entry.isDirectory()) return walkContractSources(absolute); + if (entry.isDirectory()) { + if (ignoredWalkDirectories.has(entry.name)) return []; + return walkContractSources(absolute); + } return /\.(?:css|d\.ts|html|js|json|md|rs|scss|ts|tsx)$/.test(entry.name) ? [absolute] : []; }); } diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 62c59a7689..8eb356677b 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,61 @@ 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 product-full explicitly assembles service and tool capability owners', () => { + for (const required of [ + '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 +485,85 @@ 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', + '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 +573,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 +582,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 +591,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 +602,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 +998,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 +1244,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', [ @@ -1311,6 +1439,41 @@ 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', 'multipart', 'query', 'form']; + 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'], + }]); + 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'], + }], + ); + + 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', () => { @@ -1398,6 +1561,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', () => { diff --git a/scripts/check-github-config.test.mjs b/scripts/check-github-config.test.mjs index 35885c2427..c53371815e 100644 --- a/scripts/check-github-config.test.mjs +++ b/scripts/check-github-config.test.mjs @@ -275,3 +275,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 0000000000..2ccdeff49b --- /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 0c00d7ca84..b76ccc00ae 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)) { @@ -254,19 +252,18 @@ const REQWEST_TRANSPORT_FEATURES = [ '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-services-integrations', { dependencyFeatures: REQWEST_TRANSPORT_FEATURES, optional: true, servicesOwners: true, }], + ['bitfun-ai-adapters', { + dependencyFeatures: [...REQWEST_TRANSPORT_FEATURES, 'rustls', 'socks'], + optional: false, + allowedPackageFeatureRefs: new Set(['reqwest/rustls']), + }], ...[ - 'bitfun-ai-adapters', 'bitfun-cli', 'bitfun-desktop', 'bitfun-miniapp-market-service', @@ -776,44 +773,112 @@ export function findProductEntrypointCoreFeatureViolations( const reviewedCoreFeatureClosures = new Map([ ['bitfun-cli', [ 'agent-runtime', - 'canvas-runtime', + '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', + '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', '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', '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 +963,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 fbd68a3717..0742b296e2 100644 --- a/scripts/core-boundaries/checker.mjs +++ b/scripts/core-boundaries/checker.mjs @@ -40,6 +40,7 @@ import { agentRuntimeIntegrationTestTargets, checkAgentRuntimeIntegrationTestTopology, checkCliIntegrationTestTopology, + checkServiceIntegrationTestTopologies, cliIntegrationTestTargets, validateExplicitIntegrationTestTopology, } from './explicit-test-topology.mjs'; @@ -1122,6 +1123,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 e43a0659c3..9a157af1d4 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/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 18b036e8cd..536e8fe6d4 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -67,7 +67,7 @@ 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: 'axum', ownerFeatures: ['debug-log', 'mcp-runtime'] }, { depName: 'bitfun-ai-adapters', ownerFeatures: ['ai-adapter-runtime'] }, { depName: 'bitfun-agent-runtime', ownerFeatures: ['agent-runtime'] }, { depName: 'bitfun-agent-stream', ownerFeatures: ['agent-runtime'] }, @@ -78,46 +78,77 @@ 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', + ], + }, { 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', + 'web-tools', + 'workspace-search', + ], + }, + { + depName: 'bitfun-tool-packs', + ownerFeatures: [ + 'tool-packs', + 'tools-agent-control', + 'tools-basic', + 'tools-browser-web', + 'tools-canvas', + 'tools-computer-use', + 'tools-git', + 'tools-image-analysis', + 'tools-mcp', + 'tools-miniapp', ], }, - { depName: 'bitfun-tool-packs', ownerFeatures: ['tool-packs'] }, - { depName: 'chrono-tz', ownerFeatures: ['agent-runtime'] }, - { depName: 'cron', ownerFeatures: ['agent-runtime'] }, + { depName: 'chrono-tz', ownerFeatures: ['scheduled-jobs'] }, + { depName: 'cron', ownerFeatures: ['scheduled-jobs'] }, { depName: 'dashmap', ownerFeatures: ['agent-runtime'] }, { depName: 'filetime', ownerFeatures: ['agent-runtime'] }, { depName: 'flate2', ownerFeatures: ['agent-runtime'] }, { depName: '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'] }, + { depName: 'reqwest', ownerFeatures: ['ai-adapter-runtime', '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', 'tools-basic', 'web-tools'] }, ], }, { @@ -210,6 +241,23 @@ export const coreProductFullFeatureAssemblyRule = { featureName: 'product-full', requiredFeatureRefs: [ 'agent-runtime', + 'browser-control', + 'deep-research', + 'mcp-runtime', + 'model-catalog', + 'remote-connect', + 'scheduled-jobs', + 'tools-agent-control', + 'tools-basic', + 'tools-browser-web', + 'tools-canvas', + 'tools-computer-use', + 'tools-git', + 'tools-image-analysis', + 'tools-mcp', + 'tools-miniapp', + 'web-tools', + 'workspace-search', 'announcement', 'canvas-runtime', 'debug-log', @@ -229,7 +277,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', @@ -245,8 +293,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', @@ -255,41 +301,29 @@ export const coreClosedFeatureProfileRules = [ 'dep:indexmap', 'dep:image', 'dep:md5', - 'dep:reqwest', - 'dep:semver', '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', @@ -299,11 +333,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', ], @@ -317,11 +355,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, @@ -334,6 +375,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', @@ -345,11 +389,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, @@ -358,15 +405,256 @@ 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: 'tools-basic', + requiredFeatureRefs: [ + 'bitfun-tool-packs/basic', + 'tool-runtime/document-read', + '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: @@ -503,7 +791,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 c716b558b3..d6eaab22e7 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 ad4378da94..1fe145be9a 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 774928a2db..5510e2d0ad 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -16,7 +16,6 @@ export const requiredContentRules = [ ...[ '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', ].map((path) => ({ @@ -29,6 +28,17 @@ export const requiredContentRules = [ }, ], })), + { + path: 'src/crates/adapters/ai-adapters/Cargo.toml', + reason: + 'the AI adapter owns Rustls HTTPS clients and the product-supported SOCKS proxy transport', + patterns: [ + { + regex: /^reqwest\s*=\s*\{\s*workspace\s*=\s*true,\s*features\s*=\s*\[\s*"rustls"\s*,\s*"socks"\s*\]\s*\}/m, + message: 'AI adapter Reqwest dependency must explicitly enable rustls and socks', + }, + ], + }, { path: 'src/crates/services/services-core/src/lib.rs', reason: @@ -230,7 +240,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: [ @@ -3930,16 +3940,24 @@ export const requiredContentRules = [ 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: /function-agents = \[[^\]]*"ai-adapter-runtime"[^\]]*\]/, + message: 'core function-agent facade must explicitly opt into AI adapter runtime while concrete AI adapters remain optional', + }, + { + regex: /function-agents = \[[^\]]*"bitfun-services-integrations\/function-agents"[^\]]*\]/, + message: 'core function-agent facade must enable the function-agent service owner feature it imports', }, { - regex: /product-domains = \[[^\]]*"bitfun-services-integrations\/function-agents"[^\]]*\]/, - message: 'core product-domain 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: /product-domains = \[[^\]]*"bitfun-services-integrations\/miniapp-runtime"[^\]]*\]/, - message: 'core product-domain facade must enable the MiniApp service owner 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 +3966,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 +3987,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 +4000,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 +4019,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, @@ -4100,24 +4112,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\(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\(feature = "agent-runtime"\)\]\s*pub mod remote_connect\b/s, - message: 'Core Remote Connect product bridge must stay behind agent-runtime', + 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 +7240,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 +7533,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 +8434,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 419ddd9eb0..f2ce820c41 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']], @@ -1555,12 +1641,6 @@ export function runManifestParserSelfTest({ 'ExternalSourceControlRequestV1', 'ExternalSourceOperationStage', 'ExternalSourceRecoveryActionV1', - 'EXTERNAL_APPLICATION_SCHEMA_V2', - 'ExternalApplicationSnapshotV2', - 'ExternalApplicationReviewPageV2', - 'ExternalApplicationControlActionV2', - 'ExternalApplicationControlRequestV2', - 'ExternalApplicationControlResultV2', ]) { if (!externalSourceControlPublicApiRule?.allowedSymbolEntries.some( (entry) => entry.symbol === requiredSymbol @@ -1611,9 +1691,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 @@ -3785,6 +3862,7 @@ export function runManifestParserSelfTest({ 'StaticToolProviderFactory', 'create_registry_from_static_provider_entries', 'create_product_tool_registry_from_plan', + 'unavailable_feature_groups', 'materialize_tool', 'GetToolSpecTool', ], @@ -3873,6 +3951,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', @@ -4207,9 +4286,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'], @@ -4230,13 +4313,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', ], }, { @@ -4246,8 +4329,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/stage-github-release-assets.mjs b/scripts/stage-github-release-assets.mjs new file mode 100644 index 0000000000..307744a13c --- /dev/null +++ b/scripts/stage-github-release-assets.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node + +import { + copyFileSync, + mkdirSync, + rmSync, + statSync, +} from 'node:fs'; +import path from 'node:path'; + +const args = process.argv.slice(2); +const outDirIndex = args.indexOf('--out-dir'); +if (outDirIndex === -1 || !args[outDirIndex + 1]) { + fail('Missing required --out-dir argument'); +} + +const outDir = path.resolve(args[outDirIndex + 1]); +const inputs = args.filter( + (_, index) => index !== outDirIndex && index !== outDirIndex + 1, +); + +if (inputs.length === 0) { + fail('No release assets were provided'); +} + +const byName = new Map(); +for (const input of inputs) { + const source = path.resolve(input); + let stats; + try { + stats = statSync(source); + } catch { + fail(`Release asset was not found: ${input}`); + } + if (!stats.isFile()) { + fail(`Release asset is not a file: ${input}`); + } + + const name = path.basename(source); + const previous = byName.get(name); + if (previous) { + fail(`Duplicate release asset name ${name}: ${previous} conflicts with ${source}`); + } + byName.set(name, source); +} + +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); + +for (const [name, source] of byName) { + copyFileSync(source, path.join(outDir, name)); +} + +console.log(`Staged ${byName.size} uniquely named GitHub release assets in ${outDir}`); + +function fail(message) { + console.error(`[stage-release-assets] ${message}`); + process.exit(1); +} diff --git a/scripts/tauri-release-manifest.test.mjs b/scripts/tauri-release-manifest.test.mjs index 17c4a162f2..9eaaa1512b 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 0fdf26e358..7210dd2839 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -32,10 +32,21 @@ path = "tests/terminal_process_contracts.rs" # Internal crates bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = [ "agent-runtime", - "canvas-runtime", + "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" } diff --git a/src/apps/cli/src/account.rs b/src/apps/cli/src/account.rs index cfb5e7926b..28ffd1dd37 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, -{ - let mut cancelled = Box::pin(account_sync_cancel().notified()); - cancelled.as_mut().enable(); - if !account_context_is_current(generation) { - return Err(anyhow!("account sync cancelled")); - } - tokio::select! { - _ = &mut cancelled => Err(anyhow!("account sync cancelled")), - result = future => { - if !account_context_is_current(generation) { - Err(anyhow!("account sync cancelled")) - } else { - Ok(result) +#[async_trait] +impl AccountSessionBackupPort for CliAccountSessionBackupPort { + async fn list_session_backups( + &self, + workspace_path: &std::path::Path, + ) -> Result> { + let metadata = self + .compatibility + .list_persisted_sessions(workspace_path) + .await + .map_err(|error| anyhow!("list sessions: {error}"))?; + let mut backups = Vec::new(); + for item in &metadata { + if let Err(error) = ensure_relay_session_history_exportable(item) { + tracing::debug!("Skipping CLI account session export: {error}"); + continue; } + let turns = self + .compatibility + .load_persisted_session_turns(workspace_path, &item.session_id, None) + .await + .map_err(|error| anyhow!("load turns: {error}"))?; + backups.push(build_session_backup(item, &turns)?); } + Ok(backups) } } -async fn invalidate_and_wait_for_account_sync() -> AccountContextTransitionGuard { - let transition_guard = ACCOUNT_CONTEXT_TRANSITION_LOCK.lock().await; - let transition = AccountContextTransitionPermit::begin(); - let sync_guard = ACCOUNT_SYNC_LOCK.lock().await; - bitfun_core::service::remote_connect::settings_sync::wait_for_sync_operations_idle().await; - let routing_guard = DEVICE_ROUTING_LIFECYCLE.write().await; - AccountContextTransitionGuard { - sync_guard: Some(sync_guard), - transition: Some(transition), - routing_guard: Some(routing_guard), - transition_guard: Some(transition_guard), - } +/// CLI-owned routing effects injected into the shared Account Runtime. +pub(crate) struct CliAccountRoutingHost { + self_ref: Weak, + runtime: OnceLock>, + relay_client: RwLock>>, + /// Read leases cover one routing event through its response. Routing owner + /// changes take the write lease, so old events cannot escape through a new + /// account's Relay client. + lifecycle: Arc>, } -async fn invalidate_and_wait_if_account_current( - expected_generation: u64, -) -> Option { - let transition_guard = ACCOUNT_CONTEXT_TRANSITION_LOCK.lock().await; - if !account_context_is_current(expected_generation) { - return None; +impl CliAccountRoutingHost { + fn new() -> Arc { + Arc::new_cyclic(|self_ref| Self { + self_ref: self_ref.clone(), + runtime: OnceLock::new(), + relay_client: RwLock::new(None), + lifecycle: Arc::new(RwLock::new(())), + }) } - let transition = AccountContextTransitionPermit::begin(); - let sync_guard = ACCOUNT_SYNC_LOCK.lock().await; - bitfun_core::service::remote_connect::settings_sync::wait_for_sync_operations_idle().await; - let routing_guard = DEVICE_ROUTING_LIFECYCLE.write().await; - Some(AccountContextTransitionGuard { - sync_guard: Some(sync_guard), - transition: Some(transition), - routing_guard: Some(routing_guard), - transition_guard: Some(transition_guard), - }) -} -/// The background device-routing relay client. Holding this keeps the WS -/// connection alive (the internal read/write tasks own the socket). Dropping it -/// tears the connection down. -static DEVICE_RELAY_CLIENT: OnceLock>>> = OnceLock::new(); - -/// Set when the relay returns an auth error (token expired or invalid). -/// The chat loop checks this via `is_token_expired()` and prompts the user. -static TOKEN_EXPIRED: AtomicBool = AtomicBool::new(false); - -/// True while credentials succeeded but the user has not yet chosen -/// cloud-vs-local settings. Session is held in memory only; a process kill -/// must not restore a logged-in state (same contract as desktop -/// `account_login` / `account_finalize_login`). -static PENDING_SYNC_CHOICE: AtomicBool = AtomicBool::new(false); - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct AutomaticAccountSyncPolicy { - pub(crate) background_engine: bool, - pub(crate) management_push: bool, -} - -fn automatic_account_sync_policy_for_pending( - pending_sync_choice: bool, -) -> AutomaticAccountSyncPolicy { - let allowed = !pending_sync_choice; - AutomaticAccountSyncPolicy { - background_engine: allowed, - management_push: allowed, + fn bind_runtime(&self, runtime: Weak) { + self.runtime + .set(runtime) + .unwrap_or_else(|_| panic!("CLI account routing runtime was bound twice")); } -} - -/// Automatic sync must remain idle while an authenticated account is waiting -/// for the user to choose whether cloud or local settings should win. Explicit -/// first-login sync is intentionally not governed by this policy. -pub(crate) fn automatic_account_sync_policy() -> AutomaticAccountSyncPolicy { - automatic_account_sync_policy_for_pending(PENDING_SYNC_CHOICE.load(Ordering::Acquire)) -} - -fn account_context() -> &'static Arc>> { - ACCOUNT_CONTEXT.get_or_init(|| Arc::new(RwLock::new(None))) -} - -fn device_relay_client() -> &'static RwLock>> { - DEVICE_RELAY_CLIENT.get_or_init(|| RwLock::new(None)) -} -/// Read both the session and relay URL, returning owned clones to avoid holding -/// locks across awaits. -pub(crate) async fn read_account_context() -> Result<(AccountSession, String)> { - let generation = account_context_generation(); - read_account_context_for_generation(generation).await -} - -async fn read_account_context_raw() -> Result<(AccountSession, String)> { - account_context() - .read() - .await - .clone() - .map(|context| (context.session, context.relay_url)) - .ok_or_else(|| anyhow!("not logged in")) -} - -pub(crate) async fn read_account_context_for_generation( - generation: u64, -) -> Result<(AccountSession, String)> { - if !account_context_is_current(generation) { - return Err(anyhow!("account context changed")); - } - let context = read_account_context_raw().await?; - if !account_context_is_current(generation) { - return Err(anyhow!("account context changed")); + fn runtime(&self) -> Result> { + self.runtime + .get() + .and_then(Weak::upgrade) + .ok_or_else(|| anyhow!("account runtime is unavailable")) } - Ok(context) -} -/// Whether an account session is currently held and login is finalized. -/// Matches desktop `account_status`: pending cloud/local sync choice is not -/// treated as logged in. -pub(crate) async fn is_logged_in() -> bool { - if PENDING_SYNC_CHOICE.load(Ordering::Acquire) { - return false; - } - read_account_context().await.is_ok() -} - -pub(crate) fn pending_sync_choice() -> bool { - PENDING_SYNC_CHOICE.load(Ordering::Acquire) -} - -fn normalize_relay_url(relay_url: &str) -> Result { - let parsed = validate_relay_base_url(relay_url.trim())?; - Ok(parsed.as_str().trim_end_matches('/').to_string()) -} - -/// Attempt to restore a persisted session from disk. Called at startup. -/// Returns `Some(user_id)` if a session was restored. -pub(crate) async fn try_restore_session() -> Option { - let _sync_guard = invalidate_and_wait_for_account_sync().await; - match session_store::load_session_detailed() { - Ok(Some(loaded)) => { - let relay_url = match normalize_relay_url(&loaded.relay_url) { - Ok(url) => url, - Err(error) => { - tracing::warn!("Ignoring invalid persisted relay URL: {error}"); - session_store::clear_session(); - return None; - } - }; - let user_id = loaded.user_id.clone(); - if let Some(device_id) = loaded.device_id.as_deref() { - if let Err(e) = DeviceIdentity::adopt_account_device_id(device_id) { - tracing::warn!("Failed to adopt restored session device_id: {e}"); - } - } - let session = AccountSession { - token: loaded.token, - user_id: user_id.clone(), - master_key: loaded.master_key, - }; - *account_context().write().await = Some(AccountContextState { session, relay_url }); - tracing::info!("Restored account session for user {user_id}"); - Some(user_id) - } - Ok(None) => None, - Err(e) => { - tracing::warn!("Failed to load persisted session: {e}"); - None + async fn start_routing(&self, request: AccountRoutingStartRequest) -> Result<()> { + let runtime = self.runtime()?; + if !runtime.account_context_is_current(request.account_generation) { + return Err(anyhow!("account context changed")); } - } -} - -/// Whether the relay has reported the account token as expired/invalid. -/// The TUI prompts re-login via this; the daemon exits on it. -pub(crate) fn is_token_expired() -> bool { - TOKEN_EXPIRED.load(Ordering::Relaxed) -} - -/// Mark the account token as rejected by the relay (expired / revoked). -/// Called by the settings sync engine when a sync request gets a 401. -pub(crate) fn mark_token_expired() { - TOKEN_EXPIRED.store(true, Ordering::Relaxed); -} - -/// Resolve the current device identity (machine-based). -fn current_device_identity() -> Result { - DeviceIdentity::from_current_machine().map_err(|e| anyhow!("detect device: {e}")) -} - -/// Structured result of a successful credential login. -#[derive(Debug, Clone)] -pub(crate) struct LoginResult { - pub user_id: String, - pub relay_url: String, - /// True when the relay already has a settings blob (Desktop overwrite prompt). - pub has_cloud_settings: bool, - pub status_message: String, -} - -/// Log in with credentials collected by the Login TUI form. -/// -/// Same fields as Desktop Account Login: Auth Server (relay URL), Username, -/// Password. Persists encrypted session + non-secret hint, then starts device -/// routing so this CLI becomes a Peer Device Mode host. -pub(crate) async fn login_with_credentials( - relay_url: &str, - username: &str, - password: &str, -) -> Result { - let _login_guard = ACCOUNT_LOGIN_LOCK.lock().await; - let relay_url_input = relay_url.trim(); - let username = username.trim(); - if relay_url_input.is_empty() { - return Err(anyhow!("Auth Server is required")); - } - if username.is_empty() { - return Err(anyhow!("Username is required")); - } - if password.is_empty() { - return Err(anyhow!("Password is required")); - } - let relay_url = normalize_relay_url(relay_url_input)?; - let expected_generation = account_context_generation(); - if !account_context_is_current(expected_generation) { - return Err(anyhow!("account context changed")); - } - - let device = current_device_identity()?; - let client = AccountClient::new(); - let session = client - .login(&relay_url, username, password, &device) - .await - .map_err(|e| anyhow!("login failed: {e}"))?; - - let has_cloud_settings = - match resolve_cloud_settings_probe(client.fetch_settings(&relay_url, &session).await) { - Ok(has_cloud_settings) => has_cloud_settings, - Err(error) => { - revoke_rejected_login_candidate(&client, &relay_url, &session).await; - return Err(error); + self.stop_routing().await; + + let ws_url = format!( + "{}/ws", + request + .relay_url + .replace("https://", "wss://") + .replace("http://", "ws://") + ); + let (client, mut event_rx) = RelayClient::new(); + client.connect(&ws_url).await?; + client + .connect_authenticated(&request.session.token, &request.device_name) + .await?; + let client = Arc::new(client); + { + let _routing_guard = self.lifecycle.write().await; + if !runtime.account_context_is_current(request.account_generation) { + client.disconnect().await; + return Err(anyhow!("account context changed")); } - }; - - // A daemon is a separate process with its own in-memory session and WebSocket. - // Retire it after candidate authentication but before beginning the local - // generation transition. If retirement fails, the old local owner keeps - // its original generation and remains usable. A clean daemon exit is not - // auto-restarted by the generated launchd/systemd service definitions. - // Snapshot the old owner before the guarded replacement. A generation race - // rejects the transition below, in which case this snapshot is never used. - let previous_account_context = account_context().read().await.clone(); - let (retired_daemon, transition_guard) = match begin_candidate_account_transition( - expected_generation, - retire_running_daemon_for_account_switch().await, - ) - .await - { - Ok(transition) => transition, - Err(CandidateAccountTransitionError::DaemonRetirement(failure)) => { - let recovery_message = if failure.daemon_may_exit { - schedule_routing_recovery_after_daemon_exit( - expected_generation, - device.device_name.clone(), - ); - "; this CLI will restore local routing if the daemon exits" - } else { - "" - }; - revoke_rejected_login_candidate(&client, &relay_url, &session).await; - return Err(anyhow!( - "{}; the old account context and generation were preserved{}", - failure.error, - recovery_message - )); + *self.relay_client.write().await = Some(client.clone()); } - Err(CandidateAccountTransitionError::AccountContextChanged) => { - revoke_rejected_login_candidate(&client, &relay_url, &session).await; + if !runtime.account_context_is_current(request.account_generation) { + self.retire_routing_client_if_same(&client).await; + client.disconnect().await; return Err(anyhow!("account context changed")); } - }; - - // The transition owns the routing lifecycle write lease. Retire any - // in-process owner before making the candidate context observable. - clear_replaced_persisted_session(); - stop_device_routing_locked().await; - - let user_id = session.user_id.clone(); - let device_name = device.device_name.clone(); - let token = session.token.clone(); - let master_key = session.master_key; - *account_context().write().await = Some(AccountContextState { - session, - relay_url: relay_url.clone(), - }); - session_store::save_credential_hint(username, &relay_url); - TOKEN_EXPIRED.store(false, Ordering::Relaxed); - if has_cloud_settings { - // Defer disk persist until the sync choice is accepted. Killing the - // process during the choice panel must not restore a logged-in session. - PENDING_SYNC_CHOICE.store(true, Ordering::Release); - transition_guard.finish(); - revoke_replaced_account_context(&client, previous_account_context, &relay_url, &token) - .await; - return Ok(LoginResult { - user_id: user_id.clone(), - relay_url: relay_url.clone(), - has_cloud_settings, - status_message: format!( - "Authenticated as user {} on {}. Choose cloud or local settings to finish login.{}", - user_id, - relay_url, - if retired_daemon { - " The previous CLI daemon was stopped; routing will resume after the sync choice." - } else { - "" + let routing = self + .self_ref + .upgrade() + .ok_or_else(|| anyhow!("account routing is unavailable"))?; + let expected_token = request.session.token; + let generation = request.account_generation; + tokio::spawn(async move { + loop { + if !routing.routing_loop_is_current(generation, &client).await { + tracing::debug!("Stopping stale device routing event loop"); + break; + } + let Some(event) = event_rx.recv().await else { + break; + }; + if !routing.routing_loop_is_current(generation, &client).await { + tracing::debug!("Stopping stale device routing event loop"); + break; } - ), + routing + .handle_relay_event(event, &client, generation, &expected_token) + .await; + } + routing.retire_routing_client_if_same(&client).await; + tracing::info!("Device routing event loop exited"); }); + Ok(()) } - PENDING_SYNC_CHOICE.store(false, Ordering::Release); - if let Err(e) = session_store::save_session_with_device( - &token, - &user_id, - &master_key, - &relay_url, - Some(device.device_id.as_str()), - ) { - tracing::warn!("Failed to persist session: {e}"); + async fn stop_routing(&self) { + let _routing_guard = self.lifecycle.write().await; + self.stop_routing_locked().await; } - let generation = transition_guard.finish(); - let routing_msg = match spawn_device_routing(&relay_url, &device_name, generation).await { - Ok(()) if retired_daemon => " The previous CLI daemon was stopped and routing is connected in this CLI process. Restart `bitfun daemon run` to restore always-on routing.".to_string(), - Ok(()) => " Device routing connected (Peer Host ready). Tip: `bitfun daemon install` keeps this device reachable after exit or reboot.".to_string(), - Err(e) if retired_daemon => format!(" (Warning: the previous CLI daemon was stopped, but replacement routing failed: {e})"), - Err(e) => format!(" (Warning: device routing failed: {e})"), - }; - revoke_replaced_account_context(&client, previous_account_context, &relay_url, &token).await; - - Ok(LoginResult { - user_id: user_id.clone(), - relay_url: relay_url.clone(), - has_cloud_settings, - status_message: format!( - "Logged in as user {} on {}.{}", - user_id, relay_url, routing_msg - ), - }) -} - -async fn revoke_rejected_login_candidate( - client: &AccountClient, - relay_url: &str, - session: &AccountSession, -) { - if let Err(error) = client.revoke_token(relay_url, session).await { - tracing::warn!("Failed to revoke rejected login candidate token: {error}"); + pub(crate) async fn stop_device_routing(&self) { + self.stop_routing().await; } -} -fn clear_replaced_persisted_session() { - // Once this candidate has won the transition, the old account must never - // be restored after a crash. A finalized replacement is persisted below; - // a pending cloud-sync choice intentionally leaves no restorable session. - session_store::clear_session(); -} - -fn replaced_account_revocation_target( - previous: Option, - replacement_relay_url: &str, - replacement_token: &str, -) -> Option { - previous.filter(|context| { - context.relay_url != replacement_relay_url || context.session.token != replacement_token - }) -} - -async fn revoke_replaced_account_context( - client: &AccountClient, - previous: Option, - replacement_relay_url: &str, - replacement_token: &str, -) { - let Some(previous) = - replaced_account_revocation_target(previous, replacement_relay_url, replacement_token) - else { - return; - }; - if let Err(error) = client - .revoke_token(&previous.relay_url, &previous.session) - .await - { - // B is already the committed in-memory owner. Relay cleanup of A is - // best-effort and must never roll the replacement back. - tracing::warn!("Failed to revoke replaced account token: {error}"); + async fn stop_routing_locked(&self) { + if let Some(client) = self.relay_client.write().await.take() { + client.disconnect().await; + } + crate::peer_host::update_controller_presence(Vec::new()).await; } -} - -fn resolve_cloud_settings_probe(result: Result>) -> Result { - result.map(|settings| settings.is_some()).map_err(|error| { - anyhow!("could not check cloud settings: {error}; the current account remains active") - }) -} - -struct DaemonRetirementFailure { - error: anyhow::Error, - daemon_may_exit: bool, -} -enum CandidateAccountTransitionError { - DaemonRetirement(DaemonRetirementFailure), - AccountContextChanged, -} - -async fn begin_candidate_account_transition( - expected_generation: u64, - daemon_retirement: std::result::Result, -) -> std::result::Result<(bool, AccountContextTransitionGuard), CandidateAccountTransitionError> { - let retired_daemon = - daemon_retirement.map_err(CandidateAccountTransitionError::DaemonRetirement)?; - let transition_guard = invalidate_and_wait_if_account_current(expected_generation) - .await - .ok_or(CandidateAccountTransitionError::AccountContextChanged)?; - Ok((retired_daemon, transition_guard)) -} - -async fn retire_running_daemon_for_account_switch( -) -> std::result::Result { - if !crate::daemon::is_daemon_running() { - return Ok(false); - } - if !crate::daemon::request_daemon_shutdown() { - return Err(DaemonRetirementFailure { - error: anyhow!("could not stop the CLI daemon; the current account remains active"), - daemon_may_exit: false, - }); + async fn is_current_routing_client(&self, client: &Arc) -> bool { + same_routing_client(self.relay_client.read().await.as_ref(), client) } - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); - while crate::daemon::is_daemon_running() { - if tokio::time::Instant::now() >= deadline { - return Err(DaemonRetirementFailure { - error: anyhow!( - "CLI daemon did not stop in time; the current account remains active" - ), - daemon_may_exit: true, - }); + async fn routing_loop_is_current( + &self, + account_generation: u64, + client: &Arc, + ) -> bool { + let Ok(runtime) = self.runtime() else { + return false; + }; + if !runtime.account_context_is_current(account_generation) { + return false; } - tokio::time::sleep(Duration::from_millis(50)).await; + let matches = self.is_current_routing_client(client).await; + matches && runtime.account_context_is_current(account_generation) } - Ok(true) -} -fn schedule_routing_recovery_after_daemon_exit(expected_generation: u64, device_name: String) { - if !account_context_is_current(expected_generation) - || ROUTING_RECOVERY_GENERATION.swap(expected_generation, Ordering::AcqRel) - == expected_generation - { - return; - } - tokio::spawn(async move { - while ROUTING_RECOVERY_GENERATION.load(Ordering::Acquire) == expected_generation - && account_context_is_current(expected_generation) - && crate::daemon::is_daemon_running() - { - tokio::time::sleep(Duration::from_millis(100)).await; + async fn retire_routing_client_if_same(&self, client: &Arc) -> bool { + let _routing_guard = self.lifecycle.write().await; + let mut current = self.relay_client.write().await; + if !take_routing_client_if_same(&mut current, client) { + return false; } - if ROUTING_RECOVERY_GENERATION.load(Ordering::Acquire) == expected_generation - && account_context_is_current(expected_generation) - && !crate::daemon::is_daemon_running() - { - if let Err(error) = restore_device_routing(&device_name).await { - tracing::warn!( - "Failed to restore old account routing after delayed daemon exit: {error}" - ); - } - } - let _ = ROUTING_RECOVERY_GENERATION.compare_exchange( - expected_generation, - 0, - Ordering::AcqRel, - Ordering::Acquire, - ); - }); -} - -/// Persist the in-memory session after the user accepts the sync choice, then -/// start device routing (same as a first login with no cloud settings). -pub(crate) async fn finalize_login_after_sync_choice() -> Result<()> { - let generation = account_context_generation(); - let sync_guard = lock_account_sync(generation).await?; - let device = current_device_identity()?; - let (session, relay_url) = read_account_context().await?; - let retired_daemon = retire_running_daemon_for_account_switch() - .await - .map_err(|failure| failure.error)?; - session_store::save_session_with_device( - &session.token, - &session.user_id, - &session.master_key, - &relay_url, - Some(device.device_id.as_str()), - ) - .map_err(|e| anyhow!("persist session: {e}"))?; - PENDING_SYNC_CHOICE.store(false, Ordering::Release); - - if retired_daemon { - tracing::info!( - "Stopped the previous CLI daemon before finalizing replacement account routing" - ); - } - drop(sync_guard); - spawn_device_routing(&relay_url, &device.device_name, generation) - .await - .map_err(|e| anyhow!("device routing failed: {e}")) -} - -/// Snapshot of the logged-in account for the Account status page. -#[derive(Debug, Clone)] -pub(crate) struct AccountInfo { - pub user_id: String, - pub relay_url: String, - pub device_id: String, - pub device_name: String, -} - -pub(crate) async fn account_info() -> Result { - let (session, relay_url) = read_account_context().await?; - let device = current_device_identity()?; - Ok(AccountInfo { - user_id: session.user_id, - relay_url, - device_id: device.device_id, - device_name: device.device_name, - }) -} - -/// Public wrapper for restoring device routing after session restore at startup. -pub(crate) async fn restore_device_routing(device_name: &str) -> Result<()> { - let generation = account_context_generation(); - let (_, relay_url) = read_account_context().await?; - spawn_device_routing(&relay_url, device_name, generation).await -} - -/// Connect to the account relay for device-to-device routing and spawn the -/// background task that handles incoming RPC commands. -async fn spawn_device_routing( - relay_url: &str, - device_name: &str, - account_generation: u64, -) -> Result<()> { - let _sync_guard = lock_account_sync(account_generation).await?; - let relay_url = normalize_relay_url(relay_url)?; - // Tear down any previous connection first. - stop_device_routing().await; - - let (session, current_relay_url) = read_account_context().await?; - if current_relay_url != relay_url { - return Err(anyhow!("account context changed")); + drop(current); + crate::peer_host::update_controller_presence(Vec::new()).await; + true } - let ws_url = format!( - "{}/ws", - relay_url - .replace("https://", "wss://") - .replace("http://", "ws://") - ); - - let (client, mut event_rx) = RelayClient::new(); - client.connect(&ws_url).await?; - client - .connect_authenticated(&session.token, device_name) - .await?; - let client_arc = Arc::new(client); - { - let _routing_guard = DEVICE_ROUTING_LIFECYCLE.write().await; - let mut current_client = device_relay_client().write().await; - if !account_context_is_current(account_generation) { - drop(current_client); - client_arc.disconnect().await; - return Err(anyhow!("account context changed")); + async fn handle_relay_event( + self: &Arc, + event: RelayEvent, + relay_client: &Arc, + account_generation: u64, + expected_token: &str, + ) { + if let RelayEvent::AuthError { message } = event { + self.handle_relay_auth_error(message, relay_client, account_generation, expected_token) + .await; + return; } - *current_client = Some(client_arc.clone()); - } - let account_context = account_context().clone(); - let relay_client_arc = client_arc.clone(); - tokio::spawn(async move { - loop { - if !routing_loop_is_current(account_generation, &relay_client_arc).await { - tracing::debug!("Stopping stale device routing event loop"); - break; - } - let Some(event) = event_rx.recv().await else { - break; - }; - if !routing_loop_is_current(account_generation, &relay_client_arc).await { - tracing::debug!("Stopping stale device routing event loop"); - break; - } - handle_relay_event( - event, - &account_context, - &relay_client_arc, - account_generation, - &session.token, - ) - .await; + let _routing_lease = self.lifecycle.read().await; + if !self + .routing_loop_is_current(account_generation, relay_client) + .await + { + tracing::debug!("Ignoring event from a stale device routing client"); + return; } - retire_routing_client_if_same(&relay_client_arc).await; - tracing::info!("Device routing event loop exited"); - }); - - Ok(()) -} - -/// Disconnect the device-routing connection (if any). -pub(crate) async fn stop_device_routing() { - let _routing_guard = DEVICE_ROUTING_LIFECYCLE.write().await; - stop_device_routing_locked().await; -} - -/// Stop routing while the caller holds the lifecycle write lease. -async fn stop_device_routing_locked() { - let client = { device_relay_client().write().await.take() }; - if let Some(client) = client { - client.disconnect().await; - } - crate::peer_host::update_controller_presence(Vec::new()).await; -} - -async fn is_current_routing_client(client: &Arc) -> bool { - same_routing_client(device_relay_client().read().await.as_ref(), client) -} - -fn same_routing_client(current: Option<&Arc>, expected: &Arc) -> bool { - current.is_some_and(|client| Arc::ptr_eq(client, expected)) -} - -fn take_routing_client_if_same(current: &mut Option>, expected: &Arc) -> bool { - if !same_routing_client(current.as_ref(), expected) { - return false; - } - current.take(); - true -} - -/// Validate both halves of a routing-loop lease. The generation is checked -/// again after awaiting the client slot so a concurrent account transition -/// cannot make the pre-lock snapshot look current. -async fn routing_loop_is_current(account_generation: u64, relay_client: &Arc) -> bool { - if !account_context_is_current(account_generation) { - return false; - } - let matches = is_current_routing_client(relay_client).await; - matches && account_context_is_current(account_generation) -} - -/// Retire only the client owned by this loop. Keep the lifecycle write lease -/// while clearing controller presence so a replacement cannot publish its -/// presence and then have it erased by the old loop's cleanup. -async fn retire_routing_client_if_same(relay_client: &Arc) -> bool { - let _routing_guard = DEVICE_ROUTING_LIFECYCLE.write().await; - let mut current = device_relay_client().write().await; - if !take_routing_client_if_same(&mut current, relay_client) { - return false; - } - drop(current); - crate::peer_host::update_controller_presence(Vec::new()).await; - true -} - -/// Log out: tear down routing, revoke the token (best-effort), clear state. -pub(crate) async fn logout() -> Result<()> { - let _sync_guard = invalidate_and_wait_for_account_sync().await; - stop_device_routing_locked().await; - // Take the always-on daemon down with the account: the token is revoked - // below, so leaving the daemon connected would keep this device online - // with a doomed token until its next reconnect fails. - if crate::daemon::request_daemon_shutdown() { - tracing::info!("Signalled the CLI daemon to shut down after logout"); - } - let result = read_account_context_raw().await; - if let Ok((session, relay_url)) = result { - let _ = AccountClient::new() - .revoke_token(&relay_url, &session) + let fanout_owner = PeerFanoutOwner { + account_generation, + account_token: expected_token.to_string(), + relay_client: Arc::clone(relay_client), + runtime: Arc::downgrade(&self.runtime().expect("bound account runtime")), + routing: Arc::downgrade(self), + }; + ACTIVE_PEER_FANOUT_OWNER + .scope(fanout_owner, async { + self.handle_current_relay_event( + event, + relay_client, + account_generation, + expected_token, + ) + .await; + }) .await; } - *account_context().write().await = None; - PENDING_SYNC_CHOICE.store(false, Ordering::Release); - session_store::clear_session(); - session_store::clear_credential_hint(); - TOKEN_EXPIRED.store(false, Ordering::Relaxed); - Ok(()) -} -/// Handle a single relay event for the device-routing loop. -async fn handle_relay_event( - event: RelayEvent, - account_context: &Arc>>, - relay_client: &Arc, - account_generation: u64, - expected_token: &str, -) { - let event = match event { - RelayEvent::AuthError { message } => { - handle_relay_auth_error( - message, - account_context, - relay_client, - account_generation, - expected_token, - ) - .await; - return; - } - event => event, - }; - - let _routing_lease = DEVICE_ROUTING_LIFECYCLE.read().await; - if !routing_loop_is_current(account_generation, relay_client).await { - tracing::debug!("Ignoring event from a stale device routing client"); - return; - } - let fanout_owner = PeerFanoutOwner { - account_generation, - account_token: expected_token.to_string(), - relay_client: Arc::clone(relay_client), - }; - ACTIVE_PEER_FANOUT_OWNER - .scope(fanout_owner, async { - match event { - RelayEvent::AuthOk { user_id, device_id } => { - tracing::info!("Device routing auth ok: user={user_id} device={device_id}"); - if let Err(e) = DeviceIdentity::adopt_account_device_id(&device_id) { - tracing::warn!("Failed to adopt AuthOk device_id: {e}"); - } else if let Some(context) = account_context.read().await.clone() { - if routing_loop_is_current(account_generation, relay_client).await - && context.session.token == expected_token - { - if let Err(e) = session_store::save_session_with_device( - &context.session.token, - &context.session.user_id, - &context.session.master_key, - &context.relay_url, - Some(device_id.as_str()), - ) { - tracing::warn!( - "Failed to persist AuthOk device_id into session: {e}" - ); - } + async fn handle_current_relay_event( + &self, + event: RelayEvent, + relay_client: &Arc, + account_generation: u64, + expected_token: &str, + ) { + let runtime = match self.runtime() { + Ok(runtime) => runtime, + Err(_) => return, + }; + match event { + RelayEvent::AuthOk { user_id, device_id } => { + tracing::info!("Device routing auth ok: user={user_id} device={device_id}"); + if let Err(error) = DeviceIdentity::adopt_account_device_id(&device_id) { + tracing::warn!("Failed to adopt AuthOk device_id: {error}"); + return; + } + if let Ok((session, relay_url)) = runtime + .read_account_context_for_generation(account_generation) + .await + { + if session.token == expected_token + && self + .routing_loop_is_current(account_generation, relay_client) + .await + { + if let Err(error) = session_store::save_session_with_device( + &session.token, + &session.user_id, + &session.master_key, + &relay_url, + Some(device_id.as_str()), + ) { + tracing::warn!("Failed to persist AuthOk device_id: {error}"); } } } - RelayEvent::AuthError { .. } => { - unreachable!("AuthError handled before routing read lease") + } + RelayEvent::DevicePresence { devices } => { + tracing::info!("Device presence updated: {} online", devices.len()); + if !self + .routing_loop_is_current(account_generation, relay_client) + .await + { + return; } - RelayEvent::DevicePresence { devices } => { - tracing::info!("Device presence updated: {} online", devices.len()); - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - crate::peer_host::update_controller_presence( - devices.into_iter().map(|device| device.device_id).collect(), - ) - .await; - if !routing_loop_is_current(account_generation, relay_client).await { - tracing::debug!("Account changed while applying device presence"); - } + crate::peer_host::update_controller_presence( + devices.into_iter().map(|device| device.device_id).collect(), + ) + .await; + } + RelayEvent::DeviceMessageReceived { + source_device_id, + correlation_id, + encrypted_data, + nonce, + } => { + let Ok((session, _)) = runtime + .read_account_context_for_generation(account_generation) + .await + else { + return; + }; + if session.token != expected_token + || !self + .routing_loop_is_current(account_generation, relay_client) + .await + { + return; } - RelayEvent::DeviceMessageReceived { - source_device_id, - correlation_id, - encrypted_data, - nonce, - } => { - let context = account_context.read().await.clone(); - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - let Some(context) = context else { - return; - }; - if context.session.token != expected_token { + let plaintext = match encryption::decrypt_from_base64( + &session.master_key, + &encrypted_data, + &nonce, + ) { + Ok(plaintext) => plaintext, + Err(error) => { + tracing::warn!("Failed to decrypt device message: {error}"); return; } - let plaintext = match encryption::decrypt_from_base64( - &context.session.master_key, - &encrypted_data, - &nonce, - ) { - Ok(p) => p, - Err(e) => { - tracing::warn!("Failed to decrypt device message: {e}"); - return; - } - }; - use remote_connect::remote_server::{RemoteCommand, RemoteResponse}; - let cmd: RemoteCommand = match serde_json::from_str(&plaintext) { - Ok(c) => c, - Err(e) => { - tracing::warn!("Could not parse device command: {e}"); - return; - } - }; - tracing::info!( - "Device command from {source_device_id}: {cmd:?} corr={correlation_id}" - ); - - if !routing_loop_is_current(account_generation, relay_client).await { + }; + use remote_connect::remote_server::{RemoteCommand, RemoteResponse}; + let command: RemoteCommand = match serde_json::from_str(&plaintext) { + Ok(command) => command, + Err(error) => { + tracing::warn!("Could not parse device command: {error}"); return; } - let response = match &cmd { - RemoteCommand::HostInvoke { command, args } => { - let response = - crate::peer_host::handle_host_invoke(command, args.clone()).await; - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - response - } - RemoteCommand::DeviceEvent { .. } => { - crate::peer_host::handle_device_event_command() - } - other => { - let server = RemoteServer::new(context.session.master_key); - let response = server.dispatch(other).await; - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - response - } - }; - - let resp_json = match serde_json::to_string(&response) { - Ok(s) => s, - Err(e) => { - tracing::warn!("Failed to serialize RPC response: {e}"); - serde_json::to_string(&RemoteResponse::Error { - message: format!("failed to serialize RPC response: {e}"), - }) - .unwrap_or_else(|_| { - r#"{"resp":"error","message":"serialize failed"}"#.to_string() - }) - } - }; - - match encryption::encrypt_to_base64(&context.session.master_key, &resp_json) { - Ok((enc_resp, resp_nonce)) => { - // HTTP RPC bridge expects replies targeted at "rpc". - let reply_target = if source_device_id == "rpc" { - "rpc" - } else { - source_device_id.as_str() - }; - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - let send_result = relay_client - .send_device_message( - reply_target, - &correlation_id, - &enc_resp, - &resp_nonce, - ) - .await; - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - if let Err(e) = send_result { - tracing::warn!("Failed to send RPC response: {e}"); - } - } - Err(e) => { - tracing::warn!("Failed to encrypt RPC response: {e}"); - } - } - } - RelayEvent::Disconnected => { - tracing::info!("Device routing disconnected"); - if !routing_loop_is_current(account_generation, relay_client).await { - return; + }; + tracing::info!( + "Device command from {source_device_id}: {command:?} corr={correlation_id}" + ); + let response = match &command { + RemoteCommand::HostInvoke { command, args } => { + crate::peer_host::handle_host_invoke(command, args.clone()).await } - crate::peer_host::update_controller_presence(Vec::new()).await; - if !routing_loop_is_current(account_generation, relay_client).await { - tracing::debug!("Account changed while clearing device presence"); + RemoteCommand::DeviceEvent { .. } => { + crate::peer_host::handle_device_event_command() } + other => RemoteServer::new(session.master_key).dispatch(other).await, + }; + if !self + .routing_loop_is_current(account_generation, relay_client) + .await + { + return; } - RelayEvent::Reconnected => { - tracing::info!("Device routing reconnected"); - } - RelayEvent::Error { message } => { - tracing::warn!("Device routing error: {message}"); + let response_json = serde_json::to_string(&response).unwrap_or_else(|error| { + serde_json::to_string(&RemoteResponse::Error { + message: format!("failed to serialize RPC response: {error}"), + }) + .unwrap_or_else(|_| { + r#"{"resp":"error","message":"serialize failed"}"#.to_string() + }) + }); + let Ok((encrypted_response, response_nonce)) = + encryption::encrypt_to_base64(&session.master_key, &response_json) + else { + tracing::warn!("Failed to encrypt RPC response"); + return; + }; + let reply_target = if source_device_id == "rpc" { + "rpc" + } else { + source_device_id.as_str() + }; + if let Err(error) = relay_client + .send_device_message( + reply_target, + &correlation_id, + &encrypted_response, + &response_nonce, + ) + .await + { + tracing::warn!("Failed to send RPC response: {error}"); } - _ => {} } + RelayEvent::Disconnected => { + tracing::info!("Device routing disconnected"); + crate::peer_host::update_controller_presence(Vec::new()).await; + } + RelayEvent::Reconnected => tracing::info!("Device routing reconnected"), + RelayEvent::Error { message } => { + tracing::warn!("Device routing error: {message}") + } + RelayEvent::AuthError { .. } => unreachable!("AuthError handled before routing lease"), + _ => {} + } + } + + async fn handle_relay_auth_error( + &self, + message: String, + relay_client: &Arc, + account_generation: u64, + expected_token: &str, + ) { + tracing::warn!("Device routing auth error: {message}"); + { + let _routing_guard = self.lifecycle.write().await; + let mut current = self.relay_client.write().await; + if !take_routing_client_if_same(&mut current, relay_client) { + tracing::debug!("Ignoring auth error from a replaced routing client"); + return; + } + drop(current); + relay_client.disconnect().await; + } + let Ok(runtime) = self.runtime() else { + return; + }; + if runtime + .expire_rejected_context(account_generation, expected_token) + .await + { + crate::peer_host::update_controller_presence(Vec::new()).await; + } + } + + pub(crate) async fn capture_peer_fanout_owner(&self) -> Result { + let runtime = self.runtime()?; + let generation = runtime.account_context_generation(); + let _routing_lease = self.lifecycle.read().await; + let (session, _) = runtime + .read_account_context_for_generation(generation) + .await?; + let relay_client = self + .relay_client + .read() + .await + .clone() + .ok_or_else(|| anyhow!("device routing not connected"))?; + if !self + .routing_loop_is_current(generation, &relay_client) + .await + { + return Err(anyhow!("account context changed")); + } + Ok(PeerFanoutOwner { + account_generation: generation, + account_token: session.token, + relay_client, + runtime: Arc::downgrade(&runtime), + routing: self.self_ref.clone(), }) - .await; + } } -/// Auth failure starts an account transition, which owns the lifecycle write -/// lease. It cannot be handled under the ordinary event read lease because -/// upgrading a Tokio `RwLock` would deadlock. -async fn handle_relay_auth_error( - message: String, - account_context: &Arc>>, - relay_client: &Arc, - account_generation: u64, - expected_token: &str, -) { - tracing::warn!("Device routing auth error: {message}"); - let Some(_transition_guard) = invalidate_and_wait_if_account_current(account_generation).await - else { - tracing::debug!("Ignoring auth error from a stale account generation"); - return; - }; - if !is_current_routing_client(relay_client).await { - tracing::debug!("Ignoring auth error from a replaced routing client"); - return; +#[async_trait] +impl AccountRuntimeHost for CliAccountRoutingHost { + async fn retire_background_routing_owner( + &self, + ) -> std::result::Result { + if !crate::daemon::is_daemon_running() { + return Ok(false); + } + if !crate::daemon::request_daemon_shutdown() { + return Err(BackgroundRoutingOwnerRetirementError { + error: anyhow!("could not stop the CLI daemon; the current account remains active"), + owner_may_exit: false, + }); + } + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while crate::daemon::is_daemon_running() { + if tokio::time::Instant::now() >= deadline { + return Err(BackgroundRoutingOwnerRetirementError { + error: anyhow!( + "CLI daemon did not stop in time; the current account remains active" + ), + owner_may_exit: true, + }); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + Ok(true) } - let token_matches = account_context - .read() - .await - .as_ref() - .is_some_and(|context| context.session.token == expected_token); - if !token_matches || !is_current_routing_client(relay_client).await { - tracing::debug!("Ignoring auth error from a replaced routing client"); - return; + + fn background_routing_owner_is_running(&self) -> bool { + crate::daemon::is_daemon_running() + } + + fn request_background_routing_owner_shutdown(&self) -> bool { + crate::daemon::request_daemon_shutdown() } - // Keep CLI/daemon semantics aligned with Desktop: a relay-rejected token - // is no longer a usable local login and must not be restored again on the - // next process start. Preserve the non-secret hint for the re-login form. - relay_client.disconnect().await; - if !is_current_routing_client(relay_client).await { - tracing::debug!("Ignoring auth error cleanup for a replaced routing client"); - return; + async fn start_device_routing(&self, request: AccountRoutingStartRequest) -> Result<()> { + self.start_routing(request).await } - let mut current_client = device_relay_client().write().await; - if !same_routing_client(current_client.as_ref(), relay_client) { - tracing::debug!("Ignoring auth error cleanup for a replaced routing client"); - return; + async fn stop_device_routing(&self) { + self.stop_routing().await; } - let mut current_context = account_context.write().await; - if current_context - .as_ref() - .is_none_or(|context| context.session.token != expected_token) - { - tracing::debug!("Ignoring auth error cleanup for a replaced account"); - return; + + fn notify_controllers_settings_changed(&self) { + crate::peer_host::notify_controllers_settings_changed(); } - take_routing_client_if_same(&mut current_client, relay_client); - *current_context = None; - drop(current_context); - drop(current_client); +} - TOKEN_EXPIRED.store(true, Ordering::Relaxed); - PENDING_SYNC_CHOICE.store(false, Ordering::Release); - session_store::clear_session(); - crate::peer_host::update_controller_presence(Vec::new()).await; +fn same_routing_client(current: Option<&Arc>, expected: &Arc) -> bool { + current.is_some_and(|client| Arc::ptr_eq(client, expected)) +} + +fn take_routing_client_if_same(current: &mut Option>, expected: &Arc) -> bool { + if !same_routing_client(current.as_ref(), expected) { + return false; + } + current.take(); + true } -/// Immutable routing owner captured when a Peer DeviceEvent enters the bounded -/// delivery queue. It prevents an event from account A being encrypted or sent -/// through account B after waiting behind older events. +/// Immutable routing owner captured when a Peer DeviceEvent enters the queue. #[derive(Clone)] pub(crate) struct PeerFanoutOwner { account_generation: u64, account_token: String, relay_client: Arc, + runtime: Weak, + routing: Weak, } tokio::task_local! { @@ -1132,6 +584,8 @@ impl PeerFanoutOwner { account_generation, account_token: account_token.to_string(), relay_client: Arc::new(relay_client), + runtime: Weak::new(), + routing: Weak::new(), } } @@ -1141,107 +595,58 @@ impl PeerFanoutOwner { } } -/// Stable fan-out context. The read lease is intentionally retained through -/// encryption and all target sends; account replacement takes the write lease. pub(crate) struct PeerFanoutLease { pub(crate) session: AccountSession, pub(crate) relay_client: Arc, - _routing_lease: tokio::sync::RwLockReadGuard<'static, ()>, -} - -pub(crate) async fn capture_peer_fanout_owner() -> Result { - let generation = account_context_generation(); - let _routing_lease = DEVICE_ROUTING_LIFECYCLE.read().await; - let (session, _) = read_account_context_for_generation(generation).await?; - let client = device_relay_client() - .read() - .await - .clone() - .ok_or_else(|| anyhow!("device routing not connected"))?; - if !account_context_is_current(generation) || !is_current_routing_client(&client).await { - return Err(anyhow!("account context changed")); - } - Ok(PeerFanoutOwner { - account_generation: generation, - account_token: session.token, - relay_client: client, - }) + _routing_lease: tokio::sync::OwnedRwLockReadGuard<()>, } pub(crate) async fn acquire_peer_fanout_lease(owner: &PeerFanoutOwner) -> Result { - let routing_lease = DEVICE_ROUTING_LIFECYCLE.read().await; - if !account_context_is_current(owner.account_generation) { + let runtime = owner + .runtime + .upgrade() + .ok_or_else(|| anyhow!("account runtime stopped"))?; + let routing = owner + .routing + .upgrade() + .ok_or_else(|| anyhow!("account routing stopped"))?; + let routing_lease = routing.lifecycle.clone().read_owned().await; + if !runtime.account_context_is_current(owner.account_generation) { return Err(anyhow!("queued Peer event account changed")); } - let context = account_context() - .read() - .await - .clone() - .ok_or_else(|| anyhow!("not logged in"))?; - let client = device_relay_client() + let (session, _) = runtime + .read_account_context_for_generation(owner.account_generation) + .await?; + let client = routing + .relay_client .read() .await .clone() .ok_or_else(|| anyhow!("device routing not connected"))?; - if !account_context_is_current(owner.account_generation) - || !owner.matches( - account_context_generation(), - &context.session.token, - &client, - ) - { + if !owner.matches( + runtime.account_context_generation(), + &session.token, + &client, + ) { return Err(anyhow!("queued Peer event routing owner changed")); } Ok(PeerFanoutLease { - session: context.session, + session, relay_client: client, _routing_lease: routing_lease, }) } -/// A textual device listing entry for display. -pub(crate) struct AccountDevice { - pub(crate) device_id: String, - pub(crate) device_name: String, - pub(crate) online: bool, -} - -/// List all devices in the account. -pub(crate) async fn list_devices() -> Result> { - let (session, relay_url) = read_account_context().await?; - let devices = AccountClient::new() - .list_devices(&relay_url, &session) - .await?; - Ok(devices - .into_iter() - .map(|d| AccountDevice { - device_id: d.device_id, - device_name: d.device_name, - online: d.online, - }) - .collect()) -} - #[cfg(test)] mod tests { - use std::sync::Arc; + use super::*; use std::time::Duration; - use super::{ - account_context_generation, automatic_account_sync_policy_for_pending, - begin_candidate_account_transition, clear_replaced_persisted_session, - inherited_peer_fanout_owner, login_with_credentials, replaced_account_revocation_target, - resolve_cloud_settings_probe, take_routing_client_if_same, AccountContextState, - CandidateAccountTransitionError, DaemonRetirementFailure, PeerFanoutOwner, - ACCOUNT_LOGIN_LOCK, ACTIVE_PEER_FANOUT_OWNER, DEVICE_ROUTING_LIFECYCLE, - }; - #[test] fn stale_routing_loop_cannot_clear_replacement_client() { let stale = Arc::new("stale"); let replacement = Arc::new("replacement"); let mut current = Some(Arc::clone(&replacement)); - assert!(!take_routing_client_if_same(&mut current, &stale)); assert!(current .as_ref() @@ -1249,226 +654,35 @@ mod tests { } #[test] - fn routing_loop_can_clear_only_its_own_client() { - let owned = Arc::new("owned"); - let mut current = Some(Arc::clone(&owned)); - - assert!(take_routing_client_if_same(&mut current, &owned)); - assert!(current.is_none()); + fn queued_fanout_owner_requires_generation_token_and_client_identity() { + let owner = PeerFanoutOwner::for_test(11, "token-a"); + let owned_client = Arc::clone(&owner.relay_client); + let replacement = PeerFanoutOwner::for_test(12, "token-b"); + assert!(owner.matches(11, "token-a", &owned_client)); + assert!(!owner.matches(12, "token-a", &owned_client)); + assert!(!owner.matches(11, "token-b", &owned_client)); + assert!(!owner.matches(11, "token-a", &replacement.relay_client)); } #[tokio::test] - async fn routing_replacement_waits_for_in_flight_event_lease() { - let event_lease = DEVICE_ROUTING_LIFECYCLE.read().await; + async fn routing_replacement_waits_for_an_in_flight_event_lease() { + let routing = CliAccountRoutingHost::new(); + let event_lease = routing.lifecycle.read().await; + let lifecycle = routing.lifecycle.clone(); let (attempting_tx, attempting_rx) = tokio::sync::oneshot::channel(); let replacement = tokio::spawn(async move { let _ = attempting_tx.send(()); - let _replacement_lease = DEVICE_ROUTING_LIFECYCLE.write().await; + let _replacement_lease = lifecycle.write().await; }); attempting_rx.await.expect("replacement task started"); tokio::task::yield_now().await; assert!(!replacement.is_finished()); - drop(event_lease); - tokio::time::timeout(Duration::from_secs(1), replacement) - .await - .expect("replacement should acquire the lifecycle after event completion") - .expect("replacement task should finish"); - } - - #[tokio::test] - async fn inherited_fanout_owner_does_not_reacquire_routing_read_lease() { - let event_lease = DEVICE_ROUTING_LIFECYCLE.read().await; - let (attempting_tx, attempting_rx) = tokio::sync::oneshot::channel(); - let replacement = tokio::spawn(async move { - let _ = attempting_tx.send(()); - let _replacement_lease = DEVICE_ROUTING_LIFECYCLE.write().await; - }); - - attempting_rx.await.expect("replacement task started"); - tokio::task::yield_now().await; - assert!(!replacement.is_finished()); - - let owner = PeerFanoutOwner::for_test(21, "token-a"); - let inherited = tokio::time::timeout( - Duration::from_millis(100), - ACTIVE_PEER_FANOUT_OWNER.scope(owner, async { inherited_peer_fanout_owner() }), - ) - .await - .expect("inherited owner lookup must not wait behind the queued writer") - .expect("task-local owner should be visible"); - assert_eq!(inherited.generation_for_test(), 21); - assert!(!replacement.is_finished()); - drop(event_lease); tokio::time::timeout(Duration::from_secs(1), replacement) .await - .expect("replacement should proceed after the outer event lease is released") + .expect("replacement should acquire the lifecycle after event completion") .expect("replacement task should finish"); } - - #[tokio::test] - async fn invalid_login_does_not_invalidate_the_current_account() { - let generation = account_context_generation(); - - let error = login_with_credentials("", "user", "password") - .await - .expect_err("empty relay URL must be rejected"); - - assert!(error.to_string().contains("Auth Server is required")); - assert_eq!(account_context_generation(), generation); - } - - #[test] - fn cloud_settings_probe_errors_are_not_treated_as_missing_settings() { - assert!(!resolve_cloud_settings_probe(Ok(None)).expect("missing settings is valid")); - assert!( - resolve_cloud_settings_probe(Ok(Some("encrypted settings".to_string()))) - .expect("existing settings is valid") - ); - - let error = resolve_cloud_settings_probe(Err(anyhow::anyhow!("relay unavailable"))) - .expect_err("probe failure must reject the candidate login"); - assert!(error.to_string().contains("could not check cloud settings")); - assert!(error.to_string().contains("relay unavailable")); - } - - #[test] - fn pending_sync_choice_blocks_automatic_pull_and_push_until_finalized() { - let pending = automatic_account_sync_policy_for_pending(true); - assert!(!pending.background_engine); - assert!(!pending.management_push); - - let finalized = automatic_account_sync_policy_for_pending(false); - assert!(finalized.background_engine); - assert!(finalized.management_push); - } - - #[tokio::test] - async fn daemon_retirement_failure_does_not_begin_account_transition() { - let generation = account_context_generation(); - let result = begin_candidate_account_transition( - generation, - Err(DaemonRetirementFailure { - error: anyhow::anyhow!("daemon stayed alive"), - daemon_may_exit: true, - }), - ) - .await; - - assert!(matches!( - result, - Err(CandidateAccountTransitionError::DaemonRetirement(_)) - )); - assert_eq!(account_context_generation(), generation); - } - - #[test] - fn pending_replacement_cannot_restore_the_previous_persisted_account() { - let directory = std::env::temp_dir().join(format!( - "bitfun-cli-account-session-{}", - uuid::Uuid::new_v4() - )); - bitfun_core::service::remote_connect::session_store::set_session_store_directory_for_test( - directory, - ); - let old_master_key = [7_u8; 32]; - bitfun_core::service::remote_connect::session_store::save_session_with_device( - "account-a-token", - "account-a", - &old_master_key, - "https://relay-a.example", - Some("device-a"), - ) - .expect("persist account A"); - assert_eq!( - bitfun_core::service::remote_connect::session_store::load_session_detailed() - .expect("load account A") - .expect("account A should be persisted") - .token, - "account-a-token" - ); - - // This is the disk step used after candidate B wins the transition and - // before B is exposed as awaiting its cloud/local sync choice. - clear_replaced_persisted_session(); - - assert!( - bitfun_core::service::remote_connect::session_store::load_session_detailed() - .expect("load after candidate B becomes pending") - .is_none() - ); - } - - #[test] - fn replacement_revokes_only_the_previous_distinct_token() { - let previous = AccountContextState { - session: bitfun_core::service::remote_connect::AccountSession { - token: "old-token".to_string(), - user_id: "same-account".to_string(), - master_key: [3_u8; 32], - }, - relay_url: "https://relay.example".to_string(), - }; - - let target = replaced_account_revocation_target( - Some(previous.clone()), - "https://relay.example", - "new-token", - ) - .expect("a new token for the same account must retire the old bearer"); - assert_eq!(target.session.token, "old-token"); - assert_eq!(target.session.user_id, "same-account"); - assert_eq!(target.relay_url, "https://relay.example"); - - assert!(replaced_account_revocation_target( - Some(previous.clone()), - "https://relay.example", - "old-token" - ) - .is_none()); - assert!(replaced_account_revocation_target( - Some(previous), - "https://other-relay.example", - "old-token" - ) - .is_some()); - assert!( - replaced_account_revocation_target(None, "https://relay.example", "new-token") - .is_none() - ); - } - - #[tokio::test] - async fn candidate_login_attempts_are_serialized() { - let first_candidate = ACCOUNT_LOGIN_LOCK.lock().await; - let (attempting_tx, attempting_rx) = tokio::sync::oneshot::channel(); - let second_candidate = tokio::spawn(async move { - let _ = attempting_tx.send(()); - let _guard = ACCOUNT_LOGIN_LOCK.lock().await; - }); - - attempting_rx.await.expect("second candidate started"); - tokio::task::yield_now().await; - assert!(!second_candidate.is_finished()); - - drop(first_candidate); - tokio::time::timeout(Duration::from_secs(1), second_candidate) - .await - .expect("second candidate should proceed after the first") - .expect("second candidate task should finish"); - } - - #[test] - fn queued_fanout_owner_requires_generation_token_and_client_identity() { - let owner = PeerFanoutOwner::for_test(11, "token-a"); - let owned_client = Arc::clone(&owner.relay_client); - let replacement = PeerFanoutOwner::for_test(12, "token-b"); - - assert!(owner.matches(11, "token-a", &owned_client)); - assert!(!owner.matches(12, "token-a", &owned_client)); - assert!(!owner.matches(11, "token-b", &owned_client)); - assert!(!owner.matches(11, "token-a", &replacement.relay_client)); - } } diff --git a/src/apps/cli/src/account_sync.rs b/src/apps/cli/src/account_sync.rs deleted file mode 100644 index 576c129218..0000000000 --- a/src/apps/cli/src/account_sync.rs +++ /dev/null @@ -1,508 +0,0 @@ -//! CLI account auto-sync (settings + session upload), matching Desktop semantics. - -use std::path::{Path, PathBuf}; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, OnceLock, -}; - -use anyhow::{anyhow, Result}; -use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; - -use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; -use bitfun_core::service::config::get_global_config_service; -use bitfun_core::service::remote_connect::account::{ - ensure_relay_session_history_exportable, relay_session_export_metadata, -}; -use bitfun_core::service::remote_connect::settings_sync; -use bitfun_core::service::remote_connect::{sync_state, AccountClient}; - -use crate::account::{ - account_context_generation, account_context_is_current, automatic_account_sync_policy, - await_account_sync_current, lock_account_sync, read_account_context, -}; - -const UPLOAD_CONCURRENCY_CHUNK: usize = 5; - -/// Start the continuous settings sync loop (debounced push + 30s pull). -/// Started once per process (interactive TUI and daemon); every cycle -/// silently skips while logged out and converges as soon as an account -/// session exists. Peer Mode controllers are notified via DeviceEvent when -/// this host's effective settings change. -pub(crate) fn start_settings_sync_loop() { - let hooks = settings_sync::SettingsSyncHooks { - account_context: Some(Arc::new(|| { - Box::pin(async { - if !automatic_account_sync_policy().background_engine { - return Err(anyhow!("account login is awaiting a sync choice")); - } - let generation = account_context_generation(); - if !account_context_is_current(generation) { - return Err(anyhow!("account context is transitioning")); - } - let (account, relay_url) = read_account_context().await?; - if !automatic_account_sync_policy().background_engine - || !account_context_is_current(generation) - { - return Err(anyhow!("account context changed while reading")); - } - Ok((account, relay_url, generation)) - }) - })), - is_account_context_current: Some(Arc::new(account_context_is_current)), - on_settings_applied: Some(Arc::new(|| { - crate::peer_host::notify_controllers_settings_changed(); - })), - on_settings_pushed: Some(Arc::new(|| { - crate::peer_host::notify_controllers_settings_changed(); - })), - on_token_expired: Some(Arc::new(crate::account::mark_token_expired)), - ..Default::default() - }; - settings_sync::start_settings_sync_engine(hooks); -} - -/// Notify the sync loop that local settings changed (TUI edits, peer -/// `set_config`). Upload is debounced and content-hash deduped. -pub(crate) fn notify_local_settings_changed() { - settings_sync::notify_settings_changed(); -} - -/// Best-effort one-shot settings push for short-lived management commands -/// (e.g. `bitfun models set-default`) where the sync loop never starts. -/// Silently no-ops when logged out; failures are logged, not fatal. -pub(crate) async fn push_settings_after_local_change() { - if !automatic_account_sync_policy().management_push { - return; - } - // Management commands never restore the persisted account session into - // memory — do it on demand so the push can authenticate. - if read_account_context().await.is_err() { - crate::account::try_restore_session().await; - } - let generation = account_context_generation(); - let Ok(_sync_guard) = lock_account_sync(generation).await else { - return; - }; - if !automatic_account_sync_policy().management_push { - return; - } - let Ok((account, relay_url)) = read_account_context().await else { - return; - }; - match settings_sync::push_settings_now(&account, &relay_url).await { - Ok(true) => tracing::info!("Settings pushed to account cloud"), - Ok(false) => {} - Err(e) => tracing::warn!("Settings push failed: {e}"), - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(crate) enum SyncStatus { - #[default] - Idle, - Syncing, - Done, - Failed, - Cancelled, -} - -#[derive(Debug, Clone)] -pub(crate) struct SyncProgress { - pub operation_id: Option, - pub status: SyncStatus, - pub phase: String, - pub percent: u8, - pub current: Option, - pub total: Option, - pub detail: Option, - pub error: Option, - pub settings_synced: bool, - pub sessions_exported: usize, -} - -impl Default for SyncProgress { - fn default() -> Self { - Self { - operation_id: None, - status: SyncStatus::Idle, - phase: String::new(), - percent: 0, - current: None, - total: None, - detail: None, - error: None, - settings_synced: false, - sessions_exported: 0, - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct AutoSyncResult { - pub settings_synced: bool, - pub sessions_exported: usize, - #[allow(dead_code)] - pub sessions_imported: usize, -} - -#[derive(Serialize, Deserialize)] -struct SessionBundle { - session_id: String, - metadata: serde_json::Value, - turns: Vec, - source_device_id: Option, - source_device_name: Option, -} - -static SYNC_PROGRESS: OnceLock>> = OnceLock::new(); -static AUTO_SYNC_IN_FLIGHT: AtomicBool = AtomicBool::new(false); - -fn sync_progress_store() -> &'static Arc> { - SYNC_PROGRESS.get_or_init(|| Arc::new(RwLock::new(SyncProgress::default()))) -} - -pub(crate) async fn current_sync_progress() -> SyncProgress { - sync_progress_store().read().await.clone() -} - -async fn set_progress(mut update: impl FnMut(&mut SyncProgress)) { - let mut guard = sync_progress_store().write().await; - update(&mut guard); -} - -async fn emit_progress( - phase: &str, - percent: u8, - current: Option, - total: Option, - detail: Option<&str>, -) { - set_progress(|p| { - p.status = SyncStatus::Syncing; - p.phase = phase.to_string(); - p.percent = percent; - p.current = current; - p.total = total; - p.detail = detail.map(|s| s.to_string()); - p.error = None; - }) - .await; -} - -/// Start auto-sync in the background. Returns immediately; progress is in -/// [`current_sync_progress`]. -pub(crate) async fn start_auto_sync_background( - compatibility: CoreAgentRuntimeCompatibility, - operation_id: String, - is_first_login: bool, - workspace_path: PathBuf, -) -> bool { - if AUTO_SYNC_IN_FLIGHT.swap(true, Ordering::SeqCst) { - tracing::warn!("Account auto-sync already in flight; skipping duplicate start"); - return false; - } - set_progress(|progress| { - progress.operation_id = Some(operation_id.clone()); - }) - .await; - tokio::spawn(async move { - let result = run_auto_sync(&compatibility, is_first_login, &workspace_path).await; - AUTO_SYNC_IN_FLIGHT.store(false, Ordering::SeqCst); - match result { - Ok(r) => { - set_progress(|p| { - if p.operation_id.as_deref() != Some(operation_id.as_str()) { - return; - } - p.status = SyncStatus::Done; - p.phase = "done".into(); - p.percent = 100; - p.settings_synced = r.settings_synced; - p.sessions_exported = r.sessions_exported; - p.error = None; - }) - .await; - } - Err(e) => { - set_progress(|p| { - if p.operation_id.as_deref() != Some(operation_id.as_str()) { - return; - } - p.status = SyncStatus::Failed; - p.error = Some(e.to_string()); - }) - .await; - tracing::warn!("Account auto-sync failed: {e}"); - } - } - }); - true -} - -pub(crate) async fn mark_sync_cancelled(operation_id: String) { - set_progress(|progress| { - progress.operation_id = Some(operation_id.clone()); - progress.status = SyncStatus::Cancelled; - progress.phase = "cancelled".to_string(); - progress.error = None; - }) - .await; -} - -pub(crate) async fn run_auto_sync( - compatibility: &CoreAgentRuntimeCompatibility, - is_first_login: bool, - workspace_path: &Path, -) -> Result { - let generation = account_context_generation(); - let _sync_guard = lock_account_sync(generation).await?; - set_progress(|p| { - *p = SyncProgress { - operation_id: p.operation_id.clone(), - status: SyncStatus::Syncing, - phase: "starting".into(), - percent: 1, - ..SyncProgress::default() - }; - }) - .await; - - let (acct_session, relay_url) = read_account_context().await?; - let client = AccountClient::new(); - - let settings_synced = if is_first_login { - emit_progress("uploading_settings", 5, None, None, None).await; - let config_service = get_global_config_service() - .await - .map_err(|e| anyhow!("config service: {e}"))?; - let exported = config_service - .export_config() - .await - .map_err(|e| anyhow!("export config: {e}"))?; - let config_json = - serde_json::to_string(&exported).map_err(|e| anyhow!("serialize config: {e}"))?; - await_account_sync_current( - generation, - settings_sync::upload_settings_payload(&acct_session, &relay_url, &config_json), - ) - .await? - .map_err(|e| anyhow!("upload settings: {e}"))?; - emit_progress("settings_done", 15, None, None, None).await; - true - } else { - emit_progress("downloading_settings", 5, None, None, None).await; - let cloud = await_account_sync_current( - generation, - client.fetch_settings_with_version(&relay_url, &acct_session), - ) - .await? - .map_err(|e| anyhow!("fetch settings: {e}"))?; - if let Some(blob) = cloud { - emit_progress("applying_settings", 10, None, None, None).await; - // Explicit user choice ("use cloud") — always apply, even when the - // cursor says this device already has this version. - await_account_sync_current( - generation, - settings_sync::apply_settings_blob(&acct_session, &blob, true), - ) - .await? - .map_err(|e| anyhow!("apply cloud config: {e}"))?; - emit_progress("settings_done", 15, None, None, None).await; - true - } else { - emit_progress("settings_done", 15, None, None, None).await; - false - } - }; - - emit_progress("listing_sessions", 18, None, None, None).await; - let storage_path = workspace_path.to_path_buf(); - - let local_sessions = compatibility - .list_persisted_sessions(&storage_path) - .await - .map_err(|e| anyhow!("list sessions: {e}"))?; - - emit_progress( - "exporting_sessions", - 20, - Some(0), - Some(local_sessions.len()), - None, - ) - .await; - - let mut sync_state_local = sync_state::load(&acct_session.user_id); - let mut pending_uploads: Vec<(String, String, String)> = Vec::new(); - for meta in local_sessions.iter() { - if !account_context_is_current(generation) { - return Err(anyhow!("account sync cancelled")); - } - if let Err(error) = ensure_relay_session_history_exportable(meta) { - tracing::debug!("Skipping CLI account session export: {error}"); - continue; - } - let turns = compatibility - .load_persisted_session_turns(&storage_path, &meta.session_id, None) - .await - .map_err(|e| anyhow!("load turns: {e}"))?; - let metadata = relay_session_export_metadata(meta, turns.len()); - let metadata_json = - serde_json::to_value(metadata).map_err(|e| anyhow!("serialize metadata: {e}"))?; - let turns_json: Vec = turns - .iter() - .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null)) - .collect(); - let bundle = SessionBundle { - session_id: meta.session_id.clone(), - metadata: metadata_json, - turns: turns_json, - source_device_id: None, - source_device_name: None, - }; - let bundle_json = - serde_json::to_string(&bundle).map_err(|e| anyhow!("serialize bundle: {e}"))?; - let hash = sync_state::content_hash(&bundle_json); - if sync_state_local.uploaded_hash(&meta.session_id) == Some(hash.as_str()) { - continue; - } - pending_uploads.push((meta.session_id.clone(), bundle_json, hash)); - } - - let upload_total = pending_uploads.len(); - emit_progress("exporting_sessions", 20, Some(0), Some(upload_total), None).await; - - let mut uploaded: Vec<(String, String, i64)> = Vec::new(); - let mut upload_errors: Vec = Vec::new(); - for (chunk_idx, chunk) in pending_uploads.chunks(UPLOAD_CONCURRENCY_CHUNK).enumerate() { - let mut handles = Vec::new(); - for (session_id, bundle_json, hash) in chunk { - let client = AccountClient::new(); - let relay_url = relay_url.clone(); - let acct_session = acct_session.clone(); - let session_id = session_id.clone(); - let bundle_json = bundle_json.clone(); - let hash = hash.clone(); - handles.push(tokio::spawn(async move { - let result = await_account_sync_current( - generation, - client.upload_session(&relay_url, &acct_session, &session_id, &bundle_json), - ) - .await; - (session_id, hash, result) - })); - } - for handle in handles { - let done_base = chunk_idx * UPLOAD_CONCURRENCY_CHUNK; - match handle.await { - Ok((session_id, hash, Ok(Ok(version)))) => { - uploaded.push((session_id.clone(), hash, version)); - let done = uploaded.len(); - let percent = if upload_total == 0 { - 95u8 - } else { - 20 + ((75 * done) / upload_total) as u8 - }; - emit_progress( - "exporting_sessions", - percent.min(95), - Some(done), - Some(upload_total), - Some(&session_id), - ) - .await; - } - Ok((session_id, _, Ok(Err(e)))) => { - tracing::warn!("Auto-sync upload {session_id} failed: {e}"); - upload_errors.push(format!("{session_id}: {e}")); - let _ = done_base; - } - Ok((_, _, Err(e))) => return Err(e), - Err(e) => { - tracing::warn!("Auto-sync upload task join failed: {e}"); - upload_errors.push(format!("upload task join failed: {e}")); - } - } - } - if !account_context_is_current(generation) { - return Err(anyhow!("account sync cancelled")); - } - } - - let exported = uploaded.len(); - let mut max_uploaded_version = sync_state_local.last_session_since; - for (session_id, hash, version) in uploaded { - sync_state_local.set_uploaded_hash(&session_id, hash); - if version > max_uploaded_version { - max_uploaded_version = version; - } - } - if max_uploaded_version > sync_state_local.last_session_since { - sync_state_local.last_session_since = max_uploaded_version; - } - let _ = sync_state::save(&acct_session.user_id, &sync_state_local); - - ensure_session_backup_complete(upload_total, exported, &upload_errors)?; - - tracing::info!("Auto-sync: settings={settings_synced} exported={exported} imported=0"); - emit_progress("done", 100, Some(exported), Some(0), None).await; - - Ok(AutoSyncResult { - settings_synced, - sessions_exported: exported, - sessions_imported: 0, - }) -} - -fn ensure_session_backup_complete( - total: usize, - uploaded: usize, - upload_errors: &[String], -) -> Result<()> { - if uploaded == total { - return Ok(()); - } - let detail = upload_errors - .first() - .map(|err| err.as_str()) - .unwrap_or("retry will resume remaining sessions"); - Err(anyhow!( - "session backup incomplete: uploaded {uploaded} of {total}; {detail}" - )) -} - -#[cfg(test)] -mod tests { - use super::ensure_session_backup_complete; - - #[test] - fn partial_session_backup_is_not_reported_as_success() { - assert!(ensure_session_backup_complete(4, 4, &[]).is_ok()); - assert!(ensure_session_backup_complete( - 4, - 1, - &["s1: relay returned HTTP 507 Insufficient Storage".into()] - ) - .unwrap_err() - .to_string() - .contains("HTTP 507")); - } - - #[test] - fn cli_session_backup_uses_the_shared_import_guard_and_visible_count() { - let source = include_str!("account_sync.rs").replace("\r\n", "\n"); - let export_loop = source - .split_once("for meta in local_sessions.iter()") - .expect("CLI account Session export loop") - .1 - .split_once("let upload_total = pending_uploads.len()") - .expect("CLI account Session export loop boundary") - .0; - - assert!(export_loop.contains("ensure_relay_session_history_exportable(meta)")); - assert!(export_loop.contains("relay_session_export_metadata(meta, turns.len())")); - assert!(export_loop.contains("pending_uploads.push")); - } -} diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index 5b8b35a86e..c85e359182 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -586,9 +586,9 @@ static ACTION_SPECS: &[ActionSpec] = &[ }, ActionSpec { id: "extensions", - name: "External integrations", + name: "Extensions", aliases: &["/extensions"], - description: "View external source status and Safe Mode", + description: "View and manage extensions", contexts: CHAT, availability: ActionAvailability::Always, handler: ActionHandler::Extensions, @@ -603,7 +603,7 @@ static ACTION_SPECS: &[ActionSpec] = &[ id: "hooks", name: "Hooks", aliases: &["/hooks"], - description: "Review and manage native and imported Hooks", + description: "View and manage Hooks", contexts: CHAT, availability: ActionAvailability::Always, handler: ActionHandler::NativeHooks, @@ -625,7 +625,7 @@ static ACTION_SPECS: &[ActionSpec] = &[ default_bindings: &[], fallback_bindings: &[], shortcut_field: None, - palette: palette("Tools", false), + palette: None, shortcut_label: None, slash_on_startup: false, }, @@ -1327,6 +1327,7 @@ pub(crate) fn slash_actions(state: ActionState) -> Vec { .filter(|spec| { spec.available(state) && !spec.aliases.is_empty() + && spec.id != "hooks_external" && (state.context != ActionContext::Startup || spec.slash_on_startup) }) .flat_map(|spec| { @@ -2484,7 +2485,22 @@ mod tests { assert_eq!(tools.handler, ActionHandler::Tools); let extensions = action_for_alias("/extensions", ActionContext::Chat).unwrap(); assert_eq!(extensions.handler, ActionHandler::Extensions); - assert!(extensions.description.contains("Safe Mode")); + assert_eq!(extensions.name, "Extensions"); + assert_eq!(extensions.description, "View and manage extensions"); + assert_eq!( + action_for_alias("/hooks_external", ActionContext::Chat) + .expect("legacy Hook alias remains parseable") + .handler, + ActionHandler::ExternalHooks + ); + assert!(!slash_actions(ActionState::chat(false, false)) + .iter() + .any(|action| action.id == "hooks_external")); + assert!(!palette_actions(ActionState::chat(false, false)) + .iter() + .any(|action| action.id == "hooks_external")); + let hooks = action_for_alias("/hooks", ActionContext::Chat).unwrap(); + assert_eq!(hooks.description, "View and manage Hooks"); let agents = action_for_alias("/agent", ActionContext::Chat).unwrap(); assert_eq!(agents.handler, ActionHandler::OpenAgentSelector); assert_eq!(agents.description, "Switch modes and manage agents"); diff --git a/src/apps/cli/src/agent/tui_client.rs b/src/apps/cli/src/agent/tui_client.rs index bec380e236..7c85d763d0 100644 --- a/src/apps/cli/src/agent/tui_client.rs +++ b/src/apps/cli/src/agent/tui_client.rs @@ -22,11 +22,7 @@ use bitfun_app_server_protocol::workspace::*; use bitfun_app_server_protocol::worktree::*; use bitfun_core_types::SessionUsageReport; use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; -use bitfun_product_domains::external_source_control::{ - ExternalApplicationControlRequestV2, ExternalApplicationControlResultV2, - ExternalApplicationReviewPageRequestV2, ExternalApplicationReviewPageV2, - ExternalApplicationSnapshotV2, ExternalSourceControlRequestV1, -}; +use bitfun_product_domains::external_source_control::ExternalSourceControlRequestV1; use bitfun_product_domains::external_sources::{ ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourcePublicSnapshot, NativePromptCommandDescriptor, PromptCommandShellReviewDecision, @@ -467,49 +463,6 @@ impl TuiAgentClient { .map_err(external_source_backend_error) } - pub(crate) async fn external_application_snapshot_v2( - &self, - force_refresh: bool, - ) -> std::result::Result { - self.backend - .external_application_snapshot_v2(ExternalApplicationSnapshotRequestV2 { - workspace_path: Some(self.workspace_path_string()), - force_refresh, - }) - .await - .map(|response| response.0) - .map_err(external_source_backend_error) - } - - pub(crate) async fn external_application_review_page_v2( - &self, - request: ExternalApplicationReviewPageRequestV2, - ) -> std::result::Result { - self.backend - .external_application_review_page_v2(ExternalApplicationReviewPageRequest { - workspace_path: Some(self.workspace_path_string()), - request, - }) - .await - .map(|response| response.0) - .map_err(external_source_backend_error) - } - - pub(crate) async fn apply_external_application_action_v2( - &self, - request: ExternalApplicationControlRequestV2, - ) -> std::result::Result { - let operation_id = request.operation_id.clone(); - self.backend - .apply_external_application_action_v2(ExternalApplicationActionRequest { - workspace_path: Some(self.workspace_path_string()), - request, - }) - .await - .map(|response| response.0) - .map_err(|error| external_source_backend_error_with_id(error, Some(&operation_id))) - } - pub(crate) fn subscribe_external_source_updates( &self, ) -> Result> { diff --git a/src/apps/cli/src/daemon/runner.rs b/src/apps/cli/src/daemon/runner.rs index 7f41286a9c..7f406dfe90 100644 --- a/src/apps/cli/src/daemon/runner.rs +++ b/src/apps/cli/src/daemon/runner.rs @@ -11,7 +11,7 @@ use anyhow::{anyhow, Result}; use bitfun_core::service::remote_connect::DeviceIdentity; -use crate::{account, runtime, BootstrapProfile}; +use crate::{runtime, BootstrapProfile}; use super::pid; @@ -27,14 +27,15 @@ pub(crate) async fn run_daemon() -> Result<()> { // The daemon is not bound to the caller's cwd; peer commands carry their // own workspace paths. Home is a stable root for the runtime context. let workspace_root = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - let _runtime = crate::initialize_core_services( + let runtime = crate::initialize_core_services( &workspace_root, runtime::approval::CliApprovalPolicy::Ask, BootstrapProfile::Interactive, ) .await?; - let Some(user_id) = account::try_restore_session().await else { + let account = runtime.account_runtime(); + let Some(user_id) = account.try_restore_session().await else { return Err(anyhow!( "not logged in; run `bitfun`, log in with `/login`, then start the daemon again" )); @@ -43,12 +44,12 @@ pub(crate) async fn run_daemon() -> Result<()> { let device = DeviceIdentity::from_current_machine().map_err(|e| anyhow!("detect device: {e}"))?; - account::restore_device_routing(&device.device_name).await?; + account.restore_device_routing(&device.device_name).await?; // Continuous account settings sync (30s pull + debounced push) so this // always-on host converges with cloud changes made on other devices and // attached controllers see fresh config without reconnecting. - crate::account_sync::start_settings_sync_loop(); + account.start_settings_sync_loop(); pid::write_pid_file()?; tracing::info!("bitfun daemon running (pid {})", std::process::id()); @@ -62,7 +63,7 @@ pub(crate) async fn run_daemon() -> Result<()> { break; } _ = expired_check.tick() => { - if account::is_token_expired() { + if account.is_token_expired() { // Exit 0 on purpose: re-authentication needs a human, so // Restart=on-failure must not loop the daemon. tracing::warn!("Account token rejected by the relay; daemon exiting"); @@ -72,7 +73,7 @@ pub(crate) async fn run_daemon() -> Result<()> { } } - account::stop_device_routing().await; + runtime.account_routing().stop_device_routing().await; pid::remove_pid_file(); crate::shutdown_mcp_servers().await; tracing::info!("bitfun daemon stopped"); diff --git a/src/apps/cli/src/dispatch/mod.rs b/src/apps/cli/src/dispatch/mod.rs index 4adce82f2e..5e200aea92 100644 --- a/src/apps/cli/src/dispatch/mod.rs +++ b/src/apps/cli/src/dispatch/mod.rs @@ -128,6 +128,13 @@ async fn probe(request: DispatchProbeRequest) -> Result { .iter() .map(|capability| capability.to_string()) .collect(); + // Accepting the controller's model-sync audit row is a request-validation + // fact, not a runtime one, so it is advertised regardless of whether this + // platform can host detached workers. + capabilities.push( + bitfun_services_core::dispatch_contract::DISPATCH_SETUP_AUDIT_MODEL_SYNC_CAPABILITY + .to_string(), + ); if runner::is_supported() { capabilities.push( bitfun_services_core::dispatch_contract::DISPATCH_DETACHED_WORKER_CAPABILITY @@ -852,7 +859,9 @@ fn validate_submit_request(request: &DispatchSubmitRequest) -> Result<()> { bail!("dispatch setup audit exceeds the 32-event safety limit"); } for event in &request.setup_audit { - if event.action != "cli-install" { + if !bitfun_services_core::dispatch_contract::dispatch_supported_setup_audit_actions() + .any(|action| action == event.action) + { bail!("dispatch setup audit contains an unsupported action"); } if event.timestamp.trim().is_empty() diff --git a/src/apps/cli/src/embedded_app_server.rs b/src/apps/cli/src/embedded_app_server.rs index ca39c1a3f9..062d463f8f 100644 --- a/src/apps/cli/src/embedded_app_server.rs +++ b/src/apps/cli/src/embedded_app_server.rs @@ -24,14 +24,9 @@ impl EmbeddedAppServerHost { runtime.agent_event_source(), ) .with_context_reload(Arc::new(runtime.compatibility().clone())); - let account_host = Arc::new( - crate::tui_account_management::CliAccountManagementHost::new( - runtime.compatibility().clone(), - ), - ); - let worktree_host = Arc::new(crate::tui_worktree_management::CliWorktreeManagementHost); let management = Arc::new( - AppManagementService::load_with_hosts(Some(account_host), Some(worktree_host)).await?, + AppManagementService::load_for_local_host(Some(runtime.account_runtime().clone())) + .await?, ); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let server_thread = std::thread::Builder::new() diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index c1cf588593..2cfc3d4f36 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -9,7 +9,6 @@ /// - Single command execution /// - Batch task processing mod account; -mod account_sync; mod acp_cli; mod actions; mod agent; @@ -37,9 +36,7 @@ mod self_update; mod shared_runtime; mod shared_tui_backend; mod terminal_attention; -mod tui_account_management; mod tui_backend; -mod tui_worktree_management; mod ui; use anyhow::{anyhow, Result}; @@ -987,7 +984,10 @@ async fn run_interactive( }; // 3.5 Restore persisted account session (if any) if !shared { - if let Some(user_id) = account::try_restore_session().await { + let runtime = runtime + .as_ref() + .expect("Embedded account startup requires the CLI Runtime"); + if let Some(user_id) = runtime.account_runtime().try_restore_session().await { tracing::info!("Restored account session for user {user_id}"); if daemon::is_daemon_running() { tracing::info!( @@ -996,7 +996,11 @@ async fn run_interactive( } else { let device = DeviceIdentity::from_current_machine() .map_err(|e| anyhow!("detect device: {e}"))?; - if let Err(e) = account::restore_device_routing(&device.device_name).await { + if let Err(e) = runtime + .account_runtime() + .restore_device_routing(&device.device_name) + .await + { tracing::warn!("Failed to restore device routing: {e}"); } } @@ -1006,7 +1010,11 @@ async fn run_interactive( // 3.6 Continuous account settings sync (30s pull + debounced push). // Safe to start before login: cycles skip while logged out. if !shared { - account_sync::start_settings_sync_loop(); + runtime + .as_ref() + .expect("Embedded settings sync requires the CLI Runtime") + .account_runtime() + .start_settings_sync_loop(); } // Resolve agent override: validate against the agent registry AFTER core services init diff --git a/src/apps/cli/src/management.rs b/src/apps/cli/src/management.rs index 26ea57db5f..dba216345b 100644 --- a/src/apps/cli/src/management.rs +++ b/src/apps/cli/src/management.rs @@ -189,7 +189,9 @@ pub(crate) async fn set_default_model(model_id: &str) -> Result<()> { // Short-lived management process: the sync loop never runs here, so push // the change directly (no-op when logged out). - crate::account_sync::push_settings_after_local_change().await; + crate::account::build_management_account_runtime() + .push_settings_after_local_change() + .await; Ok(()) } diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index c5f41ffa3e..82aa7d6513 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -557,8 +557,9 @@ pub(crate) struct ChatMode { external_tool_notice_key: Option, external_tool_review_snapshot: Option, external_tool_mutation_rx: Option>, + external_control_snapshot: + Option, external_control_mutation_rx: Option>, - external_application_ui: ExternalApplicationUiState, external_agent_notice_key: Option, external_agent_review_snapshot: Option, external_agent_mutation_rx: Option>, @@ -623,8 +624,8 @@ impl ChatMode { external_tool_notice_key: None, external_tool_review_snapshot: None, external_tool_mutation_rx: None, + external_control_snapshot: None, external_control_mutation_rx: None, - external_application_ui: ExternalApplicationUiState::default(), external_agent_notice_key: None, external_agent_review_snapshot: None, external_agent_mutation_rx: None, diff --git a/src/apps/cli/src/modes/chat/external_review.rs b/src/apps/cli/src/modes/chat/external_review.rs index 9e680b9a70..4aeb970379 100644 --- a/src/apps/cli/src/modes/chat/external_review.rs +++ b/src/apps/cli/src/modes/chat/external_review.rs @@ -1,16 +1,6 @@ // Pure projections and review text derived from the external-source catalog. use bitfun_product_domains::external_source_control::{ - ExternalApplicationControlActionV2, ExternalApplicationControlRequestV2, - ExternalApplicationControlResultV2, ExternalApplicationEffectiveStatusV2, - ExternalApplicationHealthV2, ExternalApplicationOperationOutcomeV2, - ExternalApplicationPrimaryActionV2, ExternalApplicationRecoveryActionV2, - ExternalApplicationReviewItemRefV2, ExternalApplicationReviewPageRequestV2, - ExternalApplicationReviewPageV2, ExternalApplicationReviewSelectionBaselineV2, - ExternalApplicationReviewSelectionOverrideV2, ExternalApplicationRiskLevelV2, - ExternalApplicationSafetyCeilingV2, ExternalApplicationSnapshotV2, - ExternalApplicationTargetScopeV2, ExternalSourceDesiredState, ExternalSourceEffectiveStatus, - ExternalSourceRecoveryActionV1, ExternalSourceSupportState, - EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS, EXTERNAL_APPLICATION_SCHEMA_V2, + ExternalSourceDesiredState, ExternalSourceEffectiveStatus, ExternalSourceRecoveryActionV1, }; fn external_command_projections( @@ -271,7 +261,7 @@ enum ExternalControlUiAction { Show, Refresh, SetSafeMode(bool), - SetSourceEnabled { source_key: String, enabled: bool }, + SetSourceEnabled { source_index: usize, enabled: bool }, } fn parse_external_control_action(arguments: &str) -> Result { @@ -280,863 +270,105 @@ fn parse_external_control_action(arguments: &str) -> Result Ok(ExternalControlUiAction::Refresh), ["safe-mode", "on"] => Ok(ExternalControlUiAction::SetSafeMode(true)), ["safe-mode", "off"] => Ok(ExternalControlUiAction::SetSafeMode(false)), - ["source", "enable", source_key] => Ok(ExternalControlUiAction::SetSourceEnabled { - source_key: (*source_key).to_string(), + ["enable", source_number] => Ok(ExternalControlUiAction::SetSourceEnabled { + source_index: parse_positive_index(Some(source_number), "extension number")?, enabled: true, }), - ["source", "disable", source_key] => Ok(ExternalControlUiAction::SetSourceEnabled { - source_key: (*source_key).to_string(), + ["disable", source_number] => Ok(ExternalControlUiAction::SetSourceEnabled { + source_index: parse_positive_index(Some(source_number), "extension number")?, enabled: false, }), - _ => Err("usage: /extensions [status | refresh | safe-mode on | safe-mode off | source enable | source disable ]".to_string()), - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ExternalReviewDirection { - Next, - Previous, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ExternalReviewNavigation { - Open, - Move { - expected_cursor: Option, - previous_cursors: Vec>, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ExternalApplicationUiAction { - Show, - Refresh, - ConnectApplication { - application_id: String, - }, - DisconnectApplication { - application_id: String, - }, - DeferApplication { - application_id: String, - }, - OpenReview, - ReviewNext, - ReviewPrevious, - SetReviewItem { - item_ref: ExternalApplicationReviewItemRefV2, - selected: bool, - }, - SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2, - immediate_selection: Option<(ExternalApplicationReviewItemRefV2, bool)>, - }, -} - -struct ExternalApplicationReviewUiState { - page: ExternalApplicationReviewPageV2, - previous_cursors: Vec>, - selection_overrides: Vec<(ExternalApplicationReviewItemRefV2, bool)>, -} - -enum ExternalApplicationAsyncResult { - Snapshot(ExternalApplicationSnapshotV2), - LegacySnapshot(bitfun_app_server_protocol::external_source::ExternalSourceSnapshotResponse), - ReviewPage { - page: ExternalApplicationReviewPageV2, - navigation: ExternalReviewNavigation, - }, - Mutation { - result: ExternalApplicationControlResultV2, - snapshot: ExternalApplicationSnapshotV2, - }, -} - -fn should_fallback_to_legacy_external_status( - shared: bool, - error: &ExternalSourceOperationError, -) -> bool { - !shared - && matches!( - error.code, - ExternalSourceOperationErrorCode::HostCapabilityUnavailable - | ExternalSourceOperationErrorCode::Unsupported - ) -} - -enum ExternalApplicationPendingRequest { - Snapshot { - force_refresh: bool, - }, - ReviewPage { - request: ExternalApplicationReviewPageRequestV2, - navigation: ExternalReviewNavigation, - }, - Mutation(ExternalApplicationControlRequestV2), -} - -struct ExternalApplicationMutationResult { - action: ExternalApplicationUiAction, - result: std::result::Result, -} - -#[derive(Default)] -struct ExternalApplicationUiState { - snapshot: Option, - review: Option, - pending_rx: Option>, -} - -impl ExternalApplicationUiState { - fn replace_snapshot(&mut self, snapshot: ExternalApplicationSnapshotV2) -> Result<(), String> { - snapshot.validate().map_err(str::to_string)?; - let keep_review = self.review.as_ref().is_some_and(|review| { - snapshot.review_summary.as_ref().is_some_and(|summary| { - summary.review_id == review.page.review_id - && snapshot.preference_revision == review.page.preference_revision - && snapshot.execution_domain_id == review.page.execution_domain_id - && snapshot.workspace_scope_id == review.page.workspace_scope_id - }) - }); - if !keep_review { - self.review = None; - } - self.snapshot = Some(snapshot); - Ok(()) - } - - fn snapshot(&self) -> Result<&ExternalApplicationSnapshotV2, String> { - self.snapshot.as_ref().ok_or_else(|| { - "External application V2 status is unavailable; run /extensions status".to_string() - }) - } - - fn can_mutate(&self) -> Result<(), String> { - let snapshot = self.snapshot()?; - let scope_allowed = if snapshot.workspace_scope_id.is_some() { - snapshot.host_capabilities.can_manage_workspace_override - } else { - snapshot.host_capabilities.can_manage_user_default - }; - if snapshot.host_capabilities.can_mutate && scope_allowed { - Ok(()) - } else { - Err("This host is read-only for external application changes.".to_string()) - } - } - - fn target_scope( - snapshot: &ExternalApplicationSnapshotV2, - ) -> (ExternalApplicationTargetScopeV2, Option) { - match snapshot.workspace_scope_id.clone() { - Some(workspace_scope_id) => ( - ExternalApplicationTargetScopeV2::WorkspaceOverride, - Some(workspace_scope_id), - ), - None => (ExternalApplicationTargetScopeV2::UserDefault, None), - } - } - - fn control_request( - &self, - operation_id: &str, - action: ExternalApplicationControlActionV2, - ) -> Result { - self.can_mutate()?; - let snapshot = self.snapshot()?; - let (target_scope, workspace_scope_id) = Self::target_scope(snapshot); - let request = ExternalApplicationControlRequestV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: snapshot.execution_domain_id.clone(), - workspace_scope_id, - target_scope, - operation_id: operation_id.to_string(), - expected_preference_revision: snapshot.preference_revision, - action, - }; - request.validate().map_err(str::to_string)?; - Ok(request) - } - - fn open_review_page_request(&self) -> Result { - let snapshot = self.snapshot()?; - if !snapshot.host_capabilities.can_read_review { - return Err("This host cannot read the external application review.".to_string()); - } - let summary = snapshot - .review_summary - .as_ref() - .ok_or_else(|| "No external application review is pending.".to_string())?; - let (target_scope, workspace_scope_id) = Self::target_scope(snapshot); - Ok(ExternalApplicationReviewPageRequestV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: snapshot.execution_domain_id.clone(), - workspace_scope_id, - target_scope, - review_id: summary.review_id.clone(), - preference_revision: snapshot.preference_revision, - expected_generations: Vec::new(), - cursor: None, - page_size: EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS, - }) - } - - fn review_page_request( - &self, - direction: ExternalReviewDirection, - ) -> Result< - ( - ExternalApplicationReviewPageRequestV2, - ExternalReviewNavigation, + _ => Err( + "usage: /extensions [status | refresh | enable | disable ]" + .to_string(), ), - String, - > { - let review = self - .review - .as_ref() - .ok_or_else(|| "Open /extensions review before changing review pages.".to_string())?; - let (cursor, previous_cursors) = match direction { - ExternalReviewDirection::Next => { - let cursor = review.page.next_cursor.clone().ok_or_else(|| { - "The external application review has no next page.".to_string() - })?; - let mut history = review.previous_cursors.clone(); - history.push(review.page.cursor.clone()); - (Some(cursor), history) - } - ExternalReviewDirection::Previous => { - let mut history = review.previous_cursors.clone(); - let cursor = history.pop().ok_or_else(|| { - "The external application review has no previous page.".to_string() - })?; - (cursor, history) - } - }; - let request = ExternalApplicationReviewPageRequestV2 { - schema_version: review.page.schema_version, - execution_domain_id: review.page.execution_domain_id.clone(), - workspace_scope_id: review.page.workspace_scope_id.clone(), - target_scope: review.page.target_scope, - review_id: review.page.review_id.clone(), - preference_revision: review.page.preference_revision, - expected_generations: review.page.expected_generations.clone(), - cursor: cursor.clone(), - page_size: EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS, - }; - Ok(( - request, - ExternalReviewNavigation::Move { - expected_cursor: cursor, - previous_cursors, - }, - )) - } - - fn replace_review_page( - &mut self, - page: ExternalApplicationReviewPageV2, - navigation: ExternalReviewNavigation, - ) -> Result<(), String> { - page.validate().map_err(str::to_string)?; - let snapshot = self.snapshot()?; - let summary = snapshot.review_summary.as_ref().ok_or_else(|| { - "The external application review is stale; refresh /extensions.".to_string() - })?; - let (expected_scope, expected_workspace_scope_id) = Self::target_scope(snapshot); - let opening = matches!(&navigation, ExternalReviewNavigation::Open); - if page.execution_domain_id != snapshot.execution_domain_id - || page.workspace_scope_id != expected_workspace_scope_id - || page.target_scope != expected_scope - || page.preference_revision != snapshot.preference_revision - || (!opening && page.review_id != summary.review_id) - { - return Err( - "The external application review is stale; refresh /extensions.".to_string(), - ); - } - if matches!(&navigation, ExternalReviewNavigation::Move { .. }) - && self - .review - .as_ref() - .is_none_or(|review| review.page.expected_generations != page.expected_generations) - { - return Err( - "The external application review generation is stale; reopen /extensions review." - .to_string(), - ); - } - let (expected_cursor, previous_cursors, keep_overrides) = match navigation { - ExternalReviewNavigation::Open => (None, Vec::new(), false), - ExternalReviewNavigation::Move { - expected_cursor, - previous_cursors, - } => (expected_cursor, previous_cursors, true), - }; - if page.cursor != expected_cursor { - return Err( - "The external application review page is stale; reopen /extensions review." - .to_string(), - ); - } - let selection_overrides = if keep_overrides { - self.review - .take() - .map(|review| review.selection_overrides) - .unwrap_or_default() - } else { - Vec::new() - }; - self.review = Some(ExternalApplicationReviewUiState { - page, - previous_cursors, - selection_overrides, - }); - Ok(()) - } - - fn review_item_selected(&self, index: usize) -> Result { - let review = self - .review - .as_ref() - .ok_or_else(|| "Open /extensions review before selecting items.".to_string())?; - let item = review.page.items.get(index).ok_or_else(|| { - "That review item is not on the current page; reopen /extensions review.".to_string() - })?; - Ok(review - .selection_overrides - .iter() - .find_map(|(item_ref, selected)| (item_ref == &item.item_ref).then_some(*selected)) - .unwrap_or(item.recommended)) - } - - fn set_review_item_selected(&mut self, index: usize, selected: bool) -> Result<(), String> { - self.can_mutate()?; - let review = self - .review - .as_mut() - .ok_or_else(|| "Open /extensions review before selecting items.".to_string())?; - let item = review.page.items.get(index).ok_or_else(|| { - "That review item is not on the current page; reopen /extensions review.".to_string() - })?; - if selected == item.recommended { - review - .selection_overrides - .retain(|(item_ref, _)| item_ref != &item.item_ref); - } else if let Some((_, current)) = review - .selection_overrides - .iter_mut() - .find(|(item_ref, _)| item_ref == &item.item_ref) - { - *current = selected; - } else { - review - .selection_overrides - .push((item.item_ref.clone(), selected)); - } - Ok(()) - } - - fn review_submit_request( - &self, - operation_id: &str, - selection_baseline: ExternalApplicationReviewSelectionBaselineV2, - immediate_selection: Option<(&ExternalApplicationReviewItemRefV2, bool)>, - ) -> Result { - let review = self - .review - .as_ref() - .ok_or_else(|| "Open /extensions review before applying it.".to_string())?; - let mut selection_overrides = if matches!( - selection_baseline, - ExternalApplicationReviewSelectionBaselineV2::Recommended - ) { - review - .selection_overrides - .iter() - .map( - |(item_ref, selected)| ExternalApplicationReviewSelectionOverrideV2 { - item_ref: item_ref.clone(), - selected: *selected, - }, - ) - .collect::>() - } else { - Vec::new() - }; - if let Some((item_ref, selected)) = immediate_selection { - let baseline_selected = match selection_baseline { - ExternalApplicationReviewSelectionBaselineV2::Recommended => review - .page - .items - .iter() - .find_map(|item| (item.item_ref == *item_ref).then_some(item.recommended)) - .unwrap_or(false), - ExternalApplicationReviewSelectionBaselineV2::None => false, - }; - selection_overrides.retain(|selection| selection.item_ref != *item_ref); - if selected != baseline_selected { - selection_overrides.push(ExternalApplicationReviewSelectionOverrideV2 { - item_ref: item_ref.clone(), - selected, - }); - } - } - self.control_request( - operation_id, - ExternalApplicationControlActionV2::SubmitApplicationReview { - review_id: review.page.review_id.clone(), - expected_generations: review.page.expected_generations.clone(), - selection_overrides, - selection_baseline, - }, - ) - } -} - -fn external_application_for_number( - state: &ExternalApplicationUiState, - value: Option<&str>, -) -> Result { - let index = parse_positive_index(value, "application number")?; - state - .snapshot()? - .applications - .get(index) - .cloned() - .ok_or_else(|| { - "That application is not in the displayed V2 snapshot; run /extensions status." - .to_string() - }) -} - -fn external_review_item_for_number( - state: &ExternalApplicationUiState, - value: Option<&str>, -) -> Result { - let index = parse_positive_index(value, "review item number")?; - state - .review - .as_ref() - .and_then(|review| review.page.items.get(index)) - .map(|item| item.item_ref.clone()) - .ok_or_else(|| { - "That item is not in the displayed review page; reopen /extensions review.".to_string() - }) -} - -fn parse_external_application_action( - arguments: &str, - state: &ExternalApplicationUiState, -) -> Result { - let mut parts = arguments.split_whitespace(); - let Some(command) = parts.next() else { - return Ok(ExternalApplicationUiAction::Show); - }; - if command.eq_ignore_ascii_case("status") { - if parts.next().is_none() { - return Ok(ExternalApplicationUiAction::Show); - } - } else if command.eq_ignore_ascii_case("refresh") { - if parts.next().is_none() { - return Ok(ExternalApplicationUiAction::Refresh); - } - } else if command.eq_ignore_ascii_case("connect") - || command.eq_ignore_ascii_case("disconnect") - || command.eq_ignore_ascii_case("defer") - { - state.can_mutate()?; - let application = external_application_for_number(state, parts.next())?; - if parts.next().is_some() { - return Err(format!("usage: /extensions {command} ")); - } - let allowed = if command.eq_ignore_ascii_case("connect") { - application.primary_action == ExternalApplicationPrimaryActionV2::Connect - } else if command.eq_ignore_ascii_case("disconnect") { - application.effective_status == ExternalApplicationEffectiveStatusV2::Connected - } else { - application.effective_status == ExternalApplicationEffectiveStatusV2::NeedsAttention - }; - if !allowed { - return Err(format!( - "Application {} no longer offers that next action; run /extensions status.", - application.application_id - )); - } - return if command.eq_ignore_ascii_case("connect") { - Ok(ExternalApplicationUiAction::ConnectApplication { - application_id: application.application_id.clone(), - }) - } else if command.eq_ignore_ascii_case("disconnect") { - Ok(ExternalApplicationUiAction::DisconnectApplication { - application_id: application.application_id.clone(), - }) - } else { - Ok(ExternalApplicationUiAction::DeferApplication { - application_id: application.application_id.clone(), - }) - }; - } else if command.eq_ignore_ascii_case("review") { - let Some(review_command) = parts.next() else { - return Ok(ExternalApplicationUiAction::OpenReview); - }; - if review_command.eq_ignore_ascii_case("next") && parts.next().is_none() { - state.review_page_request(ExternalReviewDirection::Next)?; - return Ok(ExternalApplicationUiAction::ReviewNext); - } - if review_command.eq_ignore_ascii_case("previous") && parts.next().is_none() { - state.review_page_request(ExternalReviewDirection::Previous)?; - return Ok(ExternalApplicationUiAction::ReviewPrevious); - } - if review_command.eq_ignore_ascii_case("include") - || review_command.eq_ignore_ascii_case("exclude") - { - state.can_mutate()?; - let item_ref = external_review_item_for_number(state, parts.next())?; - if parts.next().is_some() { - return Err(format!( - "usage: /extensions review {review_command} " - )); - } - return Ok(ExternalApplicationUiAction::SetReviewItem { - item_ref, - selected: review_command.eq_ignore_ascii_case("include"), - }); - } - if review_command.eq_ignore_ascii_case("allow") && parts.next().is_none() { - state.can_mutate()?; - let review = state - .review - .as_ref() - .ok_or_else(|| "Open /extensions review before applying it.".to_string())?; - if review.page.total_count != 1 || review.page.items.len() != 1 { - return Err("Use /extensions review include , then /extensions review apply for multiple items.".to_string()); - } - let item = &review.page.items[0]; - if item.safety_ceiling == ExternalApplicationSafetyCeilingV2::Blocked { - return Err("This item cannot be enabled; use /extensions review deny.".to_string()); - } - return Ok(ExternalApplicationUiAction::SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2::Recommended, - immediate_selection: Some((item.item_ref.clone(), true)), - }); - } - if review_command.eq_ignore_ascii_case("apply") && parts.next().is_none() { - state.can_mutate()?; - return Ok(ExternalApplicationUiAction::SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2::Recommended, - immediate_selection: None, - }); - } - if review_command.eq_ignore_ascii_case("deny") && parts.next().is_none() { - state.can_mutate()?; - return Ok(ExternalApplicationUiAction::SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2::None, - immediate_selection: None, - }); - } - } - Err("usage: /extensions [status | refresh | connect | disconnect | defer | review [next | previous | include | exclude | allow | apply | deny]]".to_string()) -} - -fn external_application_status_label(status: ExternalApplicationEffectiveStatusV2) -> &'static str { - match status { - ExternalApplicationEffectiveStatusV2::Connected => "Connected", - ExternalApplicationEffectiveStatusV2::ConfigurationAvailable => "Configuration available", - ExternalApplicationEffectiveStatusV2::NoConfiguration => "No configuration", - ExternalApplicationEffectiveStatusV2::NeedsAttention => "Needs attention", - ExternalApplicationEffectiveStatusV2::TemporarilyUnavailable => "Temporarily unavailable", - } -} - -fn external_application_health_label(health: ExternalApplicationHealthV2) -> &'static str { - match health { - ExternalApplicationHealthV2::Healthy => "healthy", - ExternalApplicationHealthV2::Degraded => "degraded", - ExternalApplicationHealthV2::Unavailable => "unavailable", } } -fn external_application_recovery_label( - action: &ExternalApplicationRecoveryActionV2, -) -> &'static str { - match action { - ExternalApplicationRecoveryActionV2::Refresh => "refresh", - ExternalApplicationRecoveryActionV2::Retry => "retry", - ExternalApplicationRecoveryActionV2::ReconnectHost => "reconnect host", - ExternalApplicationRecoveryActionV2::Review => "review", - ExternalApplicationRecoveryActionV2::UpgradeHost => "upgrade host", - ExternalApplicationRecoveryActionV2::ViewReason => "view reason", - ExternalApplicationRecoveryActionV2::ExitSafeMode => "exit safe mode", - ExternalApplicationRecoveryActionV2::ResolveConflict => "resolve conflict", - ExternalApplicationRecoveryActionV2::InstallRuntime => "install runtime", +fn external_control_status_text( + control: &bitfun_product_domains::external_source_control::ExternalSourceControlSnapshotV1, +) -> String { + let mut lines = vec!["Extensions".to_string(), String::new()]; + if control.safe_mode { + lines.push("External access is paused. Resume: /extensions safe-mode off".to_string()); + lines.push(String::new()); } -} -fn external_application_overview_text(snapshot: &ExternalApplicationSnapshotV2) -> String { - let mut lines = vec!["External applications".to_string(), String::new()]; - if snapshot.safe_mode { - lines.push("Safe Mode: on".to_string()); + if control.sources.is_empty() { + lines.push("No extensions found.".to_string()); } - let scope_can_mutate = snapshot.host_capabilities.can_mutate - && if snapshot.workspace_scope_id.is_some() { - snapshot.host_capabilities.can_manage_workspace_override - } else { - snapshot.host_capabilities.can_manage_user_default + for (index, source) in control.sources.iter().enumerate() { + let effective = match source.effective_status { + ExternalSourceEffectiveStatus::Discovering => "Checking", + ExternalSourceEffectiveStatus::Disabled => "Off", + ExternalSourceEffectiveStatus::ReviewRequired => "Needs permission", + ExternalSourceEffectiveStatus::Conflict => "Needs attention", + ExternalSourceEffectiveStatus::Active => "On", + ExternalSourceEffectiveStatus::Degraded => "Needs attention", + ExternalSourceEffectiveStatus::Unsupported => "Unavailable", + ExternalSourceEffectiveStatus::Available => "Available", + ExternalSourceEffectiveStatus::Removed => "Not found", }; - for (index, application) in snapshot.applications.iter().enumerate() { let number = index + 1; - lines.push(format!( - "{number}. {} - {}", - application.display_name, - external_application_status_label(application.effective_status) - )); - let mut facts = Vec::new(); - if application.health != ExternalApplicationHealthV2::Healthy { - facts.push(format!( - "Health: {}", - external_application_health_label(application.health) - )); - } - if application.blocked_count > 0 { - facts.push(format!("{} blocked", application.blocked_count)); - } - if application.conflict_count > 0 { - facts.push(format!("{} conflicts", application.conflict_count)); - } - if !application.recovery_actions.is_empty() { - facts.push(format!( - "Recovery: {}", - application - .recovery_actions - .iter() - .map(external_application_recovery_label) - .collect::>() - .join(", ") - )); - } - if !facts.is_empty() { - lines.push(format!(" {}", facts.join("; "))); - } - if scope_can_mutate { - match application.primary_action { - ExternalApplicationPrimaryActionV2::Connect => { - lines.push(format!(" Next: /extensions connect {number}")) - } - ExternalApplicationPrimaryActionV2::Review => {} - ExternalApplicationPrimaryActionV2::Retry => { - lines.push(" Next: /extensions refresh".to_string()) - } - ExternalApplicationPrimaryActionV2::None - | ExternalApplicationPrimaryActionV2::View - | ExternalApplicationPrimaryActionV2::ViewReason => {} - } - if application.effective_status == ExternalApplicationEffectiveStatusV2::Connected { - lines.push(format!(" Disconnect: /extensions disconnect {number}")); - } + lines.push(format!("{number}. {} - {effective}", source.display_name)); + if control.host_capabilities.can_manage_sources { + let (verb, command) = match source.desired { + ExternalSourceDesiredState::Enabled => ("Disable", "disable"), + ExternalSourceDesiredState::Disabled => ("Enable", "enable"), + }; + lines.push(format!(" {verb}: /extensions {command} {number}")); } } - if snapshot.review_summary.is_some() && snapshot.host_capabilities.can_read_review { - lines.push(String::new()); - lines.push("Review: /extensions review".to_string()); - } - lines.join("\n") -} - -fn external_application_risk_label(risk: ExternalApplicationRiskLevelV2) -> &'static str { - match risk { - ExternalApplicationRiskLevelV2::Low => "low", - ExternalApplicationRiskLevelV2::Moderate => "moderate", - ExternalApplicationRiskLevelV2::High => "high", - } -} -fn external_application_review_text(state: &ExternalApplicationUiState) -> Result { - let review = state - .review - .as_ref() - .ok_or_else(|| "Open /extensions review before displaying it.".to_string())?; - let mut lines = vec![ - "External application review".to_string(), - String::new(), - format!("{} items total", review.page.total_count), - ]; - let can_mutate = state.can_mutate().is_ok(); - if can_mutate { - let direct = review.page.total_count == 1 - && review.page.items.len() == 1 - && review.page.items[0].safety_ceiling != ExternalApplicationSafetyCeilingV2::Blocked; - if direct { - lines.push("Enable: /extensions review allow".to_string()); - lines.push("Keep disabled: /extensions review deny".to_string()); - } else { - lines.push("Apply selections: /extensions review apply".to_string()); - lines.push("Keep all disabled: /extensions review deny".to_string()); - } - } - lines.push(String::new()); - lines.push("Adjust individual items:".to_string()); - for (index, item) in review.page.items.iter().enumerate() { - let selected = state.review_item_selected(index)?; - lines.push(format!( - "{}. [{}] {} [{}]", - index + 1, - if selected { "x" } else { " " }, - item.display_name, - external_application_risk_label(item.risk_level) - )); - } - if !review.previous_cursors.is_empty() { - lines.push("Previous: /extensions review previous".to_string()); - } - if review.page.next_cursor.is_some() { - lines.push("Next: /extensions review next".to_string()); + if !control.host_capabilities.can_manage_sources { + lines.push("This connection can only show extension status.".to_string()); } - if can_mutate { - lines.push("Adjust: /extensions review ".to_string()); + if control.sources.iter().any(|source| { + matches!( + source.effective_status, + ExternalSourceEffectiveStatus::ReviewRequired | ExternalSourceEffectiveStatus::Conflict + ) + }) { + lines.push("Manage permissions: /tools, /agent, /mcp, or /hooks".to_string()); } - Ok(lines.join("\n")) -} - -fn external_control_review_text( - control: &bitfun_product_domains::external_source_control::ExternalSourceControlSnapshotV1, -) -> String { - external_control_review_text_impl(control, true) -} -fn external_control_read_only_review_text( - control: &bitfun_product_domains::external_source_control::ExternalSourceControlSnapshotV1, -) -> String { - external_control_review_text_impl(control, false) -} - -fn external_control_review_text_impl( - control: &bitfun_product_domains::external_source_control::ExternalSourceControlSnapshotV1, - include_mutations: bool, -) -> String { - use bitfun_product_domains::external_source_control::{ - ExternalCapabilityKindV1, ExternalSourceRuntimeState, - }; - - let mut lines = vec![ - "External integrations".to_string(), - String::new(), - format!( - "Safe Mode: {}", - if control.safe_mode { "on" } else { "off" } - ), - format!("Execution domain: {}", control.execution_domain_id), - format!("Generation: {}", control.refresh_generation), - format!("Sources: {}", control.sources.len()), - ]; - if control.safe_mode { - lines.push( - "New external Tool, Agent, and MCP calls are blocked; calls already in progress are not cancelled." - .to_string(), - ); - lines.push( - "Safe Mode applies only to this Host process and execution domain; restarting the Host turns it off." - .to_string(), - ); - } - for source in &control.sources { - let desired = match source.desired { - ExternalSourceDesiredState::Enabled => "enabled", - ExternalSourceDesiredState::Disabled => "disabled", - }; - let effective = match source.effective_status { - ExternalSourceEffectiveStatus::Discovering => "discovering", - ExternalSourceEffectiveStatus::Disabled => "disabled", - ExternalSourceEffectiveStatus::ReviewRequired => "review required", - ExternalSourceEffectiveStatus::Conflict => "conflict", - ExternalSourceEffectiveStatus::Active => "active", - ExternalSourceEffectiveStatus::Degraded => "degraded", - ExternalSourceEffectiveStatus::Unsupported => "unsupported", - ExternalSourceEffectiveStatus::Available => "available", - ExternalSourceEffectiveStatus::Removed => "removed", - }; - lines.push(format!( - "Source {}: {} ({desired}, {effective})", - source.stable_key, source.display_name - )); - } - for capability in &control.capabilities { - let label = match capability.kind { - ExternalCapabilityKindV1::Command => "Commands", - ExternalCapabilityKindV1::Tool => "Tools", - ExternalCapabilityKindV1::Subagent => "Agents", - ExternalCapabilityKindV1::Mcp => "MCP servers", - }; - let runtime = match capability.runtime { - ExternalSourceRuntimeState::NotApplicable => "not applicable", - ExternalSourceRuntimeState::Inactive => "inactive", - ExternalSourceRuntimeState::Starting => "starting", - ExternalSourceRuntimeState::Active => "active", - ExternalSourceRuntimeState::Degraded => "degraded", - ExternalSourceRuntimeState::Quarantined => "quarantined", - ExternalSourceRuntimeState::Unsupported => "unsupported", - }; - let support = match capability.support { - ExternalSourceSupportState::Supported => "", - ExternalSourceSupportState::Partial => ", support: partial", - ExternalSourceSupportState::Unsupported => ", support: unsupported", - ExternalSourceSupportState::Unavailable => ", support: unavailable", - }; - lines.push(format!( - "{label}: {} items, {} review, {} conflicts, {runtime}{support}", - capability.item_count, - capability.pending_review_count, - capability.unresolved_conflict_count, - )); - } const MAX_STATUS_DETAILS: usize = 4; if !control.diagnostics.is_empty() { lines.push(String::new()); - lines.push("Issues".to_string()); + lines.push("Needs attention".to_string()); for diagnostic in control.diagnostics.iter().take(MAX_STATUS_DETAILS) { - let severity = match diagnostic.severity { - ExternalSourceDiagnosticSeverity::Info => "info", - ExternalSourceDiagnosticSeverity::Warning => "warning", - ExternalSourceDiagnosticSeverity::Error => "error", - _ => "notice", - }; lines.push(format!( - " - {severity}: [{}] {}", - diagnostic.code, + " - {}", external_source_diagnostic_summary(&diagnostic.code) )); } let hidden = control.diagnostics.len().saturating_sub(MAX_STATUS_DETAILS); if hidden > 0 { - lines.push(format!( - " - {hidden} more; refresh after fixing the listed issue(s)." - )); + lines.push(format!(" - {hidden} more issue(s).")); } } - if include_mutations && !control.recovery_actions.is_empty() { - lines.push(String::new()); - lines.push("Recovery".to_string()); - for action in control.recovery_actions.iter().take(MAX_STATUS_DETAILS) { - lines.push(format!( - " - {}", - external_recovery_action_label(action, "extensions") - )); + if !control.recovery_actions.is_empty() { + let recovery = control + .recovery_actions + .iter() + .filter(|action| { + !matches!( + action, + ExternalSourceRecoveryActionV1::Review + | ExternalSourceRecoveryActionV1::ExitSafeMode + ) + }) + .take(MAX_STATUS_DETAILS) + .map(|action| external_recovery_action_label(action, "extensions")) + .collect::>(); + if !recovery.is_empty() { + lines.push(String::new()); + lines.push(format!("Next: {}", recovery.join("; "))); } } lines.push(String::new()); - lines.push("Refresh: /extensions refresh".to_string()); - if include_mutations { - lines.push(if control.safe_mode { - "Exit Safe Mode: /extensions safe-mode off".to_string() - } else { - "Enter Safe Mode: /extensions safe-mode on".to_string() - }); - lines.push("Enable source: /extensions source enable ".to_string()); - lines.push("Disable source: /extensions source disable ".to_string()); - } else { - lines.push( - "Read-only compatibility status: upgrade or reconnect the Host to manage applications." - .to_string(), - ); + if control.host_capabilities.can_refresh { + lines.push("Refresh: /extensions refresh".to_string()); } lines.join("\n") } @@ -2469,581 +1701,6 @@ fn external_agent_pending_notice_key( external_agent_attention(previous, snapshot).key } -#[cfg(test)] -mod external_application_v2_tests { - use super::*; - use bitfun_product_domains::external_source_control::{ - ExternalApplicationConnectionStateV2, ExternalApplicationDefaultConnectionPolicyV2, - ExternalApplicationDesiredConnectionV2, ExternalApplicationDiscoveryStateV2, - ExternalApplicationEffectiveStatusV2, ExternalApplicationHealthV2, - ExternalApplicationHostCapabilitiesV2, ExternalApplicationOwnerGenerationV2, - ExternalApplicationPrimaryActionV2, ExternalApplicationReviewCategoryCountV2, - ExternalApplicationReviewItemKindV2, ExternalApplicationReviewItemRefV2, - ExternalApplicationReviewItemV2, ExternalApplicationReviewPageV2, - ExternalApplicationReviewRecommendationSummaryV2, ExternalApplicationReviewSummaryV2, - ExternalApplicationRiskLevelV2, ExternalApplicationRiskSummaryV2, - ExternalApplicationSafetyCeilingV2, ExternalApplicationSnapshotV2, - ExternalApplicationSummaryV2, ExternalApplicationTargetScopeV2, - ExternalApplicationUserDecisionV2, EXTERNAL_APPLICATION_SCHEMA_V2, - }; - use bitfun_product_domains::external_sources::ExecutionDomainId; - - fn risk() -> ExternalApplicationRiskSummaryV2 { - ExternalApplicationRiskSummaryV2 { - highest_level: Some(ExternalApplicationRiskLevelV2::High), - reason_codes: vec!["process_execution".to_string()], - } - } - - fn application( - id: &str, - status: ExternalApplicationEffectiveStatusV2, - action: ExternalApplicationPrimaryActionV2, - ) -> ExternalApplicationSummaryV2 { - ExternalApplicationSummaryV2 { - application_id: id.to_string(), - ecosystem_id: id.to_string(), - display_name: id.to_string(), - discovery: if status == ExternalApplicationEffectiveStatusV2::NoConfiguration { - ExternalApplicationDiscoveryStateV2::NotDiscovered - } else { - ExternalApplicationDiscoveryStateV2::Discovered - }, - connection: if status == ExternalApplicationEffectiveStatusV2::Connected { - ExternalApplicationConnectionStateV2::Connected - } else { - ExternalApplicationConnectionStateV2::Disconnected - }, - desired_connection: ExternalApplicationDesiredConnectionV2::Unspecified, - health: ExternalApplicationHealthV2::Healthy, - effective_status: status, - primary_action: action, - default_connection_policy: ExternalApplicationDefaultConnectionPolicyV2::DiscoverOnly, - default_connection_reason: "product_policy".to_string(), - enabled_count: 1, - pending_review_count: usize::from( - status == ExternalApplicationEffectiveStatusV2::NeedsAttention, - ), - blocked_count: 0, - conflict_count: 0, - risk_summary: risk(), - notice_key: None, - user_decision: ExternalApplicationUserDecisionV2::None, - recovery_actions: Vec::new(), - } - } - - fn snapshot( - capabilities: ExternalApplicationHostCapabilitiesV2, - ) -> ExternalApplicationSnapshotV2 { - ExternalApplicationSnapshotV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: ExecutionDomainId::new("host-a").unwrap(), - workspace_scope_id: Some("workspace:0123456789abcdef".to_string()), - effective_connection_scope: ExternalApplicationTargetScopeV2::WorkspaceOverride, - refresh_generation: 7, - preference_revision: 11, - safe_mode: true, - host_capabilities: capabilities, - applications: vec![ - application( - "connected", - ExternalApplicationEffectiveStatusV2::Connected, - ExternalApplicationPrimaryActionV2::View, - ), - application( - "available", - ExternalApplicationEffectiveStatusV2::ConfigurationAvailable, - ExternalApplicationPrimaryActionV2::Connect, - ), - application( - "missing", - ExternalApplicationEffectiveStatusV2::NoConfiguration, - ExternalApplicationPrimaryActionV2::None, - ), - application( - "attention", - ExternalApplicationEffectiveStatusV2::NeedsAttention, - ExternalApplicationPrimaryActionV2::Review, - ), - application( - "unavailable", - ExternalApplicationEffectiveStatusV2::TemporarilyUnavailable, - ExternalApplicationPrimaryActionV2::Retry, - ), - ], - review_summary: Some(ExternalApplicationReviewSummaryV2 { - review_id: "review-7".to_string(), - total_count: 2, - category_counts: vec![ExternalApplicationReviewCategoryCountV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - count: 2, - }], - max_selection_count: 2, - risk_summary: risk(), - recommendation_summary: ExternalApplicationReviewRecommendationSummaryV2 { - recommended_count: 1, - optional_count: 1, - blocked_count: 0, - }, - safety_ceiling: ExternalApplicationSafetyCeilingV2::ReviewRequired, - }), - } - } - - fn item(stable_id: &str, recommended: bool) -> ExternalApplicationReviewItemV2 { - ExternalApplicationReviewItemV2 { - item_ref: ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: stable_id.to_string(), - }, - display_name: stable_id.to_string(), - display_summary: "Runs an external tool".to_string(), - risk_level: ExternalApplicationRiskLevelV2::High, - risk_reason_codes: vec!["process_execution".to_string()], - recommended, - safety_ceiling: ExternalApplicationSafetyCeilingV2::ReviewRequired, - } - } - - fn page(cursor: Option<&str>, next_cursor: Option<&str>) -> ExternalApplicationReviewPageV2 { - ExternalApplicationReviewPageV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: ExecutionDomainId::new("host-a").unwrap(), - workspace_scope_id: Some("workspace:0123456789abcdef".to_string()), - target_scope: ExternalApplicationTargetScopeV2::WorkspaceOverride, - review_id: "review-7".to_string(), - preference_revision: 11, - expected_generations: vec![ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Tool, - generation: 7, - }], - cursor: cursor.map(str::to_string), - next_cursor: next_cursor.map(str::to_string), - total_count: 2, - items: vec![item("tool-recommended", true), item("tool-optional", false)], - } - } - - #[test] - fn overview_uses_five_shared_states_and_hides_mutations_for_read_only_hosts() { - let writable = external_application_overview_text(&snapshot( - ExternalApplicationHostCapabilitiesV2::read_write(), - )); - for expected in [ - "Connected", - "Configuration available", - "No configuration", - "Needs attention", - "Temporarily unavailable", - ] { - assert!(writable.contains(expected), "{expected}\n{writable}"); - } - assert!(writable.contains("/extensions connect 2")); - assert!(writable.contains("/extensions review")); - assert!(writable.contains("Safe Mode: on")); - assert!(!writable.contains("Health: healthy"), "{writable}"); - assert!(!writable.contains(" enabled,"), "{writable}"); - assert!( - !writable.contains("Refresh: /extensions refresh"), - "{writable}" - ); - - let read_only = external_application_overview_text(&snapshot( - ExternalApplicationHostCapabilitiesV2::read_only(), - )); - for forbidden in [ - "/extensions connect", - "/extensions disconnect", - "/extensions defer", - "/extensions review allow", - "/extensions review deny", - ] { - assert!(!read_only.contains(forbidden), "{forbidden}\n{read_only}"); - } - } - - #[test] - fn legacy_status_fallback_is_embedded_read_only_only() { - for code in [ - ExternalSourceOperationErrorCode::HostCapabilityUnavailable, - ExternalSourceOperationErrorCode::Unsupported, - ] { - let error = ExternalSourceOperationError::new(code, "V2 unavailable", false); - assert!(should_fallback_to_legacy_external_status(false, &error)); - assert!(!should_fallback_to_legacy_external_status(true, &error)); - } - let unrelated = ExternalSourceOperationError::new( - ExternalSourceOperationErrorCode::Internal, - "Host failed", - false, - ); - assert!(!should_fallback_to_legacy_external_status( - false, &unrelated - )); - } - - #[test] - fn overview_preserves_host_health_and_recovery_without_recomputing_status() { - let mut host = snapshot(ExternalApplicationHostCapabilitiesV2::read_write()); - host.applications[0].health = ExternalApplicationHealthV2::Degraded; - host.applications[0].recovery_actions = vec![ - bitfun_product_domains::external_source_control::ExternalApplicationRecoveryActionV2::ReconnectHost, - bitfun_product_domains::external_source_control::ExternalApplicationRecoveryActionV2::ViewReason, - ]; - - let text = external_application_overview_text(&host); - assert!(text.contains("connected - Connected")); - assert!(text.contains("Health: degraded")); - assert!(text.contains("Recovery: reconnect host, view reason")); - } - - #[test] - fn numbered_application_actions_require_the_rendered_v2_snapshot() { - let unavailable = ExternalApplicationUiState::default(); - assert!(parse_external_application_action("connect 1", &unavailable) - .unwrap_err() - .contains("V2")); - - let mut state = ExternalApplicationUiState::default(); - state - .replace_snapshot(snapshot(ExternalApplicationHostCapabilitiesV2::read_write())) - .unwrap(); - assert_eq!( - parse_external_application_action("connect 2", &state).unwrap(), - ExternalApplicationUiAction::ConnectApplication { - application_id: "available".to_string() - } - ); - assert_eq!( - parse_external_application_action("disconnect 1", &state).unwrap(), - ExternalApplicationUiAction::DisconnectApplication { - application_id: "connected".to_string() - } - ); - assert_eq!( - parse_external_application_action("defer 4", &state).unwrap(), - ExternalApplicationUiAction::DeferApplication { - application_id: "attention".to_string() - } - ); - assert!(parse_external_application_action("connect 1", &state) - .unwrap_err() - .contains("next action")); - assert!(parse_external_application_action("disconnect 2", &state) - .unwrap_err() - .contains("next action")); - assert!(parse_external_application_action("defer 2", &state) - .unwrap_err() - .contains("next action")); - - let read_only = { - let mut state = ExternalApplicationUiState::default(); - state - .replace_snapshot(snapshot(ExternalApplicationHostCapabilitiesV2::read_only())) - .unwrap(); - state - }; - assert!(parse_external_application_action("connect 2", &read_only) - .unwrap_err() - .contains("read-only")); - } - - #[test] - fn workspace_context_targets_an_override_even_when_the_effective_value_is_inherited() { - let mut inherited = snapshot(ExternalApplicationHostCapabilitiesV2::read_write()); - inherited.effective_connection_scope = ExternalApplicationTargetScopeV2::UserDefault; - let mut state = ExternalApplicationUiState::default(); - state.replace_snapshot(inherited).unwrap(); - - let request = state - .control_request( - "operation-workspace", - ExternalApplicationControlActionV2::ConnectApplication { - application_id: "available".to_string(), - }, - ) - .unwrap(); - assert_eq!( - request.target_scope, - ExternalApplicationTargetScopeV2::WorkspaceOverride - ); - assert_eq!( - request.workspace_scope_id.as_deref(), - Some("workspace:0123456789abcdef") - ); - - let mut user_default = snapshot(ExternalApplicationHostCapabilitiesV2::read_write()); - user_default.workspace_scope_id = None; - user_default.effective_connection_scope = ExternalApplicationTargetScopeV2::UserDefault; - state.replace_snapshot(user_default).unwrap(); - let request = state - .control_request( - "operation-user", - ExternalApplicationControlActionV2::ConnectApplication { - application_id: "available".to_string(), - }, - ) - .unwrap(); - assert_eq!( - request.target_scope, - ExternalApplicationTargetScopeV2::UserDefault - ); - assert_eq!(request.workspace_scope_id, None); - } - - #[test] - fn review_selection_stores_only_overrides_to_the_recommended_baseline() { - let mut state = ExternalApplicationUiState::default(); - state - .replace_snapshot(snapshot(ExternalApplicationHostCapabilitiesV2::read_write())) - .unwrap(); - state - .replace_review_page(page(None, Some("page-2")), ExternalReviewNavigation::Open) - .unwrap(); - - assert!(state.review_item_selected(0).unwrap()); - assert!(!state.review_item_selected(1).unwrap()); - state.set_review_item_selected(0, false).unwrap(); - state.set_review_item_selected(1, true).unwrap(); - - let request = state - .review_submit_request( - "operation-1", - ExternalApplicationReviewSelectionBaselineV2::Recommended, - None, - ) - .unwrap(); - let bitfun_product_domains::external_source_control::ExternalApplicationControlActionV2::SubmitApplicationReview { - selection_baseline, - selection_overrides, - .. - } = request.action else { - panic!("expected review action"); - }; - assert_eq!( - selection_baseline, - bitfun_product_domains::external_source_control::ExternalApplicationReviewSelectionBaselineV2::Recommended - ); - assert_eq!(selection_overrides.len(), 2); - assert!(!selection_overrides[0].selected); - assert!(selection_overrides[1].selected); - - let deny_request = state - .review_submit_request( - "operation-deny", - ExternalApplicationReviewSelectionBaselineV2::None, - None, - ) - .unwrap(); - let ExternalApplicationControlActionV2::SubmitApplicationReview { - selection_baseline, - selection_overrides, - .. - } = deny_request.action - else { - panic!("expected review action"); - }; - assert_eq!( - selection_baseline, - ExternalApplicationReviewSelectionBaselineV2::None - ); - assert!(selection_overrides.is_empty()); - } - - #[test] - fn review_commands_keep_single_decisions_direct_and_batch_application_explicit() { - let mut state = ExternalApplicationUiState::default(); - state - .replace_snapshot(snapshot(ExternalApplicationHostCapabilitiesV2::read_write())) - .unwrap(); - state - .replace_review_page(page(None, None), ExternalReviewNavigation::Open) - .unwrap(); - - assert!(parse_external_application_action("review allow", &state).is_err()); - assert_eq!( - parse_external_application_action("review apply", &state).unwrap(), - ExternalApplicationUiAction::SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2::Recommended, - immediate_selection: None, - } - ); - assert_eq!( - parse_external_application_action("review deny", &state).unwrap(), - ExternalApplicationUiAction::SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2::None, - immediate_selection: None, - } - ); - - let text = external_application_review_text(&state).unwrap(); - assert!(text.contains("Apply selections: /extensions review apply")); - assert!(text.contains("Keep all disabled: /extensions review deny")); - assert!(!text.contains("Runs an external tool")); - assert!(!text.contains("Baseline:")); - assert!(!text.contains("review defer")); - - let mut direct = page(None, None); - direct.total_count = 1; - direct.items = vec![item("tool-optional", false)]; - state - .replace_review_page(direct, ExternalReviewNavigation::Open) - .unwrap(); - let direct_action = parse_external_application_action("review allow", &state).unwrap(); - assert_eq!( - direct_action, - ExternalApplicationUiAction::SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2::Recommended, - immediate_selection: Some(( - ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: "tool-optional".to_string(), - }, - true, - )), - } - ); - let ExternalApplicationUiAction::SubmitReview { - baseline, - immediate_selection, - } = direct_action - else { - panic!("expected direct review submission"); - }; - let request = state - .review_submit_request( - "operation-direct", - baseline, - immediate_selection - .as_ref() - .map(|(item_ref, selected)| (item_ref, *selected)), - ) - .unwrap(); - let ExternalApplicationControlActionV2::SubmitApplicationReview { - selection_overrides, - .. - } = request.action - else { - panic!("expected review action"); - }; - assert_eq!(selection_overrides.len(), 1); - assert_eq!(selection_overrides[0].item_ref.stable_id, "tool-optional"); - assert!(selection_overrides[0].selected); - let direct_text = external_application_review_text(&state).unwrap(); - assert!(direct_text.contains("Enable: /extensions review allow")); - assert!(direct_text.contains("Keep disabled: /extensions review deny")); - } - - #[test] - fn review_navigation_binds_cursors_and_rejects_stale_pages() { - let mut state = ExternalApplicationUiState::default(); - state - .replace_snapshot(snapshot(ExternalApplicationHostCapabilitiesV2::read_write())) - .unwrap(); - state - .replace_review_page(page(None, Some("page-2")), ExternalReviewNavigation::Open) - .unwrap(); - - let (next, navigation) = state - .review_page_request(ExternalReviewDirection::Next) - .unwrap(); - assert_eq!(next.cursor.as_deref(), Some("page-2")); - state - .replace_review_page(page(Some("page-2"), None), navigation) - .unwrap(); - let (previous, navigation) = state - .review_page_request(ExternalReviewDirection::Previous) - .unwrap(); - assert_eq!(previous.cursor, None); - state - .replace_review_page(page(None, Some("page-2")), navigation) - .unwrap(); - - let mut stale = page(None, None); - stale.preference_revision += 1; - assert!(state - .replace_review_page(stale, ExternalReviewNavigation::Open) - .unwrap_err() - .contains("stale")); - - let (next, navigation) = state - .review_page_request(ExternalReviewDirection::Next) - .unwrap(); - let mut stale_generation = page(next.cursor.as_deref(), None); - stale_generation.expected_generations[0].generation += 1; - assert!(state - .replace_review_page(stale_generation, navigation) - .unwrap_err() - .contains("stale")); - } - - #[test] - fn opening_review_accepts_the_hosts_current_read_only_plan() { - let mut state = ExternalApplicationUiState::default(); - state - .replace_snapshot(snapshot(ExternalApplicationHostCapabilitiesV2::read_write())) - .unwrap(); - let mut current = page(None, None); - current.review_id = "review-current".to_string(); - current.expected_generations[0].generation += 1; - - state - .replace_review_page(current, ExternalReviewNavigation::Open) - .unwrap(); - assert_eq!( - state.review.as_ref().unwrap().page.review_id, - "review-current" - ); - } - - #[test] - fn review_commands_resolve_current_page_numbers_and_batch_decisions() { - let mut state = ExternalApplicationUiState::default(); - state - .replace_snapshot(snapshot(ExternalApplicationHostCapabilitiesV2::read_write())) - .unwrap(); - state - .replace_review_page(page(None, None), ExternalReviewNavigation::Open) - .unwrap(); - - assert_eq!( - parse_external_application_action("review include 2", &state).unwrap(), - ExternalApplicationUiAction::SetReviewItem { - item_ref: ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: "tool-optional".to_string(), - }, - selected: true, - } - ); - assert_eq!( - parse_external_application_action("review exclude 1", &state).unwrap(), - ExternalApplicationUiAction::SetReviewItem { - item_ref: ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: "tool-recommended".to_string(), - }, - selected: false, - } - ); - assert_eq!( - parse_external_application_action("review apply", &state).unwrap(), - ExternalApplicationUiAction::SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2::Recommended, - immediate_selection: None, - } - ); - assert_eq!( - parse_external_application_action("review deny", &state).unwrap(), - ExternalApplicationUiAction::SubmitReview { - baseline: ExternalApplicationReviewSelectionBaselineV2::None, - immediate_selection: None, - } - ); - } -} - fn parse_external_agent_review_action( arguments: &str, current_snapshot: Option<&ExternalSourceCatalogSnapshot>, diff --git a/src/apps/cli/src/modes/chat/external_sources.rs b/src/apps/cli/src/modes/chat/external_sources.rs index 385f0bb45d..b98017f2f1 100644 --- a/src/apps/cli/src/modes/chat/external_sources.rs +++ b/src/apps/cli/src/modes/chat/external_sources.rs @@ -376,267 +376,6 @@ impl ChatMode { } fn handle_external_control( - &mut self, - arguments: &str, - chat_view: &mut ChatView, - chat_state: &ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let legacy_command = arguments.split_whitespace().next().is_some_and(|command| { - command.eq_ignore_ascii_case("safe-mode") || command.eq_ignore_ascii_case("source") - }); - if !legacy_command { - self.handle_external_application(arguments, chat_view, rt_handle); - return; - } - self.handle_legacy_external_control(arguments, chat_view, chat_state, rt_handle); - } - - fn handle_external_application( - &mut self, - arguments: &str, - chat_view: &mut ChatView, - rt_handle: &tokio::runtime::Handle, - ) { - let action = - match parse_external_application_action(arguments, &self.external_application_ui) { - Ok(action) => action, - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - }; - if self.external_application_ui.pending_rx.is_some() { - chat_view.set_status(Some( - "An external application update is already running; input remains available." - .to_string(), - )); - return; - } - - if let ExternalApplicationUiAction::SetReviewItem { item_ref, selected } = &action { - let index = self - .external_application_ui - .review - .as_ref() - .and_then(|review| { - review - .page - .items - .iter() - .position(|item| item.item_ref == *item_ref) - }); - let result = index - .ok_or_else(|| "That review item is stale; reopen /extensions review.".to_string()) - .and_then(|index| { - self.external_application_ui - .set_review_item_selected(index, *selected) - }); - match result - .and_then(|()| external_application_review_text(&self.external_application_ui)) - { - Ok(text) => { - chat_view.show_info_popup(text); - chat_view.set_status(Some( - "Review selection updated; run /extensions review apply to use it." - .to_string(), - )); - } - Err(error) => chat_view.set_status(Some(error)), - } - return; - } - let pending = match &action { - ExternalApplicationUiAction::Show => ExternalApplicationPendingRequest::Snapshot { - force_refresh: false, - }, - ExternalApplicationUiAction::Refresh => ExternalApplicationPendingRequest::Snapshot { - force_refresh: true, - }, - ExternalApplicationUiAction::OpenReview => { - let request = match self.external_application_ui.open_review_page_request() { - Ok(request) => request, - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - }; - ExternalApplicationPendingRequest::ReviewPage { - request, - navigation: ExternalReviewNavigation::Open, - } - } - ExternalApplicationUiAction::ReviewNext => { - let (request, navigation) = match self - .external_application_ui - .review_page_request(ExternalReviewDirection::Next) - { - Ok(request) => request, - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - }; - ExternalApplicationPendingRequest::ReviewPage { - request, - navigation, - } - } - ExternalApplicationUiAction::ReviewPrevious => { - let (request, navigation) = match self - .external_application_ui - .review_page_request(ExternalReviewDirection::Previous) - { - Ok(request) => request, - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - }; - ExternalApplicationPendingRequest::ReviewPage { - request, - navigation, - } - } - ExternalApplicationUiAction::ConnectApplication { application_id } => { - let request = self.external_application_ui.control_request( - &format!("tui-{}", uuid::Uuid::new_v4()), - ExternalApplicationControlActionV2::ConnectApplication { - application_id: application_id.clone(), - }, - ); - match request { - Ok(request) => ExternalApplicationPendingRequest::Mutation(request), - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - } - } - ExternalApplicationUiAction::DisconnectApplication { application_id } => { - let request = self.external_application_ui.control_request( - &format!("tui-{}", uuid::Uuid::new_v4()), - ExternalApplicationControlActionV2::DisconnectApplication { - application_id: application_id.clone(), - }, - ); - match request { - Ok(request) => ExternalApplicationPendingRequest::Mutation(request), - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - } - } - ExternalApplicationUiAction::DeferApplication { application_id } => { - let request = self.external_application_ui.control_request( - &format!("tui-{}", uuid::Uuid::new_v4()), - ExternalApplicationControlActionV2::SetApplicationDeferred { - application_id: application_id.clone(), - }, - ); - match request { - Ok(request) => ExternalApplicationPendingRequest::Mutation(request), - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - } - } - ExternalApplicationUiAction::SubmitReview { - baseline, - immediate_selection, - } => { - match self.external_application_ui.review_submit_request( - &format!("tui-{}", uuid::Uuid::new_v4()), - *baseline, - immediate_selection - .as_ref() - .map(|(item_ref, selected)| (item_ref, *selected)), - ) { - Ok(request) => ExternalApplicationPendingRequest::Mutation(request), - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - } - } - ExternalApplicationUiAction::SetReviewItem { .. } => unreachable!(), - }; - - let agent = self.agent.clone(); - let shared = agent.is_shared(); - let task_action = action.clone(); - let (sender, receiver) = mpsc::channel(); - rt_handle.spawn(async move { - let result = match pending { - ExternalApplicationPendingRequest::Snapshot { force_refresh } => { - match agent.external_application_snapshot_v2(force_refresh).await { - Ok(snapshot) => Ok(ExternalApplicationAsyncResult::Snapshot(snapshot)), - Err(error) if should_fallback_to_legacy_external_status(shared, &error) => { - agent - .external_source_snapshot(force_refresh) - .await - .map(ExternalApplicationAsyncResult::LegacySnapshot) - } - Err(error) => Err(error), - } - } - ExternalApplicationPendingRequest::ReviewPage { - request, - navigation, - } => agent - .external_application_review_page_v2(request) - .await - .map(|page| ExternalApplicationAsyncResult::ReviewPage { page, navigation }), - ExternalApplicationPendingRequest::Mutation(request) => { - let result = agent.apply_external_application_action_v2(request).await; - match result { - Ok(result) => { - agent - .external_application_snapshot_v2(false) - .await - .map(|snapshot| ExternalApplicationAsyncResult::Mutation { - result, - snapshot, - }) - } - Err(error) => Err(error), - } - } - }; - let _ = sender.send(ExternalApplicationMutationResult { - action: task_action, - result, - }); - }); - self.external_application_ui.pending_rx = Some(receiver); - let status = match action { - ExternalApplicationUiAction::Show => "Reading external applications", - ExternalApplicationUiAction::Refresh => "Refreshing external applications", - ExternalApplicationUiAction::OpenReview - | ExternalApplicationUiAction::ReviewNext - | ExternalApplicationUiAction::ReviewPrevious => "Reading external application review", - ExternalApplicationUiAction::ConnectApplication { .. } => { - "Connecting external application" - } - ExternalApplicationUiAction::DisconnectApplication { .. } => { - "Disconnecting external application" - } - ExternalApplicationUiAction::DeferApplication { .. } => { - "Deferring external application decision" - } - ExternalApplicationUiAction::SubmitReview { .. } => { - "Applying external application review" - } - ExternalApplicationUiAction::SetReviewItem { .. } => unreachable!(), - }; - chat_view.set_status(Some(format!( - "{status}; you can continue typing or cancel other UI work" - ))); - } - - fn handle_legacy_external_control( &mut self, arguments: &str, chat_view: &mut ChatView, @@ -652,14 +391,40 @@ impl ChatMode { }; if self.external_control_mutation_rx.is_some() { chat_view.set_status(Some( - "An external integration update is already running; input remains available." - .to_string(), + "An extension update is already running; input remains available.".to_string(), )); return; } + let source_selection = match &action { + ExternalControlUiAction::SetSourceEnabled { + source_index, + enabled, + } => { + let Some(control) = self.external_control_snapshot.as_ref() else { + chat_view.set_status(Some( + "Open /extensions before changing an extension.".to_string(), + )); + return; + }; + if !control.host_capabilities.can_manage_sources { + chat_view.set_status(Some( + "This connection can only show extension status.".to_string(), + )); + return; + } + let Some(source) = control.sources.get(*source_index) else { + chat_view.set_status(Some( + "That extension is no longer listed. Run /extensions refresh.".to_string(), + )); + return; + }; + Some((source.stable_key.clone(), *enabled)) + } + _ => None, + }; let expected_preference_revision = self - .external_source_snapshot + .external_control_snapshot .as_ref() .map(|snapshot| snapshot.preference_revision); let task_action = action.clone(); @@ -681,13 +446,15 @@ impl ChatMode { ExternalControlUiAction::SetSafeMode(enabled) => { ExternalSourceControlActionV1::SetSafeMode { enabled: *enabled } } - ExternalControlUiAction::SetSourceEnabled { - source_key, - enabled, - } => ExternalSourceControlActionV1::SetSourceEnabled { - source_key: source_key.clone(), - enabled: *enabled, - }, + ExternalControlUiAction::SetSourceEnabled { .. } => { + let (source_key, enabled) = source_selection + .clone() + .expect("source selection was resolved before spawning"); + ExternalSourceControlActionV1::SetSourceEnabled { + source_key, + enabled, + } + } ExternalControlUiAction::Show => unreachable!(), }; let response = agent @@ -712,15 +479,13 @@ impl ChatMode { }); self.external_control_mutation_rx = Some(receiver); let status = match action { - ExternalControlUiAction::Show => "Reading external integration status", - ExternalControlUiAction::Refresh => "Refreshing external integrations", - ExternalControlUiAction::SetSafeMode(true) => "Entering External Safe Mode", - ExternalControlUiAction::SetSafeMode(false) => "Exiting External Safe Mode", - ExternalControlUiAction::SetSourceEnabled { enabled: true, .. } => { - "Enabling external source" - } + ExternalControlUiAction::Show => "Reading extension status", + ExternalControlUiAction::Refresh => "Refreshing extensions", + ExternalControlUiAction::SetSafeMode(true) => "Pausing external access", + ExternalControlUiAction::SetSafeMode(false) => "Resuming external access", + ExternalControlUiAction::SetSourceEnabled { enabled: true, .. } => "Enabling extension", ExternalControlUiAction::SetSourceEnabled { enabled: false, .. } => { - "Disabling external source" + "Disabling extension" } }; chat_view.set_status(Some(format!( @@ -728,161 +493,14 @@ impl ChatMode { ))); } - fn poll_external_application_mutation(&mut self, chat_view: &mut ChatView) -> bool { - let outcome = match self - .external_application_ui - .pending_rx - .as_ref() - .map(Receiver::try_recv) - { - Some(Ok(outcome)) => outcome, - Some(Err(MpscTryRecvError::Empty)) | None => return false, - Some(Err(MpscTryRecvError::Disconnected)) => { - self.external_application_ui.pending_rx = None; - chat_view.set_status(Some( - "External application status stopped before returning a result; retry /extensions status." - .to_string(), - )); - return true; - } - }; - self.external_application_ui.pending_rx = None; - match outcome.result { - Ok(ExternalApplicationAsyncResult::Snapshot(snapshot)) => { - match self.external_application_ui.replace_snapshot(snapshot) { - Ok(()) => { - if let Ok(snapshot) = self.external_application_ui.snapshot() { - chat_view.show_info_popup(external_application_overview_text(snapshot)); - chat_view.set_status(Some( - if matches!(outcome.action, ExternalApplicationUiAction::Refresh) { - "External applications refreshed" - } else { - "External application status updated" - } - .to_string(), - )); - } - } - Err(error) => chat_view.set_status(Some(error)), - } - } - Ok(ExternalApplicationAsyncResult::LegacySnapshot(response)) => { - self.replace_external_conflict_preferences(response.preferences.into()); - self.update_external_source_view(chat_view, &response.snapshot); - self.external_source_snapshot = Some(response.snapshot); - self.external_application_ui.snapshot = None; - self.external_application_ui.review = None; - chat_view - .show_info_popup(external_control_read_only_review_text(&response.control)); - chat_view.set_status(Some( - "External application status is shown in read-only compatibility mode" - .to_string(), - )); - } - Ok(ExternalApplicationAsyncResult::ReviewPage { page, navigation }) => { - match self - .external_application_ui - .replace_review_page(page, navigation) - .and_then(|()| external_application_review_text(&self.external_application_ui)) - { - Ok(text) => { - chat_view.show_info_popup(text); - chat_view.set_status(Some( - "External application review page updated".to_string(), - )); - } - Err(error) => { - self.external_application_ui.review = None; - chat_view.set_status(Some(error)); - } - } - } - Ok(ExternalApplicationAsyncResult::Mutation { result, snapshot }) => { - if let Err(error) = result.validate() { - chat_view.set_status(Some(format!( - "The external application response was invalid: {error}" - ))); - return true; - } - let operation_outcome = result.outcome; - let partial = result - .item_results - .iter() - .any(|item| item.outcome != ExternalApplicationOperationOutcomeV2::Applied); - if let Err(error) = self.external_application_ui.replace_snapshot(snapshot) { - chat_view.set_status(Some(error)); - return true; - } - if let Ok(snapshot) = self.external_application_ui.snapshot() { - chat_view.show_info_popup(external_application_overview_text(snapshot)); - } - let status = match operation_outcome { - ExternalApplicationOperationOutcomeV2::Applied if partial => { - "External application changes were partially applied; review the refreshed status" - } - ExternalApplicationOperationOutcomeV2::Applied => match outcome.action { - ExternalApplicationUiAction::ConnectApplication { .. } => { - "External application connected" - } - ExternalApplicationUiAction::DisconnectApplication { .. } => { - "External application disconnected" - } - ExternalApplicationUiAction::DeferApplication { .. } => { - "External application decision deferred" - } - ExternalApplicationUiAction::SubmitReview { .. } => { - "External application review applied" - } - _ => "External application change applied", - }, - ExternalApplicationOperationOutcomeV2::Stale => { - "Nothing was applied because the external application data changed; review the refreshed status" - } - ExternalApplicationOperationOutcomeV2::Blocked => { - "External application change was blocked; review the refreshed status" - } - ExternalApplicationOperationOutcomeV2::Rejected => { - "External application change was rejected; review the refreshed status" - } - ExternalApplicationOperationOutcomeV2::Failed => { - "External application change failed; review the refreshed status" - } - }; - chat_view.set_status(Some(status.to_string())); - } - Err(error) => { - if matches!( - error.code, - ExternalSourceOperationErrorCode::HostCapabilityUnavailable - | ExternalSourceOperationErrorCode::Unsupported - | ExternalSourceOperationErrorCode::IncompatibleVersion - ) { - self.external_application_ui.snapshot = None; - self.external_application_ui.review = None; - } else if matches!(error.code, ExternalSourceOperationErrorCode::StaleRevision) { - self.external_application_ui.review = None; - } - tracing::warn!( - error_code = error.code.as_str(), - correlation_id = error.correlation_id.as_deref().unwrap_or("none"), - operation_stage = ?error.stage, - "External application action failed" - ); - chat_view.set_status(Some(external_operation_error_status("extensions", &error))); - } - } - true - } - fn poll_external_control_mutation(&mut self, chat_view: &mut ChatView) -> bool { - let application_changed = self.poll_external_application_mutation(chat_view); let outcome = match self .external_control_mutation_rx .as_ref() .map(Receiver::try_recv) { Some(Ok(outcome)) => outcome, - Some(Err(MpscTryRecvError::Empty)) | None => return application_changed, + Some(Err(MpscTryRecvError::Empty)) | None => return false, Some(Err(MpscTryRecvError::Disconnected)) => { self.external_control_mutation_rx = None; chat_view.set_status(Some( @@ -902,19 +520,18 @@ impl ChatMode { self.update_external_source_view(chat_view, &catalog); self.external_source_snapshot = Some(catalog); } - chat_view.show_info_popup(external_control_review_text(&control)); + chat_view.show_info_popup(external_control_status_text(&control)); + self.external_control_snapshot = Some(control); let status = match outcome.action { - ExternalControlUiAction::Show => "External integration status updated", - ExternalControlUiAction::Refresh => "External integrations refreshed", - ExternalControlUiAction::SetSafeMode(true) => "External Safe Mode is active", - ExternalControlUiAction::SetSafeMode(false) => { - "External Safe Mode is off; eligible integrations were reconciled" - } + ExternalControlUiAction::Show => "Extension status updated", + ExternalControlUiAction::Refresh => "Extensions refreshed", + ExternalControlUiAction::SetSafeMode(true) => "External access is paused", + ExternalControlUiAction::SetSafeMode(false) => "External access resumed", ExternalControlUiAction::SetSourceEnabled { enabled: true, .. } => { - "External source enabled" + "Extension enabled" } ExternalControlUiAction::SetSourceEnabled { enabled: false, .. } => { - "External source disabled" + "Extension disabled" } }; chat_view.set_status(Some(status.to_string())); diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index 3e6e4f0d04..966650120d 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -11,8 +11,7 @@ mod tests { command_route, consume_selected_native_command_once, context_compression_tool_event, extension_command_help_request, external_agent_attention, external_agent_diagnostic_lines, external_agent_pending_notice_key, external_agent_result_is_stale, - external_agent_review_text, external_command_projections, - external_control_read_only_review_text, external_control_review_text, + external_agent_review_text, external_command_projections, external_control_status_text, external_hook_help_text, external_integration_policy_lines, external_operation_error_status, external_tool_mutation_result_label, external_tool_pending_notice_key, external_tool_result_is_stale, external_tool_review_text, @@ -164,21 +163,25 @@ mod tests { ExternalControlUiAction::SetSafeMode(false) ); assert_eq!( - parse_external_control_action("source disable opencode.commands:project").unwrap(), + parse_external_control_action("disable 1").unwrap(), ExternalControlUiAction::SetSourceEnabled { - source_key: "opencode.commands:project".to_string(), + source_index: 0, enabled: false, } ); assert_eq!( - parse_external_control_action("source enable opencode.commands:project").unwrap(), + parse_external_control_action("enable 2").unwrap(), ExternalControlUiAction::SetSourceEnabled { - source_key: "opencode.commands:project".to_string(), + source_index: 1, enabled: true, } ); assert!(parse_external_control_action("safe-mode toggle").is_err()); assert!(parse_external_control_action("enable-everything").is_err()); + assert!(parse_external_control_action("review").is_err()); + let usage = parse_external_control_action("unknown").unwrap_err(); + assert!(!usage.contains("safe-mode")); + assert!(!usage.contains("review")); } #[test] @@ -224,23 +227,36 @@ mod tests { })) .unwrap(); - let text = external_control_review_text(&control); - assert!(text.contains("Safe Mode: on")); - assert!(text.contains("Generation: 9")); - assert!(text.contains("Execution domain: local-user")); - assert!(text.contains("New external Tool, Agent, and MCP calls are blocked")); - assert!(text.contains("restarting the Host turns it off")); - assert!(text.contains("Source opencode.commands:project")); - assert!(text.contains("source disable ")); - assert!(text.contains("Tools: 2 items, 1 review, 0 conflicts, inactive")); - assert!(text.contains("/extensions safe-mode off")); + let text = external_control_status_text(&control); + assert!(text.contains("Extensions")); + assert!(text.contains("1. OpenCode project commands - Available")); + assert!(text.contains("Disable: /extensions disable 1")); + assert!(text.contains("Refresh: /extensions refresh")); + assert!(text.contains("External access is paused. Resume: /extensions safe-mode off")); + for hidden in [ + "Generation", + "Execution domain", + "opencode.commands:project", + "review", + "items", + "conflicts", + "", + ] { + assert!(!text.contains(hidden), "leaked {hidden}:\n{text}"); + } + + let mut read_only = control.clone(); + read_only.host_capabilities.can_manage_sources = false; + let read_only_text = external_control_status_text(&read_only); + assert!(read_only_text.contains("This connection can only show extension status.")); + assert!(!read_only_text.contains("/extensions disable 1")); - let read_only = external_control_read_only_review_text(&control); - assert!(read_only.contains("Read-only compatibility status")); - assert!(read_only.contains("/extensions refresh")); - assert!(!read_only.contains("/extensions safe-mode")); - assert!(!read_only.contains("source enable ")); - assert!(!read_only.contains("source disable ")); + let mut permission_needed = control.clone(); + permission_needed.sources[0].effective_status = + bitfun_product_domains::external_source_control::ExternalSourceEffectiveStatus::ReviewRequired; + let permission_text = external_control_status_text(&permission_needed); + assert!(permission_text.contains("Needs permission")); + assert!(permission_text.contains("Manage permissions: /tools, /agent, /mcp, or /hooks")); } #[test] @@ -282,11 +298,11 @@ mod tests { })) .unwrap(); - let text = external_control_review_text(&control); - assert!(text.contains("Tools: 0 items, 0 review, 0 conflicts, inactive, support: partial")); - assert!(text.contains("Issues")); - assert!(text.contains("[external_tool.runtime_unavailable]")); - assert!(text.contains("Recovery")); + let text = external_control_status_text(&control); + assert!(text.contains("No extensions found.")); + assert!(!text.contains("External access is paused")); + assert!(text.contains("Needs attention")); + assert!(!text.contains("external_tool.runtime_unavailable")); assert!(text.contains("/extensions refresh")); assert!(text.contains("install or repair the required runtime")); } diff --git a/src/apps/cli/src/peer_host/bootstrap.rs b/src/apps/cli/src/peer_host/bootstrap.rs index 81856d809a..d073e01491 100644 --- a/src/apps/cli/src/peer_host/bootstrap.rs +++ b/src/apps/cli/src/peer_host/bootstrap.rs @@ -40,6 +40,8 @@ pub(crate) async fn ensure_peer_host_ready(runtime: &CliRuntimeContext) -> Resul agent_runtime: runtime.agent_runtime().clone(), local_workspace_snapshot: runtime.local_workspace_snapshot().clone(), compatibility: runtime.compatibility().clone(), + account_runtime: runtime.account_runtime().clone(), + account_routing: runtime.account_routing().clone(), turns: PeerTurnTracker::new(), workspace_service, filesystem_service, diff --git a/src/apps/cli/src/peer_host/commands/config.rs b/src/apps/cli/src/peer_host/commands/config.rs index a5b82f2a20..f771ccbd48 100644 --- a/src/apps/cli/src/peer_host/commands/config.rs +++ b/src/apps/cli/src/peer_host/commands/config.rs @@ -8,6 +8,7 @@ use bitfun_core::service::config::get_global_config_service; use bitfun_core::util::errors::BitFunError; use crate::peer_host::args::{optional_bool, request_value}; +use crate::peer_host::state::PeerHostState; fn is_expected_config_path_not_found(error: &BitFunError, path: Option<&str>) -> bool { match (error, path) { @@ -86,7 +87,7 @@ pub(crate) async fn get_configs(args: &Value) -> Result { Ok(json!(configs)) } -pub(crate) async fn set_config(args: &Value) -> Result { +pub(crate) async fn set_config(state: &PeerHostState, args: &Value) -> Result { let request = request_value(args); let path = request .get("path") @@ -110,7 +111,7 @@ pub(crate) async fn set_config(args: &Value) -> Result { // Config changed on this host via a peer controller — schedule the cloud // push so other same-account devices converge. - crate::account_sync::notify_local_settings_changed(); + state.account_runtime.notify_local_settings_changed(); Ok(json!("Configuration set successfully")) } diff --git a/src/apps/cli/src/peer_host/commands/external_sources.rs b/src/apps/cli/src/peer_host/commands/external_sources.rs index fb0f422184..07c9ff545a 100644 --- a/src/apps/cli/src/peer_host/commands/external_sources.rs +++ b/src/apps/cli/src/peer_host/commands/external_sources.rs @@ -3,9 +3,8 @@ use std::path::PathBuf; use bitfun_core::external_sources::{ - apply_external_application_action_v2, apply_external_source_control_action, - choose_external_mcp_conflict, choose_external_subagent_conflict, external_source_snapshot, - get_external_application_review_page_v2, get_external_application_snapshot_v2, + apply_external_source_control_action, choose_external_mcp_conflict, + choose_external_subagent_conflict, external_source_snapshot, get_external_source_control_snapshot, set_external_mcp_server_decision, set_external_prompt_command_conflict_choice, set_external_source_enabled, set_external_subagent_activation, set_external_subagent_model_binding, @@ -15,38 +14,11 @@ use bitfun_core::external_sources::{ ExternalSourceOperationErrorCode, ExternalSourceOperationResult, ExternalSourcePublicSnapshot, ExternalSubagentModelBindingTarget, }; -use bitfun_product_domains::external_source_control::{ - ExternalApplicationControlRequestV2, ExternalApplicationHostCapabilitiesV2, - ExternalApplicationReviewPageRequestV2, -}; use serde_json::Value; use crate::peer_host::args::request_value; use crate::peer_host::state::PeerHostState; -pub(super) fn supports(command: &str) -> bool { - matches!( - command, - "get_external_source_snapshot" - | "get_external_source_control_snapshot" - | "reveal_external_source_location" - | "apply_external_source_control_action_command" - | "set_external_source_enabled_command" - | "set_external_source_conflict_choice_command" - | "set_external_tool_target_decision_command" - | "set_external_tool_conflict_choice_command" - | "set_external_subagent_activation_command" - | "set_external_subagent_model_binding_command" - | "choose_external_subagent_conflict_command" - | "set_external_mcp_server_decision_command" - | "choose_external_mcp_conflict_command" - | "update_external_integration_policy_command" - | "get_external_application_snapshot_v2" - | "get_external_application_review_page_v2" - | "apply_external_application_action_v2" - ) -} - fn required_bool(request: &Value, key: &str) -> ExternalSourceOperationResult { optional_bool_field(request, key)?.ok_or_else(|| { ExternalSourceOperationError::invalid_request(format!("Missing or invalid '{key}'")) @@ -161,32 +133,6 @@ fn public_snapshot( }) } -fn application_response(response: impl serde::Serialize) -> ExternalSourceOperationResult { - serde_json::to_value(response).map_err(|_| { - ExternalSourceOperationError::new( - ExternalSourceOperationErrorCode::Internal, - "External application response could not be encoded", - false, - ) - }) -} - -fn domain_request( - request: &Value, -) -> ExternalSourceOperationResult { - request - .get("request") - .cloned() - .ok_or_else(|| ExternalSourceOperationError::invalid_request("Missing request")) - .and_then(|request| { - serde_json::from_value(request).map_err(|_| { - ExternalSourceOperationError::invalid_request( - "Invalid external application request", - ) - }) - }) -} - pub(crate) async fn dispatch( command: &str, args: &Value, @@ -210,36 +156,6 @@ async fn dispatch_inner( let request = request_value(args); let workspace = workspace_root(state, request).await?; let workspace = workspace.as_deref(); - if command == "get_external_application_snapshot_v2" { - let snapshot = get_external_application_snapshot_v2( - workspace, - optional_bool_field(request, "forceRefresh")?.unwrap_or(false), - ExternalApplicationHostCapabilitiesV2::read_write(), - ) - .await - .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error)?; - return application_response(snapshot); - } - if command == "get_external_application_review_page_v2" { - let page_request: ExternalApplicationReviewPageRequestV2 = domain_request(request)?; - page_request - .validate() - .map_err(ExternalSourceOperationError::invalid_request)?; - let page = get_external_application_review_page_v2(workspace, page_request) - .await - .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error)?; - return application_response(page); - } - if command == "apply_external_application_action_v2" { - let action_request: ExternalApplicationControlRequestV2 = domain_request(request)?; - action_request - .validate() - .map_err(ExternalSourceOperationError::invalid_request)?; - let result = apply_external_application_action_v2(workspace, action_request) - .await - .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error)?; - return application_response(result); - } if command == "get_external_source_control_snapshot" { let snapshot = get_external_source_control_snapshot( workspace, @@ -403,19 +319,6 @@ mod tests { use super::*; use bitfun_core::external_sources::ExternalSourceControlActionV1; - #[test] - fn peer_external_source_router_recognizes_v1_and_v2_without_catching_unrelated_commands() { - for command in [ - "get_external_source_snapshot", - "get_external_application_snapshot_v2", - "get_external_application_review_page_v2", - "apply_external_application_action_v2", - ] { - assert!(supports(command), "missing {command}"); - } - assert!(!supports("get_config")); - } - #[test] fn optional_host_fields_reject_wrong_types() { let request = serde_json::json!({ diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index 4921847a41..5f45ea6875 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -36,10 +36,23 @@ pub(crate) async fn dispatch( "reload_config" => workspace::reload_config().await, "get_config" => config::get_config(args).await, "get_configs" => config::get_configs(args).await, - "set_config" => config::set_config(args).await, + "set_config" => config::set_config(state, args).await, "get_agent_profile_config" => config::get_agent_profile_config(args).await, "get_agent_profile_configs" => config::get_agent_profile_configs().await, - command if external_sources::supports(command) => { + "get_external_source_snapshot" + | "get_external_source_control_snapshot" + | "reveal_external_source_location" + | "apply_external_source_control_action_command" + | "set_external_source_enabled_command" + | "set_external_source_conflict_choice_command" + | "set_external_tool_target_decision_command" + | "set_external_tool_conflict_choice_command" + | "set_external_subagent_activation_command" + | "set_external_subagent_model_binding_command" + | "choose_external_subagent_conflict_command" + | "set_external_mcp_server_decision_command" + | "choose_external_mcp_conflict_command" + | "update_external_integration_policy_command" => { external_sources::dispatch(command, args, state).await } diff --git a/src/apps/cli/src/peer_host/fanout.rs b/src/apps/cli/src/peer_host/fanout.rs index 3fa826358e..d1a15b76e1 100644 --- a/src/apps/cli/src/peer_host/fanout.rs +++ b/src/apps/cli/src/peer_host/fanout.rs @@ -408,7 +408,9 @@ async fn handle_agentic_event(state: &PeerHostState, event: AgenticEvent) -> Res return Err("no attached Peer controller can receive Agent events".to_string()); } let generation = state.turns.current_event_stream_generation()?; - let owner = crate::account::capture_peer_fanout_owner() + let owner = state + .account_routing + .capture_peer_fanout_owner() .await .map_err(|error| format!("Peer event routing owner unavailable: {error}"))?; enqueue_peer_device_event( @@ -546,8 +548,14 @@ pub(crate) async fn fanout_peer_device_event(event: String, payload: serde_json: let inherits_routing_lease = inherited_owner.is_some(); let owner = match inherited_owner { Some(owner) => owner, - None => match crate::account::capture_peer_fanout_owner().await { - Ok(owner) => owner, + None => match super::state::peer_host_state().map(|state| state.account_routing.clone()) { + Ok(routing) => match routing.capture_peer_fanout_owner().await { + Ok(owner) => owner, + Err(error) => { + tracing::debug!("Peer event fanout skipped before enqueue: {error}"); + return; + } + }, Err(error) => { tracing::debug!("Peer event fanout skipped before enqueue: {error}"); return; diff --git a/src/apps/cli/src/peer_host/state.rs b/src/apps/cli/src/peer_host/state.rs index 25209d6b23..8e0cad1f11 100644 --- a/src/apps/cli/src/peer_host/state.rs +++ b/src/apps/cli/src/peer_host/state.rs @@ -901,6 +901,9 @@ pub(crate) struct PeerHostState { pub(crate) agent_runtime: AgentRuntime, pub(crate) local_workspace_snapshot: Arc, pub(crate) compatibility: CoreAgentRuntimeCompatibility, + pub(crate) account_runtime: + Arc, + pub(crate) account_routing: Arc, pub(crate) turns: PeerTurnTracker, pub(crate) workspace_service: Arc, pub(crate) filesystem_service: Arc, diff --git a/src/apps/cli/src/runtime/mod.rs b/src/apps/cli/src/runtime/mod.rs index 05bc062cc4..e33ce328cc 100644 --- a/src/apps/cli/src/runtime/mod.rs +++ b/src/apps/cli/src/runtime/mod.rs @@ -10,9 +10,11 @@ use bitfun_core::product_runtime::{ CoreLocalWorkspaceSnapshot, CoreProductAgentRuntime, CoreProductEventQueueOwner, }; use bitfun_core::runtime_ports::PluginRuntimeAvailability; +use bitfun_core::service::remote_connect::account_runtime::AccountRuntime; use bitfun_runtime_ports::LocalWorkspaceSnapshotPort; use bitfun_runtime_services::RuntimeServices; +use crate::account::{build_account_runtime, CliAccountRoutingHost}; use crate::product_assembly::{assemble_acp_runtime_parts, assemble_cli_runtime_parts}; pub(crate) mod approval; @@ -53,6 +55,8 @@ pub(crate) struct CliRuntimeContext { agent_runtime: AgentRuntime, local_workspace_snapshot: Arc, compatibility: CoreAgentRuntimeCompatibility, + account_runtime: Arc, + account_routing: Arc, _agent_event_queue_owner: CoreProductEventQueueOwner, services: RuntimeServices, product: CliProductRuntimeState, @@ -97,6 +101,7 @@ impl CliRuntimeContext { .context("Failed to build CLI Agent Runtime SDK")?; let compatibility = CoreAgentRuntimeCompatibility::build(agentic_system.coordinator.clone(), scheduler); + let account = build_account_runtime(compatibility.clone()); let local_workspace_snapshot = CoreLocalWorkspaceSnapshot::build(); debug_assert_eq!( @@ -114,6 +119,8 @@ impl CliRuntimeContext { agent_runtime, local_workspace_snapshot, compatibility, + account_runtime: account.runtime, + account_routing: account.routing, services, product, approval_policy, @@ -136,6 +143,14 @@ impl CliRuntimeContext { &self.compatibility } + pub(crate) fn account_runtime(&self) -> &Arc { + &self.account_runtime + } + + pub(crate) fn account_routing(&self) -> &Arc { + &self.account_routing + } + pub(crate) fn local_workspace_snapshot(&self) -> &Arc { &self.local_workspace_snapshot } diff --git a/src/apps/cli/src/tui_account_management.rs b/src/apps/cli/src/tui_account_management.rs deleted file mode 100644 index af7ad1bcda..0000000000 --- a/src/apps/cli/src/tui_account_management.rs +++ /dev/null @@ -1,248 +0,0 @@ -use std::path::PathBuf; - -use async_trait::async_trait; -use bitfun_app_server::management::{ - AccountManagementHost, AppManagementError, AppManagementResult, -}; -use bitfun_app_server_protocol::account::*; -use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; - -#[derive(Clone)] -pub(crate) struct CliAccountManagementHost { - compatibility: CoreAgentRuntimeCompatibility, -} - -impl CliAccountManagementHost { - pub(crate) fn new(compatibility: CoreAgentRuntimeCompatibility) -> Self { - Self { compatibility } - } - - async fn snapshot(&self, workspace_path: String) -> AccountSnapshotResponse { - let logged_in = crate::account::is_logged_in().await; - let info = if logged_in { - crate::account::account_info() - .await - .ok() - .map(project_account_info) - } else { - None - }; - let devices = if logged_in { - crate::account::list_devices() - .await - .unwrap_or_default() - .into_iter() - .map(project_account_device) - .collect() - } else { - Vec::new() - }; - let _ = workspace_path; - AccountSnapshotResponse { - logged_in, - pending_sync_choice: crate::account::pending_sync_choice(), - info, - devices, - sync: project_sync_progress(crate::account_sync::current_sync_progress().await), - } - } -} - -#[async_trait] -impl AccountManagementHost for CliAccountManagementHost { - async fn account_snapshot( - &self, - request: AccountSnapshotRequest, - ) -> AppManagementResult { - Ok(self.snapshot(request.workspace_path).await) - } - - async fn account_login( - &self, - request: AccountLoginRequest, - ) -> AppManagementResult { - validate_operation_id(&request.operation_id)?; - let result = crate::account::login_with_credentials( - &request.relay_url, - &request.username, - &request.password, - ) - .await - .map_err(|error| account_error(error, &request))?; - Ok(AccountLoginResponse { - user_id: result.user_id, - relay_url: result.relay_url, - has_cloud_settings: result.has_cloud_settings, - status_message: result.status_message, - }) - } - - async fn account_finalize_login( - &self, - request: AccountFinalizeLoginRequest, - ) -> AppManagementResult { - validate_operation_id(&request.operation_id)?; - crate::account::finalize_login_after_sync_choice() - .await - .map_err(internal_account_error)?; - if !crate::account_sync::start_auto_sync_background( - self.compatibility.clone(), - request.operation_id.clone(), - request.choice == AccountSyncChoice::Local, - PathBuf::from(&request.workspace_path), - ) - .await - { - return Err(AppManagementError::invalid_request( - "Account settings sync is already in progress", - )); - } - Ok(self.snapshot(request.workspace_path).await) - } - - async fn account_logout( - &self, - request: AccountLogoutRequest, - ) -> AppManagementResult { - validate_operation_id(&request.operation_id)?; - crate::account::logout() - .await - .map_err(internal_account_error)?; - crate::account_sync::mark_sync_cancelled(request.operation_id).await; - Ok(self.snapshot(request.workspace_path).await) - } - - async fn settings_sync_start( - &self, - request: SettingsSyncStartRequest, - ) -> AppManagementResult { - validate_operation_id(&request.operation_id)?; - if !crate::account::is_logged_in().await { - return Err(AppManagementError::invalid_request( - "Account login must be finalized before settings sync starts", - )); - } - if !crate::account_sync::start_auto_sync_background( - self.compatibility.clone(), - request.operation_id, - request.is_first_login, - PathBuf::from(request.workspace_path), - ) - .await - { - return Err(AppManagementError::invalid_request( - "Account settings sync is already in progress", - )); - } - Ok(current_sync_response().await) - } - - async fn settings_sync_snapshot( - &self, - _request: SettingsSyncSnapshotRequest, - ) -> AppManagementResult { - Ok(current_sync_response().await) - } - - async fn settings_sync_cancel( - &self, - request: SettingsSyncCancelRequest, - ) -> AppManagementResult { - validate_operation_id(&request.operation_id)?; - crate::account::logout() - .await - .map_err(internal_account_error)?; - crate::account_sync::mark_sync_cancelled(request.operation_id).await; - Ok(current_sync_response().await) - } - - async fn settings_sync_local_changed( - &self, - request: SettingsSyncLocalChangedRequest, - ) -> AppManagementResult { - validate_operation_id(&request.operation_id)?; - crate::account_sync::notify_local_settings_changed(); - Ok(current_sync_response().await) - } -} - -async fn current_sync_response() -> SettingsSyncResponse { - SettingsSyncResponse { - progress: project_sync_progress(crate::account_sync::current_sync_progress().await), - } -} - -fn project_account_info(info: crate::account::AccountInfo) -> AccountInfo { - AccountInfo { - user_id: info.user_id, - relay_url: info.relay_url, - device_id: info.device_id, - device_name: info.device_name, - } -} - -fn project_account_device(device: crate::account::AccountDevice) -> AccountDevice { - AccountDevice { - device_id: device.device_id, - device_name: device.device_name, - online: device.online, - } -} - -fn project_sync_progress(progress: crate::account_sync::SyncProgress) -> SettingsSyncProgress { - SettingsSyncProgress { - operation_id: progress.operation_id, - status: match progress.status { - crate::account_sync::SyncStatus::Idle => SettingsSyncStatus::Idle, - crate::account_sync::SyncStatus::Syncing => SettingsSyncStatus::Syncing, - crate::account_sync::SyncStatus::Done => SettingsSyncStatus::Done, - crate::account_sync::SyncStatus::Failed => SettingsSyncStatus::Failed, - crate::account_sync::SyncStatus::Cancelled => SettingsSyncStatus::Cancelled, - }, - phase: progress.phase, - percent: progress.percent, - current: progress.current, - total: progress.total, - detail: progress.detail, - error: progress.error, - settings_synced: progress.settings_synced, - sessions_exported: progress.sessions_exported, - } -} - -fn validate_operation_id(operation_id: &str) -> AppManagementResult<()> { - let valid = !operation_id.trim().is_empty() - && operation_id.len() <= 128 - && operation_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')); - if valid { - Ok(()) - } else { - Err(AppManagementError::invalid_request( - "Account operation ID is invalid", - )) - } -} - -fn account_error(error: anyhow::Error, request: &AccountLoginRequest) -> AppManagementError { - let mut message = error.to_string(); - for secret in [&request.relay_url, &request.username, &request.password] { - if !secret.is_empty() { - message = message.replace(secret, ""); - } - } - AppManagementError::internal(bounded_error(message)) -} - -fn internal_account_error(error: anyhow::Error) -> AppManagementError { - AppManagementError::internal(bounded_error(error.to_string())) -} - -fn bounded_error(message: String) -> String { - message - .chars() - .filter(|character| !character.is_control()) - .take(500) - .collect() -} diff --git a/src/apps/cli/src/tui_backend.rs b/src/apps/cli/src/tui_backend.rs index e754f96111..4aa747bdc7 100644 --- a/src/apps/cli/src/tui_backend.rs +++ b/src/apps/cli/src/tui_backend.rs @@ -49,16 +49,6 @@ impl std::fmt::Display for TuiBackendError { impl std::error::Error for TuiBackendError {} -fn external_application_v2_unsupported() -> TuiBackendError { - TuiBackendError { - message: "External application V2 is unavailable on this TUI backend".to_string(), - outcome_unknown: false, - kind: TuiBackendErrorKind::Unsupported { - capability: "tui.externalApplicationsV2".to_string(), - }, - } -} - #[async_trait] #[allow(dead_code)] pub(crate) trait TuiBackend: Send + Sync { @@ -297,24 +287,6 @@ pub(crate) trait TuiBackend: Send + Sync { &self, request: ExternalSourceSnapshotRequest, ) -> Result; - async fn external_application_snapshot_v2( - &self, - _request: ExternalApplicationSnapshotRequestV2, - ) -> Result { - Err(external_application_v2_unsupported()) - } - async fn external_application_review_page_v2( - &self, - _request: ExternalApplicationReviewPageRequest, - ) -> Result { - Err(external_application_v2_unsupported()) - } - async fn apply_external_application_action_v2( - &self, - _request: ExternalApplicationActionRequest, - ) -> Result { - Err(external_application_v2_unsupported()) - } async fn external_source_control( &self, request: ExternalSourceControlRequest, @@ -781,34 +753,6 @@ impl TuiBackend for AppServerTuiBackend { map(self.client.external_source_snapshot(request).await) } - async fn external_application_snapshot_v2( - &self, - request: ExternalApplicationSnapshotRequestV2, - ) -> Result { - map(self.client.external_application_snapshot_v2(request).await) - } - - async fn external_application_review_page_v2( - &self, - request: ExternalApplicationReviewPageRequest, - ) -> Result { - map(self - .client - .external_application_review_page_v2(request) - .await) - } - - async fn apply_external_application_action_v2( - &self, - request: ExternalApplicationActionRequest, - ) -> Result { - map_client( - self.client - .apply_external_application_action_v2(request) - .await, - ) - } - async fn external_source_control( &self, request: ExternalSourceControlRequest, @@ -958,10 +902,7 @@ fn backend_error_from_data(message: String, data: AppServerErrorData) -> TuiBack #[cfg(test)] mod tests { - use super::{ - external_application_v2_unsupported, map_protocol_error, TuiBackendErrorKind, TuiEffect, - TuiEffectRoute, - }; + use super::{map_protocol_error, TuiBackendErrorKind, TuiEffect, TuiEffectRoute}; use bitfun_app_server_protocol::error::{AppServerErrorData, AppServerErrorKind}; use bitfun_app_server_protocol::external_source::ExternalSourceErrorData; use bitfun_product_domains::external_sources::{ @@ -982,18 +923,6 @@ mod tests { assert_ne!(TuiEffectRoute::AppServer, TuiEffectRoute::HostCapability); } - #[test] - fn unavailable_v2_backend_is_explicitly_read_only() { - let error = external_application_v2_unsupported(); - assert_eq!( - error.kind, - TuiBackendErrorKind::Unsupported { - capability: "tui.externalApplicationsV2".to_string() - } - ); - assert!(!error.outcome_unknown); - } - #[test] fn method_not_found_is_treated_as_an_unsupported_host_method() { let mapped = diff --git a/src/apps/cli/src/tui_worktree_management.rs b/src/apps/cli/src/tui_worktree_management.rs deleted file mode 100644 index 961943bcca..0000000000 --- a/src/apps/cli/src/tui_worktree_management.rs +++ /dev/null @@ -1,143 +0,0 @@ -use async_trait::async_trait; -use bitfun_app_server::management::{ - AppManagementError, AppManagementResult, WorktreeManagementHost, -}; -use bitfun_app_server_protocol::worktree::*; -use bitfun_core::service::git::GitService; -use bitfun_core::service::worktree::{WorktreeService, WorktreeSessionBindingRequest}; -use bitfun_core_types::{WorktreeError, WorktreeErrorCode}; -use bitfun_runtime_ports::AgentSessionWorkspaceBinding; - -#[derive(Clone, Default)] -pub(crate) struct CliWorktreeManagementHost; - -#[async_trait] -impl WorktreeManagementHost for CliWorktreeManagementHost { - async fn repository_status( - &self, - request: WorktreeRepositoryStatusRequest, - ) -> AppManagementResult { - if request.is_remote() { - return Err(worktree_error(WorktreeOperationError { - code: WorktreeErrorCode::RemoteUnsupported, - message: "Repository status is not supported for remote workspaces".to_string(), - recovery_path: None, - operation_id: None, - })); - } - - let repository = - match GitService::resolve_worktree_repository(&request.workspace_path).await { - Ok(repository) => GitService::get_repository_basic(repository.query_path).await, - Err(error) => Err(error), - }; - match repository { - Ok(repository) => Ok(WorktreeRepositoryStatusResponse { - is_repository: true, - current_branch: Some(repository.current_branch), - }), - Err(_) => Ok(WorktreeRepositoryStatusResponse { - is_repository: false, - current_branch: None, - }), - } - } - - async fn bind_session( - &self, - request: WorktreeBindSessionRequest, - ) -> AppManagementResult { - self.transition( - request.is_remote(), - request.operation_id, - request.session_id, - request.project_workspace_path, - true, - ) - .await - } - - async fn release_session( - &self, - request: WorktreeReleaseSessionRequest, - ) -> AppManagementResult { - self.transition( - request.is_remote(), - request.operation_id, - request.session_id, - request.project_workspace_path, - false, - ) - .await - } -} - -impl CliWorktreeManagementHost { - async fn transition( - &self, - remote: bool, - operation_id: String, - session_id: String, - project_workspace_path: Option, - enabled: bool, - ) -> AppManagementResult { - validate_operation_id(&operation_id)?; - if remote { - return Err(worktree_error(WorktreeOperationError { - code: WorktreeErrorCode::RemoteUnsupported, - message: "Managed worktrees are not supported for remote workspaces".to_string(), - recovery_path: None, - operation_id: Some(operation_id), - })); - } - - let result = WorktreeService::bind_session(WorktreeSessionBindingRequest { - request_id: operation_id.clone(), - session_id, - project_workspace_path, - enabled, - }) - .await - .map_err(|error| worktree_error(project_error(error, Some(operation_id.clone()))))?; - let execution_target = result.execution_target.clone(); - Ok(WorktreeBindingResponse { - workspace_binding: AgentSessionWorkspaceBinding { - workspace_id: result.workspace_id, - workspace_path: result.workspace_path, - project_workspace_path: Some(result.project_workspace_path), - execution_target: Some(execution_target), - remote_connection_id: None, - remote_ssh_host: None, - }, - retained_worktree_path: result.retained_worktree_path, - }) - } -} - -fn validate_operation_id(operation_id: &str) -> AppManagementResult<()> { - if !operation_id.trim().is_empty() - && operation_id.len() <= 160 - && operation_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) - { - Ok(()) - } else { - Err(AppManagementError::invalid_request( - "Worktree operation ID is invalid", - )) - } -} - -fn project_error(error: WorktreeError, operation_id: Option) -> WorktreeOperationError { - WorktreeOperationError { - code: error.code, - message: error.message, - recovery_path: error.recovery_path, - operation_id, - } -} - -fn worktree_error(error: WorktreeOperationError) -> AppManagementError { - AppManagementError::internal(error.encode()) -} diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index 7bffee62b0..2fe4abc5ba 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -68,7 +68,6 @@ const DEFAULT_ITEM_ORDER: &[&str] = &[ "mcp_servers", "extensions", "hooks", - "hooks_external", "login", "logout", "status", diff --git a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs index b74d4d2180..2f453a9c95 100644 --- a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs +++ b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs @@ -141,7 +141,10 @@ fn doctor_rejects_incomplete_e2e_storage_roots() { #[test] fn remaining_cli_local_persistence_stays_behind_explicit_owner_boundaries() { - const ACCOUNT_SYNC: &str = include_str!("../../src/account_sync.rs"); + const ACCOUNT_ADAPTER: &str = include_str!("../../src/account.rs"); + const ACCOUNT_RUNTIME: &str = include_str!( + "../../../../crates/assembly/core/src/service/remote_connect/account_runtime.rs" + ); const STARTUP_PAGE: &str = include_str!("../../src/ui/startup.rs"); const PEER_BOOTSTRAP: &str = include_str!("../../src/peer_host/bootstrap.rs"); const PEER_STATE: &str = include_str!("../../src/peer_host/state.rs"); @@ -151,7 +154,7 @@ fn remaining_cli_local_persistence_stays_behind_explicit_owner_boundaries() { include_str!("../../../../crates/assembly/core/src/product_runtime/runtime_services.rs"); for (path, source) in [ - ("account_sync.rs", ACCOUNT_SYNC), + ("account.rs", ACCOUNT_ADAPTER), ("ui/startup.rs", STARTUP_PAGE), ("peer_host/bootstrap.rs", PEER_BOOTSTRAP), ("peer_host/state.rs", PEER_STATE), @@ -165,8 +168,10 @@ fn remaining_cli_local_persistence_stays_behind_explicit_owner_boundaries() { } assert!( - ACCOUNT_SYNC.contains("CoreAgentRuntimeCompatibility"), - "account sync must receive the narrow Core compatibility facade" + ACCOUNT_RUNTIME.contains("pub struct AccountRuntime") + && ACCOUNT_ADAPTER.contains("impl AccountRuntimeHost for CliAccountRoutingHost") + && ACCOUNT_ADAPTER.contains("impl AccountSessionBackupPort"), + "account state must live in the shared owner while CLI keeps narrow Host adapters" ); assert!( STARTUP_PAGE.contains("self.agent.account_snapshot()") @@ -205,6 +210,26 @@ fn remaining_cli_local_persistence_stays_behind_explicit_owner_boundaries() { ); } +#[test] +fn embedded_account_management_adapts_the_shared_runtime_directly() { + const EMBEDDED_APP_SERVER: &str = include_str!("../../src/embedded_app_server.rs"); + const CLI_MAIN: &str = include_str!("../../src/main.rs"); + const MANAGEMENT: &str = + include_str!("../../../../crates/interfaces/app-server/src/management.rs"); + const MANAGEMENT_SERVICE: &str = + include_str!("../../../../crates/interfaces/app-server/src/management/service.rs"); + + assert!( + EMBEDDED_APP_SERVER.contains("runtime.account_runtime().clone()") + && MANAGEMENT_SERVICE.contains("Option>") + && MANAGEMENT_SERVICE.contains("login_with_credentials") + && !MANAGEMENT.contains("AccountManagementHost") + && !CLI_MAIN.contains("mod tui_account_management") + && !CLI_MAIN.contains("mod account_sync"), + "Embedded account management must adapt AccountRuntime without a management Host trait" + ); +} + #[test] fn peer_session_control_and_usage_persistence_use_runtime_sdk() { const PEER_SESSION_COMMANDS: &str = include_str!("../../src/peer_host/commands/session.rs"); @@ -475,8 +500,10 @@ fn interactive_tui_worktrees_stay_behind_the_typed_backend() { const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); const TUI_BACKEND: &str = include_str!("../../src/tui_backend.rs"); const SHARED_BACKEND: &str = include_str!("../../src/shared_tui_backend.rs"); - const WORKTREE_HOST: &str = include_str!("../../src/tui_worktree_management.rs"); + const WORKTREE_MANAGEMENT: &str = + include_str!("../../../../crates/interfaces/app-server/src/management/worktree.rs"); const EMBEDDED_APP_SERVER: &str = include_str!("../../src/embedded_app_server.rs"); + const CLI_MAIN: &str = include_str!("../../src/main.rs"); for direct_owner in [ "GitService", @@ -504,13 +531,16 @@ fn interactive_tui_worktrees_stay_behind_the_typed_backend() { ); } assert!( - WORKTREE_HOST.contains("WorktreeService::bind_session") - && EMBEDDED_APP_SERVER.contains("CliWorktreeManagementHost"), - "the Embedded Host must inject the CLI Worktree owner" + WORKTREE_MANAGEMENT.contains("WorktreeService::bind_session") + && EMBEDDED_APP_SERVER.contains("load_for_local_host") + && !EMBEDDED_APP_SERVER.contains("LocalWorktreeManagement") + && !EMBEDDED_APP_SERVER.contains("tui_worktree_management"), + "the Embedded Host must enable the App Server's built-in local Worktree management" ); assert!( SHARED_BACKEND.contains("WORKTREES_CAPABILITY") - && SHARED_BACKEND.contains("does not fall back"), + && SHARED_BACKEND.contains("does not fall back") + && CLI_MAIN.contains("AppManagementService::load().await?"), "Shared Worktree management must fail closed" ); } diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index c7ccac71ee..5f96147e62 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -82,6 +82,9 @@ resvg = { workspace = true } arboard = "3" tempfile = { workspace = true } +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } + [target.'cfg(target_os = "macos")'.dependencies] bitflags = { workspace = true } core-foundation = { workspace = true } @@ -137,9 +140,6 @@ devtools = ["tauri/devtools", "bitfun-webdriver/embedded"] atspi = { workspace = true } leptess = { workspace = true } -[dev-dependencies] -tempfile = { workspace = true } - [target.'cfg(not(target_env = "ohos"))'.dependencies] xz2 = { workspace = true } screenshots = { workspace = true } diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 4e4c32ac3a..d0af494793 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -1172,9 +1172,14 @@ pub async fn initialize_ai(state: State<'_, AppState>) -> Result let ai_config = bitfun_core::util::types::AIConfig::try_from(model_config.clone()) .map_err(|e| format!("Failed to convert AI configuration: {}", e))?; + let proxy_config = if global_config.ai.proxy.enabled { + Some(global_config.ai.proxy.clone()) + } else { + None + }; let ai_client = bitfun_core::infrastructure::ai::AIClient::new_with_runtime_options( ai_config, - None, + proxy_config, stream_options, ); @@ -1209,16 +1214,26 @@ async fn create_transient_ai_client_for_config( let mut ai_config: bitfun_core::util::types::AIConfig = model_config .try_into() .map_err(|e| format!("Failed to convert configuration: {}", e))?; - - bitfun_core::infrastructure::ai::client_factory::apply_subscription_auth(&auth, &mut ai_config) - .await - .map_err(|e| format!("Failed to resolve subscription auth: {}", e))?; + let skip_ssl_verify = ai_config.skip_ssl_verify; let proxy_config = if global_config.ai.proxy.enabled { Some(global_config.ai.proxy.clone()) } else { None }; + let subscription_options = + bitfun_core::infrastructure::subscription_auth::SubscriptionHttpOptions::new( + proxy_config.clone(), + skip_ssl_verify, + ); + + bitfun_core::infrastructure::ai::client_factory::apply_subscription_auth_with_options( + &auth, + &mut ai_config, + &subscription_options, + ) + .await + .map_err(|e| format!("Failed to resolve subscription auth: {}", e))?; Ok( bitfun_core::infrastructure::ai::AIClient::new_with_runtime_options( @@ -5652,6 +5667,22 @@ pub struct SubscriptionLoginRequest { pub session_id: String, } +async fn configured_ai_proxy( + state: &State<'_, AppState>, +) -> Result, String> { + let global_config: bitfun_core::service::config::GlobalConfig = state + .config_service + .get_config(None) + .await + .map_err(|e| format!("Failed to get configuration: {}", e))?; + + Ok(global_config + .ai + .proxy + .enabled + .then_some(global_config.ai.proxy)) +} + #[tauri::command] pub async fn list_subscription_accounts( ) -> Result, String> { @@ -5660,11 +5691,18 @@ pub async fn list_subscription_accounts( #[tauri::command] pub async fn start_subscription_login( + state: State<'_, AppState>, request: SubscriptionLoginRequest, ) -> Result { - bitfun_core::infrastructure::subscription_auth::start_login( + let proxy_config = configured_ai_proxy(&state).await?; + let options = bitfun_core::infrastructure::subscription_auth::SubscriptionHttpOptions::new( + proxy_config, + false, + ); + bitfun_core::infrastructure::subscription_auth::start_login_with_options( request.provider, request.session_id, + options, ) .await .map_err(|e| format!("Failed to start subscription login: {e:#}")) @@ -5703,9 +5741,18 @@ pub async fn logout_subscription_account( #[tauri::command] pub async fn refresh_subscription_account( + state: State<'_, AppState>, request: SubscriptionProviderRequest, ) -> Result { - bitfun_core::infrastructure::subscription_auth::refresh_account(request.provider) - .await - .map_err(|e| format!("Failed to refresh subscription account: {e:#}")) + let proxy_config = configured_ai_proxy(&state).await?; + let options = bitfun_core::infrastructure::subscription_auth::SubscriptionHttpOptions::new( + proxy_config, + false, + ); + bitfun_core::infrastructure::subscription_auth::refresh_account_with_options( + request.provider, + &options, + ) + .await + .map_err(|e| format!("Failed to refresh subscription account: {e:#}")) } diff --git a/src/apps/desktop/src/api/event_coalescer.rs b/src/apps/desktop/src/api/event_coalescer.rs new file mode 100644 index 0000000000..6cec99e775 --- /dev/null +++ b/src/apps/desktop/src/api/event_coalescer.rs @@ -0,0 +1,707 @@ +//! Time-window coalescing of streamed text chunks before transport emit. +//! +//! The agent stream emits one `TextChunk` / `ThinkingChunk` event per provider +//! chunk ([`bitfun_events::AgenticEvent`]). Forwarding every chunk to the +//! WebView costs one Tauri IPC message (JSON serialization, WebView2 boundary +//! crossing, JS parse + dispatch) and, when peer devices are attached, one +//! end-to-end encrypted relay message. This module merges chunks of the same +//! stream (session / turn / round / attempt / contentType) within a short +//! window so the frontend still receives content-equivalent events at a +//! fraction of the message rate. +//! +//! Semantics: +//! - Text chunks accumulate by appending; thinking chunks append and OR their +//! `is_end` flag. +//! - A non-chunk event flushes all pending merged events first, then passes +//! through unchanged, so text always precedes completion / error / +//! cancellation for the same stream. +//! - Nothing is dropped: the merged payload is identical to what the frontend +//! would have accumulated itself. +//! - Buffering is per stream, so concurrently streaming sessions do not flush +//! each other's pending text. +//! - Merged events are delivered in first-arrival order of their streams, so +//! the original FIFO sequence is preserved: for the same stream the producer +//! emits thinking chunks before text chunks, and `flush` therefore emits the +//! merged thinking event before the merged text event. + +use bitfun_events::AgenticEvent; +use std::collections::HashMap; +use std::time::Duration; + +/// Decide which flush deadline to keep after a batch of queued events has been +/// drained by the event loop. +/// +/// Pure scheduling decision so the arm/keep/clear rules of the 50ms coalescing +/// window are unit-testable without a live tokio task: +/// - Buffered chunks and no running deadline: arm `now + window`. +/// - Buffered chunks and a running deadline: keep the original deadline so the +/// window is not extended by a steady chunk stream. +/// - Nothing buffered: no deadline. +pub fn next_flush_deadline( + pending: bool, + deadline: Option, + now: tokio::time::Instant, + window: Duration, +) -> Option { + if pending { + Some(deadline.unwrap_or(now + window)) + } else { + None + } +} + +/// Maximum time a streamed chunk waits in the coalescer before being emitted +/// as a merged event. +pub const TEXT_CHUNK_COALESCE_WINDOW_MS: u64 = 50; + +// --------------------------------------------------------------------------- +// Rate-adaptive window +// --------------------------------------------------------------------------- +// +// The window grows with the measured stream rate so that fast streams merge +// more chunks per message (their latency is hidden by the frontend typewriter +// backlog) while slow streams keep a small window (their latency is directly +// visible as boundary stalls). The window is a throttle, not a debounce: it is +// fixed at arm time and never extended by a steady stream. + +/// Smallest window, used for slow streams (thinking phases, low-throughput +/// models). Keeps first-char latency and boundary stalls minimal. +pub const WINDOW_MIN_MS: u64 = 30; + +/// Largest window, reached only by fast streams (body text peaks). Bounds the +/// worst-case text delivery delay and the crash-loss window. +pub const WINDOW_MAX_MS: u64 = 100; + +/// Window used when the measured rate equals `WINDOW_REF_CPS`; matches the +/// previous fixed 50ms behavior at the measured median rate of a typical +/// streaming session, so average-speed streams see no regression. +pub const WINDOW_BASE_MS: u64 = 50; + +/// Reference stream rate (chars/sec) at which the window equals +/// `WINDOW_BASE_MS`. Calibrated to the measured median rate of real sessions +/// (~87 tokens/sec of Chinese text at ~0.92 chars/token). +pub const WINDOW_REF_CPS: f64 = 80.0; + +/// EMA smoothing factor applied to the measured instant rate. +const RATE_EMA_ALPHA: f64 = 0.7; + +/// A window longer than this resets the rate estimate instead of blending it; +/// used to forget the previous stream's rate after an idle gap. +const RATE_EMA_RESET_MS: u128 = 1000; + +/// Map a measured stream rate (chars/sec) to the coalescing window. +/// +/// Linear in the rate, clamped to `[WINDOW_MIN_MS, WINDOW_MAX_MS]`: +/// `window = base * (rate / ref)`. +pub fn next_window(rate_cps: f64) -> Duration { + let window_ms = WINDOW_BASE_MS as f64 * (rate_cps.max(0.0) / WINDOW_REF_CPS); + Duration::from_millis(window_ms.clamp(WINDOW_MIN_MS as f64, WINDOW_MAX_MS as f64) as u64) +} + +/// Blend a freshly measured stream rate into the EMA estimate. +/// +/// `flushed_chars` is the content emitted by one window flush and `elapsed` +/// the duration of that window. A long window (idle gap, stream restart) +/// resets the estimate to the instant rate instead of blending. +pub fn update_rate_ema(previous: f64, flushed_chars: usize, elapsed: Duration) -> f64 { + let elapsed_ms = elapsed.as_millis().max(1) as f64; + let instant_cps = flushed_chars as f64 * 1000.0 / elapsed_ms; + if elapsed.as_millis() > RATE_EMA_RESET_MS { + instant_cps + } else { + RATE_EMA_ALPHA * instant_cps + (1.0 - RATE_EMA_ALPHA) * previous + } +} + +/// Initial EMA value: the reference rate, so the very first window of a +/// session behaves exactly like the previous fixed 50ms window. +pub const INITIAL_RATE_EMA_CPS: f64 = WINDOW_REF_CPS; + +/// Stable merge key for one streaming text/thinking stream. +type ChunkStreamKey = (String, String, String, String, bool); + +fn resolve_attempt_token(attempt_id: &Option, attempt_index: Option) -> String { + if let Some(id) = attempt_id { + if !id.is_empty() { + return id.clone(); + } + } + match attempt_index { + Some(index) => format!("idx-{index}"), + None => "none".to_string(), + } +} + +enum PendingChunk { + Text { + session_id: String, + turn_id: String, + round_id: String, + attempt_id: Option, + attempt_index: Option, + text: String, + }, + Thinking { + session_id: String, + turn_id: String, + round_id: String, + attempt_id: Option, + attempt_index: Option, + content: String, + is_end: bool, + }, +} + +impl PendingChunk { + fn into_event(self) -> AgenticEvent { + match self { + PendingChunk::Text { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + text, + } => AgenticEvent::TextChunk { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + text, + }, + PendingChunk::Thinking { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + content, + is_end, + } => AgenticEvent::ThinkingChunk { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + content, + is_end, + }, + } + } +} + +/// Coalesces streamed text/thinking chunks within a short time window. +pub struct TextChunkCoalescer { + pending: HashMap, + /// First-arrival order of the buffered stream keys. Kept in sync with + /// `pending` (a key is pushed exactly when its entry is inserted) so that + /// `flush` reproduces the producer's FIFO sequence instead of reordering + /// streams by key. + order: Vec, + /// Total content characters buffered since the last flush. Used by the + /// caller to measure the stream rate for the adaptive window. + buffered_chars: usize, +} + +impl Default for TextChunkCoalescer { + fn default() -> Self { + Self::new() + } +} + +impl TextChunkCoalescer { + pub fn new() -> Self { + Self { + pending: HashMap::new(), + order: Vec::new(), + buffered_chars: 0, + } + } + + /// Whether the coalescer currently holds at least one buffered chunk. + pub fn is_pending(&self) -> bool { + !self.pending.is_empty() + } + + /// Content characters buffered since the last flush (0 once flushed). + pub fn buffered_chars(&self) -> usize { + self.buffered_chars + } + + /// Feed one event and return the events that must be delivered immediately. + /// + /// Text/thinking chunks of the same stream are buffered (an empty vector is + /// returned); a chunk of a different stream is buffered independently. Any + /// non-chunk event first flushes all pending merged events, then passes + /// through unchanged. + pub fn push(&mut self, event: AgenticEvent) -> Vec { + match event { + AgenticEvent::TextChunk { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + text, + } => { + let key = ( + session_id.clone(), + turn_id.clone(), + round_id.clone(), + resolve_attempt_token(&attempt_id, attempt_index), + false, + ); + match self.pending.get_mut(&key) { + Some(PendingChunk::Text { text: pending, .. }) => { + pending.push_str(&text); + self.buffered_chars += text.chars().count(); + Vec::new() + } + _ => { + let len = text.chars().count(); + self.pending.insert( + key.clone(), + PendingChunk::Text { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + text, + }, + ); + self.order.push(key); + self.buffered_chars += len; + Vec::new() + } + } + } + AgenticEvent::ThinkingChunk { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + content, + is_end, + } => { + let key = ( + session_id.clone(), + turn_id.clone(), + round_id.clone(), + resolve_attempt_token(&attempt_id, attempt_index), + true, + ); + match self.pending.get_mut(&key) { + Some(PendingChunk::Thinking { + content: pending, + is_end: pending_is_end, + .. + }) => { + pending.push_str(&content); + *pending_is_end |= is_end; + self.buffered_chars += content.chars().count(); + Vec::new() + } + _ => { + let len = content.chars().count(); + self.pending.insert( + key.clone(), + PendingChunk::Thinking { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + content, + is_end, + }, + ); + self.order.push(key); + self.buffered_chars += len; + Vec::new() + } + } + } + other => { + let mut events = self.flush(); + events.push(other); + events + } + } + } + + /// Emit all buffered chunks as merged events and clear the buffer. + /// + /// Merged events are emitted in first-arrival order of their streams, which + /// restores the FIFO sequence the frontend relied on: for the same stream + /// the producer emits thinking chunks before text chunks, so the merged + /// thinking event (with its OR'd `is_end`) precedes the merged text event + /// even though they buffer under separate keys. + pub fn flush(&mut self) -> Vec { + let mut events = Vec::with_capacity(self.order.len()); + for key in self.order.drain(..) { + if let Some(chunk) = self.pending.remove(&key) { + events.push(chunk.into_event()); + } + } + self.buffered_chars = 0; + events + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_chunk( + session_id: &str, + turn_id: &str, + round_id: &str, + attempt_id: Option<&str>, + attempt_index: Option, + text: &str, + ) -> AgenticEvent { + AgenticEvent::TextChunk { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + round_id: round_id.to_string(), + attempt_id: attempt_id.map(str::to_string), + attempt_index, + text: text.to_string(), + } + } + + fn thinking_chunk( + session_id: &str, + turn_id: &str, + round_id: &str, + attempt_id: Option<&str>, + attempt_index: Option, + content: &str, + is_end: bool, + ) -> AgenticEvent { + AgenticEvent::ThinkingChunk { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + round_id: round_id.to_string(), + attempt_id: attempt_id.map(str::to_string), + attempt_index, + content: content.to_string(), + is_end, + } + } + + #[test] + fn merges_same_stream_text_chunks() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, Some(1), "hello ")) + .is_empty()); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, Some(1), "world")) + .is_empty()); + + let events = coalescer.flush(); + assert_eq!(events.len(), 1); + match &events[0] { + AgenticEvent::TextChunk { text, .. } => assert_eq!(text, "hello world"), + other => panic!("expected TextChunk, got {other:?}"), + } + } + + #[test] + fn merges_same_stream_thinking_chunks_and_ors_is_end() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(thinking_chunk("s", "t", "r", None, None, "think ", false)) + .is_empty()); + assert!(coalescer + .push(thinking_chunk("s", "t", "r", None, None, "more", false)) + .is_empty()); + assert!(coalescer + .push(thinking_chunk("s", "t", "r", None, None, "", true)) + .is_empty()); + + let events = coalescer.flush(); + assert_eq!(events.len(), 1); + match &events[0] { + AgenticEvent::ThinkingChunk { + content, is_end, .. + } => { + assert_eq!(content, "think more"); + assert!(is_end); + } + other => panic!("expected ThinkingChunk, got {other:?}"), + } + } + + #[test] + fn keeps_text_and_thinking_streams_separate() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(thinking_chunk("s", "t", "r", None, None, "think", false)) + .is_empty()); + assert!(coalescer + .push(thinking_chunk("s", "t", "r", None, None, "", true)) + .is_empty()); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "answer ")) + .is_empty()); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "text")) + .is_empty()); + + let events = coalescer.flush(); + assert_eq!(events.len(), 2); + // Same-stream delivery must follow the producer's FIFO order: the + // merged thinking event (with its OR'd is_end) precedes the merged + // text event. Regression guard for the flush-order reversal where + // text was emitted before thinking. + match &events[0] { + AgenticEvent::ThinkingChunk { + content, is_end, .. + } => { + assert_eq!(content, "think"); + assert!(is_end); + } + other => panic!("expected ThinkingChunk first, got {other:?}"), + } + match &events[1] { + AgenticEvent::TextChunk { text, .. } => assert_eq!(text, "answer text"), + other => panic!("expected TextChunk second, got {other:?}"), + } + } + + #[test] + fn flush_preserves_first_arrival_order_across_streams() { + let mut coalescer = TextChunkCoalescer::new(); + // The "z" stream starts buffering before the "a" stream; flush must + // follow arrival order, not lexicographic key order. + assert!(coalescer + .push(text_chunk("s", "t", "z", None, None, "z-first")) + .is_empty()); + assert!(coalescer + .push(text_chunk("s", "t", "a", None, None, "a-second")) + .is_empty()); + + let events = coalescer.flush(); + assert_eq!(events.len(), 2); + assert!(matches!(&events[0], AgenticEvent::TextChunk { round_id, .. } if round_id == "z")); + assert!(matches!(&events[1], AgenticEvent::TextChunk { round_id, .. } if round_id == "a")); + } + + #[test] + fn different_stream_chunks_are_buffered_independently() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(text_chunk("s1", "t", "r", None, None, "a")) + .is_empty()); + assert!(coalescer + .push(text_chunk("s2", "t", "r", None, None, "b")) + .is_empty()); + + let events = coalescer.flush(); + assert_eq!(events.len(), 2); + } + + #[test] + fn non_chunk_event_flushes_pending_text_first() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "final ")) + .is_empty()); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "words")) + .is_empty()); + + let events = coalescer.push(AgenticEvent::DialogTurnCompleted { + session_id: "s".to_string(), + turn_id: "t".to_string(), + total_rounds: 1, + total_tools: 0, + duration_ms: 10, + partial_recovery_reason: None, + success: Some(true), + finish_reason: Some("stop".to_string()), + has_final_response: Some(true), + }); + + assert_eq!(events.len(), 2); + assert!( + matches!(&events[0], AgenticEvent::TextChunk { text, .. } if text == "final words") + ); + assert!(matches!( + &events[1], + AgenticEvent::DialogTurnCompleted { .. } + )); + assert!(!coalescer.is_pending()); + } + + #[test] + fn flush_clears_buffer() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "x")) + .is_empty()); + assert_eq!(coalescer.flush().len(), 1); + assert!(coalescer.flush().is_empty()); + assert!(!coalescer.is_pending()); + } + + #[test] + fn preserves_attempt_identity_on_merged_event() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(text_chunk("s", "t", "r", Some("attempt-7"), Some(3), "a")) + .is_empty()); + assert!(coalescer + .push(text_chunk("s", "t", "r", Some("attempt-7"), Some(3), "b")) + .is_empty()); + + let events = coalescer.flush(); + match &events[0] { + AgenticEvent::TextChunk { + attempt_id, + attempt_index, + text, + .. + } => { + assert_eq!(attempt_id.as_deref(), Some("attempt-7")); + assert_eq!(*attempt_index, Some(3)); + assert_eq!(text, "ab"); + } + other => panic!("expected TextChunk, got {other:?}"), + } + } + + #[test] + fn arms_deadline_when_pending_without_one() { + let now = tokio::time::Instant::now(); + let window = Duration::from_millis(TEXT_CHUNK_COALESCE_WINDOW_MS); + assert_eq!( + next_flush_deadline(true, None, now, window), + Some(now + window) + ); + } + + #[test] + fn keeps_existing_deadline_when_pending() { + let now = tokio::time::Instant::now(); + let window = Duration::from_millis(TEXT_CHUNK_COALESCE_WINDOW_MS); + let existing = now + Duration::from_millis(10); + assert_eq!( + next_flush_deadline(true, Some(existing), now, window), + Some(existing) + ); + } + + #[test] + fn clears_deadline_when_buffer_drained() { + let now = tokio::time::Instant::now(); + let window = Duration::from_millis(TEXT_CHUNK_COALESCE_WINDOW_MS); + let existing = now + Duration::from_millis(10); + assert_eq!( + next_flush_deadline(false, Some(existing), now, window), + None + ); + assert_eq!(next_flush_deadline(false, None, now, window), None); + } + + #[test] + fn window_is_base_at_reference_rate() { + assert_eq!( + next_window(WINDOW_REF_CPS), + Duration::from_millis(WINDOW_BASE_MS) + ); + } + + #[test] + fn window_grows_with_rate_and_clamps() { + // Slow stream: clamped to the minimum (smaller than the fixed 50ms). + assert_eq!(next_window(0.0), Duration::from_millis(WINDOW_MIN_MS)); + assert_eq!(next_window(10.0), Duration::from_millis(WINDOW_MIN_MS)); + // Double the reference rate -> double the window (within the cap). + assert_eq!( + next_window(WINDOW_REF_CPS * 2.0), + Duration::from_millis(100) + ); + // Fast stream: clamped to the maximum. + assert_eq!( + next_window(WINDOW_REF_CPS * 10.0), + Duration::from_millis(WINDOW_MAX_MS) + ); + // Negative rates are treated as zero. + assert_eq!(next_window(-5.0), Duration::from_millis(WINDOW_MIN_MS)); + } + + #[test] + fn rate_ema_blends_instant_rate() { + // 40 chars flushed over a 50ms window -> 800 chars/sec instant. + let blended = update_rate_ema(INITIAL_RATE_EMA_CPS, 40, Duration::from_millis(50)); + let expected = 0.7 * 800.0 + 0.3 * INITIAL_RATE_EMA_CPS; + assert!((blended - expected).abs() < 1e-9); + } + + #[test] + fn rate_ema_resets_after_idle_gap() { + // A window longer than the reset threshold replaces the estimate with + // the instant rate instead of blending (stream restart). + let reset = update_rate_ema(INITIAL_RATE_EMA_CPS, 80, Duration::from_millis(1000)); + assert!((reset - 80.0).abs() < 1e-9); + } + + #[test] + fn buffered_chars_tracks_pending_content() { + let mut coalescer = TextChunkCoalescer::new(); + assert_eq!(coalescer.buffered_chars(), 0); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "abcd")) + .is_empty()); + assert_eq!(coalescer.buffered_chars(), 4); + // Merging into the same stream accumulates. + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "ef")) + .is_empty()); + assert_eq!(coalescer.buffered_chars(), 6); + // Thinking content counts too. + assert!(coalescer + .push(thinking_chunk("s", "t", "r", None, None, "xyz", false)) + .is_empty()); + assert_eq!(coalescer.buffered_chars(), 9); + // Flush drains and resets the counter. + assert_eq!(coalescer.flush().len(), 2); + assert_eq!(coalescer.buffered_chars(), 0); + } + + #[test] + fn buffered_chars_counts_unicode_not_bytes() { + let mut coalescer = TextChunkCoalescer::new(); + // "中文" is 2 Unicode characters but 6 UTF-8 bytes. + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "中文")) + .is_empty()); + assert_eq!(coalescer.buffered_chars(), 2); + assert!(coalescer + .push(text_chunk("s", "t", "r", None, None, "a")) + .is_empty()); + assert_eq!(coalescer.buffered_chars(), 3); + assert!(coalescer + .push(thinking_chunk("s", "t", "r", None, None, "世界", false)) + .is_empty()); + assert_eq!(coalescer.buffered_chars(), 5); + assert_eq!(coalescer.flush().len(), 2); + assert_eq!(coalescer.buffered_chars(), 0); + } + + #[test] + fn rate_ema_resets_after_long_idle_gap() { + // A window longer than the reset threshold must replace the estimate + // with the instant rate. Use a previous estimate that differs from the + // instant rate so the reset is observable. + let reset = update_rate_ema(160.0, 80, Duration::from_millis(2000)); + // 80 chars over 2000 ms -> 40 chars/sec; reset should discard the old 160. + assert!((reset - 40.0).abs() < 1e-9); + } +} diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index a863d02d4c..0890159bac 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -1,13 +1,10 @@ //! Desktop host API for ecosystem-neutral external AI application sources. use bitfun_core::external_sources::{ - acknowledge_external_ecosystems, - apply_external_application_action_v2 as core_apply_external_application_action_v2, - apply_external_source_control_action, choose_external_mcp_conflict, - choose_external_subagent_conflict, expand_external_prompt_command, - external_source_location_for_host_action, external_source_snapshot, - get_external_application_review_page_v2 as core_get_external_application_review_page_v2, - get_external_application_snapshot_v2 as core_get_external_application_snapshot_v2, + acknowledge_external_ecosystems, apply_external_source_control_action, + choose_external_mcp_conflict, choose_external_subagent_conflict, + expand_external_prompt_command, external_source_location_for_host_action, + external_source_snapshot, get_external_source_control_snapshot as core_get_external_source_control_snapshot, native_prompt_command_conflicts, set_external_mcp_server_decision, set_external_prompt_command_conflict_choice, set_external_source_enabled, @@ -27,11 +24,6 @@ use bitfun_core::service::remote_ssh::workspace_state::{ canonicalize_local_workspace_root, local_workspace_roots_equal, }; use bitfun_core::service::workspace::manager::WorkspaceKind; -use bitfun_product_domains::external_source_control::{ - ExternalApplicationControlRequestV2, ExternalApplicationControlResultV2, - ExternalApplicationHostCapabilitiesV2, ExternalApplicationReviewPageRequestV2, - ExternalApplicationReviewPageV2, ExternalApplicationSnapshotV2, -}; use bitfun_product_domains::external_sources::{ ExternalMcpImportApplyRequestV1, ExternalMcpImportApplyResultV1, ExternalMcpImportPlanV1, }; @@ -50,28 +42,6 @@ pub struct ExternalSourceSnapshotRequest { pub force_refresh: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationSnapshotCommandRequest { - pub workspace_path: Option, - #[serde(default)] - pub force_refresh: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewPageCommandRequest { - pub workspace_path: Option, - pub request: ExternalApplicationReviewPageRequestV2, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationActionCommandRequest { - pub workspace_path: Option, - pub request: ExternalApplicationControlRequestV2, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct WorkspaceReferenceSnapshotRequest { @@ -263,9 +233,6 @@ pub struct ApplyExternalMcpImportRequest { } pub type ExternalSourceSnapshotResponse = ExternalSourcePublicSnapshot; -pub type ExternalApplicationSnapshotResponseV2 = ExternalApplicationSnapshotV2; -pub type ExternalApplicationReviewPageResponseV2 = ExternalApplicationReviewPageV2; -pub type ExternalApplicationActionResponseV2 = ExternalApplicationControlResultV2; pub type ExternalSourceControlResponse = ExternalSourceSurfaceSnapshotV1; pub type ExpandExternalPromptCommandResponse = PromptCommandInvocationOutcome; pub type NativePromptCommandConflictsResponse = NativePromptCommandConflictSnapshot; @@ -314,44 +281,6 @@ pub(super) async fn require_local_workspace( Ok(Some(path)) } -fn ensure_application_v2_workspace_binding( - requested_workspace: Option<&Path>, - current_workspace: Option<&Path>, -) -> ExternalSourceOperationResult<()> { - let Some(requested_workspace) = requested_workspace else { - return Ok(()); - }; - let Some(current_workspace) = current_workspace else { - return Err(ExternalSourceOperationError::invalid_request( - "External application workspace scope requires an active workspace", - )); - }; - if !local_workspace_roots_equal(requested_workspace, current_workspace) { - return Err(ExternalSourceOperationError::invalid_request( - "External application workspace scope does not match the active workspace", - )); - } - Ok(()) -} - -async fn require_application_v2_workspace<'a>( - state: &State<'_, AppState>, - workspace_path: Option<&'a str>, -) -> ExternalSourceOperationResult> { - let workspace = require_local_workspace(workspace_path).await?; - if workspace.is_none() { - return Ok(None); - } - let current_workspace = state.workspace_service.get_current_workspace().await; - ensure_application_v2_workspace_binding( - workspace, - current_workspace - .as_ref() - .map(|workspace| workspace.root_path.as_path()), - )?; - Ok(workspace) -} - #[tauri::command] pub async fn update_external_integration_policy_command( request: UpdateExternalIntegrationPolicyRequest, @@ -374,54 +303,6 @@ pub async fn get_external_source_snapshot( .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } -#[tauri::command] -pub async fn get_external_application_snapshot_v2( - state: State<'_, AppState>, - request: ExternalApplicationSnapshotCommandRequest, -) -> ExternalSourceOperationResult { - let workspace = - require_application_v2_workspace(&state, request.workspace_path.as_deref()).await?; - core_get_external_application_snapshot_v2( - workspace, - request.force_refresh, - ExternalApplicationHostCapabilitiesV2::read_write(), - ) - .await - .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) -} - -#[tauri::command] -pub async fn get_external_application_review_page_v2( - state: State<'_, AppState>, - request: ExternalApplicationReviewPageCommandRequest, -) -> ExternalSourceOperationResult { - request - .request - .validate() - .map_err(ExternalSourceOperationError::invalid_request)?; - let workspace = - require_application_v2_workspace(&state, request.workspace_path.as_deref()).await?; - core_get_external_application_review_page_v2(workspace, request.request) - .await - .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) -} - -#[tauri::command] -pub async fn apply_external_application_action_v2( - state: State<'_, AppState>, - request: ExternalApplicationActionCommandRequest, -) -> ExternalSourceOperationResult { - request - .request - .validate() - .map_err(ExternalSourceOperationError::invalid_request)?; - let workspace = - require_application_v2_workspace(&state, request.workspace_path.as_deref()).await?; - core_apply_external_application_action_v2(workspace, request.request) - .await - .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) -} - #[tauri::command] pub async fn get_workspace_reference_snapshot( state: State<'_, AppState>, @@ -825,37 +706,6 @@ mod tests { )); } - #[test] - fn desktop_application_v2_workspace_binding_accepts_only_the_current_workspace() { - let directory = tempfile::tempdir().unwrap(); - let current_root = directory.path().join("current"); - let unrelated_root = directory.path().join("unrelated"); - for path in [¤t_root, &unrelated_root] { - std::fs::create_dir_all(path).unwrap(); - } - - assert!(ensure_application_v2_workspace_binding(None, Some(¤t_root)).is_ok()); - assert!( - ensure_application_v2_workspace_binding(Some(¤t_root), Some(¤t_root)) - .is_ok() - ); - - let unrelated = - ensure_application_v2_workspace_binding(Some(&unrelated_root), Some(¤t_root)) - .unwrap_err(); - assert_eq!( - unrelated.code, - ExternalSourceOperationErrorCode::InvalidRequest - ); - - let missing_current = - ensure_application_v2_workspace_binding(Some(¤t_root), None).unwrap_err(); - assert_eq!( - missing_current.code, - ExternalSourceOperationErrorCode::InvalidRequest - ); - } - #[test] fn desktop_snapshot_never_serializes_prompt_templates() { let snapshot: ExternalSourceCatalogSnapshot = serde_json::from_value(serde_json::json!({ @@ -1026,52 +876,4 @@ mod tests { .is_err() ); } - - #[test] - fn desktop_application_v2_requests_wrap_only_host_scope_and_typed_domain_input() { - let snapshot: ExternalApplicationSnapshotCommandRequest = - serde_json::from_value(serde_json::json!({ - "workspacePath": null, - "forceRefresh": true - })) - .unwrap(); - assert!(snapshot.workspace_path.is_none()); - assert!(snapshot.force_refresh); - - let page: ExternalApplicationReviewPageCommandRequest = - serde_json::from_value(serde_json::json!({ - "workspacePath": "D:/workspace/project", - "request": { - "schemaVersion": 2, - "executionDomainId": "host-a", - "workspaceScopeId": "workspace-a", - "targetScope": "workspace_override", - "reviewId": "review-a", - "preferenceRevision": 2, - "expectedGenerations": [], - "pageSize": 32 - } - })) - .unwrap(); - assert_eq!(page.request.page_size, 32); - - let action: ExternalApplicationActionCommandRequest = - serde_json::from_value(serde_json::json!({ - "workspacePath": "D:/workspace/project", - "request": { - "schemaVersion": 2, - "executionDomainId": "host-a", - "workspaceScopeId": "workspace-a", - "targetScope": "workspace_override", - "operationId": "operation-a", - "expectedPreferenceRevision": 2, - "action": { - "type": "set_application_deferred", - "applicationId": "codex" - } - } - })) - .unwrap(); - assert_eq!(action.request.operation_id, "operation-a"); - } } diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 4b3b752105..465b009951 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -23,6 +23,7 @@ pub mod dispatch_api; pub(crate) mod dispatch_host; pub mod dto; pub mod editor_ai_api; +pub mod event_coalescer; pub mod external_hooks_api; pub mod external_sources_api; pub mod feedback_api; diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 6a4fc8315c..825ea77ef5 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -229,10 +229,6 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("add_skill", RemoteWorkspacePolicy::LegacyUnaudited), ("analyze_work_state", RemoteWorkspacePolicy::LegacyUnaudited), - ( - "apply_external_application_action_v2", - RemoteWorkspacePolicy::RemoteUnsupported, - ), ( "apply_external_mcp_import_command", RemoteWorkspacePolicy::RemoteUnsupported, @@ -618,14 +614,6 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "get_directory_children_paginated", RemoteWorkspacePolicy::LegacyUnaudited, ), - ( - "get_external_application_review_page_v2", - RemoteWorkspacePolicy::RemoteUnsupported, - ), - ( - "get_external_application_snapshot_v2", - RemoteWorkspacePolicy::RemoteUnsupported, - ), ( "get_external_hook_catalog", RemoteWorkspacePolicy::RemoteUnsupported, @@ -2238,25 +2226,6 @@ mod tests { ); } - #[test] - fn external_application_v2_commands_never_fall_back_to_controller_local_state() { - for command in [ - "get_external_application_snapshot_v2", - "get_external_application_review_page_v2", - "apply_external_application_action_v2", - ] { - assert_eq!( - remote_workspace_policy(command), - Some(RemoteWorkspacePolicy::RemoteUnsupported), - "{command} must execute on the workspace Host" - ); - assert!( - registered_commands().contains(command), - "{command} must be registered by Desktop" - ); - } - } - /// `LegacyUnaudited` is a frozen backlog: commands may graduate out of it /// once their remote workspace behavior is audited, but no command may be /// added to it. Do not append to this list; give new commands a real diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 55c2d100c9..9405ef9614 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -41,6 +41,7 @@ use bitfun_core::infrastructure::{get_path_manager_arc, try_get_path_manager_arc use bitfun_core::service::search::get_global_workspace_search_service; use bitfun_core::service::workspace::get_global_workspace_service; use bitfun_core::util::{elapsed_ms, TimingCollector}; +use bitfun_events::AgenticEvent; use bitfun_transport::{TauriTransportAdapter, TransportAdapter}; use serde::Deserialize; use std::sync::{ @@ -554,6 +555,16 @@ pub async fn _run() { eprintln!("=== BitFun Desktop Starting ==="); + if let Err(error) = bitfun_core::agentic::system::select_agentic_system_profile( + bitfun_core::agentic::system::DeliveryProfile::Desktop, + ) { + log::error!("Failed to select Desktop agent profile: {}", error); + show_fatal_startup_error(&format!( + "BitFun could not select its Desktop agent profile and cannot continue.\n\n{error}\n\nSee early-startup.log for details." + )); + return; + } + let step_started = Instant::now(); if let Err(e) = bitfun_core::service::config::initialize_global_config().await { log::error!("Failed to initialize global config service: {}", e); @@ -1323,9 +1334,6 @@ pub async fn _run() { apply_external_hook_import_command, mutate_external_hook_import_command, get_external_source_snapshot, - get_external_application_snapshot_v2, - get_external_application_review_page_v2, - apply_external_application_action_v2, get_workspace_reference_snapshot, plan_external_mcp_import_command, apply_external_mcp_import_command, @@ -2301,45 +2309,228 @@ fn configure_workspace_search_daemon_env() -> Option { path } -fn start_event_loop_with_transport( +/// Deliver one event to the WebView and, when peer controllers are attached, +/// fan it out to paired devices. Text chunks arrive here already coalesced by +/// `TextChunkCoalescer`. +async fn deliver_event_to_webview(transport: &TauriTransportAdapter, event: AgenticEvent) { + if let Err(e) = transport.emit_event(event.clone()).await { + log::error!("Failed to emit event: {:?}", e); + } + + if !api::peer_host_invoke::attached_controllers().is_empty() { + if let Some(projected) = bitfun_events::project_agentic_frontend_event(event) { + api::remote_connect_api::fanout_peer_device_event( + projected.event_name, + projected.payload, + ); + } + } +} + +/// Update the rate EMA from a flush that produced `flushed_chars` characters. +/// +/// `arm_time` is when the flushed window was armed (the first buffered chunk). +/// When there is a recorded previous flush, the elapsed interval is measured +/// from that point so that an idle gap longer than `RATE_EMA_RESET_MS` resets +/// the estimate instead of blending the old stream's rate into the new one. +fn update_rate_after_flush( + rate_ema: &mut f64, + flushed_chars: usize, + arm_time: tokio::time::Instant, + last_flush_time: &mut Option, +) { + let now = tokio::time::Instant::now(); + let elapsed = last_flush_time + .map(|t| now - t) + .unwrap_or_else(|| arm_time.elapsed()); + *rate_ema = crate::api::event_coalescer::update_rate_ema(*rate_ema, flushed_chars, elapsed); + *last_flush_time = Some(now); +} + +/// Flush all buffered chunks as merged events and feed the flushed content +/// volume back into the rate estimate that sizes the next window. +async fn flush_coalesced( + deliver: &mut D, + coalescer: &mut crate::api::event_coalescer::TextChunkCoalescer, + rate_ema: &mut f64, + arm_time: tokio::time::Instant, + last_flush_time: &mut Option, +) where + D: FnMut(AgenticEvent) -> F, + F: std::future::Future, +{ + let flushed_chars = coalescer.buffered_chars(); + update_rate_after_flush(rate_ema, flushed_chars, arm_time, last_flush_time); + for event in coalescer.flush() { + deliver(event).await; + } +} + +/// Drive the agentic event queue: route raw events to internal subscribers, +/// coalesce streamed text chunks, and deliver merged events through `deliver`. +/// +/// Scheduling contract: +/// - The coalescing window is armed as soon as the first chunk is buffered +/// (even while the queue is still being drained), so the window counts from +/// the first chunk, not from the end of the drain. +/// - The window timer is only polled at the outer `select!`. Under sustained +/// load the queue may stay non-empty and the drain loop never exits, so an +/// expired deadline is also honored inside the drain: the buffered text is +/// flushed in place before processing continues. Text therefore waits at +/// most one window regardless of queue pressure. +async fn event_loop_driver( event_queue: Arc, event_router: Arc, - transport: Arc, -) { - tauri::async_runtime::spawn(async move { - loop { - event_queue.wait_for_events().await; - loop { - let batch = event_queue.dequeue_configured_batch().await; - if batch.is_empty() { - break; - } + mut deliver: D, +) where + D: FnMut(AgenticEvent) -> F, + F: std::future::Future, +{ + use crate::api::event_coalescer::{ + next_flush_deadline, next_window, TextChunkCoalescer, INITIAL_RATE_EMA_CPS, + }; + use tokio::time::{sleep_until, Instant}; + + let mut coalescer = TextChunkCoalescer::new(); + let mut flush_deadline: Option = None; + // Instant at which the current `flush_deadline` was armed. Kept in sync + // with the deadline so flushes can measure the actual window elapsed. + let mut flush_arm_time: Option = None; + // Instant of the previous flush. Used to detect idle gaps that should + // reset the stream-rate EMA. + let mut last_flush_time: Option = None; + // Measured stream rate (chars/sec), blended per window flush. Starts at + // the reference rate so the first window matches the previous fixed + // 50ms behavior. + let mut rate_ema = INITIAL_RATE_EMA_CPS; + let mut last_window = next_window(rate_ema); + + loop { + let window_timer = async { + match flush_deadline { + Some(deadline) => sleep_until(deadline).await, + // No buffered chunks: wait for the queue without a timer. + None => std::future::pending::<()>().await, + } + }; - for envelope in batch { - // Route to internal subscribers (e.g. RemoteSessionStateTracker) - // sequentially so that text chunks are appended in order. - if let Err(e) = event_router.route(envelope.clone()).await { - log::warn!("Internal event routing failed: {:?}", e); + tokio::select! { + _ = event_queue.wait_for_events() => { + loop { + let batch = event_queue.dequeue_configured_batch().await; + if batch.is_empty() { + break; } - let event_for_fanout = envelope.event.clone(); - if let Err(e) = transport.emit_event(envelope.event).await { - log::error!("Failed to emit event: {:?}", e); - } + for envelope in batch { + // Route to internal subscribers (e.g. RemoteSessionStateTracker) + // sequentially so that text chunks are appended in order. + // Internal routing stays on the raw events; only the + // WebView / peer delivery below is coalesced. + if let Err(e) = event_router.route(envelope.clone()).await { + log::warn!("Internal event routing failed: {:?}", e); + } - if !api::peer_host_invoke::attached_controllers().is_empty() { - if let Some(projected) = - bitfun_events::project_agentic_frontend_event(event_for_fanout) - { - api::remote_connect_api::fanout_peer_device_event( - projected.event_name, - projected.payload, - ); + // A non-chunk event flushes pending text immediately. + // Capture the flushed volume and the arm time before the + // coalescer drains, then feed it into the rate estimate + // through the same path as a timer-driven flush. + let pre_flush_chars = coalescer.buffered_chars(); + let arm_time = flush_arm_time; + let pushed = coalescer.push(envelope.event); + let did_flush = !pushed.is_empty(); + + for event in pushed { + deliver(event).await; + } + + if did_flush && pre_flush_chars > 0 { + if let Some(arm) = arm_time { + update_rate_after_flush( + &mut rate_ema, + pre_flush_chars, + arm, + &mut last_flush_time, + ); + } + } + + // Arm the coalescing window as soon as the first chunk + // is buffered so the window counts while the drain is + // still running; clear a stale deadline when a flush + // (e.g. a non-chunk event) drained the buffer. + if coalescer.is_pending() && flush_deadline.is_none() { + last_window = next_window(rate_ema); + } + let now = Instant::now(); + let new_deadline = next_flush_deadline( + coalescer.is_pending(), + flush_deadline, + now, + last_window, + ); + // Keep the arm time in sync with the deadline: record + // it when the window is armed, clear it when drained. + match (flush_deadline, new_deadline) { + (None, Some(_)) => flush_arm_time = Some(now), + (Some(_), None) => flush_arm_time = None, + _ => {} } + flush_deadline = new_deadline; + } + + // The window timer is only polled at the outer select, but + // the queue may stay non-empty under sustained load. Honor + // an expired deadline here so the throttle semantics hold + // (text waits at most one window) no matter how busy the + // queue is. + if flush_deadline.is_some_and(|deadline| Instant::now() >= deadline) { + let arm_time = flush_arm_time + .expect("arm_time must be set when a deadline is armed"); + flush_deadline = None; + flush_arm_time = None; + flush_coalesced( + &mut deliver, + &mut coalescer, + &mut rate_ema, + arm_time, + &mut last_flush_time, + ) + .await; } } } + _ = window_timer => { + let arm_time = flush_arm_time + .expect("arm_time must be set when a deadline is armed"); + flush_deadline = None; + flush_arm_time = None; + flush_coalesced( + &mut deliver, + &mut coalescer, + &mut rate_ema, + arm_time, + &mut last_flush_time, + ) + .await; + } } + } +} + +fn start_event_loop_with_transport( + event_queue: Arc, + event_router: Arc, + transport: Arc, +) { + tauri::async_time::spawn(async move { + event_loop_driver(event_queue, event_router, |event| { + let transport = transport.clone(); + async move { + deliver_event_to_webview(&transport, event).await; + } + }) + .await; }); } @@ -2654,3 +2845,178 @@ fn spawn_ingest_server_with_config_listener() { } pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg(test)] +mod event_loop_driver_tests { + use super::*; + use bitfun_core::agentic::events::{EventQueue, EventQueueConfig, EventRouter}; + + fn text_chunk(text: &str) -> AgenticEvent { + AgenticEvent::TextChunk { + session_id: "s".to_string(), + turn_id: "t".to_string(), + round_id: "r".to_string(), + attempt_id: None, + attempt_index: None, + text: text.to_string(), + } + } + + /// Regression test for the P1 scheduling issue: the window timer is only + /// polled at the outer `select!`, so a drain loop that never finds an + /// empty queue (sustained producer load) must still honor the deadline + /// in place. The first flush must happen ~one window after the first + /// chunk, and further windows must keep firing while the queue stays + /// non-empty. + /// + /// Setup: the producer enqueues one chunk per millisecond and delivery + /// stalls one millisecond per event, so the drain loop never sees an + /// empty queue. The paused clock steps 1ms at a time so producer and + /// driver advance deterministically. + #[tokio::test(start_paused = true)] + async fn flush_timer_fires_while_queue_stays_non_empty() { + let queue = Arc::new(EventQueue::new(EventQueueConfig { + max_queue_size: 10000, + batch_size: 10, + })); + let router = Arc::new(EventRouter::new()); + let received: Arc>> = + Arc::new(tokio::sync::Mutex::new(Vec::new())); + + let producer_queue = queue.clone(); + let producer = tokio::spawn(async move { + for i in 0..1000 { + producer_queue + .enqueue(text_chunk(&format!("chunk{i} ")), None) + .await + .expect("enqueue should succeed"); + tokio::time::sleep(Duration::from_millis(1)).await; + } + }); + + let driver_queue = queue.clone(); + let driver_received = received.clone(); + let driver = tokio::spawn(async move { + event_loop_driver(driver_queue, router, |event| { + let received = driver_received.clone(); + async move { + received.lock().await.push(event); + // Slow delivery down so the drain never finds the queue + // empty while the producer keeps enqueueing. + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await; + }); + + let mut first_flush_at_ms: Option = None; + for step in 0..300 { + tokio::time::advance(Duration::from_millis(1)).await; + tokio::task::yield_now().await; + tokio::task::yield_now().await; + tokio::task::yield_now().await; + if first_flush_at_ms.is_none() && !received.lock().await.is_empty() { + first_flush_at_ms = Some(step as u128 + 1); + } + } + + let first = first_flush_at_ms.expect( + "expected a flush within the first window; with the drain loop never \ + exiting, the deadline must still be honored in place", + ); + // First chunk lands at ~1ms; the initial window is 50ms, so the first + // flush must land around 51ms. 40..=120 is a generous bound that still + // fails if the deadline only starts after the drain loop exits. + assert!( + (40..=120).contains(&first), + "first flush at {first}ms, expected ~50ms after the first chunk" + ); + + let total = received.lock().await.len(); + assert!( + total >= 3, + "expected multiple window flushes during sustained drain, got {total}" + ); + + driver.abort(); + producer.abort(); + } + + /// Under sustained drain, merged text must stay a growing prefix of the + /// produced stream: no chunk is dropped and none is duplicated. + #[tokio::test(start_paused = true)] + async fn sustained_drain_does_not_lose_or_duplicate_text() { + let queue = Arc::new(EventQueue::new(EventQueueConfig { + max_queue_size: 10000, + batch_size: 10, + })); + let router = Arc::new(EventRouter::new()); + let received: Arc>> = + Arc::new(tokio::sync::Mutex::new(Vec::new())); + + let producer_queue = queue.clone(); + let producer = tokio::spawn(async move { + for i in 0..1000 { + producer_queue + .enqueue(text_chunk(&format!("x{i} ")), None) + .await + .expect("enqueue should succeed"); + tokio::time::sleep(Duration::from_millis(1)).await; + } + }); + + let driver_queue = queue.clone(); + let driver_received = received.clone(); + let driver = tokio::spawn(async move { + event_loop_driver(driver_queue, router, |event| { + let received = driver_received.clone(); + async move { + received.lock().await.push(event); + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await; + }); + + for _ in 0..300 { + tokio::time::advance(Duration::from_millis(1)).await; + tokio::task::yield_now().await; + tokio::task::yield_now().await; + tokio::task::yield_now().await; + } + + let events = received.lock().await; + assert!( + events.len() >= 3, + "expected multiple flushes, got {}", + events.len() + ); + // Each merged event carries only the chunks of its own window; the + // frontend appends them to the same text item. Concatenated, they must + // reproduce the producer's chunk sequence exactly: contiguous, no + // loss, no duplication, no reordering. + let mut joined = String::new(); + for event in events.iter() { + if let AgenticEvent::TextChunk { text, .. } = event { + joined.push_str(text); + } + } + let numbers: Vec = joined + .split_whitespace() + .map(|word| { + word.strip_prefix('x') + .and_then(|n| n.parse::().ok()) + .unwrap_or_else(|| panic!("unexpected chunk payload: {word:?}")) + }) + .collect(); + for (index, number) in numbers.iter().enumerate() { + assert_eq!( + *number as usize, index, + "chunk sequence must be contiguous: got x{number} at position {index}" + ); + } + + driver.abort(); + producer.abort(); + } +} diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index c177c473f1..15e9ccc1a3 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -2,6 +2,44 @@ These rules apply to all changes under `src/apps/mobile/harmonyos`. +## MVVM Refactor Boundaries + +This app has one `entry` module, so MVVM is the file-organization boundary for +the module. Keep the official responsibilities explicit: + +- Model/services own data access, persistence, transport, and business logic; + they do not import views or page components. +- Views own presentation and user input; they consume projected state and emit + intents/events rather than calling services directly. +- ViewModels bridge services and views by owning feature state, projecting data, + and handling intents. ViewModels must not import components. + +The following constraints are enforced incrementally by +`pnpm run harmony:architecture` (the runtime behavior checks remain in +`entry/src/test/ArchitectureUnit.test.ets`): + +1. `services/**` must not import `../pages/`. +2. `pages/components/**` must not import `pages/viewmodel/`; imports of + `pages/state/` and `pages/policy/` are allowed for observable state and pure + policies. +3. The page dependency graph must remain acyclic; ViewModels must not depend on + components. +4. Actions and Hooks use typed interfaces with object literals. Do not add + position-dependent callback constructors. +5. New components use `@ComponentV2`; do not add V1 `@Component`, `@State`, + `@Prop`, `@Link`, or `@Watch` declarations. `@BuilderParam` remains supported. +6. General Chat and Remote Chat shared observable fields belong to + `pages/state/ConversationCoreState.ets`. Page-specific state objects compose + that core and must not redeclare the shared `@Trace` fields. + +The current local HarmonyOS verification loop is: + +```bash +source scripts/ohos-env.sh +"$HVIGORW" --mode module -p product=default -p module=entry@default assembleHap --no-daemon +"$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon +``` + ## Visual reference fidelity - Before drawing a system glyph, text approximation, or new bitmap, search the existing HarmonyOS media resources and the approved desktop reference images. Reuse the established asset when one exists. diff --git a/src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md b/src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md new file mode 100644 index 0000000000..8185fa3cff --- /dev/null +++ b/src/apps/mobile/harmonyos/docs/mvvm-architecture-refactor-design.md @@ -0,0 +1,575 @@ +# HarmonyOS 端 MVVM 架构重构设计 + +Date: 2026-08-06 + +Status: Implementation in progress; S0-S5 and S7 are complete, while S6 component decomposition and the wide-screen visual matrix remain pending + +Scope: `src/apps/mobile/harmonyos/entry/src/main/ets` + +Baseline: commit `6c35485bb`(窄屏 Local/Remote 统一完成后) + +Reference: 华为官方文档 +[MVVM模式(状态管理V2)](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V13/arkts-mvvm-v2-V13)、 +[MVVM模式(V1)](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-mvvm)、 +[状态管理(V1)](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-state-management-v1) + +Related designs: + +- [`adaptive-conversation-ui-redesign.md`](adaptive-conversation-ui-redesign.md) +- [`wide-conversation-navigation-design.md`](wide-conversation-navigation-design.md) +- [`responsive-file-preview-design.md`](responsive-file-preview-design.md) +- [`native-code-preview-implementation-design.md`](native-code-preview-implementation-design.md) + +本文只负责**代码结构**,不改变任何用户可见行为。上述四篇设计继续负责路由合同、折痕几何、文件预览 placement 和会话 UI/UX;本文的每一个阶段都以"这些文档描述的行为在真机上完全不变"为验收前提。发生冲突时,以现有行为文档为准,重构方案让路。 + +--- + +## 0. 结论摘要 + +- **架构基准**:MVVM 是鸿蒙官方文档明确定义的模式,官方把它定位为**单模块内的文件组织方式**;整个应用的模块化官方推荐三层架构(products / features / commons)。本项目 `build-profile.json5` 只有一个 `entry` 模块,正落在 MVVM 覆盖的范围内——**MVVM 是本次重构正确且足够的框架,三层架构不在本次范围**。 +- **好消息**:ViewModel 层已经是干净的。7 个 `*ViewModel` 共 1379 行,**没有任何一个 import `components/`**。MVVM 里最难守住的一条,这里已经守住了。 +- **重构结果**:`services/` → `pages/`、`components` → `viewmodel`、`viewmodel` → `components` 当前均为零;运行时组合根已拆为 `AppRootRuntime` 与 `AppRootRuntimeComposition`,特性行为由四个 Controller 持有。 +- **一条被更正的判断**:初版诊断把"10 个 `components/*` import `../state/`"列为分层违规,**这是错的**,详见 §3.4。 +- **状态管理范式统一到 V2**:基线有 15 个 V1 struct(5114 行)与 19 个 V2 struct 混用;S5 已将这 15 个组件全部迁移到 V2。V2 是官方对新项目的推荐范式,也是官方 MVVM 示例的形式,详见 §2.8 与 §5 的 S5 阶段。 +- **实施方式**:S0–S7 八个阶段,每个阶段独立可发布、可回滚,前三个阶段零行为变更。 + +--- + +## 1. 架构基准 + +### 1.1 官方 MVVM 的三条职责界定 + +引自华为官方文档: + +- **model** —— 负责数据的获取和存储以及业务逻辑,**不与 view 关联**; +- **view** —— 负责界面展现和用户输入,**不与 model 关联**; +- **viewmodel** —— 作为连接二者的桥梁,负责将 model 数据转为 view 数据并管理界面状态。 + +官方 V2 示例的绑定形式是 `@ComponentV2` + `@Local` 持有 ViewModel 实例。 + +本文后续所有"违规"判定,都直接引用上面三句,不引入本文自创的架构偏好。 + +### 1.2 范围界定:MVVM vs 三层架构 + +官方对二者的分工是明确的: + +> MVVM 的目录组织方式一般适用于**单个模块内**的文件组织;为了更好地适配复杂应用开发,建议采用**三层架构**对**整个应用**功能进行模块化。 + +| 层级 | 编译产物 | 依赖约束 | +| --- | --- | --- | +| products(产品定制层) | Entry HAP | 可依赖 features / commons,禁止横向调用 | +| features(基础特性层) | HAR / HSP | 可依赖 commons,避免反向依赖 products | +| commons(公共能力层) | HAR / HSP | 不可依赖上层 | + +**本项目现状**:`build-profile.json5` 的 `modules` 只有 `entry` 一项,`compatibleSdkVersion 6.0.1(21)` / `targetSdkVersion 6.1.1(24)`。单模块 = MVVM 的适用范围。 + +**三层架构的引入时机**(记录,本次不做):当需要为不同设备形态提供差异化入口(折叠屏 / 平板 / 车机各自的 Entry HAP),或 `services/` 需要被鸿蒙端之外复用时,才是把 `services/` 抽成 commons HAR、把会话/Remote 抽成 features HSP 的时机。在只有一个 entry 的现在做这件事,只增加构建复杂度,不带来收益。 + +### 1.3 ArkTS/ArkUI 层面必须遵守的既有教训 + +这些是本模块已经付出过代价的约束,重构中任何一步都不得违反: + +1. **`@Builder` 的值参数不具备响应式**。只有按引用传入的单个对象参数才会驱动重渲染;builder 内部读 `this.` 才是可靠的。拆分 builder 时,凡是原先从父 builder 传入的宽度、来源等标量,一律改为在子 builder 内部读状态。 +2. **`NavPathStack` 不可观测**。任何存活于 `Navigation` 之外的界面(抽屉是典型)都不能靠它驱动刷新,必须消费 `AppShellState.activeRoute` 这个 `@Trace` 镜像。该镜像由 `AppShellViewModel.syncActiveRoute()` 统一维护,**新增导航路径必须经由 `AppShellViewModel`**。 +3. **V1 / V2 混用现状**:`@Component/@State/@Prop` 与 `@ComponentV2/@Local/@Param/@Event` 并存。本次**全量迁移到 V2**,范式统一后 §1.3.1 和 §1.3.2 两条约束的心智负担也随之下降(V2 的观测边界比 V1 明确)。分布数据见 §2.8,实施见 §5 的 S5 阶段。 + +--- + +## 2. 现状测量 + +以下全部为实测值,非估算。 + +### 2.1 规模基线 + +| 目录 | 文件数 | 行数 | +| --- | --- | --- | +| `pages/components` | 39 | 14518 | +| `services`(含 `general-chat` 21 / 3296) | 51 | 7492 | +| `pages/state` | 21 | 5645 | +| `i18n` | — | 581 | +| `model` | — | 465 | +| `pages/navigation` | — | 110 | +| 测试 `entry/src/test` | 8 | 6612 | + +### 2.2 `pages/state/` 的真实构成(一个目录装了三层) + +| 类别 | 文件 | 行数 | +| --- | --- | --- | +| ViewModel | `AppShellViewModel` 98、`ConversationViewModel` 22、`GeneralChatConversationViewModel` 336、`RemoteActivityViewModel` 163、`RemoteConnectionViewModel` 353、`RemoteSessionViewModel` 236、`RemoteWorkspaceViewModel` 171 | 1379 | +| State(`@ObservedV2` 绑定对象) | `AppShellState` 58、`ConversationViewState` 120、`FilePreviewState` 107、`GeneralChatPageState` 200、`RemoteCreateSessionState` 113、`RemotePageState` 373 | 971 | +| Policy(纯逻辑,零 `@Trace`) | `ConversationLayoutPolicy` 156、`FilePreviewPlacementPolicy` 185、`ConversationModelPresentationPolicy` 82、`ConversationSessionFilterPolicy` 51、`SessionActionPolicy` 31 | 505 | +| God Facade | `AppRootRuntime` | 2608 | +| 其他 | `ConversationIntentDispatcher`、`FilePreviewTarget` 等 | 约 182 | + +### 2.3 两个引力井的内部构成 + +**`AppRootPresentation.ets`(1449 行)**——可分离,各段落关注点互不相干: + +| 段落 | 行数 | 性质 | +| --- | --- | --- | +| 7 个 action DTO 定义(L62–270) | 209 | 属于 model 定义,不该在 view 文件里 | +| Remote UI builders | 305 | 一个独立特性面 | +| Remote 辅助方法 | 143 | 同上 | +| 宽屏几何计算 | 179 | 纯计算,可脱离 UI,当前零单测覆盖 | +| 宽屏 builders | 275 | 一个独立布局面 | + +共 25 个 `@Builder`、约 50 个私有方法、21 个 `@Local`(其中 13 个属于宽屏几何、8 个属于 Remote 过滤/元数据)。两组 `@Local` 混在同一 struct 内,意味着改宽屏分栏宽度会连带触发 Remote 过滤区重算。 + +**`AppRootRuntime.ets`(2608 行)**——性质不同,是"所有特性的门面开在同一个类上": + +- 183 个方法级条目,约 101 个 public,其中 **45 个是一行转发**; +- 75 个 import; +- 字段初始化块从 L140 延伸到 L761(621 行); +- 单个方法最长 `selectCloudAccountDevice` 91 行。 + +### 2.4 接线代码 + +12 个 `*Hooks` / `*Actions` 类:定义 412 行,在 `AppRootRuntime` 中的构造点 235 行,合计约 **650 行纯接线**。 + +其中 7 个定义在 `AppRootPresentation.ets` 内(L62–270,209 行)。构造点规模:`AppRootPresentationActions` 96 行、`RemoteSessionViewModelHooks` 46 行、`ConversationIntentDispatcherHooks` 39 行、两个 Hooks 各 23 行、一个 8 行。 + +全部为**位置参数构造**: + +```ts +new AppRootPresentationActions(a, b, c, d, /* …共 96 行实参 */) +``` + +代价不只是行数——新增一个回调要同步改三处(DTO 定义、构造点、消费点),且位置参数在 ArkTS 里没有编译期的名字保护:两个相邻的同签名回调若被调换顺序,编译通过、运行时行为错乱。这是本模块唯一一类"改对了也无法在编译期确认"的修改。 + +### 2.5 会话状态的重复 + +`GeneralChatPageState`(200)与 `RemotePageState`(373)有**约 15 个字段同名同义**。为了让上层统一消费,又长出两层扇入扇出: + +- `services/AppRootRouteState.ets`(88 行)——存在的唯一理由是在两者之间搬数据; +- `ConversationViewState.project(route, remote, general, …)`——再做一遍同样的归约; +- 分散各处的 `compact` 布尔与 `if (source === General)` 分支。 + +后果:每新增一项会话能力(附件、引用、重发……),要在两个 State 各写一次,再在两个投影层各接一次。 + +### 2.6 组件层 + +内联 glyph / icon builder 共 **538 行**,分布在 10 个文件:`AppSidebar` 179、`ConnectView` 99、`ToolStatusList` 91、`ConversationView` 61、`ChatMessageBubble` 35、`SessionActionSurface` 19、`CreateSessionSheet` 18,`ComposerBar` / `RemoteCreateSessionView` / `ChatTimeline` 各 12。 + +第二梯队大结构体:`ToolStatusList` 1442 行 / 16 builders、`ConnectView` 1344 / 24、`ChatMessageBubble` 1246 / 18、`AppSidebar` 908 / 29。 + +### 2.7 现有安全网 + +`entry/src/test/` 共 6612 行 hypium 用例: + +| 文件 | 行数 | +| --- | --- | +| `RemoteControllersUnit` | 2177 | +| `TransportAndGeneralChatUnit` | 1255 | +| `LocalTestFixtures` | 1078 | +| `ConversationStateUnit` | 1057 | +| `LifecycleUnit` | 748 | +| `AppRootLifecycleUnit` | 129 | +| `ArchitectureUnit` | 95 | +| `AppRootRuntimeStartupUnit` | 51 | + +本地运行方式(已实测通过,BUILD SUCCESSFUL 11s,报告落在 `entry/.test/default/outputs/test/reports/`): + +```bash +source scripts/ohos-env.sh +"$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon +``` + +注:现有 `ArchitectureUnit`(95 行)测的是**行为**(生成号失效、时间线归约、路由栈不变量),不是分层。分层目前无任何自动化约束。 + +### 2.8 V1 / V2 范式分布(重构前基线) + +**结构体**:V1(`@Component`)15 个,共 **5114 行**;V2(`@ComponentV2`)19 个。 + +**装饰器用量**: + +| V1 | 次数 | V2 | 次数 | +| --- | --- | --- | --- | +| `@Prop` | 79 | `@Param` | 155 | +| `@State` | 53 | `@Local` | 62 | +| `@BuilderParam` | 7 | `@Event` | 104 | +| `@Watch` | 4 | `@Trace` | 99 | +| `@Link` | 2 | `@ObservedV2` | 5 | +| `@Observed` / `@ObjectLink` / `@Provide` / `@Consume` / `@StorageLink` / `@StorageProp` | 0 | `@Monitor` | 4 | + +(`@BuilderParam` 在 V1 与 V2 中均受支持,不属于迁移面。) + +**V1 文件清单与迁移面**: + +| 文件 | 行数 | `@State` | `@Prop` | `@Link` | `@Watch` | +| --- | --- | --- | --- | --- | --- | +| `ConnectView.ets` | 1344 | 12 | 16 | — | — | +| `AppSidebar.ets` | 908 | 7 | 11 | — | — | +| `RemoteControlSettingsSheet.ets` | 872 | 13 | 12 | — | 1 | +| `ModelServiceSettingsPanel.ets` | 662 | 10 | 5 | — | — | +| `SettingsSheet.ets` | 297 | 4 | 8 | — | — | +| `CreateSessionSheet.ets` | 226 | — | 4 | 2 | — | +| `MarkdownContent.ets` | 199 | — | 1 | — | — | +| `BitFunAccountLoginPage.ets` | 146 | 5 | — | — | — | +| `StreamingMarkdownContent.ets` | 142 | 1 | 3 | — | 3 | +| `FileReferenceCard.ets` | 85 | — | 8 | — | — | +| `ThinkingBlock.ets` | 67 | 1 | 5 | — | — | +| `ChatStatusBar.ets` | 60 | — | 4 | — | — | +| `AppRoot.ets` | 48 | — | — | — | — | +| `ConversationSourceSwitcher.ets` | 40 | — | 1 | — | — | +| `DefaultAccountAvatar.ets` | 18 | — | 1 | — | — | + +**集中度**:前 4 个文件占 3786 行(V1 总量的 74%)、86 个 V1 状态装饰器(占 65%)。其中 `ConnectView` 与 `AppSidebar` 同时也是 S6 拆分的目标,可就近编排。 + +**当前是否已有跨范式错误用法**:已逐文件核查,**没有**。5 个 `@ObservedV2` 类(`AppShellState`、`RemotePageState`、`GeneralChatPageState`、`RemoteCreateSessionState`、`FilePreviewState`)**没有任何一处被 V1 的 `@State` / `@Prop` / `@Link` 持有**——官方不支持 `@ObservedV2` 对象走 V1 观测机制,这条目前没有被踩到。 + +所以全量迁移 V2 **不是在修复既有 bug,而是在消除一类风险**:只要 V1 struct 还在,任何一次后续改动都可能把某个 `@ObservedV2` 对象传进 V1 的 `@Prop`,届时得到的是"编译通过、界面不刷新"——与本模块此前踩过的抽屉不刷新(§1.3.2)完全同型、且同样难以定位的故障。 + +--- + +## 3. 诊断 + +### 3.1 符合官方定义的部分 + +- **ViewModel 层是干净的**:7 个 VM 共 1379 行,零 import `components/`。ViewModel 完全不知道 UI 存在。 +- **Policy 层是纯的**:5 个 Policy 共 505 行,零 `@Trace` / 零 `@ObservedV2`,可直接单测。 +- **已有一处标准 MVVM 三件套**:`ConversationViewState`(投影,120)→ `ConversationViewHost`(哑视图,91)→ `ConversationIntent` / `ConversationIntentDispatcher`(意图,120)。**这是本次重构要推广的形状,不需要发明新范式。** + +### 3.2 硬违规(按 §1.1 官方定义判定) + +| 官方职责 | 违规 | 证据 | +| --- | --- | --- | +| model **不与 view 关联** | `services/` → `pages/` 反向依赖 | `AppRootRouteState`、`FileTargetResolver`、`RemoteFilePreviewController`、`MessageFileReferenceProjector` 共 4 个文件 import `../pages/` | +| view **不与 model 关联** | view 文件持有 model 定义,导致真实模块环 | `AppRootPresentation.ets` L62–270 定义 209 行 action DTO → `AppRootRuntime` 反向 import `AppRootPresentation` | +| viewmodel 是**桥梁** | `AppRootRuntime` 不是桥梁,是 God Facade | 2608 行 / 101 public / 45 一行转发 / 621 行字段初始化块;所有 view 绑到同一个巨型对象,而非各自绑到所属特性的 VM | + +### 3.3 结构性问题(不算违规,但是主要成本来源) + +1. **接线子系统化**(§2.4,约 650 行)——位置参数构造带来无编译期保护的修改风险。 +2. **会话状态双份实现**(§2.5)——每项能力写四遍。 +3. **目录命名说谎**(§2.2)——`pages/state/` 一个目录装了 ViewModel / State / Policy / God Facade 四类东西,"这个文件属于哪一层"无法从路径判断,也导致分层断言写不出来。 +4. **组件层关注点混合**(§2.6)——538 行内联图标 + 四个千行级结构体。 + +### 3.4 更正:一条被推翻的初版判断 + +初版诊断把 **"10 个 `components/*` import `../state/`" 列为分层被打穿。这个判断是错的**,此处保留记录以免后续重复犯错。 + +逐文件查证结果——这 10 个文件 import 的**全部是 State 类与 Policy 类,没有一个 import `*ViewModel`**: + +``` +AppShell.ets → AppShellState +AppSidebar.ets → SessionActionPolicy +ConversationViewHost.ets → ConversationViewState +ComposerBar.ets → ConversationModelPresentationPolicy +FilePreviewSurface.ets → FilePreviewState +ConversationIntent.ets → FilePreviewTarget +ConversationViewSettings → ConversationSessionFilterPolicy +RemoteSessionList.ets → SessionActionPolicy, ConversationSessionFilterPolicy +RemoteCreateSessionView → RemoteCreateSessionState +AppRootPresentation.ets → AppShellState 等 6 个 State/Policy +``` + +View 持有 `@ObservedV2` 状态对象**正是 ArkUI V2 官方推荐的绑定方式**,不是违规。真正的问题是 §3.3 第 3 条:目录名叫 `state`,内容却是四层,让合规的 import 看起来像违规。 + +**因此 S6 的目标已相应修正**:从"切断 `components → state` 的 import"改为"消除内联图标与多关注点混合"。 + +--- + +## 4. 目标结构 + +依赖单向向下,`pages/state/` 按真实层次拆开: + +``` +pages/ + ├─ AppRoot.ets @Entry,组合根 + ├─ actions/ 所有 Actions/Hooks 接口定义(从 view 文件搬出,环即断) + ├─ viewmodel/ 7 个 *ViewModel + 按特性拆出的 Controller + ├─ state/ 纯 @ObservedV2 绑定对象 + ├─ policy/ 纯逻辑,无装饰器,全部可单测 + ├─ layout/ WideLayoutGeometry 等纯几何计算 + ├─ navigation/ AppRouteContract(叶子) + └─ components/ 哑视图 + Glyphs 图标库 +services/ model 层:领域与传输,禁止 import ../pages +model/ i18n/ 叶子 +``` + +**五条硬约束**(S0 写入 `AGENTS.md` 并以"已知清单"模式开始由 `ArchitectureUnit` 拦截新增违规;第 5 条在 S5 完成后转为强制,第 1–3 条在 S7 完成后转为强制): + +1. `services/**` 不得 import `../pages/`; +2. `pages/components/**` 不得 import `pages/viewmodel/`(import `state/` `policy/` 合法); +3. 不存在任何模块环,`viewmodel → components` 方向禁止; +4. Actions/Hooks 一律 `interface` + 对象字面量,禁止位置参数构造; +5. **组件一律 `@ComponentV2`**,禁止新增 `@Component` / `@State` / `@Prop` / `@Link` / `@Watch`(`@BuilderParam` 不在此列,V2 亦支持)。 + +**每个特性面的标准形状**(推广 §3.1 已有的三件套): + +``` +XxxViewState 投影:把 model 数据转成 view 数据 +XxxHost 哑视图:只接 @Param 和回调 +XxxIntent 意图:view 向上表达"用户想做什么" +XxxViewModel 桥梁:持有 state、消费 services、处理 intent +``` + +--- + +## 5. 分阶段方案 + +按"风险调整后收益"排序。S0–S2 零行为变更。每阶段独立可发布、可回滚。 + +### S0 · 立规则与护栏(0.5 天,零行为变更) + +**做什么** + +1. 把 §1.1 官方三条职责、§1.2 范围界定、§4 五条硬约束写入 `src/apps/mobile/harmonyos/AGENTS.md`; +2. 把 §2.7 的本地测试命令补进 `AGENTS.md`(目前未文档化); +3. 扩展 `ArchitectureUnit.test.ets`,新增两组源文件扫描断言,均采用**"已知清单"模式**——断言"当前违规集合 == 登记清单",从此新增违规立即失败,存量按阶段递减: + - 分层断言:登记当前 5 处(`services → pages` 4 处 + `runtime → presentation` 1 处),S7 清零; + - **范式断言**:登记当前 15 个 V1 文件(§2.8 清单),S5 清零。这一条从 S0 当天起就阻止新增 V1 组件,避免迁移期间边迁边长。 + +**为什么先做**:规则来自官方文档,不需要团队内部论证;两份清单让后续每阶段的进度可测,且"只减不增"是机器保证的。 + +**风险**:无。不触碰产物代码。 + +--- + +### S1 · 从 view 中取出 model 定义,断环 + 拆分引力井(1–2 天,零行为变更) + +**做什么** + +1. **7 个 action DTO(L62–270,209 行)→ `pages/actions/`**。单独这一步就消掉硬违规 ② 与循环依赖,建议独立成第一个 commit。 +2. Remote builders + helpers(448 行)→ `pages/components/remote/RemoteSurfaceHost.ets`,带走 8 个 Remote `@Local`。 +3. 宽屏几何(179 行)→ `pages/layout/WideLayoutGeometry.ets`,纯函数,**顺带补单测**(当前零覆盖)。宽屏 builders 带走 13 个几何 `@Local`。 +4. `pages/state/` 按 §4 拆成 `viewmodel/` `state/` `policy/`——纯改目录与 import 路径,零逻辑改动,但让 S0 的断言写得出来。 + +目标:`AppRootPresentation.ets` 从 1449 行收敛到约 300 行的装配壳。 + +**实际结果(2026-08-07)**:7 组 action DTO 已迁入 `pages/actions/`;Remote、 +窄屏路由、宽屏会话与根级 overlay 分别由 `RemoteSurfaceHost`、 +`ConversationRouteSurface`、`WideConversationHost`、`AppRootOverlaySurfaces` +持有。宽屏几何已迁入 `pages/layout/WideLayoutGeometry.ets`,并由 +`ArchitectureUnit` 覆盖关键几何约束。`AppRootPresentation.ets` 从基线 1449 行 +收敛到 406 行,保留响应式测量、`Navigation`、compact preview overlay、Remote +settings sheet 与顶层装配。架构门禁要求该文件不超过 500 行,并要求上述拆分文件 +持续存在。 + +HAP、LocalTest 与窄屏真机 Local → Remote → Local 往返均通过。真机 smoke 曾发现 +`@BuilderParam` slot 内直接构造 V2 组件会触发 `class constructor cannot called without +'new'`;现已改为由 `@Builder` 方法承接 slot,并复验进程在完整往返中持续存活。 +当前两个 target 分别为 1080 × 2444 真机和 466 × 466 模拟器,均不能提供宽屏三栏 +验收条件,因此 S1 的宽屏视觉复验仍记为待办。 + +**风险点(本阶段唯一)**:`@Builder` 值参数不响应式(§1.3.1)。拆分后凡是原先由父 builder 传入的标量,必须改为子 builder 内读状态——`wideMasterPaneCurrentWidth()` 就是这个坑的既有修复案例。 + +**验证**:完整验证回路 + **必须真机复验宽屏三栏与窄屏抽屉来源切换**。 + +--- + +### S2 · 消灭位置参数接线(2–3 天,零行为变更) + +**做什么**:12 个 `*Hooks` / `*Actions` 由 `class` + 位置构造改为 `interface` + 对象字面量。 + +```ts +// before —— 96 行实参,顺序错了编译期无感 +new AppRootPresentationActions(onA, onB, onC, /* … */) + +// after —— 字段名保护,新增回调只改两处 +const actions: AppRootPresentationActions = { + onA: () => { /* … */ }, + onB: () => { /* … */ }, + onC: () => { /* … */ } +}; +``` + +约 650 行接线降至约 250 行。可按 12 个类逐个 commit,每个独立可回滚。 + +**风险**:低。ArkTS 对象字面量要求有明确声明类型,`interface` 满足;改造过程中若某个 Hooks 含方法实现而非纯回调字段,保留为 class 但改为具名参数对象构造。 + +--- + +### S3 · 统一会话状态(3–5 天,**有行为风险**) + +**做什么** + +1. 抽出承载 §2.5 那 15 个共享字段的公共载体;`GeneralChatPageState` / `RemotePageState` 只保留各自特有字段; +2. 删除 `services/AppRootRouteState.ets`(88 行)——同时消掉硬违规 ① 的四分之一; +3. 收敛 `ConversationViewState.project` 的双源分支。 + +**前置 spike(0.5 天,必做)**:验证 ArkUI V2 的 `@Trace` 能否穿透 `@ObservedV2` 基类继承——本模块目前没有先例,不能假设。 + +- 若可以 → 用继承(`ConversationSessionState` 基类)。 +- 若不行 → **退化为组合**:两个 State 各持有一个 `ConversationCore` 字段,投影层只读 core。效果等价,只是访问路径多一层。 + +**Spike 结论(2026-08-06)**:采用组合方案。当前工程没有可证明 `@Trace` +跨 `@ObservedV2` 基类继承订阅关系的运行时先例,HAP 编译和 LocalTest 只能证明语法与 +状态行为,不能证明 UI 订阅穿透。`GeneralChatPageState` 与 `RemotePageState` 因此各自组合 +独立的 `ConversationCoreState`,组件和 `ConversationViewState` 直接读取 core。已通过窄屏 +真机 Local → Remote → Local 往返验证;宽屏真机仍需在折叠设备展开后复验。 + +**风险**:本方案中最高。但安全网充足——`ConversationStateUnit`(1057)+ `RemoteControllersUnit`(2177)直接覆盖这块。 + +**验证**:完整回路 + 真机走通四条路径:本地新建/继续会话、Remote 新建/继续会话、窄屏抽屉来源切换、宽屏来源切换。 + +--- + +### S4 · 拆解 God Facade(4–6 天,分批) + +**做什么**:按 S3 建立的特性边界,把 `AppRootRuntime` 切成 `ConversationController` / `RemoteConnectionController` / `SettingsController` / `FilePreviewController`,`AppRootRuntime` 退化为持有它们的组合根。 + +**实施结果(2026-08-07,已完成)**:已落地 `FilePreviewController`、 +`SettingsController`,并建立 `ConversationController` 的首批跨表面 composer/voice 状态边界; +对应旧方法已从 `AppRootRuntime` 删除,静态门禁禁止回流。现有连接实现也已从 +`RemoteConnectionViewModel` 更名为 `RemoteConnectionController`,根运行时的 21 个状态 getter +和 11 个连接状态转发已删除;路由、workspace/session 列表、polling/heartbeat 的 28 个 +owner 转发也已改为直接绑定。云账号凭据、持久化、云模型目录、权限设置与账号设备切换 +闭环也已迁入 `SettingsController`,包括原 91 行的 `selectCloudAccountDevice`。 +远程会话的发送、停止/重试、工具动作、时间线投影与 polling cursor 运行态已迁入 +`ConversationController`;Remote 新建会话的设备/workspace/模型选择、提交与路由流程也由其 +统一持有。本地会话的打开/新建/发送、草稿、归档与时间线投影同样已收口到该 owner。 +根运行时由 2608 行降至 372 行;纯依赖实例化和回调接线迁入 +`AppRootRuntimeComposition`,其抽象端口仍由根运行时实现,避免装配层反向拥有页面生命周期行为。 +HAP、完整 LocalTest 与窄屏真机 Local → Remote → Local 往返均通过。尚未完成 +宽屏复验,仍等待可用的展开设备。 + +顺序(每步独立 commit): + +1. 清理 45 个一行转发——调用点直接指向真正的 owner; +2. 拆 621 行字段初始化块(L140–761)为各 Controller 的构造; +3. 处理 `selectCloudAccountDevice`(91 行)等长方法; +4. 按官方 V2 形状收口:view 用 `@ComponentV2` + `@Local` 持有**所属特性的** ViewModel,而非同一个巨型对象。 + +**与 S5 的次序说明**:本阶段涉及的装配层(`AppRootPresentation` 及其拆出的 host)已经是 V2,`AppRoot.ets` 虽是 V1 但无任何状态装饰器,因此第 4 步不需要等 S5。S5 排在其后,是因为它的主体(`ConnectView`、`AppSidebar` 等叶子组件)与 Controller 拆分互不相干,放在结构稳定之后迁移,可以避免同一文件被两种性质的改动连续翻动。 + +目标:`AppRootRuntime` < 500 行。消除硬违规 ③。 + +**风险**:中。生命周期是重点——`aboutToAppear` / `onPageShow` / `onPageHide` / `aboutToDisappear` / `handleRootBack` 的调用顺序与轮询启停必须逐一保持。`LifecycleUnit`(748)+ `AppRootLifecycleUnit`(129)+ `AppRootRuntimeStartupUnit`(51)覆盖此处。 + +--- + +### S5 · V1 全量迁移到 V2(4–5 天,**逐文件有行为风险,已完成 2026-08-07**) + +**做什么**:把 §2.8 清单里的 15 个 V1 struct 全部迁到 `@ComponentV2`,之后 `pages/` 下不再存在 V1 装饰器。 + +**为什么值得单列一个阶段**(而不是像初版那样"顺手统一"): + +1. **官方推荐**。V2 是官方对新项目的推荐范式,官方 MVVM 示例也是 `@ComponentV2` + `@Local` 持有 ViewModel 实例的形式。范式统一后 §4 的目标结构与官方文档一一对应,不需要读代码的人在两套心智模型间切换。 +2. **消除一类难定位故障**。§2.8 已核查:目前**没有**任何 `@ObservedV2` 对象被 V1 装饰器持有。但只要 V1 struct 还在,后续任何一次改动都可能把状态对象传进 `@Prop`,得到"编译通过、界面不刷新"——与抽屉不刷新(§1.3.2)同型的故障,本模块已经为这类问题付出过一次排查成本。 +3. **观测边界更明确**。V2 的 `@Trace` 深度观测与 `@Monitor` 的新旧值回调,比 V1 的 `@Observed` / `@ObjectLink` 嵌套观测更容易推理,也更容易在 review 中判断对错。 + +**迁移映射表**(逐条替换,不是全局改名): + +| V1 | V2 | 语义差异——**必须逐字段确认,这是本阶段的主要风险**| +| --- | --- | --- | +| `@Component` | `@ComponentV2` | — | +| `@State`(53) | `@Local` | 基本等价,子组件自有状态 | +| `@Prop`(79) | `@Param` | **不等价**。V1 `@Prop` 是**深拷贝**,子组件可以本地改写;V2 `@Param` 是**按引用只读**,子组件不可赋值。凡是子组件确实在本地改写该字段的,需迁为 `@Param @Once`(仅初始同步、之后子组件自持)或 `@Local` + 显式初始化 | +| `@Link`(2) | `@Param` + `@Event` | **不等价**。V2 取消了双向绑定,须拆成"向下传值 + 向上回调"。仅 `CreateSessionSheet.ets` 的 `sessionTitle` / `instruction` 两处 | +| `@Watch`(4) | `@Monitor` | 回调签名不同,`@Monitor` 提供新旧值;`RemoteControlSettingsSheet` 1 处、`StreamingMarkdownContent` 3 处 | +| `@BuilderParam`(7) | 不变 | V2 同样支持,不属于迁移面 | + +**顺序**(每个文件独立 commit,从小到大以便先摸清坑): + +1. 先迁 5 个小文件(`DefaultAccountAvatar` 18、`ConversationSourceSwitcher` 40、`AppRoot` 48、`ChatStatusBar` 60、`ThinkingBlock` 67)——`AppRoot` 无任何状态装饰器,是纯粹的 `@Component` → `@ComponentV2` 改名,可作为第一个 commit 验证工具链; +2. 迁 `@Link` / `@Watch` 三个特殊文件(`CreateSessionSheet` 226、`StreamingMarkdownContent` 142、`RemoteControlSettingsSheet` 872)——语义变化集中在这里,单独处理便于 review; +3. 迁剩余中等文件(`FileReferenceCard` 85、`BitFunAccountLoginPage` 146、`MarkdownContent` 199、`SettingsSheet` 297、`ModelServiceSettingsPanel` 662); +4. 最后迁 `AppSidebar`(908)与 `ConnectView`(1344)——这两个占 V1 总量 44%,且是 S6 的拆分目标,**先迁后拆**:若先拆再迁,会在拆分过程中制造 V1/V2 交界,把两类风险叠在同一个 commit 里。 + +**风险**:中。集中在 `@Prop` → `@Param` 的 79 处——**不能批量替换**,每一处都要确认子组件是否本地改写。`StreamingMarkdownContent` 尤其要小心:它的 3 个 `@Prop` 全部带 `@Watch`,流式 Markdown 的增量渲染依赖这套回调时序。 + +**验证**:完整回路,且**每个 commit 都要真机验证该组件所在界面**。重点回归:连接流程(`ConnectView`)、侧栏与会话列表(`AppSidebar`)、Remote 控制设置(`RemoteControlSettingsSheet`)、流式回复渲染(`StreamingMarkdownContent`)、新建会话(`CreateSessionSheet`)。 + +**完成标志**:`ArchitectureUnit` 的 V1 已知清单清空,范式断言由"等于清单"翻为"必须为空";此后新增 V1 组件在 CI 直接失败。 + +**实际结果**:15 个 V1 页面组件全部迁移。逐字段审计结论是:只读父输入迁为 +`@Param`;需要用户编辑的值由子组件 `@Local` draft 持有,并通过显式事件上送; +`CreateSessionSheet` 的两个 `@Link` 拆为 `@Param` + `@Event`; +`StreamingMarkdownContent` 与 `RemoteControlSettingsSheet` 的监听迁为 `@Monitor`。 +本轮没有字段符合“只接收一次父级初值、之后完全由子组件持有”的语义,因此没有使用 +`@Param @Once`。HAP 编译同时验证 `@Param` 未被子组件赋值,架构门禁中的 V1 清单 +已经为空。HAP、LocalTest、窄屏启动与 Local → Remote → Local 往返均通过。 + +--- + +### S6 · 纯化组件层(3–4 天,纯视觉风险) + +**做什么** + +1. 侧栏和工具列表的重复 glyph 已分别收口到 `SidebarGlyphs.ets`、`ToolGlyphs.ets`; +2. 按视觉关注点拆出 `ConnectAccountDevicePage`(账号设备选择)、 + `ChatMessageContent`(图片/Markdown/文件卡片)两个 V2 子组件, + `AppSidebar` 从 908 行降至 700 行,`ConnectView` 从 1344 行降至 1055 行。 + `ToolStatusList` 的业务分组和交互状态仍保留在原 owner,避免纯视觉迁移改变工具动作时序。 +3. S1 同时完成根展示面的纯视觉拆分:Remote、窄屏路由、宽屏会话和 overlay 已由 + 四组 V2 host/surface 组件持有,`AppRootPresentation` 当前为 406 行。 +4. 第二批拆分已落地:`ConnectManualPairingOverlay` 持有手工配对表单, + `ToolInteractionPanels` 持有工具 JSON 编辑/批准和问答草稿,`ChatMessageChrome` + 持有用户气泡、重试提示和流式三点动画。对应主文件当前分别为 + `ConnectView` 695 行、`ToolStatusList` 1106 行、`ChatMessageBubble` 972 行;预算已写入 + `pnpm run harmony:architecture`,禁止展示职责回流。 + +**目标已按 §3.4 修正**:不包含"切断 `components → state`"——该 import 合法。**也不再包含装饰器统一**——S5 已完成,本阶段拆出的新组件天然是 V2。 + +**风险**:纯视觉回归。**每一步必须真机截图,窄屏 + 宽屏 × 浅色 + 深色四组**;所有颜色走 `Theme.ets` 语义 token,`pnpm run theme:color-audit:all` 必须干净。 + +**实际进度(2026-08-07,进行中)**:已完成窄屏浅色启动、侧栏展开、 +Local → Remote → Local 往返截图;新接入 HUAWEI MatePad Pro `WEB-W00` +(2880 × 1920),已安装本轮 HAP,并完成 Pad 浅色/深色下 Local、Remote Home 和连接 +设备面板截图,应用进程持续存活。宽屏合同不等于 Pad 合同:现有 +`ConversationLayoutPolicy` 同时读取零/一/两道纵向折痕,两道折痕的三折叠继续使用 +“左屏 master + 中/右两屏同一个 detail”,正文与关键热区选择不跨第二道折痕的最宽 +内容带;零/一/两道折痕、非对称三屏和非法折痕均有 LocalTest 覆盖。 + +三折叠完整展开及双屏/三屏动态切换仍需要真实两折痕设备验证,Pad 不能替代该项; +文件预览打开/关闭矩阵也尚未闭合,因此 S6 仍不能标记为完成。 + +--- + +### S7 · 关闭护栏(0.5 天) + +原 3 处 `services/` → `pages/` 反向依赖已在 S1/S3 的文件归属迁移中清零;当前 +`pnpm run harmony:architecture` 的 `serviceToPages`、`componentToViewmodel`、 +`viewmodelToComponents` 均为空,V1 清单也为空。门禁已从基线清单切换为永久空集, +并补齐了 `AGENTS.md` 与 `ArchitectureUnit` 的归属说明。 + +--- + +## 6. 每阶段固定验证回路 + +```bash +source scripts/ohos-env.sh + +# 1. 构建 +"$HVIGORW" --mode module -p product=default -p module=entry@default assembleHap --no-daemon + +# 2. 本地单元测试(6612 行 hypium 用例) +"$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon + +# 3. 颜色审计 +pnpm run theme:color-audit:all + +# 4. 真机验证(折叠设备 5ZU0226202001116) +hdc -t 5ZU0226202001116 shell snapshot_display -f /data/local/tmp/s.jpeg +hdc -t 5ZU0226202001116 file recv /data/local/tmp/s.jpeg ./s.jpeg +``` + +设备侧注意事项(已踩过的坑): + +- bundle 名是 **`com.example.bitfun_mobile`**,不是 `com.bitfun.mobile`; +- `hdc` 必须带 `-t `,否则报 `[Fail]ExecuteCommand need connect-key`(列出了两个 target); +- 外屏分辨率 1080×2444;点击用 `hdc -t shell uinput -T -c X Y`。 + +**真机验证的最低集合**(每阶段都要过):窄屏抽屉 Local ↔ Remote 来源切换、宽屏三栏、文件预览打开/关闭、深浅色各一轮。 + +--- + +## 7. 明确不做的事 + +- **不引入三层架构(products / features / commons)**。理由见 §1.2:单 entry 模块,收益为零、构建复杂度为正。 +- **不引入新的状态管理库或跨端抽象层**。问题是组织方式,不是工具。 +- **不重构 `services/general-chat/`(21 文件 / 3296 行)内部结构**。它自身分层是干净的,只需在 S7 切断对 `pages/` 的反向依赖。 +- **不追求行数目标本身**。S1 + S2 净减约 800 行是副产品;真正的收益是"改一处不用改三处"和"违规能被 CI 挡住"。 + +> 初版方案曾把"V1 → V2 全量迁移"列在本节。该判断已推翻——理由见 §5 的 S5 阶段,迁移已提升为独立阶段。 + +--- + +## 8. 遗留事项 + +- **窄屏"刷新"与"助手选择"入口缺失**(baseline `6c35485bb` 引入)。删除 `RemoteHomeView.ets` 统一窄屏 Remote 界面时,这两个入口一并移除,宽屏本来就没有。待定:是否补进共享侧栏的 `...` 菜单。此项与本重构无依赖关系,可独立处理。 +- **S6 组件纯化尚未完成**。优先继续拆分 `ToolStatusList`、`ChatMessageBubble` 与 + `ConnectView`,每次拆分保持动作 owner 和时序不变。 +- **视觉验证矩阵尚未闭合**。仍需补窄屏深色、文件预览打开/关闭,以及宽屏三栏的 + 深浅色截图;后者等待可用的展开折叠屏或平板 target。 diff --git a/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md b/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md index 35e2db0e71..a2296504cc 100644 --- a/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md +++ b/src/apps/mobile/harmonyos/docs/wide-conversation-navigation-design.md @@ -6,7 +6,7 @@ Scope: `src/apps/mobile/harmonyos`,主要涉及双屏/三屏布局、会话来 ## 实施状态 -截至 2026-07-30: +截至 2026-08-07: ### 已实现 @@ -35,6 +35,7 @@ Scope: `src/apps/mobile/harmonyos`,主要涉及双屏/三屏布局、会话来 - 在同一设备的折叠单屏态(`1080 x 2444`)验证:页面保持原单屏头部和 Composer,可打开原侧边栏;点击 `Remote` 继续打开原“选择桌面设备”Sheet,系统返回可关闭 Sheet 并恢复本地 Home;本地历史会话的显示保持原样。 - 折叠单屏连接已有在线桌面后验证:远程 Home 保留原头部、菜单和会话列表;进入已有远程会话后保留原会话头部与 Composer;系统返回从远程会话回到远程 Home;打开原侧边栏并选择本地会话可恢复本地内容。全程未发送消息、运行命令或启动远程任务。 - 单屏根 `ChatHome` 的系统返回基线已核实:历史本地会话仍投影在根路由,侧边栏可见时也未接入根返回拦截,因此返回会退出 Ability。本次宽屏改动不改变该行为;是否优化应作为独立单屏导航问题处理。 +- 在 HUAWEI MatePad Pro(`WEB-W00`,`2880 x 1920`)安装最新 HAP,浅色与深色均验证本地/Remote 来源选择器、常驻 master、Remote 未连接占位和居中的连接设备面板;布局边界稳定,应用进程持续存活。Pad 验证只覆盖无折痕宽屏,不替代下述三折叠真机项。 ### 待验证 @@ -450,6 +451,7 @@ MasterDetail -> 双屏和三屏共同使用的 master-detail | 展开宽屏,本地会话 | 来源选择器保持“本地”,会话选中态正确,右侧显示当前会话 | | 展开宽屏,远程 Home | 来源选择器选中“Remote”,可一步切回本地,不显示全局侧边栏按钮 | | 展开宽屏,远程会话 | 来源选择器保持“Remote”,会话选中态正确,右侧显示当前会话,不显示全局侧边栏按钮 | +| 宽屏点击远程会话 | 左侧立即选中新会话;右侧立即进入该会话,慢加载时显示时间线骨架,完成后原位替换为历史消息 | | 三屏完整展开,本地会话 | 左屏显示本地 master,中间和右侧共同显示一个本地 detail | | 三屏完整展开,远程会话 | 左屏显示远程 master,中间和右侧共同显示一个远程 detail | | 三屏远程断开 | 左屏仍显示来源选择器,右侧两屏显示一个连续断开状态 | diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewTarget.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/FilePreviewTarget.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewTarget.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/model/FilePreviewTarget.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets index 4a918697db..3c22ccc445 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets @@ -1,9 +1,9 @@ import { AppRootPresentation } from './components/AppRootPresentation'; import { ArkUiAppRootHostAdapter } from './host/AppRootHostAdapter'; -import { AppRootRuntime } from './state/AppRootRuntime'; +import { AppRootRuntime } from './runtime/AppRootRuntime'; @Entry -@Component +@ComponentV2 struct AppRoot { private readonly hostAdapter: ArkUiAppRootHostAdapter = new ArkUiAppRootHostAdapter(); private readonly runtime: AppRootRuntime = new AppRootRuntime(this.hostAdapter); @@ -38,7 +38,7 @@ struct AppRoot { remoteCreateState: this.runtime.remoteCreateState, generalPageState: this.runtime.generalChatPageState, filePreviewState: this.runtime.filePreviewState, - deviceId: this.runtime.remoteConnectionViewModel.getDeviceId(), + deviceId: this.runtime.remoteConnectionController.getDeviceId(), actions: this.runtime.presentationActions }) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets new file mode 100644 index 0000000000..63fecd397e --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets @@ -0,0 +1,152 @@ +import { RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ConversationIntent } from './ConversationIntent'; +import { AppRoute, ConversationSource } from '../navigation/AppRouteContract'; + +export interface AppRootPresentationActions { + readonly onNavigationBack: (route: AppRoute) => boolean; + readonly onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void; + readonly onCloseSidebar: () => void; + readonly onWideConversationSource: (source: ConversationSource) => void; + readonly onCompactConversationSource: (source: ConversationSource) => void; + readonly onCompactLayoutEntered: () => void; + readonly onLayoutModeChanged: (wideLayout: boolean) => void; + readonly onRemoteHome: RemoteHomePresentationActions; + readonly onRemoteCreate: RemoteCreatePresentationActions; + readonly onSidebar: SidebarPresentationActions; + readonly onSettings: SettingsPresentationActions; + readonly onConnect: ConnectPresentationActions; + readonly onFilePreview: FilePreviewPresentationActions; + readonly generalStatus: () => string; +} + +export interface FilePreviewPresentationActions { + readonly close: () => void; + readonly refresh: () => void; + readonly download: (path: string) => void; + readonly openLink: (reference: string, label: string) => void; +} + +export interface RemoteCreatePresentationActions { + readonly back: () => void; + readonly toggleDevices: () => void; + readonly toggleWorkspaces: () => void; + readonly selectDevice: (device: CloudAccountDevice) => void; + readonly selectWorkspace: (path: string) => void; + readonly draftChanged: (value: string) => void; + readonly voiceInput: () => void; + readonly selectModel: (modelId: string) => void; + readonly send: () => void; +} + +export interface RemoteHomePresentationActions { + readonly openSidebar: () => void; + readonly connectWorkspace: () => void; + readonly addConnection: () => void; + readonly openSettings: () => void; + readonly refresh: () => void; + readonly showWorkspaces: () => void; + readonly showAssistants: () => void; + readonly selectWorkspace: (path: string) => void; + readonly selectAssistant: (path: string) => void; + readonly cancelWorkspace: () => void; + readonly cancelAssistant: () => void; + readonly queryChanged: (query: string) => void; + readonly search: () => void; + readonly loadMore: () => void; + readonly reconnect: () => void; + readonly disconnect: () => void; + readonly clearPairing: () => void; + readonly create: (agentType: string) => void; + readonly createInPlace: (agentType: string) => void; + readonly createAssistant: () => void; + readonly createInWorkspace: (path: string, agentType: string) => void; + readonly createInWorkspaceInPlace: (path: string, agentType: string) => void; + readonly openSession: (session: RemoteSession) => void; + readonly openSessionInPlace: (session: RemoteSession) => void; + readonly deleteSession: (session: RemoteSession) => void; +} + +export interface SidebarPresentationActions { + readonly close: () => void; + readonly newChat: () => void; + readonly enterCode: () => void; + readonly settings: () => void; + readonly openAccount: () => void; + readonly openSession: (session: RemoteSession) => void; + readonly archive: (session: RemoteSession, archived: boolean) => void; + readonly exportSession: (session: RemoteSession) => void; + readonly deleteSession: (session: RemoteSession) => void; +} + +export interface SettingsPresentationActions { + readonly close: () => void; + readonly addConnection: () => void; + readonly disconnect: () => void; + readonly reconnect: () => void; + readonly openAccount: () => void; + readonly cloudLogin: (relayUrl: string, username: string, password: string) => Promise; + readonly cloudSync: () => Promise; + readonly cloudLogout: () => Promise; + readonly cloudListDevices: () => Promise; + readonly getPermissionMode: () => Promise; + readonly setPermissionMode: (mode: RemotePermissionMode) => Promise; + readonly testGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; + readonly saveGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; +} + +export interface ConnectPresentationActions { + readonly back: () => void; + readonly connect: (password?: string) => void; + readonly clearPairing: () => void; + readonly urlChanged: (url: string) => void; + readonly userChanged: (user: string) => void; + readonly detected: (url: string) => boolean; + readonly inputVisible: (visible: boolean) => void; + readonly paste: () => void; + readonly scan: () => void; + readonly cloudListDevices: () => Promise; + readonly cloudSelectDevice: (device: CloudAccountDevice) => Promise; +} + +export function emptyAppRootPresentationActions(): AppRootPresentationActions { + return { + onNavigationBack: () => false, + onConversationIntent: () => {}, + onCloseSidebar: () => {}, + onWideConversationSource: () => {}, + onCompactConversationSource: () => {}, + onCompactLayoutEntered: () => {}, + onLayoutModeChanged: () => {}, + onRemoteHome: { + openSidebar: () => {}, connectWorkspace: () => {}, addConnection: () => {}, openSettings: () => {}, + refresh: () => {}, showWorkspaces: () => {}, showAssistants: () => {}, selectWorkspace: () => {}, + selectAssistant: () => {}, cancelWorkspace: () => {}, cancelAssistant: () => {}, queryChanged: () => {}, + search: () => {}, loadMore: () => {}, reconnect: () => {}, disconnect: () => {}, clearPairing: () => {}, + create: () => {}, createInPlace: () => {}, createAssistant: () => {}, createInWorkspace: () => {}, + createInWorkspaceInPlace: () => {}, openSession: () => {}, openSessionInPlace: () => {}, deleteSession: () => {} + }, + onRemoteCreate: { + back: () => {}, toggleDevices: () => {}, toggleWorkspaces: () => {}, selectDevice: () => {}, + selectWorkspace: () => {}, draftChanged: () => {}, voiceInput: () => {}, selectModel: () => {}, send: () => {} + }, + onSidebar: { + close: () => {}, newChat: () => {}, enterCode: () => {}, settings: () => {}, openAccount: () => {}, + openSession: () => {}, archive: () => {}, exportSession: () => {}, deleteSession: () => {} + }, + onSettings: { + close: () => {}, addConnection: () => {}, disconnect: () => {}, reconnect: () => {}, openAccount: () => {}, + cloudLogin: async () => '', cloudSync: async () => '', cloudLogout: async () => {}, + cloudListDevices: async () => [], getPermissionMode: async () => 'ask', + setPermissionMode: async (mode: RemotePermissionMode) => mode, + testGeneral: async () => '', saveGeneral: async () => '' + }, + onConnect: { + back: () => {}, connect: () => {}, clearPairing: () => {}, urlChanged: () => {}, userChanged: () => {}, + detected: () => false, inputVisible: () => {}, paste: () => {}, scan: () => {}, + cloudListDevices: async () => [], cloudSelectDevice: async () => {} + }, + onFilePreview: { close: () => {}, refresh: () => {}, download: () => {}, openLink: () => {} }, + generalStatus: () => '' + }; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets similarity index 94% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets index 6531352eeb..76dc0a66e0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationIntent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets @@ -1,5 +1,5 @@ -import { ConversationUiQuestionAnswer } from './ConversationUiModels'; -import { FilePreviewRequest } from '../state/FilePreviewTarget'; +import { ConversationUiQuestionAnswer } from '../components/ConversationUiModels'; +import { FilePreviewRequest } from '../../model/FilePreviewTarget'; export enum ConversationIntentType { OpenSidebar = 'open_sidebar', diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets similarity index 67% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets index 0c2c8bbf7e..a07455141e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationIntentDispatcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets @@ -1,10 +1,10 @@ import { RemoteQuestionAnswerPayload, RemoteSession } from '../../model/RemoteModels'; import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; -import { ConversationIntent, ConversationIntentType } from '../components/ConversationIntent'; +import { ConversationIntent, ConversationIntentType } from './ConversationIntent'; import { toRemoteQuestionAnswer } from '../components/ConversationUiModels'; -import { FilePreviewRequest } from './FilePreviewTarget'; +import { FilePreviewRequest } from '../../model/FilePreviewTarget'; -export class ConversationIntentDispatcherHooks { +export interface ConversationIntentDispatcherHooks { readonly openSidebar: () => void; readonly back: () => void; readonly newRemoteSession: () => void; @@ -35,33 +35,6 @@ export class ConversationIntentDispatcherHooks { readonly send: () => Promise; readonly voiceInput: () => Promise; readonly inputChanged: (route: AppRoute, value: string) => void; - - constructor( - openSidebar: () => void, back: () => void, newRemoteSession: () => void, newGeneralSession: () => void, - activeGeneralSession: () => RemoteSession, activeGeneralSessionId: () => string, - isGeneralBusy: () => boolean, isPinned: (id: string) => boolean, - pin: (session: RemoteSession, pinned: boolean, busy: boolean) => Promise, - archive: (session: RemoteSession) => Promise, deleteSession: (session: RemoteSession) => Promise, - showToast: (text: string) => void, uploadedFileCount: () => number, - stop: () => Promise, loadOlder: () => Promise, approve: (id: string, input?: Object) => Promise, - reject: (id: string) => Promise, cancel: (id: string) => Promise, - answer: (id: string, answers: RemoteQuestionAnswerPayload) => Promise, rename: (title: string) => Promise, - copy: (text: string) => Promise, retry: (text: string) => Promise, selectModel: (id: string) => Promise, - pickImages: () => Promise, removeImage: (id: string) => void, - openFilePreview: (route: AppRoute, request: FilePreviewRequest) => void, downloadFile: (path: string) => void, - send: () => Promise, voiceInput: () => Promise, inputChanged: (route: AppRoute, value: string) => void - ) { - this.openSidebar = openSidebar; this.back = back; this.newRemoteSession = newRemoteSession; - this.newGeneralSession = newGeneralSession; this.activeGeneralSession = activeGeneralSession; - this.activeGeneralSessionId = activeGeneralSessionId; this.isGeneralBusy = isGeneralBusy; - this.isPinned = isPinned; this.pin = pin; this.archive = archive; this.delete = deleteSession; - this.showToast = showToast; this.uploadedFileCount = uploadedFileCount; this.stop = stop; - this.loadOlder = loadOlder; this.approve = approve; this.reject = reject; this.cancel = cancel; - this.answer = answer; this.rename = rename; this.copy = copy; this.retry = retry; - this.selectModel = selectModel; this.pickImages = pickImages; this.removeImage = removeImage; - this.openFilePreview = openFilePreview; this.downloadFile = downloadFile; this.send = send; - this.voiceInput = voiceInput; this.inputChanged = inputChanged; - } } export class ConversationIntentDispatcher { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets new file mode 100644 index 0000000000..734b9f7ea4 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets @@ -0,0 +1,185 @@ +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; +import { AppRouteContract, ConversationSource } from '../navigation/AppRouteContract'; +import { AppShellState } from '../state/AppShellState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; +import { AppSidebar } from './AppSidebar'; +import { ConnectView } from './ConnectView'; +import { RemoteControlSettingsSheet } from './RemoteControlSettingsSheet'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from './remote/RemoteSurfaceHost'; +import { SettingsSheet } from './SettingsSheet'; + +@ComponentV2 +export struct AppSidebarSurface { + @Param shellState: AppShellState = new AppShellState(); + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Event onOpenRemoteViewSettings: () => void = () => {}; + + build() { + AppSidebar({ + sessions: this.source() === ConversationSource.Remote ? [] : this.generalPageState.recentSessions(), + pinnedSessionId: this.generalPageState.pinnedSessionId(), + selectedSessionId: this.source() === ConversationSource.Remote ? '' : + (AppRouteContract.isGeneralComposerRoute(this.shellState.activeRoute) ? + this.generalPageState.conversation.activeSession.sessionId : ''), + connectionState: this.remotePageState.connectionState, + accountUserId: this.remotePageState.accountUserId, + activeSection: this.source() === ConversationSource.Remote ? 'remote' : 'chat', + showConversationSourceSwitcher: true, + showViewSettingsButton: this.source() === ConversationSource.Remote, + showCustomContent: this.source() === ConversationSource.Remote, + conversationSource: this.source(), + contentSlot: () => { + this.RemoteContent() + }, + onClose: this.actions.onSidebar.close, + onNewChat: () => this.newChat(), + onEnterCode: this.actions.onSidebar.enterCode, + onConversationSource: this.actions.onCompactConversationSource, + onOpenViewSettings: this.onOpenRemoteViewSettings, + onSearchQueryChange: (query: string) => { + if (this.source() === ConversationSource.Remote) this.actions.onRemoteHome.queryChanged(query); + }, + onOpenSettings: () => this.openSettings(), + onOpenAccount: this.actions.onSidebar.openAccount, + onOpenSession: this.actions.onSidebar.openSession, + onArchiveSession: this.actions.onSidebar.archive, + onExportSession: this.actions.onSidebar.exportSession, + onDeleteSession: this.actions.onSidebar.deleteSession + }) + } + + @Builder + private RemoteContent() { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Master, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + showSelectedSession: true, + compact: true + }) + } + + private source(): ConversationSource { + return AppRouteContract.conversationSource(this.shellState.activeRoute); + } + + private newChat(): void { + if (this.source() === ConversationSource.Remote) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.createAssistant(); + } else { + this.actions.onSidebar.newChat(); + } + } + + private openSettings(): void { + if (this.source() === ConversationSource.Remote) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.openSettings(); + } else { + this.actions.onSidebar.settings(); + } + } +} + +@ComponentV2 +export struct AppSettingsSurface { + @Param shellState: AppShellState = new AppShellState(); + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param deviceId: string = ''; + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + + build() { + if (this.shellState.settingsMode === 'remote' || this.shellState.settingsMode === 'account') { + RemoteControlSettingsSheet({ + desktopName: this.remotePageState.desktopName, + desktopId: this.remotePageState.desktopId, + userId: this.remotePageState.userId, + accountUsername: this.remotePageState.accountUsername, + accountUserId: this.remotePageState.accountUserId, + deviceId: this.deviceId, + controlTargetType: this.remotePageState.controlTargetType, + controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, + connectionState: this.remotePageState.connectionState, + statusText: this.remotePageState.conversation.statusText, + isBusy: this.remotePageState.conversation.isBusy, + onClose: this.actions.onSettings.close, + onOpenAccount: this.actions.onSettings.openAccount, + onAddConnection: this.actions.onSettings.addConnection, + cloudLogin: this.actions.onSettings.cloudLogin, + cloudSync: this.actions.onSettings.cloudSync, + cloudLogout: this.actions.onSettings.cloudLogout, + cloudListDevices: this.actions.onSettings.cloudListDevices, + getPermissionMode: this.actions.onSettings.getPermissionMode, + setPermissionMode: this.actions.onSettings.setPermissionMode, + openAccountOnAppear: this.shellState.settingsMode === 'account', + onDisconnect: this.actions.onSettings.disconnect, + onReconnect: this.actions.onSettings.reconnect + }) + } else { + SettingsSheet({ + generalChatApiUrl: this.generalPageState.apiUrl, + generalChatModelName: this.generalPageState.modelName, + hasGeneralChatApiKey: this.generalPageState.hasApiKey, + generalChatModelCatalog: this.generalPageState.conversation.modelCatalog, + selectedGeneralChatModelId: this.generalPageState.conversation.selectedModelId, + accountUsername: this.remotePageState.accountUsername, + authenticatedUserId: this.remotePageState.accountUserId, + deviceId: this.deviceId, + onOpenAccount: this.actions.onSettings.openAccount, + onTestGeneralChatConfig: this.actions.onSettings.testGeneral, + onSaveGeneralChatConfig: this.actions.onSettings.saveGeneral, + onClose: this.actions.onSettings.close + }) + } + } +} + +@ComponentV2 +export struct AppConnectSurface { + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param deviceId: string = ''; + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + + build() { + ConnectView({ + remoteUrl: this.remotePageState.remoteUrl, + userId: this.remotePageState.userId, + statusText: this.remotePageState.conversation.statusText, + connectionState: this.remotePageState.connectionState, + connectionFailureKind: this.remotePageState.connectionFailureKind, + isBusy: this.remotePageState.conversation.isBusy, + isConnected: this.remotePageState.connectionState === 'connected', + desktopName: this.remotePageState.desktopName, + deviceId: this.deviceId, + accountUserId: this.remotePageState.accountUserId, + controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, + requiresAccountAuth: this.remotePageState.requiresAccountAuth, + accountUsername: this.remotePageState.accountUsername, + startWithScanner: true, + onBack: this.actions.onConnect.back, + onConnect: this.actions.onConnect.connect, + onRemoteUrlChange: this.actions.onConnect.urlChanged, + onUserIdChange: this.actions.onConnect.userChanged, + onRemoteUrlDetected: this.actions.onConnect.detected, + onRemoteUrlInputVisibleChange: this.actions.onConnect.inputVisible, + cloudListDevices: this.actions.onConnect.cloudListDevices, + cloudSelectDevice: this.actions.onConnect.cloudSelectDevice + }) + .width('100%') + .height('100%') + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets index c38cd9de27..cd5564c45f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets @@ -1,47 +1,42 @@ import display from '@ohos.display'; import deviceInfo from '@ohos.deviceInfo'; import mediaQuery from '@ohos.mediaquery'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; -import { CloudAccountDevice } from '../../services/CloudAccountClient'; -import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteUiState } from '../../services/RemoteUiState'; import { AppShell } from './AppShell'; -import { AppSidebar } from './AppSidebar'; -import { ConnectView } from './ConnectView'; -import { ConversationIntent } from './ConversationIntent'; -import { ComposerPresentation } from './ComposerBar'; -import { ConversationViewSettings } from './ConversationViewSettings'; -import { ConversationViewHost } from './ConversationViewHost'; -import { toConversationUiModelCatalog } from './ConversationUiModels'; import { FilePreviewSurface } from './FilePreviewSurface'; -import { GeneralChatHeader } from './GeneralChatHeader'; -import { RemoteControlSettingsSheet } from './RemoteControlSettingsSheet'; -import { RemoteCreateSessionView } from './RemoteCreateSessionView'; -import { RemoteSessionList } from './RemoteSessionList'; -import { RemoteSessionLoadingView } from './RemoteSessionLoadingView'; -import { SidebarToggleButton } from './SidebarToggleButton'; -import { SessionActionPresentation } from './SessionActionSurface'; -import { SettingsSheet } from './SettingsSheet'; -import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED } from './Theme'; -import { AppRoute, AppRouteContract, ConversationSource } from '../navigation/AppRouteContract'; +import { PAGE_BG } from './Theme'; +import { AppRoute } from '../navigation/AppRouteContract'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; import { AppShellState } from '../state/AppShellState'; import { ConversationLayoutCrease, ConversationLayoutPolicy -} from '../state/ConversationLayoutPolicy'; +} from '../policy/ConversationLayoutPolicy'; import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { RemotePageState } from '../state/RemotePageState'; import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; -import { ConversationViewState } from '../state/ConversationViewState'; -import { FilePreviewPhase, FilePreviewState } from '../state/FilePreviewState'; +import { FilePreviewState } from '../state/FilePreviewState'; import { FilePreviewLayout, FilePreviewPlacement, FilePreviewPlacementPolicy -} from '../state/FilePreviewPlacementPolicy'; - -const WIDE_DETAIL_CONTENT_MAX_WIDTH: number = 920; +} from '../policy/FilePreviewPlacementPolicy'; +import { WideLayoutGeometry } from '../layout/WideLayoutGeometry'; +import { ConversationRouteSurface } from './ConversationRouteSurface'; +import { WideConversationHost } from './WideConversationHost'; +import { + AppConnectSurface, + AppSettingsSurface, + AppSidebarSurface +} from './AppRootOverlaySurfaces'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from './remote/RemoteSurfaceHost'; function safeFoldStatus(): display.FoldStatus { try { @@ -59,216 +54,6 @@ function safeDeviceType(): string { } } -export class AppRootPresentationActions { - readonly onNavigationBack: (route: AppRoute) => boolean; - readonly onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void; - readonly onCloseSidebar: () => void; - readonly onWideConversationSource: (source: ConversationSource) => void; - readonly onCompactConversationSource: (source: ConversationSource) => void; - readonly onCompactLayoutEntered: () => void; - readonly onRemoteHome: RemoteHomePresentationActions; - readonly onRemoteCreate: RemoteCreatePresentationActions; - readonly onSidebar: SidebarPresentationActions; - readonly onSettings: SettingsPresentationActions; - readonly onConnect: ConnectPresentationActions; - readonly onFilePreview: FilePreviewPresentationActions; - readonly generalStatus: () => string; - - constructor( - onNavigationBack: (route: AppRoute) => boolean, - onConversationIntent: (route: AppRoute, intent: ConversationIntent) => void, - onCloseSidebar: () => void, - onWideConversationSource: (source: ConversationSource) => void, - onCompactConversationSource: (source: ConversationSource) => void, - onCompactLayoutEntered: () => void, - onRemoteHome: RemoteHomePresentationActions, - onRemoteCreate: RemoteCreatePresentationActions, - onSidebar: SidebarPresentationActions, - onSettings: SettingsPresentationActions, - onConnect: ConnectPresentationActions, - onFilePreview: FilePreviewPresentationActions, - generalStatus: () => string - ) { - this.onNavigationBack = onNavigationBack; - this.onConversationIntent = onConversationIntent; - this.onCloseSidebar = onCloseSidebar; - this.onWideConversationSource = onWideConversationSource; - this.onCompactConversationSource = onCompactConversationSource; - this.onCompactLayoutEntered = onCompactLayoutEntered; - this.onRemoteHome = onRemoteHome; - this.onRemoteCreate = onRemoteCreate; - this.onSidebar = onSidebar; - this.onSettings = onSettings; - this.onConnect = onConnect; - this.onFilePreview = onFilePreview; - this.generalStatus = generalStatus; - } -} - -export class FilePreviewPresentationActions { - readonly close: () => void; - readonly refresh: () => void; - readonly download: (path: string) => void; - readonly openLink: (reference: string, label: string) => void; - - constructor( - close: () => void, - refresh: () => void, - download: (path: string) => void, - openLink: (reference: string, label: string) => void - ) { - this.close = close; - this.refresh = refresh; - this.download = download; - this.openLink = openLink; - } -} - -export class RemoteCreatePresentationActions { - readonly back: () => void; - readonly toggleDevices: () => void; - readonly toggleWorkspaces: () => void; - readonly selectDevice: (device: CloudAccountDevice) => void; - readonly selectWorkspace: (path: string) => void; - readonly draftChanged: (value: string) => void; - readonly voiceInput: () => void; - readonly selectModel: (modelId: string) => void; - readonly send: () => void; - - constructor( - back: () => void, - toggleDevices: () => void, - toggleWorkspaces: () => void, - selectDevice: (device: CloudAccountDevice) => void, - selectWorkspace: (path: string) => void, - draftChanged: (value: string) => void, - voiceInput: () => void, - selectModel: (modelId: string) => void, - send: () => void - ) { - this.back = back; - this.toggleDevices = toggleDevices; - this.toggleWorkspaces = toggleWorkspaces; - this.selectDevice = selectDevice; - this.selectWorkspace = selectWorkspace; - this.draftChanged = draftChanged; - this.voiceInput = voiceInput; - this.selectModel = selectModel; - this.send = send; - } -} - -export class RemoteHomePresentationActions { - readonly openSidebar: () => void; readonly connectWorkspace: () => void; - readonly addConnection: () => void; readonly openSettings: () => void; - readonly refresh: () => void; readonly showWorkspaces: () => void; readonly showAssistants: () => void; - readonly selectWorkspace: (path: string) => void; readonly selectAssistant: (path: string) => void; - readonly cancelWorkspace: () => void; readonly cancelAssistant: () => void; - readonly queryChanged: (query: string) => void; readonly search: () => void; readonly loadMore: () => void; - readonly reconnect: () => void; readonly disconnect: () => void; readonly clearPairing: () => void; - readonly create: (agentType: string) => void; readonly createInPlace: (agentType: string) => void; - readonly createAssistant: () => void; - readonly createInWorkspace: (path: string, agentType: string) => void; - readonly createInWorkspaceInPlace: (path: string, agentType: string) => void; - readonly openSession: (session: RemoteSession) => void; - readonly openSessionInPlace: (session: RemoteSession) => void; - readonly deleteSession: (session: RemoteSession) => void; - - constructor( - openSidebar: () => void, connectWorkspace: () => void, addConnection: () => void, openSettings: () => void, - refresh: () => void, showWorkspaces: () => void, showAssistants: () => void, - selectWorkspace: (path: string) => void, selectAssistant: (path: string) => void, - cancelWorkspace: () => void, cancelAssistant: () => void, queryChanged: (query: string) => void, - search: () => void, loadMore: () => void, reconnect: () => void, disconnect: () => void, - clearPairing: () => void, create: (agentType: string) => void, createInPlace: (agentType: string) => void, - createAssistant: () => void, - createInWorkspace: (path: string, agentType: string) => void, - createInWorkspaceInPlace: (path: string, agentType: string) => void, openSession: (session: RemoteSession) => void, - openSessionInPlace: (session: RemoteSession) => void, - deleteSession: (session: RemoteSession) => void - ) { - this.openSidebar = openSidebar; this.connectWorkspace = connectWorkspace; this.addConnection = addConnection; - this.openSettings = openSettings; this.refresh = refresh; this.showWorkspaces = showWorkspaces; - this.showAssistants = showAssistants; this.selectWorkspace = selectWorkspace; this.selectAssistant = selectAssistant; - this.cancelWorkspace = cancelWorkspace; this.cancelAssistant = cancelAssistant; this.queryChanged = queryChanged; - this.search = search; this.loadMore = loadMore; this.reconnect = reconnect; this.disconnect = disconnect; - this.clearPairing = clearPairing; this.create = create; this.createInPlace = createInPlace; - this.createAssistant = createAssistant; this.createInWorkspace = createInWorkspace; - this.createInWorkspaceInPlace = createInWorkspaceInPlace; this.openSession = openSession; - this.openSessionInPlace = openSessionInPlace; this.deleteSession = deleteSession; - } -} - -export class SidebarPresentationActions { - readonly close: () => void; readonly newChat: () => void; readonly enterCode: () => void; - readonly settings: () => void; readonly openAccount: () => void; - readonly openSession: (session: RemoteSession) => void; - readonly archive: (session: RemoteSession, archived: boolean) => void; - readonly exportSession: (session: RemoteSession) => void; readonly deleteSession: (session: RemoteSession) => void; - constructor( - close: () => void, newChat: () => void, enterCode: () => void, settings: () => void, openAccount: () => void, - openSession: (session: RemoteSession) => void, archive: (session: RemoteSession, archived: boolean) => void, - exportSession: (session: RemoteSession) => void, deleteSession: (session: RemoteSession) => void - ) { - this.close = close; this.newChat = newChat; this.enterCode = enterCode; this.settings = settings; - this.openAccount = openAccount; - this.openSession = openSession; this.archive = archive; this.exportSession = exportSession; this.deleteSession = deleteSession; - } -} - -export class SettingsPresentationActions { - readonly close: () => void; readonly addConnection: () => void; readonly disconnect: () => void; - readonly reconnect: () => void; - readonly openAccount: () => void; - readonly cloudLogin: (relayUrl: string, username: string, password: string) => Promise; - readonly cloudSync: () => Promise; - readonly cloudLogout: () => Promise; - readonly cloudListDevices: () => Promise; - readonly getPermissionMode: () => Promise; - readonly setPermissionMode: (mode: RemotePermissionMode) => Promise; - readonly testGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; - readonly saveGeneral: (url: string, key: string, model: string, clear: boolean) => Promise; - constructor( - close: () => void, addConnection: () => void, disconnect: () => void, reconnect: () => void, - openAccount: () => void, - cloudLogin: (relayUrl: string, username: string, password: string) => Promise, - cloudSync: () => Promise, cloudLogout: () => Promise, - cloudListDevices: () => Promise, - getPermissionMode: () => Promise, - setPermissionMode: (mode: RemotePermissionMode) => Promise, - testGeneral: (url: string, key: string, model: string, clear: boolean) => Promise, - saveGeneral: (url: string, key: string, model: string, clear: boolean) => Promise - ) { - this.close = close; this.addConnection = addConnection; this.disconnect = disconnect; - this.reconnect = reconnect; this.openAccount = openAccount; this.cloudLogin = cloudLogin; this.cloudSync = cloudSync; - this.cloudLogout = cloudLogout; this.cloudListDevices = cloudListDevices; - this.getPermissionMode = getPermissionMode; this.setPermissionMode = setPermissionMode; - this.testGeneral = testGeneral; - this.saveGeneral = saveGeneral; - } -} - -export class ConnectPresentationActions { - readonly back: () => void; readonly connect: (password?: string) => void; readonly clearPairing: () => void; - readonly urlChanged: (url: string) => void; readonly userChanged: (user: string) => void; - readonly detected: (url: string) => boolean; readonly inputVisible: (visible: boolean) => void; - readonly paste: () => void; readonly scan: () => void; - readonly cloudListDevices: () => Promise; - readonly cloudSelectDevice: (device: CloudAccountDevice) => Promise; - constructor( - back: () => void, connect: (password?: string) => void, clearPairing: () => void, - urlChanged: (url: string) => void, userChanged: (user: string) => void, - detected: (url: string) => boolean, inputVisible: (visible: boolean) => void, - paste: () => void, scan: () => void, cloudListDevices: () => Promise, - cloudSelectDevice: (device: CloudAccountDevice) => Promise - ) { - this.back = back; this.connect = connect; this.clearPairing = clearPairing; - this.urlChanged = urlChanged; this.userChanged = userChanged; this.detected = detected; - this.inputVisible = inputVisible; this.paste = paste; this.scan = scan; - this.cloudListDevices = cloudListDevices; this.cloudSelectDevice = cloudSelectDevice; - } -} - @ComponentV2 export struct AppRootPresentation { @Param shellState: AppShellState = new AppShellState(); @@ -291,14 +76,8 @@ export struct AppRootPresentation { @Local wideMasterPaneCollapsed: boolean = false; @Local wideMasterPaneMotionActive: boolean = false; @Local restoreCollapsedMasterAfterPreview: boolean = false; - @Local remoteWideSortMode: string = 'project'; - @Local remoteWorkspaceFilter: string = ''; - @Local remoteAgentFilter: string = ''; - @Local remoteStatusFilter: string = ''; @Local showRemoteViewSettings: boolean = false; - @Local showRemoteWorkspaceMetadata: boolean = false; - @Local showRemoteUpdatedMetadata: boolean = false; - @Local showRemoteStatusMetadata: boolean = false; + @Local remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); private readonly deviceType: string = safeDeviceType(); private verticalCreases: ConversationLayoutCrease[] = []; private wideQueryListener?: mediaQuery.MediaQueryListener; @@ -312,19 +91,7 @@ export struct AppRootPresentation { this.wideLayoutMatched = result.matches; this.refreshWideGeometry(); }; - @Param actions: AppRootPresentationActions = new AppRootPresentationActions( - () => false, () => {}, () => {}, () => {}, () => {}, () => {}, - new RemoteHomePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, - () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, - () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), - new RemoteCreatePresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), - new SidebarPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}), - new SettingsPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, async (_relayUrl: string, _username: string, _password: string): Promise => '', async (): Promise => '', async (): Promise => {}, async (): Promise => [], async (): Promise => 'ask', async (mode: RemotePermissionMode): Promise => mode, async (_url: string, _key: string, _model: string, _clear: boolean): Promise => '', async (_url: string, _key: string, _model: string, _clear: boolean): Promise => ''), - new ConnectPresentationActions(() => {}, () => {}, () => {}, () => {}, () => {}, () => false, () => {}, () => {}, - () => {}, async (): Promise => [], async (_device: CloudAccountDevice): Promise => {}), - new FilePreviewPresentationActions(() => {}, () => {}, () => {}, () => {}), - () => '' - ); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); aboutToAppear(): void { this.bindResponsiveQueries(); @@ -378,423 +145,58 @@ export struct AppRootPresentation { @Builder RouteContent(route: AppRoute) { - if (this.isGeneralWideRoute(route) && this.isWideLayout()) { - this.WideGeneralChatContent(route) - } else if (this.showsWideRemoteConversation(route) && - this.filePreviewPlacement() === FilePreviewPlacement.WideFocusSplit) { - this.WideRemotePreviewFocusContent() - } else if (this.showsWideRemoteConversation(route)) { - this.WideRemoteChatContent() - } else if (route === AppRoute.RemoteHome && this.isWideLayout()) { - this.WideRemoteHomeContent() - } else if (route === AppRoute.RemoteCreate && this.isWideLayout()) { - this.WideRemoteCreateContent() + if (this.isWideLayout() && this.isConversationRoute(route)) { + WideConversationHost({ + route, + shellState: this.shellState, + remotePageState: this.remotePageState, + remoteCreateState: this.remoteCreateState, + generalPageState: this.generalPageState, + filePreviewState: this.filePreviewState, + remoteSurfaceState: this.remoteSurfaceState, + actions: this.actions, + filePreviewLayout: this.filePreviewLayout(), + wideMasterPaneWidth: this.wideMasterPaneWidth, + wideMasterDetailGap: this.wideMasterDetailGap, + wideDetailContentOffset: this.wideDetailContentOffset, + wideDetailContentWidth: this.wideDetailContentWidth, + wideCollapsedDetailContentOffset: this.wideCollapsedDetailContentOffset, + wideCollapsedDetailContentWidth: this.wideCollapsedDetailContentWidth, + wideMasterPaneCollapsed: this.wideMasterPaneCollapsed, + wideMasterPaneMotionActive: this.wideMasterPaneMotionActive, + onCollapseMasterPane: () => this.collapseWideMasterPane(), + onRestoreMasterPane: () => this.restoreWideMasterPane(), + onOpenRemoteViewSettings: () => { this.showRemoteViewSettings = true; } + }) } else { - this.RouteSurfaceContent(route, true, route !== AppRoute.ChatHome) - } - } - - @Builder - RouteSurfaceContent( - route: AppRoute, - showSidebarButton: boolean, - showBackButton: boolean, - showSidebarRestoreButton: boolean = false, - useWidePresentation: boolean = false - ) { - Column() { - if (route === AppRoute.RemoteHome) { - this.CompactRemoteHomeContent() - } else if (route === AppRoute.RemoteCreate) { - RemoteCreateSessionView({ - state: this.remoteCreateState, - presentation: useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Create, - isVoiceListening: this.remoteCreateState.isVoiceListening, - modelCatalog: toConversationUiModelCatalog(this.remotePageState.modelCatalog), - selectedModelId: this.remoteCreateState.selectedModelId, - showSidebarRestoreButton: showSidebarRestoreButton, - onRestoreSidebar: () => { - this.restoreWideMasterPane(); - }, - onBack: this.actions.onRemoteCreate.back, - onToggleDeviceMenu: this.actions.onRemoteCreate.toggleDevices, - onToggleWorkspaceMenu: this.actions.onRemoteCreate.toggleWorkspaces, - onSelectDevice: this.actions.onRemoteCreate.selectDevice, - onSelectWorkspace: (workspace) => this.actions.onRemoteCreate.selectWorkspace(workspace?.path || ''), - onDraftChange: this.actions.onRemoteCreate.draftChanged, - onVoiceInput: this.actions.onRemoteCreate.voiceInput, - onSelectModel: this.actions.onRemoteCreate.selectModel, - onSend: this.actions.onRemoteCreate.send - }) - } else { - ConversationViewHost({ - viewState: ConversationViewState.project(route, this.remotePageState, this.generalPageState, - this.actions.generalStatus()), - activeFilePreviewPath: route === AppRoute.RemoteChat && this.filePreviewState.visible ? - this.filePreviewState.target.remotePath : '', - activeFilePreviewLoading: route === AppRoute.RemoteChat && this.filePreviewState.visible && - this.filePreviewState.phase === FilePreviewPhase.Loading, - showSidebarButton: showSidebarButton, - showBackButton: showBackButton, - showSidebarRestoreButton: showSidebarRestoreButton, - composerPresentation: useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Compact, - contentHorizontalOffset: useWidePresentation ? this.collapsedDetailVisualBias() : 0, - onRestoreSidebar: () => { - this.restoreWideMasterPane(); - }, - onIntent: (intent: ConversationIntent) => this.actions.onConversationIntent(route, intent) - }) - } - }.width('100%').height('100%').backgroundColor(PAGE_BG) - } - - @Builder - WideGeneralChatContent(route: AppRoute) { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.WideMasterPane(ConversationSource.General, false) - this.WideMasterDetailGap() - } - this.WideConversationDetail(route, false) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - WideRemoteHomeContent() { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.WideMasterPane(ConversationSource.Remote, false) - this.WideMasterDetailGap() - } - - Column() { - this.RemoteFlowPlaceholder() - } - .layoutWeight(1) - .height('100%') - .backgroundColor(PAGE_BG) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - WideRemoteCreateContent() { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.WideMasterPane(ConversationSource.Remote, false) - this.WideMasterDetailGap() - } - this.WideConversationDetail(AppRoute.RemoteCreate, false) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - /** - * The single wide master pane shell. Local and Remote differ only in the - * session content they hand to the shared sidebar, so the header, source - * switcher, content origin and footer never move when the source changes. - */ - @Builder - WideMasterPane(source: ConversationSource, showSelectedSession: boolean) { - Column() { - Column() { - AppSidebar({ - sessions: source === ConversationSource.Remote ? [] : this.generalPageState.recentSessions(), - pinnedSessionId: this.generalPageState.pinnedSessionId(), - selectedSessionId: source === ConversationSource.Remote ? '' : - this.generalPageState.activeSession.sessionId, - connectionState: this.remotePageState.connectionState, - accountUserId: this.remotePageState.accountUserId, - activeSection: source === ConversationSource.Remote ? 'remote' : 'chat', - showConversationSourceSwitcher: true, - showCollapseButton: true, - showViewSettingsButton: source === ConversationSource.Remote, - showCustomContent: source === ConversationSource.Remote, - conversationSource: source, - contentSlot: () => { - this.RemoteMasterContent(showSelectedSession); - }, - onClose: this.actions.onSidebar.close, - onNewChat: source === ConversationSource.Remote ? - this.actions.onRemoteHome.createAssistant : this.actions.onSidebar.newChat, - onEnterCode: () => this.actions.onWideConversationSource(ConversationSource.Remote), - onConversationSource: this.actions.onWideConversationSource, - onCollapse: () => { - this.collapseWideMasterPane(); - }, - onOpenViewSettings: () => { - this.showRemoteViewSettings = true; - }, - onSearchQueryChange: (query: string) => { - if (source === ConversationSource.Remote) { - this.actions.onRemoteHome.queryChanged(query); - } - }, - onOpenSettings: source === ConversationSource.Remote ? - this.actions.onRemoteHome.openSettings : this.actions.onSidebar.settings, - onOpenAccount: this.actions.onSidebar.openAccount, - onOpenSession: this.actions.onSidebar.openSession, - onArchiveSession: this.actions.onSidebar.archive, - onExportSession: this.actions.onSidebar.exportSession, - onDeleteSession: this.actions.onSidebar.deleteSession - }) - } - .width('100%') - .height('100%') - .backgroundColor(FLOATING_PANEL_BG) - .borderRadius(18) - .clip(true) - .shadow({ radius: 24, color: '#14000000', offsetX: 4, offsetY: 8 }) - } - .width(this.wideMasterPaneCurrentWidth()) - .height('100%') - .padding({ left: 10, right: 6, top: 10, bottom: 10 }) - .backgroundColor(PAGE_BG) - .transition(this.wideMasterPaneMotionActive ? - TransitionEffect.translate({ x: -28, y: 0 }) - .combine(TransitionEffect.opacity(0)) - .animation({ duration: 220, curve: Curve.EaseInOut }) : - TransitionEffect.opacity(1)) - } - - /** - * Remote session content for the shared sidebar shell. The wide master pane - * opens sessions in place next to the list; the compact drawer has to close - * itself and navigate, so every entry point is routed through a compact flag - * instead of a second copy of the list. - */ - @Builder - RemoteMasterContent(showSelectedSession: boolean, compact: boolean = false) { - Column() { - this.RemoteStatusRow() - if (this.isRemoteInitialLoading()) { - RemoteSessionLoadingView() - } else if (this.canShowRemoteSessionList()) { - RemoteSessionList({ - sessions: this.remotePageState.visibleSessions(), - query: this.remotePageState.sessionQuery, - sortMode: this.remoteWideSortMode, - workspaceFilter: this.remoteWorkspaceFilter, - agentFilter: this.remoteAgentFilter, - statusFilter: this.remoteStatusFilter, - workspaceName: this.remotePageState.workspaceName, - workspacePath: this.remotePageState.workspacePath, - workspaceKind: this.remotePageState.workspaceKind, - recentWorkspaces: this.remotePageState.recentWorkspaces, - actionPresentation: SessionActionPresentation.Popover, - showWorkspaceMetadata: this.showRemoteWorkspaceMetadata, - showUpdatedMetadata: this.showRemoteUpdatedMetadata, - showStatusMetadata: this.showRemoteStatusMetadata, - hasMoreSessions: this.remotePageState.hasMoreSessions, - isBusy: this.remotePageState.isBusy || this.remotePageState.isLoadingSessions, - selectedSessionId: showSelectedSession ? this.remotePageState.activeSession.sessionId : '', - onCreate: () => { - this.createRemoteSession('code', compact); - }, - onCreateAssistantSession: () => { - this.createRemoteAssistantSession(compact); - }, - onCreateInWorkspace: (path: string, agentType: string) => { - this.createRemoteSessionInWorkspace(path, agentType, compact); - }, - onSelectWorkspace: (path: string) => { - this.actions.onRemoteHome.selectWorkspace(path); - }, - onOpenSession: (session: RemoteSession) => { - this.openRemoteSession(session, compact); - }, - onDeleteSession: (session: RemoteSession) => { - this.actions.onRemoteHome.deleteSession(session); - }, - onLoadMore: () => { - this.actions.onRemoteHome.loadMore(); - } - }) - } else { - this.RemoteDisconnectedState() - } - } - .width('100%') - .height('100%') - .alignItems(HorizontalAlign.Start) - .padding({ bottom: 84 }) - } - - /** Connection status lives in the remote content, not in the shared header. */ - @Builder - RemoteStatusRow() { - Row({ space: 6 }) { - this.RemoteStatusIndicator() - Text(this.remoteStatusText()) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) + ConversationRouteSurface({ + route, + remotePageState: this.remotePageState, + remoteCreateState: this.remoteCreateState, + generalPageState: this.generalPageState, + filePreviewState: this.filePreviewState, + remoteSurfaceState: this.remoteSurfaceState, + actions: this.actions, + showSidebarButton: true, + // Compact conversations own the drawer, not a back control: Local and + // Remote both open the sidebar over the chat instead of leaving it. + showBackButton: false, + onRestoreSidebar: () => this.restoreWideMasterPane() + }) } - .width('100%') - .margin({ top: 16, bottom: 6 }) - .alignItems(VerticalAlign.Center) } @Builder RemoteViewSettingsSheet() { - ConversationViewSettings({ - sessions: this.remotePageState.visibleSessions(), - workspaceName: this.remotePageState.workspaceName, - workspacePath: this.remotePageState.workspacePath, - workspaceKind: this.remotePageState.workspaceKind, - recentWorkspaces: this.remotePageState.recentWorkspaces, - sortMode: this.remoteWideSortMode, - workspaceFilter: this.remoteWorkspaceFilter, - agentFilter: this.remoteAgentFilter, - statusFilter: this.remoteStatusFilter, - showWorkspaceMetadata: this.showRemoteWorkspaceMetadata, - showUpdatedMetadata: this.showRemoteUpdatedMetadata, - showStatusMetadata: this.showRemoteStatusMetadata, - onSortModeChange: (mode: string) => { - this.remoteWideSortMode = mode; - }, - onWorkspaceFilterChange: (value: string) => { - RemoteLogger.info(`wide view-settings workspace received=${value.length > 0 ? value : ''}`); - this.remoteWorkspaceFilter = value; - }, - onAgentFilterChange: (value: string) => { - this.remoteAgentFilter = value; - }, - onStatusFilterChange: (value: string) => { - this.remoteStatusFilter = value; - }, - onWorkspaceMetadataChange: (value: boolean) => { - this.showRemoteWorkspaceMetadata = value; - }, - onUpdatedMetadataChange: (value: boolean) => { - this.showRemoteUpdatedMetadata = value; - }, - onStatusMetadataChange: (value: boolean) => { - this.showRemoteStatusMetadata = value; - }, - onClose: () => { - this.showRemoteViewSettings = false; - } + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Settings, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + onCloseSettings: () => { this.showRemoteViewSettings = false; } }) } - @Builder - RemoteStatusIndicator() { - if (this.isRemoteInitialLoading()) { - LoadingProgress() - .width(14) - .height(14) - .color(MUTED) - } else { - Stack() { - Text('') - } - .width(7) - .height(7) - .backgroundColor(this.remoteStatusColor()) - .borderRadius(4) - } - } - - @Builder - RemoteDisconnectedState() { - Column({ space: 12 }) { - Stack({ alignContent: Alignment.Center }) { - SymbolGlyph($r('sys.symbol.desktop')) - .fontSize(42) - .fontColor([INK]) - } - .width(74) - .height(74) - .backgroundColor(CARD) - .borderRadius(24) - .border({ width: 1, color: LINE }) - Text(RemoteI18n.t('remote.connectTitle')) - .fontSize(18) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .textAlign(TextAlign.Center) - Text(RemoteI18n.t('remote.connectText')) - .fontSize(13) - .lineHeight(20) - .fontColor(MUTED) - .textAlign(TextAlign.Center) - Text(RemoteI18n.t('connect.connect')) - .width(136) - .height(44) - .fontSize(15) - .fontColor(PRIMARY_ACTION_TEXT) - .backgroundColor(PRIMARY_ACTION) - .textAlign(TextAlign.Center) - .borderRadius(22) - .onClick(() => { - this.actions.onRemoteHome.connectWorkspace(); - }) - } - .layoutWeight(1) - .width('100%') - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - .padding({ left: 20, right: 20, bottom: 48 }) - } - - @Builder - WideRemoteChatContent() { - if (this.filePreviewPlacement() === FilePreviewPlacement.WideTriplePane) { - Row() { - this.WideMasterPane(ConversationSource.Remote, true) - this.WidePaneGap(this.filePreviewLayout().masterConversationGap) - this.WideConversationDetail( - AppRoute.RemoteChat, - false, - this.filePreviewLayout().conversationPaneWidth - ) - this.WidePaneGap(this.filePreviewLayout().conversationPreviewGap) - this.FilePreviewPane(this.filePreviewLayout().previewPaneWidth) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } else { - Row() { - if (!this.wideMasterPaneCollapsed) { - this.WideMasterPane(ConversationSource.Remote, true) - this.WideMasterDetailGap() - } - this.WideConversationDetail(AppRoute.RemoteChat, false) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - } - - @Builder - WideRemotePreviewFocusContent() { - Row() { - this.WideConversationDetail( - AppRoute.RemoteChat, - false, - this.filePreviewLayout().conversationPaneWidth - ) - this.WidePaneGap(this.filePreviewLayout().conversationPreviewGap) - this.FilePreviewPane(this.filePreviewLayout().previewPaneWidth) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - @Builder FilePreviewPane(paneWidth: number = 0) { Column() { @@ -816,232 +218,10 @@ export struct AppRootPresentation { .backgroundColor(PAGE_BG) } - @Builder - WideConversationDetail(route: AppRoute, showBackButton: boolean, paneWidth: number = 0) { - if (paneWidth > 0) { - Column() { - this.RouteSurfaceContent(route, false, showBackButton, false, true) - } - .width(paneWidth) - .height('100%') - .constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) - .backgroundColor(PAGE_BG) - } else { - Stack({ alignContent: Alignment.TopStart }) { - Row() { - if (this.currentDetailContentOffset() > 0) { - Blank().width(this.currentDetailContentOffset()) - } - Row() { - Column() { - this.RouteSurfaceContent(route, false, showBackButton, false, true) - } - .width('100%') - .height('100%') - .constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) - .backgroundColor(PAGE_BG) - } - .width(this.currentDetailContentWidth() > 0 ? this.currentDetailContentWidth() : '100%') - .height('100%') - .justifyContent(FlexAlign.Center) - if (this.currentDetailContentOffset() > 0) { - Blank().layoutWeight(1) - } - } - .width('100%') - .height('100%') - .justifyContent(FlexAlign.Center) - .backgroundColor(PAGE_BG) - - if (this.wideMasterPaneCollapsed) { - SidebarToggleButton({ - restore: true, - controlSize: 44, - onToggle: () => { - this.restoreWideMasterPane(); - } - }) - .position({ x: this.currentDetailContentOffset() + 12, y: 12 }) - .zIndex(2) - .transition(TransitionEffect.scale({ x: 0.9, y: 0.9 }) - .combine(TransitionEffect.opacity(0)) - .animation({ duration: 180, curve: Curve.EaseOut })) - } - } - .layoutWeight(1) - .height('100%') - .backgroundColor(PAGE_BG) - } - } - - @Builder - WideMasterDetailGap() { - if (this.wideMasterDetailGap > 0) { - Row() { - } - .width(this.wideMasterDetailGap) - .height('100%') - .backgroundColor(LINE) - } - } - - @Builder - WidePaneGap(width: number) { - if (width > 0) { - Row() { - } - .width(width) - .height('100%') - .backgroundColor(LINE) - } - } - - /** - * Compact Remote landing surface. The session list lives in the shared drawer - * now, so this route only carries connection state and the way back into the - * drawer — the same shape the Local composer route has. - */ - @Builder - CompactRemoteHomeContent() { - Column() { - GeneralChatHeader({ - title: RemoteI18n.t('remote.title'), - showSidebarButton: true, - onOpenSidebar: this.actions.onRemoteHome.openSidebar - }) - if (this.canShowRemoteSessionList()) { - this.CompactRemoteEmptyState() - } else { - this.RemoteDisconnectedState() - } - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - CompactRemoteEmptyState() { - Column({ space: 10 }) { - if (this.isRemoteInitialLoading()) { - LoadingProgress() - .width(28) - .height(28) - .color(MUTED) - .margin({ bottom: 8 }) - } - Text(this.compactRemoteEmptyTitle()) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .textAlign(TextAlign.Center) - Text(this.compactRemoteEmptyText()) - .fontSize(14) - .lineHeight(21) - .fontColor(MUTED) - .maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .textAlign(TextAlign.Center) - .constraintSize({ maxWidth: 280 }) - Text(RemoteI18n.t('remote.startSession')) - .width(148) - .height(46) - .fontSize(15) - .fontWeight(FontWeight.Medium) - .fontColor(PRIMARY_ACTION_TEXT) - .backgroundColor(PRIMARY_ACTION) - .textAlign(TextAlign.Center) - .borderRadius(23) - .margin({ top: 12 }) - .onClick(() => { - this.actions.onRemoteHome.createAssistant(); - }) - } - .width('100%') - .layoutWeight(1) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - .padding({ left: 24, right: 24, bottom: 56 }) - } - - @Builder - RemoteFlowPlaceholder() { - Column() { - Row({ space: 8 }) { - if (this.wideMasterPaneCollapsed) { - SidebarToggleButton({ - restore: true, - controlSize: 48, - onToggle: () => { - this.restoreWideMasterPane(); - } - }) - } else { - Blank().width(48).height(48) - } - Column({ space: 4 }) { - Text(RemoteI18n.t('remote.chats')) - .fontSize(20) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - Text(this.remoteDesktopName()) - .fontSize(13) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Center) - Blank().width(48).height(48) - } - .width('100%') - .height(76) - .padding({ left: 16, right: 16, top: 14, bottom: 12 }) - .border({ width: { bottom: 1 }, color: LINE }) - - Column({ space: 8 }) { - if (this.isRemoteInitialLoading()) { - LoadingProgress() - .width(28) - .height(28) - .color(MUTED) - .margin({ bottom: 8 }) - } - Text(this.remoteFlowPlaceholderTitle()) - .fontSize(22) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - Text(this.remoteStatusText()) - .fontSize(14) - .fontColor(MUTED) - .maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .width('100%') - .layoutWeight(1) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - .padding({ left: 24, right: 24, bottom: 48 }) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - private isWideLayout(): boolean { return this.largeScreenLayout; } - /** - * Read inside the master pane builder rather than passed in: a @Builder only - * re-renders on parameters passed by reference, so a width handed over as a - * value would freeze at whatever the pane measured on its first render. - */ - private wideMasterPaneCurrentWidth(): number { - return this.filePreviewPlacement() === FilePreviewPlacement.WideTriplePane ? - this.filePreviewLayout().masterPaneWidth : this.wideMasterPaneWidth; - } - private collapseWideMasterPane(): void { if (!this.isWideLayout() || this.filePreviewState.visible) { return; @@ -1067,24 +247,6 @@ export struct AppRootPresentation { }, 240); } - private currentDetailContentOffset(): number { - return this.wideMasterPaneCollapsed ? - this.wideCollapsedDetailContentOffset : this.wideDetailContentOffset; - } - - private currentDetailContentWidth(): number { - return this.wideMasterPaneCollapsed ? - this.wideCollapsedDetailContentWidth : this.wideDetailContentWidth; - } - - private collapsedDetailVisualBias(): number { - if (!this.wideMasterPaneCollapsed || this.wideCollapsedDetailContentOffset > 0) { - return 0; - } - const availableMargin = (this.wideCollapsedDetailContentWidth - WIDE_DETAIL_CONTENT_MAX_WIDTH) / 2; - return Math.min(72, Math.max(0, availableMargin)); - } - private filePreviewPlacement(): FilePreviewPlacement { return this.filePreviewLayout().placement; } @@ -1099,16 +261,9 @@ export struct AppRootPresentation { ); } - private isGeneralWideRoute(route: AppRoute): boolean { - return route === AppRoute.ChatHome || route === AppRoute.GeneralChat; - } - - private showsWideRemoteConversation(route: AppRoute): boolean { - if (!this.isWideLayout()) { - return false; - } - return route === AppRoute.RemoteChat || - (route === AppRoute.RemoteHome && this.remotePageState.activeSession.sessionId.length > 0); + private isConversationRoute(route: AppRoute): boolean { + return route === AppRoute.ChatHome || route === AppRoute.GeneralChat || + route === AppRoute.RemoteHome || route === AppRoute.RemoteCreate || route === AppRoute.RemoteChat; } private bindResponsiveQueries(): void { @@ -1153,8 +308,7 @@ export struct AppRootPresentation { } private areaWidth(width: Object): number { - const value = Number.parseFloat(`${width}`); - return Number.isNaN(value) ? 0 : value; + return WideLayoutGeometry.areaLength(width); } private refreshWideGeometry(): void { @@ -1178,6 +332,7 @@ export struct AppRootPresentation { this.wideDetailContentWidth = geometry.detailContentWidth; this.wideCollapsedDetailContentOffset = geometry.collapsedDetailContentOffset; this.wideCollapsedDetailContentWidth = geometry.collapsedDetailContentWidth; + this.actions.onLayoutModeChanged(this.largeScreenLayout); if (wasWideLayout && !this.largeScreenLayout) { this.actions.onCompactLayoutEntered(); } @@ -1202,126 +357,6 @@ export struct AppRootPresentation { } } - /** - * Session entry points shared by the wide master pane and the compact drawer. - * The wide pane keeps the list on screen and swaps the detail pane; the - * compact drawer has to dismiss itself first and then navigate. - */ - private openRemoteSession(session: RemoteSession, compact: boolean): void { - if (compact) { - this.actions.onSidebar.openSession(session); - return; - } - this.actions.onRemoteHome.openSessionInPlace(session); - } - - private createRemoteSession(agentType: string, compact: boolean): void { - if (compact) { - this.actions.onSidebar.close(); - this.actions.onRemoteHome.create(agentType); - return; - } - this.actions.onRemoteHome.createInPlace(agentType); - } - - private createRemoteSessionInWorkspace(path: string, agentType: string, compact: boolean): void { - if (compact) { - this.actions.onSidebar.close(); - this.actions.onRemoteHome.createInWorkspace(path, agentType); - return; - } - this.actions.onRemoteHome.createInWorkspaceInPlace(path, agentType); - } - - private createRemoteAssistantSession(compact: boolean): void { - if (compact) { - this.actions.onSidebar.close(); - } - this.actions.onRemoteHome.createAssistant(); - } - - private compactSidebarSource(): ConversationSource { - return AppRouteContract.conversationSource(this.shellState.activeRoute); - } - - /** The compact drawer's new-chat and settings entries follow the active source. */ - private compactSidebarNewChat(source: ConversationSource): void { - if (source === ConversationSource.Remote) { - this.createRemoteAssistantSession(true); - return; - } - this.actions.onSidebar.newChat(); - } - - private compactSidebarSettings(source: ConversationSource): void { - if (source === ConversationSource.Remote) { - this.actions.onSidebar.close(); - this.actions.onRemoteHome.openSettings(); - return; - } - this.actions.onSidebar.settings(); - } - - private canShowRemoteSessionList(): boolean { - return this.remotePageState.connectionState === 'connected' || this.remotePageState.visibleSessions().length > 0 || - this.remotePageState.isLoadingHome || this.remotePageState.isLoadingSessions; - } - - private isRemoteInitialLoading(): boolean { - return this.remotePageState.isLoadingHome || this.isRemoteConnecting(); - } - - private isRemoteConnecting(): boolean { - return this.remotePageState.connectionState === 'parsing' || - this.remotePageState.connectionState === 'pairing' || - this.remotePageState.connectionState === 'reconnecting'; - } - - private remoteStatusText(): string { - if (this.remotePageState.statusText.length > 0) { - return this.remotePageState.statusText; - } - return this.remoteDesktopName(); - } - - private remoteStatusColor(): ResourceColor { - if (this.remotePageState.connectionState === 'connected') { - return GREEN; - } - if (this.remotePageState.connectionState === 'failed' || this.remotePageState.connectionState === 'disconnected') { - return RED; - } - return MUTED; - } - - private remoteDesktopName(): string { - return this.remotePageState.desktopName.length > 0 ? this.remotePageState.desktopName : - RemoteI18n.t('remote.settings.noDesktop'); - } - - private compactRemoteEmptyTitle(): string { - if (this.isRemoteInitialLoading()) { - return RemoteI18n.t('common.loading'); - } - return this.remotePageState.visibleSessions().length > 0 ? - RemoteI18n.t('remote.pickSession') : RemoteI18n.t('remote.emptyTitle'); - } - - private compactRemoteEmptyText(): string { - if (this.isRemoteInitialLoading()) { - return this.remoteStatusText(); - } - return this.remotePageState.visibleSessions().length > 0 ? - RemoteI18n.t('remote.pickSessionText') : RemoteI18n.t('remote.emptyText'); - } - - private remoteFlowPlaceholderTitle(): string { - if (this.isRemoteInitialLoading()) { - return RemoteI18n.t('common.loading'); - } - return this.remotePageState.visibleSessions().length > 0 ? '选择会话' : RemoteI18n.t('remote.emptyTitle'); - } - private remoteViewSettingsSheetOptions(): SheetOptions { if (!this.isWideLayout()) { return { @@ -1343,107 +378,32 @@ export struct AppRootPresentation { }; } - /** - * The compact drawer runs the same sidebar shell as the wide master pane, so - * Local and Remote are two sources inside one session container instead of a - * drawer and a separate destination page. The drawer outlives every route - * change, and a @Builder does not re-render on value parameters, so the source - * is read from the current route on each render instead of being passed in. - */ @Builder SidebarContent() { - AppSidebar({ - sessions: this.compactSidebarSource() === ConversationSource.Remote ? - [] : this.generalPageState.recentSessions(), - pinnedSessionId: this.generalPageState.pinnedSessionId(), - selectedSessionId: this.compactSidebarSource() === ConversationSource.Remote ? '' : - (AppRouteContract.isGeneralComposerRoute(this.shellState.activeRoute) ? - this.generalPageState.activeSession.sessionId : ''), - connectionState: this.remotePageState.connectionState, - accountUserId: this.remotePageState.accountUserId, - activeSection: this.compactSidebarSource() === ConversationSource.Remote ? 'remote' : 'chat', - showConversationSourceSwitcher: true, - showViewSettingsButton: this.compactSidebarSource() === ConversationSource.Remote, - showCustomContent: this.compactSidebarSource() === ConversationSource.Remote, - conversationSource: this.compactSidebarSource(), - contentSlot: () => { - this.RemoteMasterContent(true, true); - }, - onClose: this.actions.onSidebar.close, - onNewChat: () => { - this.compactSidebarNewChat(this.compactSidebarSource()); - }, - onEnterCode: this.actions.onSidebar.enterCode, - onConversationSource: this.actions.onCompactConversationSource, - onOpenViewSettings: () => { - this.showRemoteViewSettings = true; - }, - onSearchQueryChange: (query: string) => { - if (this.compactSidebarSource() === ConversationSource.Remote) { - this.actions.onRemoteHome.queryChanged(query); - } - }, - onOpenSettings: () => { - this.compactSidebarSettings(this.compactSidebarSource()); - }, - onOpenAccount: this.actions.onSidebar.openAccount, - onOpenSession: this.actions.onSidebar.openSession, - onArchiveSession: this.actions.onSidebar.archive, - onExportSession: this.actions.onSidebar.exportSession, - onDeleteSession: this.actions.onSidebar.deleteSession + AppSidebarSurface({ + shellState: this.shellState, + remotePageState: this.remotePageState, + generalPageState: this.generalPageState, + remoteSurfaceState: this.remoteSurfaceState, + actions: this.actions, + onOpenRemoteViewSettings: () => { this.showRemoteViewSettings = true; } }) } @Builder SettingsContent() { - if (this.shellState.settingsMode === 'remote' || this.shellState.settingsMode === 'account') { - RemoteControlSettingsSheet({ desktopName: this.remotePageState.desktopName, desktopId: this.remotePageState.desktopId, - userId: this.remotePageState.userId, accountUsername: this.remotePageState.accountUsername, - accountUserId: this.remotePageState.accountUserId, deviceId: this.deviceId, - controlTargetType: this.remotePageState.controlTargetType, - controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, - connectionState: this.remotePageState.connectionState, statusText: this.remotePageState.statusText, - isBusy: this.remotePageState.isBusy, onClose: this.actions.onSettings.close, - onOpenAccount: this.actions.onSettings.openAccount, - onAddConnection: this.actions.onSettings.addConnection, - cloudLogin: this.actions.onSettings.cloudLogin, - cloudSync: this.actions.onSettings.cloudSync, - cloudLogout: this.actions.onSettings.cloudLogout, - cloudListDevices: this.actions.onSettings.cloudListDevices, - getPermissionMode: this.actions.onSettings.getPermissionMode, - setPermissionMode: this.actions.onSettings.setPermissionMode, - openAccountOnAppear: this.shellState.settingsMode === 'account', - onDisconnect: this.actions.onSettings.disconnect, onReconnect: this.actions.onSettings.reconnect }) - } else { - SettingsSheet({ generalChatApiUrl: this.generalPageState.apiUrl, generalChatModelName: this.generalPageState.modelName, - hasGeneralChatApiKey: this.generalPageState.hasApiKey, - generalChatModelCatalog: this.generalPageState.modelCatalog, - selectedGeneralChatModelId: this.generalPageState.selectedModelId, - accountUsername: this.remotePageState.accountUsername, - authenticatedUserId: this.remotePageState.accountUserId, + AppSettingsSurface({ + shellState: this.shellState, + remotePageState: this.remotePageState, + generalPageState: this.generalPageState, deviceId: this.deviceId, - onOpenAccount: this.actions.onSettings.openAccount, - onTestGeneralChatConfig: this.actions.onSettings.testGeneral, - onSaveGeneralChatConfig: this.actions.onSettings.saveGeneral, - onClose: this.actions.onSettings.close }) - } + actions: this.actions + }) } @Builder ConnectContent() { - ConnectView({ remoteUrl: this.remotePageState.remoteUrl, userId: this.remotePageState.userId, - showRemoteUrlInput: this.remotePageState.showRemoteUrlInput, statusText: this.remotePageState.statusText, - connectionState: this.remotePageState.connectionState, connectionFailureKind: this.remotePageState.connectionFailureKind, - isBusy: this.remotePageState.isBusy, isConnected: this.remotePageState.connectionState === 'connected', - desktopName: this.remotePageState.desktopName, desktopId: this.remotePageState.desktopId, deviceId: this.deviceId, - accountUserId: this.remotePageState.accountUserId, - controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, - requiresAccountAuth: this.remotePageState.requiresAccountAuth, accountUsername: this.remotePageState.accountUsername, - startWithScanner: true, - onBack: this.actions.onConnect.back, onConnect: this.actions.onConnect.connect, - onClearPairing: this.actions.onConnect.clearPairing, onRemoteUrlChange: this.actions.onConnect.urlChanged, - onUserIdChange: this.actions.onConnect.userChanged, onRemoteUrlDetected: this.actions.onConnect.detected, - onRemoteUrlInputVisibleChange: this.actions.onConnect.inputVisible, - onPasteRemoteUrl: this.actions.onConnect.paste, onScanRemoteUrl: this.actions.onConnect.scan, - cloudListDevices: this.actions.onConnect.cloudListDevices, - cloudSelectDevice: this.actions.onConnect.cloudSelectDevice }) - .width('100%').height('100%') + AppConnectSurface({ + remotePageState: this.remotePageState, + deviceId: this.deviceId, + actions: this.actions + }) } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets index 08cec8c99d..6e02ff01b0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets @@ -5,43 +5,44 @@ import { ConversationSource } from '../navigation/AppRouteContract'; import { ConversationSourceSwitcher } from './ConversationSourceSwitcher'; import { SidebarToggleButton } from './SidebarToggleButton'; import { SessionActionPresentation, SessionActionSurface } from './SessionActionSurface'; -import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../state/SessionActionPolicy'; +import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../policy/SessionActionPolicy'; import { SessionDetailsView } from './SessionDetailsView'; +import { SidebarGlyph } from './SidebarGlyphs'; -@Component +@ComponentV2 export struct AppSidebar { - @Prop sessions: RemoteSession[] = []; - @Prop pinnedSessionId: string = ''; - @Prop selectedSessionId: string = ''; - @Prop connectionState: string = 'idle'; - @Prop activeSection: string = 'chat'; - @Prop accountUserId: string = ''; - @Prop showConversationSourceSwitcher: boolean = false; - @Prop showCollapseButton: boolean = false; - @Prop showViewSettingsButton: boolean = false; - @Prop showCustomContent: boolean = false; - @Prop conversationSource: ConversationSource = ConversationSource.General; - onClose: () => void = () => {}; - onNewChat: () => void = () => {}; - onEnterCode: () => void = () => {}; - onConversationSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; - onCollapse: () => void = () => {}; - onOpenViewSettings: () => void = () => {}; - onSearchQueryChange: (query: string) => void = (_query: string) => {}; - onOpenSettings: () => void = () => {}; - onOpenAccount: () => void = () => {}; - onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - onArchiveSession: (session: RemoteSession, archived: boolean) => void = + @Param sessions: RemoteSession[] = []; + @Param pinnedSessionId: string = ''; + @Param selectedSessionId: string = ''; + @Param connectionState: string = 'idle'; + @Param activeSection: string = 'chat'; + @Param accountUserId: string = ''; + @Param showConversationSourceSwitcher: boolean = false; + @Param showCollapseButton: boolean = false; + @Param showViewSettingsButton: boolean = false; + @Param showCustomContent: boolean = false; + @Param conversationSource: ConversationSource = ConversationSource.General; + @Event onClose: () => void = () => {}; + @Event onNewChat: () => void = () => {}; + @Event onEnterCode: () => void = () => {}; + @Event onConversationSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; + @Event onCollapse: () => void = () => {}; + @Event onOpenViewSettings: () => void = () => {}; + @Event onSearchQueryChange: (query: string) => void = (_query: string) => {}; + @Event onOpenSettings: () => void = () => {}; + @Event onOpenAccount: () => void = () => {}; + @Event onOpenSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; + @Event onArchiveSession: (session: RemoteSession, archived: boolean) => void = (_session: RemoteSession, _archived: boolean) => {}; - onExportSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; - @State activeActionSessionId: string = ''; - @State showSessionActionSheet: boolean = false; - @State detailsSessionId: string = ''; - @State showSessionDetails: boolean = false; - @State showSearch: boolean = false; - @State sessionSearchQuery: string = ''; - @State archivedSessionsExpanded: boolean = false; + @Event onExportSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; + @Event onDeleteSession: (session: RemoteSession) => void = (_session: RemoteSession) => {}; + @Local activeActionSessionId: string = ''; + @Local showSessionActionSheet: boolean = false; + @Local detailsSessionId: string = ''; + @Local showSessionDetails: boolean = false; + @Local showSearch: boolean = false; + @Local sessionSearchQuery: string = ''; + @Local archivedSessionsExpanded: boolean = false; /** * Session content for the current conversation source. The shell around it * (header, source switcher, content origin, footer) stays identical for every @@ -180,7 +181,7 @@ export struct AppSidebar { Row({ space: 6 }) { if (this.showViewSettingsButton) { Stack({ alignContent: Alignment.Center }) { - this.MoreDotsGlyph() + SidebarGlyph({ kind: 'session_more' }) } .width(38) .height(38) @@ -195,7 +196,7 @@ export struct AppSidebar { } Stack({ alignContent: Alignment.Center }) { - this.SearchGlyph() + SidebarGlyph({ kind: 'search' }) } .width(38) .height(38) @@ -282,7 +283,7 @@ export struct AppSidebar { private AuthenticatedFooter() { Row() { Row({ space: 9 }) { - this.EditGlyph() + SidebarGlyph({ kind: 'edit' }) Text(RemoteI18n.t('sidebar.newChat')) .fontSize(15) .fontWeight(FontWeight.Medium) @@ -303,7 +304,7 @@ export struct AppSidebar { Blank() Stack({ alignContent: Alignment.Center }) { - this.SettingsGlyph() + SidebarGlyph({ kind: 'settings' }) } .width(46) .height(46) @@ -338,7 +339,7 @@ export struct AppSidebar { @Builder NavRow(label: string, isActive: boolean, action: () => void) { Row({ space: 14 }) { - this.RemoteGlyph() + SidebarGlyph({ kind: 'remote', connectionState: this.connectionState }) Text(label) .fontSize(18) .fontWeight(FontWeight.Bold) @@ -476,21 +477,10 @@ export struct AppSidebar { }) } - @Builder - private MoreDotsGlyph() { - Row({ space: 3 }) { - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) - } - .height(8) - .alignItems(VerticalAlign.Center) - } - @Builder private SessionMoreButton(session: RemoteSession) { Stack({ alignContent: Alignment.Center }) { - this.MoreDotsGlyph() + SidebarGlyph({ kind: 'session_more' }) } .width(34) .height(40) @@ -558,205 +548,6 @@ export struct AppSidebar { .padding({ top: 8, bottom: 8 }) } - @Builder - RemoteGlyph() { - Stack({ alignContent: Alignment.Center }) { - if (this.connectionState === 'connected' || this.connectionState === 'reconnecting') { - Image($r('app.media.remote_ref_sidebar_connected')) - .width(35) - .height(34) - .objectFit(ImageFit.Contain) - .renderMode(ImageRenderMode.Template) - .foregroundColor(INK) - Text('') - .width(8) - .height(8) - .backgroundColor(GREEN) - .borderRadius(4) - .position({ x: 24, y: 22 }) - } else { - Image($r('app.media.remote_logo')) - .width(34) - .height(34) - .objectFit(ImageFit.Contain) - .renderMode(ImageRenderMode.Template) - .foregroundColor(MUTED) - } - } - .width(35) - .height(34) - } - - @Builder - SearchGlyph() { - SymbolGlyph($r('sys.symbol.magnifyingglass')) - .fontSize(22) - .fontColor([INK]) - .width(24) - .height(24) - } - - @Builder - NotebookGlyph() { - Stack() { - Text('') - .width(22) - .height(24) - .borderRadius(5) - .border({ width: 1.5, color: INK }) - .position({ x: 8, y: 5 }) - Text('') - .width(4) - .height(4) - .borderRadius(2) - .backgroundColor(INK) - .position({ x: 5, y: 11 }) - Text('') - .width(4) - .height(4) - .borderRadius(2) - .backgroundColor(INK) - .position({ x: 5, y: 20 }) - } - .width(34) - .height(34) - } - - @Builder - ClockGlyph() { - Stack() { - Text('') - .width(26) - .height(26) - .borderRadius(13) - .border({ width: 1.5, color: INK }) - .position({ x: 4, y: 4 }) - Text('') - .width(1.5) - .height(9) - .backgroundColor(INK) - .borderRadius(2) - .position({ x: 18, y: 10 }) - Text('') - .width(9) - .height(1.5) - .backgroundColor(INK) - .borderRadius(2) - .position({ x: 18, y: 20 }) - } - .width(34) - .height(34) - } - - @Builder - AppsGlyph() { - Column({ space: 8 }) { - Row({ space: 8 }) { - this.AppDot() - this.AppDot() - } - Row({ space: 8 }) { - this.AppDot() - this.AppDot() - } - } - .width(24) - .height(24) - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - } - - @Builder - AppDot() { - Text('') - .width(8) - .height(8) - .borderRadius(4) - .backgroundColor(INK) - } - - @Builder - CodeFlowerGlyph() { - Stack() { - Text('') - .width(16) - .height(16) - .borderRadius(8) - .border({ width: 1.5, color: INK }) - .backgroundColor(CARD) - .position({ x: 9, y: 1 }) - Text('') - .width(16) - .height(16) - .borderRadius(8) - .border({ width: 1.5, color: INK }) - .backgroundColor(CARD) - .position({ x: 17, y: 9 }) - Text('') - .width(16) - .height(16) - .borderRadius(8) - .border({ width: 1.5, color: INK }) - .backgroundColor(CARD) - .position({ x: 9, y: 17 }) - Text('') - .width(16) - .height(16) - .borderRadius(8) - .border({ width: 1.5, color: INK }) - .backgroundColor(CARD) - .position({ x: 1, y: 9 }) - Text('') - .width(14) - .height(14) - .borderRadius(7) - .backgroundColor(CARD) - .position({ x: 10, y: 10 }) - } - .width(34) - .height(34) - } - - @Builder - MoreGlyph() { - Row({ space: 5 }) { - this.DotGlyph() - this.DotGlyph() - this.DotGlyph() - } - .width(30) - .height(22) - .justifyContent(FlexAlign.Center) - .alignItems(VerticalAlign.Center) - } - - @Builder - DotGlyph() { - Text('') - .width(5) - .height(5) - .borderRadius(3) - .backgroundColor(INK) - } - - @Builder - EditGlyph() { - SymbolGlyph($r('sys.symbol.square_and_pencil')) - .fontSize(22) - .fontColor([INK]) - .width(24) - .height(24) - } - - @Builder - SettingsGlyph() { - SymbolGlyph($r('sys.symbol.gearshape')) - .fontSize(22) - .fontColor([INK]) - .width(24) - .height(24) - } - private visibleRecentSessions(): RemoteSession[] { const query = this.sessionSearchQuery.trim().toLowerCase(); return this.sessions.filter((session: RemoteSession) => { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets index 38375e4387..c1a6917fd0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/BitFunAccountLoginPage.ets @@ -2,17 +2,17 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { DEFAULT_CLOUD_RELAY_URL } from '../../services/CloudAccountClient'; import { CARD, INK, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SUBTLE } from './Theme'; -@Component +@ComponentV2 export struct BitFunAccountLoginPage { - cloudLogin: (relayUrl: string, username: string, password: string) => Promise = + @Event cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; - onBack: () => void = () => {}; - onLoginSuccess: () => void = () => {}; - @State relayUrl: string = DEFAULT_CLOUD_RELAY_URL; - @State username: string = ''; - @State password: string = ''; - @State errorText: string = ''; - @State isBusy: boolean = false; + @Event onBack: () => void = () => {}; + @Event onLoginSuccess: () => void = () => {}; + @Local relayUrl: string = DEFAULT_CLOUD_RELAY_URL; + @Local username: string = ''; + @Local password: string = ''; + @Local errorText: string = ''; + @Local isBusy: boolean = false; build() { Stack({ alignContent: Alignment.TopStart }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets index b2b3e8531c..66628abaa6 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets @@ -1,16 +1,10 @@ import { ConversationUiImage, ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, ConversationUiToolStatus } from './ConversationUiModels'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ACCENT, CARD, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; -import { FileReferenceCard } from './FileReferenceCard'; -import { MarkdownContent } from './MarkdownContent'; -import { StreamingMarkdownContent } from './StreamingMarkdownContent'; +import { INK, LINE, MUTED, SOFT } from './Theme'; +import { MessageFileCards, MessageImageGallery, MessageMarkdown } from './ChatMessageContent'; +import { ChatMessageRetryAction, ChatTypingDots, ChatUserMessageBubble } from './ChatMessageChrome'; import { ThinkingBlock } from './ThinkingBlock'; import { ToolStatusList } from './ToolStatusList'; -import { FileTargetResolver } from '../../services/FileTargetResolver'; -import { - MessageFileReference, - MessageFileReferenceProjectionCache -} from '../../services/MessageFileReferenceProjector'; +import { MessageFileReference, MessageFileReferenceProjectionCache } from '../../services/MessageFileReferenceProjector'; interface SubagentTaskInput { description?: string; @@ -28,13 +22,6 @@ interface StructuredRenderGroup { path: string; } -interface ActivityGroupStats { - thinkingCount: number; - readCount: number; - searchCount: number; - otherCount: number; -} - @ComponentV2 export struct ChatMessageBubble { @Param item: ConversationUiMessage = { @@ -63,83 +50,21 @@ export struct ChatMessageBubble { @Event onRetryMessage: (text: string) => void = (_text: string) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onDownloadFile: (path: string) => void = (_path: string) => {}; - @Local expandedActivityPath: string = ''; - @Local typingPhase: number = 0; - private typingTimerId: number = 0; private readonly fileReferenceCache: MessageFileReferenceProjectionCache = new MessageFileReferenceProjectionCache(); - aboutToAppear(): void { - if (!this.shouldShowTypingDots(this.item) && !this.hasRunningSubagentTask(this.item)) { - return; - } - this.typingTimerId = setInterval(() => { - this.typingPhase = (this.typingPhase + 1) % 3; - }, 360); - } - - aboutToDisappear(): void { - if (this.typingTimerId !== 0) { - clearInterval(this.typingTimerId); - this.typingTimerId = 0; - } - } - build() { if (this.item.role === 'user') { - this.UserBubble() + ChatUserMessageBubble({ + item: this.item, + showRetryAction: this.showRetryAction, + onRetryMessage: this.onRetryMessage + }) } else { this.AssistantBubble() } } - @Builder - UserBubble() { - Row() { - Blank() - Column({ space: 6 }) { - if (this.visibleMessageText(this.item).length > 0 || (this.item.images && this.item.images.length > 0)) { - Column({ space: 8 }) { - if (this.item.images && this.item.images.length > 0) { - this.UserMessageImages(this.item.images) - } - if (this.visibleMessageText(this.item).length > 0) { - Text(this.visibleMessageText(this.item)) - .fontSize(14) - .lineHeight(20) - .fontColor(INK) - } - } - .padding({ left: 10, right: 10, top: 10, bottom: 10 }) - .backgroundColor(SOFT) - .borderRadius(18) - .alignItems(HorizontalAlign.Start) - } - if (this.item.status === 'failed' && this.showRetryAction) { - Row({ space: 8 }) { - Text(RemoteI18n.t('chat.sendFailed')) - .fontSize(12) - .fontColor(RED) - Text(RemoteI18n.t('common.retry')) - .fontSize(12) - .fontColor(PRIMARY_ACTION_TEXT) - .height(28) - .padding({ left: 10, right: 10 }) - .backgroundColor(ACCENT) - .borderRadius(14) - .onClick(() => { - this.onRetryMessage(this.item.text); - }) - } - } - } - .constraintSize({ maxWidth: '70%' }) - .alignItems(HorizontalAlign.End) - } - .width('100%') - .padding({ top: 8, bottom: 12 }) - } - @Builder AssistantBubble() { Row() { @@ -154,21 +79,11 @@ export struct ChatMessageBubble { } if (this.item.status === 'failed' && this.showRetryAction && (this.item.detail || '').trim().length > 0) { - Row({ space: 8 }) { - Text(RemoteI18n.t('generalChat.replyInterrupted')) - .fontSize(12) - .fontColor(RED) - Text(RemoteI18n.t('common.retry')) - .fontSize(12) - .fontColor(PRIMARY_ACTION_TEXT) - .height(28) - .padding({ left: 10, right: 10 }) - .backgroundColor(ACCENT) - .borderRadius(14) - .onClick(() => { - this.onRetryMessage(this.item.detail || '') - }) - } + ChatMessageRetryAction({ + assistant: true, + retryText: this.item.detail || '', + onRetry: this.onRetryMessage + }) } } .layoutWeight(1) @@ -203,7 +118,7 @@ export struct ChatMessageBubble { } } if (this.shouldShowTypingDots(item)) { - this.TypingDots() + ChatTypingDots() } if (item.tools && item.tools.length > 0) { this.Tools(item.tools) @@ -219,28 +134,6 @@ export struct ChatMessageBubble { } } - @Builder - AssistantAvatar() { - Row({ space: 4 }) { - Text('') - .width(6) - .height(6) - .backgroundColor(PRIMARY_ACTION_TEXT) - .borderRadius(3) - Text('') - .width(6) - .height(6) - .backgroundColor(PRIMARY_ACTION_TEXT) - .borderRadius(3) - } - .width(32) - .height(32) - .backgroundColor(ACCENT) - .borderRadius(16) - .justifyContent(FlexAlign.Center) - .alignItems(VerticalAlign.Center) - } - @Builder StructuredItems(items: ConversationUiMessageItem[], omitActiveThinking: boolean = false) { Column({ space: 10 }) { @@ -357,7 +250,7 @@ export struct ChatMessageBubble { } .width('100%') if (entry.tool && this.isRunningTool(entry.tool)) { - this.TypingDots() + ChatTypingDots() } if (entry.content && entry.content.trim().length > 0 && !this.isTextEntry(entry) && !this.isThinkingEntry(entry)) { this.MessageText(entry.content, activeScope && (!entry.subItems || entry.subItems.length === 0), `${this.item.id}-${path}-subagent`) @@ -422,122 +315,38 @@ export struct ChatMessageBubble { }) } - @Builder - TypingDots() { - Row({ space: 5 }) { - Text('•') - .width(6) - .height(18) - .fontSize(16) - .fontColor(MUTED) - .opacity(this.typingDotOpacity(0)) - .animation({ duration: 180, curve: Curve.EaseInOut }) - Text('•') - .width(6) - .height(18) - .fontSize(16) - .fontColor(MUTED) - .opacity(this.typingDotOpacity(1)) - .animation({ duration: 180, curve: Curve.EaseInOut }) - Text('•') - .width(6) - .height(18) - .fontSize(16) - .fontColor(MUTED) - .opacity(this.typingDotOpacity(2)) - .animation({ duration: 180, curve: Curve.EaseInOut }) - } - .height(24) - .padding({ left: 2 }) - } - - private typingDotOpacity(index: number): number { - return this.typingPhase === index ? 1.0 : 0.34; - } - @Builder MessageImages(images: ConversationUiImage[]) { - Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { - ForEach(images, (image: ConversationUiImage) => { - Image(image.data_url) - .width(92) - .height(92) - .objectFit(ImageFit.Cover) - .borderRadius(14) - .border({ width: 1, color: LINE }) - .margin({ right: 8, bottom: 8 }) - }, (image: ConversationUiImage) => image.name) - } - .width('100%') - } - - @Builder - UserMessageImages(images: ConversationUiImage[]) { - Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { - ForEach(images, (image: ConversationUiImage, index: number) => { - Image(image.data_url) - .width(112) - .height(112) - .objectFit(ImageFit.Cover) - .borderRadius(12) - .border({ width: 1, color: LINE }) - .margin({ right: index % 2 === 0 && images.length > 1 ? 8 : 0, bottom: index < images.length - 2 ? 8 : 0 }) - }, (image: ConversationUiImage, index: number) => `${image.name}-${index}`) - } - .width(images.length > 1 ? 232 : 112) + MessageImageGallery({ images }) } @Builder MessageText(text: string, active: boolean = false, streamKey: string = '') { - if (active) { - StreamingMarkdownContent({ - text, - active, - streamKey, - onCopyText: (body: string) => { - this.onCopyMessage(body); - }, - onOpenLink: (reference: string, label: string) => { - this.onOpenFilePreview(reference, label); - } - }) - } else { - MarkdownContent({ - text, - onCopyText: (body: string) => { - this.onCopyMessage(body); - }, - onOpenLink: (reference: string, label: string) => { - this.onOpenFilePreview(reference, label); - } - }) - } + MessageMarkdown({ + text, + active, + streamKey, + onCopyText: this.onCopyMessage, + onOpenLink: this.onOpenFilePreview + }) } @Builder FileCards(text: string) { - Column({ space: 8 }) { - ForEach(this.fileReferences(text), (file: MessageFileReference) => { - FileReferenceCard({ - path: file.path, - label: file.label, - status: this.fileStatus(file.path), - previewLabel: RemoteI18n.t('common.open'), - buttonLabel: this.fileButtonLabel(file.path), - disabled: this.downloadingFilePath === file.path, - selected: FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), - previewLoading: this.activeFilePreviewLoading && - FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), - onPreview: (path: string, label: string) => { - this.onOpenFilePreview(path, label); - }, - onDownload: (path: string) => { - this.onDownloadFile(path); - } - }) - }, (file: MessageFileReference) => file.id) - } - .width('100%') + MessageFileCards({ + text, + downloadingFilePath: this.downloadingFilePath, + downloadedFilePath: this.downloadedFilePath, + fileDownloadStatus: this.fileDownloadStatus, + activeFilePreviewPath: this.activeFilePreviewPath, + activeFilePreviewLoading: this.activeFilePreviewLoading, + onPreview: this.onOpenFilePreview, + onDownload: this.onDownloadFile + }) + } + + private fileReferences(text: string): MessageFileReference[] { + return this.fileReferenceCache.referencesFor(text); } private visibleMessageText(item: ConversationUiMessage): string { @@ -896,56 +705,6 @@ export struct ChatMessageBubble { return ''; } - private activityGroupTitle(group: StructuredRenderGroup): string { - const stats = this.activityGroupStats(group.items); - const total = stats.thinkingCount + stats.readCount + stats.searchCount + stats.otherCount; - return `已折叠 ${total} 个思考和工具调用`; - } - - private activityGroupDetail(group: StructuredRenderGroup): string { - const stats = this.activityGroupStats(group.items); - const parts: string[] = []; - if (stats.thinkingCount > 0) { - parts.push(`思考 ${stats.thinkingCount}`); - } - if (stats.readCount > 0) { - parts.push(`读取 ${stats.readCount}`); - } - if (stats.searchCount > 0) { - parts.push(`搜索 ${stats.searchCount}`); - } - if (stats.otherCount > 0) { - parts.push(`其他 ${stats.otherCount}`); - } - return parts.join(' · '); - } - - private activityGroupStats(items: ConversationUiMessageItem[]): ActivityGroupStats { - const stats: ActivityGroupStats = { - thinkingCount: 0, - readCount: 0, - searchCount: 0, - otherCount: 0 - }; - items.forEach((entry: ConversationUiMessageItem) => { - if (this.isThinkingEntry(entry)) { - stats.thinkingCount += 1; - return; - } - if (entry.tool) { - const kind = this.activityToolKind(entry.tool); - if (kind === 'read') { - stats.readCount += 1; - } else if (kind === 'search') { - stats.searchCount += 1; - } else { - stats.otherCount += 1; - } - } - }); - return stats; - } - private activityGroupTools(group: StructuredRenderGroup): ConversationUiToolStatus[] { const tools: ConversationUiToolStatus[] = []; group.items.forEach((entry: ConversationUiMessageItem) => { @@ -1141,13 +900,6 @@ export struct ChatMessageBubble { return ''; } - private hasRunningSubagentTask(item: ConversationUiMessage): boolean { - return (item.items || []).some((entry: ConversationUiMessageItem) => { - return !!entry.tool && this.normalizedToolName(entry.tool) === 'task' && - this.isRunningTool(entry.tool); - }); - } - private structuredItemKey(entry: ConversationUiMessageItem, path: string): string { if (entry.tool && entry.tool.id) { return `${path}-tool-${entry.tool.id}`; @@ -1217,30 +969,4 @@ export struct ChatMessageBubble { normalized === 'ask_user_question'; } - private fileReferences(text: string): MessageFileReference[] { - return this.fileReferenceCache.referencesFor(text); - } - - private fileStatus(path: string): string { - if ((this.downloadingFilePath === path || this.downloadedFilePath === path) && this.fileDownloadStatus.length > 0) { - return this.fileDownloadStatus; - } - if (path.indexOf('computer://') === 0) { - return RemoteI18n.t('chat.desktopFile'); - } - if (path.indexOf('file://') === 0) { - return RemoteI18n.t('chat.fileLink'); - } - return path; - } - - private fileButtonLabel(path: string): string { - if (this.downloadingFilePath === path) { - return RemoteI18n.t('chat.reading'); - } - if (this.downloadedFilePath === path) { - return RemoteI18n.t('common.done'); - } - return RemoteI18n.t('chat.download'); - } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets new file mode 100644 index 0000000000..c485c0add8 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets @@ -0,0 +1,109 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ConversationUiMessage } from './ConversationUiModels'; +import { MessageImageGallery } from './ChatMessageContent'; +import { ACCENT, INK, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; + +@ComponentV2 +export struct ChatTypingDots { + @Local phase: number = 0; + private timerId: number = 0; + + aboutToAppear(): void { + this.timerId = setInterval(() => { + this.phase = (this.phase + 1) % 3; + }, 360); + } + + aboutToDisappear(): void { + if (this.timerId !== 0) { + clearInterval(this.timerId); + this.timerId = 0; + } + } + + build() { + Row({ space: 5 }) { + ForEach([0, 1, 2], (index: number) => { + Text('•') + .width(6) + .height(18) + .fontSize(16) + .fontColor(MUTED) + .opacity(this.phase === index ? 1.0 : 0.34) + .animation({ duration: 180, curve: Curve.EaseInOut }) + }) + } + .height(24) + .padding({ left: 2 }) + } +} + +@ComponentV2 +export struct ChatMessageRetryAction { + @Param assistant: boolean = false; + @Param retryText: string = ''; + @Event onRetry: (text: string) => void = (_text: string) => {}; + + build() { + Row({ space: 8 }) { + Text(this.assistant ? RemoteI18n.t('generalChat.replyInterrupted') : RemoteI18n.t('chat.sendFailed')) + .fontSize(12) + .fontColor(RED) + Text(RemoteI18n.t('common.retry')) + .fontSize(12) + .fontColor(PRIMARY_ACTION_TEXT) + .height(28) + .padding({ left: 10, right: 10 }) + .backgroundColor(ACCENT) + .borderRadius(14) + .onClick(() => this.onRetry(this.retryText)) + } + } +} + +@ComponentV2 +export struct ChatUserMessageBubble { + @Param item: ConversationUiMessage = { + id: '', + role: 'user', + text: '', + status: '', + detail: '' + }; + @Param showRetryAction: boolean = false; + @Event onRetryMessage: (text: string) => void = (_text: string) => {}; + + build() { + Row() { + Blank() + Column({ space: 6 }) { + if (this.visibleText().length > 0 || (this.item.images && this.item.images.length > 0)) { + Column({ space: 8 }) { + if (this.item.images && this.item.images.length > 0) { + MessageImageGallery({ images: this.item.images, userStyle: true }) + } + if (this.visibleText().length > 0) { + Text(this.visibleText()).fontSize(14).lineHeight(20).fontColor(INK) + } + } + .padding({ left: 10, right: 10, top: 10, bottom: 10 }) + .backgroundColor(SOFT) + .borderRadius(18) + .alignItems(HorizontalAlign.Start) + } + if (this.item.status === 'failed' && this.showRetryAction) { + ChatMessageRetryAction({ retryText: this.item.text, onRetry: this.onRetryMessage }) + } + } + .constraintSize({ maxWidth: '70%' }) + .alignItems(HorizontalAlign.End) + } + .width('100%') + .padding({ top: 8, bottom: 12 }) + } + + private visibleText(): string { + const text = this.item.text.trim(); + return text === '(空消息)' || text === '(empty message)' ? '' : text; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets new file mode 100644 index 0000000000..10df9e3317 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageContent.ets @@ -0,0 +1,111 @@ +import { ConversationUiImage } from './ConversationUiModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { FileTargetResolver } from '../../services/FileTargetResolver'; +import { + MessageFileReference, + MessageFileReferenceProjectionCache +} from '../../services/MessageFileReferenceProjector'; +import { FileReferenceCard } from './FileReferenceCard'; +import { MarkdownContent } from './MarkdownContent'; +import { StreamingMarkdownContent } from './StreamingMarkdownContent'; +import { LINE } from './Theme'; + +@ComponentV2 +export struct MessageImageGallery { + @Param images: ConversationUiImage[] = []; + @Param userStyle: boolean = false; + + build() { + Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { + ForEach(this.images, (image: ConversationUiImage, index: number) => { + Image(image.data_url) + .width(this.userStyle ? 112 : 92) + .height(this.userStyle ? 112 : 92) + .objectFit(ImageFit.Cover) + .borderRadius(this.userStyle ? 12 : 14) + .border({ width: 1, color: LINE }) + .margin({ + right: this.userStyle ? (index % 2 === 0 && this.images.length > 1 ? 8 : 0) : 8, + bottom: this.userStyle ? (index < this.images.length - 2 ? 8 : 0) : 8 + }) + }, (image: ConversationUiImage, index: number) => `${image.name}-${index}`) + } + .width(this.userStyle ? (this.images.length > 1 ? 232 : 112) : '100%') + } +} + +@ComponentV2 +export struct MessageMarkdown { + @Param text: string = ''; + @Param active: boolean = false; + @Param streamKey: string = ''; + @Event onCopyText: (text: string) => void = (_text: string) => {}; + @Event onOpenLink: (reference: string, label: string) => void = + (_reference: string, _label: string) => {}; + + build() { + if (this.active) { + StreamingMarkdownContent({ + text: this.text, + active: this.active, + streamKey: this.streamKey, + onCopyText: this.onCopyText, + onOpenLink: this.onOpenLink + }) + } else { + MarkdownContent({ + text: this.text, + onCopyText: this.onCopyText, + onOpenLink: this.onOpenLink + }) + } + } +} + +@ComponentV2 +export struct MessageFileCards { + @Param text: string = ''; + @Param downloadingFilePath: string = ''; + @Param downloadedFilePath: string = ''; + @Param fileDownloadStatus: string = ''; + @Param activeFilePreviewPath: string = ''; + @Param activeFilePreviewLoading: boolean = false; + @Event onPreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; + @Event onDownload: (path: string) => void = (_path: string) => {}; + private readonly cache: MessageFileReferenceProjectionCache = new MessageFileReferenceProjectionCache(); + + build() { + Column({ space: 8 }) { + ForEach(this.cache.referencesFor(this.text), (file: MessageFileReference) => { + FileReferenceCard({ + path: file.path, + label: file.label, + status: this.fileStatus(file.path), + previewLabel: RemoteI18n.t('common.open'), + buttonLabel: this.fileButtonLabel(file.path), + disabled: this.downloadingFilePath === file.path, + selected: FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), + previewLoading: this.activeFilePreviewLoading && + FileTargetResolver.matchesRemotePath(file.path, this.activeFilePreviewPath), + onPreview: this.onPreview, + onDownload: this.onDownload + }) + }, (file: MessageFileReference) => file.id) + } + .width('100%') + } + + private fileStatus(path: string): string { + if ((this.downloadingFilePath === path || this.downloadedFilePath === path) && + this.fileDownloadStatus.length > 0) return this.fileDownloadStatus; + if (path.indexOf('computer://') === 0) return RemoteI18n.t('chat.desktopFile'); + if (path.indexOf('file://') === 0) return RemoteI18n.t('chat.fileLink'); + return path; + } + + private fileButtonLabel(path: string): string { + if (this.downloadingFilePath === path) return RemoteI18n.t('chat.reading'); + if (this.downloadedFilePath === path) return RemoteI18n.t('common.done'); + return RemoteI18n.t('chat.download'); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets index e61d366e2e..aa21d5e3f4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatStatusBar.ets @@ -1,13 +1,13 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { INK, LINE, MUTED, PAGE_BG, SOFT } from './Theme'; -@Component +@ComponentV2 export struct ChatStatusBar { - @Prop title: string = ''; - @Prop detail: string = ''; - @Prop color: ResourceColor = MUTED; - @Prop canStop: boolean = false; - onStop: () => void = () => {}; + @Param title: string = ''; + @Param detail: string = ''; + @Param color: ResourceColor = MUTED; + @Param canStop: boolean = false; + @Event onStop: () => void = () => {}; build() { Row({ space: 10 }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index 8f2d8f4ba5..2c0aceb715 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets @@ -7,7 +7,7 @@ import { ConversationUiModelCatalog, ConversationUiSelectedImage } from './ConversationUiModels'; -import { ConversationModelPresentationPolicy } from '../state/ConversationModelPresentationPolicy'; +import { ConversationModelPresentationPolicy } from '../policy/ConversationModelPresentationPolicy'; import { CARD, FLOATING_PANEL_BG, GREEN, INK, LINE, MUTED, RED, SOFT } from './Theme'; export enum ComposerPresentation { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectAccountDevicePage.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectAccountDevicePage.ets new file mode 100644 index 0000000000..e1e87a723d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectAccountDevicePage.ets @@ -0,0 +1,245 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ACCENT, CARD, GREEN, INK, LINE, MUTED, PAGE_BG, RED, SOFT } from './Theme'; + +@ComponentV2 +export struct ConnectAccountDevicePage { + @Param deviceId: string = ''; + @Param controlTargetDeviceId: string = ''; + @Param connectionState: string = 'idle'; + @Event onBack: () => void = () => {}; + @Event onOpenScanner: () => void = () => {}; + @Event cloudListDevices: () => Promise = + async (): Promise => []; + @Event cloudSelectDevice: (device: CloudAccountDevice) => Promise = + async (_device: CloudAccountDevice): Promise => {}; + @Local accountDevices: CloudAccountDevice[] = []; + @Local accountDevicesBusy: boolean = false; + @Local accountDevicesError: string = ''; + @Local switchingDeviceId: string = ''; + @Local otherConnectionMethodsExpanded: boolean = false; + + aboutToAppear(): void { + this.refreshAccountDevices(); + } + + build() { + Column() { + Row({ space: 16 }) { + Stack() { + SymbolGlyph($r('sys.symbol.chevron_left')) + .fontSize(23).fontColor([INK]).width(26).height(26) + } + .width(48).height(48).backgroundColor(SOFT).borderRadius(24) + .onClick(() => this.onBack()) + Column({ space: 4 }) { + Text(RemoteI18n.t('connect.accountDevicesTitle')) + .fontSize(22).fontWeight(FontWeight.Bold).fontColor(INK).width('100%') + Text(RemoteI18n.t('connect.accountDevicesSubtitle')) + .fontSize(13).fontColor(MUTED).width('100%') + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%').height(92) + .padding({ left: 28, right: 28, top: 18 }) + .alignItems(VerticalAlign.Top) + + Scroll() { + Column({ space: 18 }) { + Text(RemoteI18n.t('connect.accountDevicesBody')) + .fontSize(14).lineHeight(21).fontColor(MUTED).width('100%') + this.AccountDeviceList() + this.OtherConnectionMethods() + } + .width('100%') + .constraintSize({ minHeight: '100%' }) + .padding({ left: 28, right: 28, top: 10, bottom: 34 }) + } + .layoutWeight(1) + .width('100%') + .scrollBar(BarState.Off) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private AccountDeviceList() { + Column({ space: 4 }) { + Row() { + Text(RemoteI18n.t('connect.availableDevices')) + .fontSize(16).fontWeight(FontWeight.Bold).fontColor(INK) + Blank() + Text(this.accountDevicesBusy ? RemoteI18n.t('common.loading') : + (this.accountDevicesError.length > 0 ? RemoteI18n.t('common.retry') : RemoteI18n.t('common.refresh'))) + .fontSize(14) + .fontColor(this.accountDevicesBusy ? MUTED : + (this.accountDevicesError.length > 0 ? RED : ACCENT)) + .onClick(async () => { await this.refreshAccountDevices(); }) + } + .width('100%').height(38) + + if (this.accountDevicesBusy && this.accountDevices.length === 0) { + Column() { + this.AccountDeviceSkeletonRow() + this.AccountDeviceSkeletonRow() + } + .width('100%').height(120) + } else if (this.desktopDevices().length === 0) { + Row() { + Text(this.accountDevicesError || RemoteI18n.t('remote.settings.deviceEmpty')) + .fontSize(14).lineHeight(20).fontColor(MUTED).width('100%') + } + .width('100%').height(120).alignItems(VerticalAlign.Center) + } else { + Scroll() { + Column() { + ForEach(this.desktopDevices(), (device: CloudAccountDevice) => { + this.AccountConnectDeviceRow(device) + }, (device: CloudAccountDevice): string => + `${device.deviceId}:${device.online ? 'online' : 'offline'}:${device.lastSeenAt || 0}:${device.deviceName}`) + } + .width('100%') + } + .width('100%').height(120).scrollBar(BarState.Off) + } + } + .width('100%').height(174) + .padding({ left: 16, right: 16, top: 8, bottom: 8 }) + .backgroundColor(CARD).borderRadius(8).border({ width: 1, color: LINE }) + } + + @Builder + private OtherConnectionMethods() { + Column() { + Row({ space: 12 }) { + Text(RemoteI18n.t('connect.otherConnectionMethods')) + .fontSize(16).fontWeight(FontWeight.Medium).fontColor(INK) + Blank() + SymbolGlyph(this.otherConnectionMethodsExpanded ? + $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) + .fontSize(13).fontColor([MUTED]) + } + .width('100%').height(58).padding({ left: 16, right: 16 }) + .onClick(() => { + this.otherConnectionMethodsExpanded = !this.otherConnectionMethodsExpanded; + }) + + if (this.otherConnectionMethodsExpanded) { + Divider().color(LINE).margin({ left: 16, right: 16 }) + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.link')) + .fontSize(20).fontColor([MUTED]).width(22).height(22).opacity(0.66) + Text(RemoteI18n.t('connect.scanPairCodeAction')) + .fontSize(16).fontWeight(FontWeight.Medium).fontColor(INK).layoutWeight(1) + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(13).fontColor([MUTED]).width(16).height(16).opacity(0.44) + } + .width('100%').height(58).padding({ left: 16, right: 16 }) + .onClick(() => this.onOpenScanner()) + } + } + .width('100%').backgroundColor(CARD).borderRadius(8).border({ width: 1, color: LINE }) + } + + @Builder + private AccountDeviceSkeletonRow() { + Row({ space: 12 }) { + Text('').width(26).height(22).backgroundColor(SOFT).borderRadius(5) + Column({ space: 7 }) { + Text('').width('58%').height(12).backgroundColor(SOFT).borderRadius(4) + Text('').width(52).height(9).backgroundColor(SOFT).borderRadius(4) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%').height(60).padding({ left: 4, right: 4 }).alignItems(VerticalAlign.Center) + } + + @Builder + private AccountConnectDeviceRow(device: CloudAccountDevice) { + Row({ space: 12 }) { + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(22).fontColor([MUTED]).width(26).height(24).opacity(device.online ? 0.68 : 0.38) + Column({ space: 3 }) { + Text(device.deviceName || device.deviceId) + .fontSize(15).fontWeight(FontWeight.Medium).fontColor(INK) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.accountDeviceStatus(device)) + .fontSize(13).fontColor(device.online ? GREEN : MUTED) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + if (device.online) { + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(13).fontColor([MUTED]).width(16).height(16).opacity(0.44) + } + } + .width('100%').height(60).padding({ left: 4, right: 4 }) + .alignItems(VerticalAlign.Center) + .opacity(this.canSelectAccountDevice(device) ? 1 : 0.64) + .onClick(async () => { + if (!this.canSelectAccountDevice(device)) return; + this.switchingDeviceId = device.deviceId; + this.accountDevicesError = ''; + try { + await this.cloudSelectDevice(device); + } catch (err) { + this.accountDevicesError = err instanceof Error ? err.message : + RemoteI18n.t('remote.settings.deviceSwitchFailed'); + } finally { + this.switchingDeviceId = ''; + } + }) + } + + private async refreshAccountDevices(): Promise { + if (this.accountDevicesBusy) return; + this.accountDevicesBusy = true; + this.accountDevicesError = ''; + try { + this.accountDevices = await this.cloudListDevices(); + } catch (err) { + this.accountDevicesError = err instanceof Error ? err.message : + RemoteI18n.t('remote.settings.deviceLoadFailed'); + } finally { + this.accountDevicesBusy = false; + if (!this.hasOnlineDesktopDevice()) { + this.otherConnectionMethodsExpanded = true; + } + } + } + + private desktopDevices(): CloudAccountDevice[] { + return this.accountDevices.filter((device: CloudAccountDevice): boolean => + device.deviceId !== this.deviceId && device.deviceName !== 'HarmonyOS Phone'); + } + + private canSelectAccountDevice(device: CloudAccountDevice): boolean { + return device.online && device.deviceId !== this.deviceId && this.switchingDeviceId.length === 0; + } + + private hasOnlineDesktopDevice(): boolean { + const devices = this.desktopDevices(); + for (let index = 0; index < devices.length; index += 1) { + if (devices[index].online) return true; + } + return false; + } + + private accountDeviceStatus(device: CloudAccountDevice): string { + if (device.deviceId === this.switchingDeviceId) { + return RemoteI18n.t('remote.settings.deviceConnecting'); + } + const presence = device.online ? RemoteI18n.t('remote.settings.deviceOnline') : + RemoteI18n.t('remote.settings.deviceOffline'); + if (device.deviceId === this.controlTargetDeviceId && this.connectionState === 'connected') { + return `${RemoteI18n.t('remote.settings.deviceControlling')} · ${presence}`; + } + if (device.deviceId === this.controlTargetDeviceId) { + return `${RemoteI18n.t('connect.deviceLastUsed')} · ${presence}`; + } + return presence; + } +} + diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets new file mode 100644 index 0000000000..e1dd2ccf16 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectManualPairingOverlay.ets @@ -0,0 +1,107 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK, LINE, MODAL_SCRIM, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, SOFT, SUBTLE } from './Theme'; + +@ComponentV2 +export struct ConnectManualPairingOverlay { + @Param remoteUrl: string = ''; + @Param userIdInput: string = ''; + @Param password: string = ''; + @Param requiresAccountAuth: boolean = false; + @Param canSubmit: boolean = false; + @Event onRemoteUrlChange: (value: string) => void = (_value: string) => {}; + @Event onUserIdChange: (value: string) => void = (_value: string) => {}; + @Event onPasswordChange: (value: string) => void = (_value: string) => {}; + @Event onCancel: () => void = () => {}; + @Event onSubmit: () => void = () => {}; + + build() { + Stack() { + Text('') + .width('100%') + .height('100%') + .backgroundColor(MODAL_SCRIM) + .onClick(this.onCancel) + + Column({ space: 20 }) { + Text(this.requiresAccountAuth ? + RemoteI18n.t('connect.accountPairTitle') : RemoteI18n.t('connect.manualPair')) + .fontSize(24) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + .width('100%') + Text(this.requiresAccountAuth ? + RemoteI18n.t('connect.accountPairIntro') : RemoteI18n.t('connect.manualPairBody')) + .fontSize(17) + .lineHeight(24) + .fontColor(MUTED) + .width('100%') + TextInput({ placeholder: RemoteI18n.t('connect.pairCodePlaceholder'), text: this.remoteUrl }) + .height(62) + .fontSize(20) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(31) + .padding({ left: 20, right: 20 }) + .defaultFocus(true) + .onChange(this.onRemoteUrlChange) + if (this.requiresAccountAuth) { + TextInput({ + placeholder: RemoteI18n.t('connect.accountUsernamePlaceholder'), + text: this.userIdInput + }) + .height(56) + .fontSize(18) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(28) + .padding({ left: 20, right: 20 }) + .onChange(this.onUserIdChange) + TextInput({ placeholder: RemoteI18n.t('connect.accountPasswordPlaceholder'), text: this.password }) + .height(56) + .fontSize(18) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(28) + .padding({ left: 20, right: 20 }) + .type(InputType.Password) + .onChange(this.onPasswordChange) + Text(RemoteI18n.t('connect.accountPairBody')) + .fontSize(13) + .lineHeight(18) + .fontColor(MUTED) + .width('100%') + } + Row({ space: 12 }) { + Button(RemoteI18n.t('common.cancel')) + .layoutWeight(1) + .height(58) + .fontSize(19) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(29) + .onClick(this.onCancel) + Button(RemoteI18n.t('connect.pair')) + .layoutWeight(1) + .height(58) + .fontSize(19) + .fontWeight(FontWeight.Bold) + .fontColor(this.canSubmit ? PRIMARY_ACTION_TEXT : SUBTLE) + .backgroundColor(this.canSubmit ? PRIMARY_ACTION : SOFT) + .borderRadius(29) + .enabled(this.canSubmit) + .onClick(this.onSubmit) + } + .width('100%') + } + .width('82%') + .constraintSize({ maxWidth: 520 }) + .padding({ left: 28, right: 28, top: 30, bottom: 28 }) + .backgroundColor(CARD) + .borderRadius(34) + .border({ width: 1, color: LINE }) + } + .width('100%') + .height('100%') + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets index 8b917cecdd..b01a2402db 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConnectView.ets @@ -2,64 +2,52 @@ import { abilityAccessCtrl, Context, Permissions } from '@kit.AbilityKit'; import { customScan, scanBarcode, scanCore } from '@kit.ScanKit'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ConnectAccountDevicePage } from './ConnectAccountDevicePage'; +import { ConnectManualPairingOverlay } from './ConnectManualPairingOverlay'; import { ACCENT, CARD, CONNECT_HERO_ACCENT, CONNECT_HERO_BG, CONNECT_HERO_SECONDARY, - CONNECT_HERO_SURFACE, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT, + CONNECT_HERO_SURFACE, CONNECT_SCAN_ACCENT, GREEN, INK, LINE, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT, SUBTLE } from './Theme'; -const CONNECT_SCAN_YELLOW: string = '#FFD021'; -const CONNECT_OVERLAY: string = '#99000000'; const CAMERA_PERMISSION: Permissions = 'ohos.permission.CAMERA'; -@Component +@ComponentV2 export struct ConnectView { private readonly scannerController: XComponentController = new XComponentController(); private scannerStarted: boolean = false; private scanCompleted: boolean = false; private scanStartRetryCount: number = 0; - @Prop remoteUrl: string = ''; - @Prop userId: string = ''; - @Prop showRemoteUrlInput: boolean = false; - @Prop statusText: string = RemoteI18n.t('status.waitingConnection'); - @Prop connectionState: string = 'idle'; - @Prop connectionFailureKind: string = ''; - @Prop isBusy: boolean = false; - @Prop isConnected: boolean = false; - @Prop desktopName: string = ''; - @Prop desktopId: string = ''; - @Prop deviceId: string = ''; - @Prop accountUserId: string = ''; - @Prop controlTargetDeviceId: string = ''; - @Prop requiresAccountAuth: boolean = false; - @Prop accountUsername: string = ''; - @Prop startWithScanner: boolean = true; - onBack: () => void = () => {}; - onConnect: (password?: string) => void = (_password?: string) => {}; - onClearPairing: () => void = () => {}; - onRemoteUrlChange: (value: string) => void = (_value: string) => {}; - onUserIdChange: (value: string) => void = (_value: string) => {}; - onRemoteUrlDetected: (value: string) => boolean = (_value: string) => false; - onRemoteUrlInputVisibleChange: (visible: boolean) => void = (_visible: boolean) => {}; - onPasteRemoteUrl: () => void = () => {}; - onScanRemoteUrl: () => void = () => {}; - cloudListDevices: () => Promise = async (): Promise => []; - cloudSelectDevice: (device: CloudAccountDevice) => Promise = + @Param remoteUrl: string = ''; + @Param userId: string = ''; + @Param statusText: string = RemoteI18n.t('status.waitingConnection'); + @Param connectionState: string = 'idle'; + @Param connectionFailureKind: string = ''; + @Param isBusy: boolean = false; + @Param isConnected: boolean = false; + @Param desktopName: string = ''; + @Param deviceId: string = ''; + @Param accountUserId: string = ''; + @Param controlTargetDeviceId: string = ''; + @Param requiresAccountAuth: boolean = false; + @Param accountUsername: string = ''; + @Param startWithScanner: boolean = true; + @Event onBack: () => void = () => {}; + @Event onConnect: (password?: string) => void = (_password?: string) => {}; + @Event onRemoteUrlChange: (value: string) => void = (_value: string) => {}; + @Event onUserIdChange: (value: string) => void = (_value: string) => {}; + @Event onRemoteUrlDetected: (value: string) => boolean = (_value: string) => false; + @Event onRemoteUrlInputVisibleChange: (visible: boolean) => void = (_visible: boolean) => {}; + @Event cloudListDevices: () => Promise = async (): Promise => []; + @Event cloudSelectDevice: (device: CloudAccountDevice) => Promise = async (_device: CloudAccountDevice): Promise => {}; - @State showHelp: boolean = false; - @State pairingStep: string = 'intro'; - @State showManualPairing: boolean = false; - @State inlineScanError: string = ''; - @State accountPassword: string = ''; - @State cameraPermissionReady: boolean = false; - @State requestingCameraPermission: boolean = false; - @State accountDevices: CloudAccountDevice[] = []; - @State accountDevicesBusy: boolean = false; - @State accountDevicesError: string = ''; - @State switchingDeviceId: string = ''; - @State otherConnectionMethodsExpanded: boolean = false; + @Local pairingStep: string = 'intro'; + @Local showManualPairing: boolean = false; + @Local inlineScanError: string = ''; + @Local accountPassword: string = ''; + @Local cameraPermissionReady: boolean = false; + @Local requestingCameraPermission: boolean = false; aboutToAppear(): void { if (this.isAccountAuthenticated()) { this.pairingStep = 'account'; - this.refreshAccountDevices(); } else if (this.startWithScanner && this.remoteUrl.trim().length === 0) { this.pairingStep = 'scan'; } @@ -89,251 +77,17 @@ export struct ConnectView { @Builder AccountDeviceSelectionPage() { - Column() { - Row({ space: 16 }) { - Stack() { - this.BackGlyph() - } - .width(48) - .height(48) - .backgroundColor(SOFT) - .borderRadius(24) - .onClick(() => { - this.stopInlineScan(); - this.onBack(); - }) - Column({ space: 4 }) { - Text(RemoteI18n.t('connect.accountDevicesTitle')) - .fontSize(22) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .width('100%') - Text(RemoteI18n.t('connect.accountDevicesSubtitle')) - .fontSize(13) - .fontColor(MUTED) - .width('100%') - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - } - .width('100%') - .height(92) - .padding({ left: 28, right: 28, top: 18 }) - .alignItems(VerticalAlign.Top) - - Scroll() { - Column({ space: 18 }) { - Text(RemoteI18n.t('connect.accountDevicesBody')) - .fontSize(14) - .lineHeight(21) - .fontColor(MUTED) - .width('100%') - - this.AccountDeviceList() - this.OtherConnectionMethods() - } - .width('100%') - .constraintSize({ minHeight: '100%' }) - .padding({ left: 28, right: 28, top: 10, bottom: 34 }) - } - .layoutWeight(1) - .width('100%') - .scrollBar(BarState.Off) - } - .width('100%') - .height('100%') - .backgroundColor(PAGE_BG) - } - - @Builder - AccountDeviceList() { - Column({ space: 4 }) { - Row() { - Text(RemoteI18n.t('connect.availableDevices')) - .fontSize(16) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - Blank() - Text(this.accountDevicesBusy ? RemoteI18n.t('common.loading') : - (this.accountDevicesError.length > 0 ? RemoteI18n.t('common.retry') : RemoteI18n.t('common.refresh'))) - .fontSize(14) - .fontColor(this.accountDevicesBusy ? MUTED : - (this.accountDevicesError.length > 0 ? RED : ACCENT)) - .onClick(async () => { - await this.refreshAccountDevices(); - }) - } - .width('100%') - .height(38) - - if (this.accountDevicesBusy && this.accountDevices.length === 0) { - Column() { - this.AccountDeviceSkeletonRow() - this.AccountDeviceSkeletonRow() - } - .width('100%') - .height(120) - } else if (this.desktopDevices().length === 0) { - Row() { - Text(this.accountDevicesError || RemoteI18n.t('remote.settings.deviceEmpty')) - .fontSize(14).lineHeight(20).fontColor(MUTED).width('100%') - } - .width('100%') - .height(120) - .alignItems(VerticalAlign.Center) - } else { - Scroll() { - Column() { - ForEach(this.desktopDevices(), (device: CloudAccountDevice) => { - this.AccountConnectDeviceRow(device) - }, (device: CloudAccountDevice): string => - `${device.deviceId}:${device.online ? 'online' : 'offline'}:${device.lastSeenAt || 0}:${device.deviceName}`) - } - .width('100%') - } - .width('100%') - .height(120) - .scrollBar(BarState.Off) - } - - } - .width('100%') - .height(174) - .padding({ left: 16, right: 16, top: 8, bottom: 8 }) - .backgroundColor(CARD) - .borderRadius(8) - .border({ width: 1, color: LINE }) - } - - @Builder - OtherConnectionMethods() { - Column() { - Row({ space: 12 }) { - Text(RemoteI18n.t('connect.otherConnectionMethods')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Blank() - if (this.otherConnectionMethodsExpanded) { - SymbolGlyph($r('sys.symbol.chevron_up')) - .fontSize(13) - .fontColor([MUTED]) - } else { - SymbolGlyph($r('sys.symbol.chevron_down')) - .fontSize(13) - .fontColor([MUTED]) - } - } - .width('100%') - .height(58) - .padding({ left: 16, right: 16 }) - .onClick(() => { - this.otherConnectionMethodsExpanded = !this.otherConnectionMethodsExpanded; - }) - - if (this.otherConnectionMethodsExpanded) { - Divider() - .color(LINE) - .margin({ left: 16, right: 16 }) - - Row({ space: 12 }) { - SymbolGlyph($r('sys.symbol.link')) - .fontSize(20) - .fontColor([MUTED]) - .width(22) - .height(22) - .opacity(0.66) - Text(RemoteI18n.t('connect.scanPairCodeAction')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .layoutWeight(1) - SymbolGlyph($r('sys.symbol.chevron_right')) - .fontSize(13) - .fontColor([MUTED]) - .width(16) - .height(16) - .opacity(0.44) - } - .width('100%') - .height(58) - .padding({ left: 16, right: 16 }) - .onClick(() => { - this.openScannerAfterPermission(); - }) - } - } - .width('100%') - .backgroundColor(CARD) - .borderRadius(8) - .border({ width: 1, color: LINE }) - } - - @Builder - AccountDeviceSkeletonRow() { - Row({ space: 12 }) { - Text('') - .width(26) - .height(22) - .backgroundColor(SOFT) - .borderRadius(5) - Column({ space: 7 }) { - Text('') - .width('58%') - .height(12) - .backgroundColor(SOFT) - .borderRadius(4) - Text('') - .width(52) - .height(9) - .backgroundColor(SOFT) - .borderRadius(4) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - } - .width('100%') - .height(60) - .padding({ left: 4, right: 4 }) - .alignItems(VerticalAlign.Center) - } - - @Builder - AccountConnectDeviceRow(device: CloudAccountDevice) { - Row({ space: 12 }) { - SymbolGlyph($r('sys.symbol.desktop')) - .fontSize(22).fontColor([MUTED]).width(26).height(24).opacity(device.online ? 0.68 : 0.38) - Column({ space: 3 }) { - Text(device.deviceName || device.deviceId) - .fontSize(15).fontWeight(FontWeight.Medium).fontColor(INK) - .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(this.accountDeviceStatus(device)) - .fontSize(13).fontColor(device.online ? GREEN : MUTED) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - if (device.online) { - SymbolGlyph($r('sys.symbol.chevron_right')) - .fontSize(13).fontColor([MUTED]).width(16).height(16).opacity(0.44) - } - } - .width('100%') - .height(60) - .padding({ left: 4, right: 4 }) - .alignItems(VerticalAlign.Center) - .opacity(this.canSelectAccountDevice(device) ? 1 : 0.64) - .onClick(async () => { - if (!this.canSelectAccountDevice(device)) return; - this.switchingDeviceId = device.deviceId; - this.accountDevicesError = ''; - try { - await this.cloudSelectDevice(device); - } catch (err) { - this.accountDevicesError = err instanceof Error ? err.message : - RemoteI18n.t('remote.settings.deviceSwitchFailed'); - } finally { - this.switchingDeviceId = ''; - } + ConnectAccountDevicePage({ + deviceId: this.deviceId, + controlTargetDeviceId: this.controlTargetDeviceId, + connectionState: this.connectionState, + cloudListDevices: this.cloudListDevices, + cloudSelectDevice: this.cloudSelectDevice, + onBack: () => { + this.stopInlineScan(); + this.onBack(); + }, + onOpenScanner: () => this.openScannerAfterPermission() }) } @@ -571,6 +325,27 @@ export struct ConnectView { .height(282) } + @Builder + ScanCorner(x: number, y: number, isLeft: boolean, isTop: boolean) { + Stack() { + Text('') + .width(42) + .height(4) + .borderRadius(2) + .backgroundColor(CONNECT_SCAN_ACCENT) + .position({ x: isLeft ? 0 : 22, y: isTop ? 0 : 60 }) + Text('') + .width(4) + .height(42) + .borderRadius(2) + .backgroundColor(CONNECT_SCAN_ACCENT) + .position({ x: isLeft ? 0 : 60, y: isTop ? 0 : 22 }) + } + .width(64) + .height(64) + .position({ x, y }) + } + @Builder PrimaryPairButton(text: string) { Button(text) @@ -622,373 +397,25 @@ export struct ConnectView { @Builder ManualPairingOverlay() { - Stack() { - Text('') - .width('100%') - .height('100%') - .backgroundColor(CONNECT_OVERLAY) - .onClick(() => { - this.stopInlineScan(); - this.showManualPairing = false; - this.resumeInlineScan(); - }) - - Column({ space: 20 }) { - Text(this.requiresAccountAuth ? RemoteI18n.t('connect.accountPairTitle') : RemoteI18n.t('connect.manualPair')) - .fontSize(24) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .width('100%') - Text(this.requiresAccountAuth ? RemoteI18n.t('connect.accountPairIntro') : RemoteI18n.t('connect.manualPairBody')) - .fontSize(17) - .lineHeight(24) - .fontColor(MUTED) - .width('100%') - TextInput({ placeholder: RemoteI18n.t('connect.pairCodePlaceholder'), text: this.remoteUrl }) - .height(62) - .fontSize(20) - .fontColor(INK) - .backgroundColor(SOFT) - .borderRadius(31) - .padding({ left: 20, right: 20 }) - .defaultFocus(true) - .onChange((value: string) => { - this.onRemoteUrlChange(value); - }) - if (this.requiresAccountAuth) { - TextInput({ placeholder: RemoteI18n.t('connect.accountUsernamePlaceholder'), text: this.displayUserIdInput() }) - .height(56) - .fontSize(18) - .fontColor(INK) - .backgroundColor(SOFT) - .borderRadius(28) - .padding({ left: 20, right: 20 }) - .onChange((value: string) => { - this.onUserIdChange(value); - }) - TextInput({ placeholder: RemoteI18n.t('connect.accountPasswordPlaceholder'), text: this.accountPassword }) - .height(56) - .fontSize(18) - .fontColor(INK) - .backgroundColor(SOFT) - .borderRadius(28) - .padding({ left: 20, right: 20 }) - .type(InputType.Password) - .onChange((value: string) => { - this.accountPassword = value; - }) - Text(RemoteI18n.t('connect.accountPairBody')) - .fontSize(13) - .lineHeight(18) - .fontColor(MUTED) - .width('100%') - } - Row({ space: 12 }) { - Button(RemoteI18n.t('common.cancel')) - .layoutWeight(1) - .height(58) - .fontSize(19) - .fontWeight(FontWeight.Bold) - .fontColor(INK) - .backgroundColor(SOFT) - .borderRadius(29) - .onClick(() => { - this.stopInlineScan(); - this.showManualPairing = false; - this.resumeInlineScan(); - }) - Button(RemoteI18n.t('connect.pair')) - .layoutWeight(1) - .height(58) - .fontSize(19) - .fontWeight(FontWeight.Bold) - .fontColor(this.canConnect() ? PRIMARY_ACTION_TEXT : SUBTLE) - .backgroundColor(this.canConnect() ? PRIMARY_ACTION : SOFT) - .borderRadius(29) - .enabled(this.canConnect()) - .onClick(() => { - this.ensureUserId(); - this.stopInlineScan(); - this.showManualPairing = false; - this.onConnect(this.accountPassword); - }) - } - .width('100%') - } - .width('82%') - .padding({ left: 28, right: 28, top: 30, bottom: 28 }) - .backgroundColor(CARD) - .borderRadius(34) - .border({ width: 1, color: LINE }) - } - .width('100%') - .height('100%') - } - - @Builder - HelpCard() { - Column({ space: 6 }) { - Text(RemoteI18n.t('connect.stepsTitle')) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .width('100%') - Text(RemoteI18n.t('connect.stepsBody')) - .fontSize(12) - .lineHeight(18) - .fontColor(MUTED) - .width('100%') - } - .padding(14) - .backgroundColor(SOFT) - .borderRadius(14) - .border({ width: 1, color: LINE }) - .width('100%') - } - - @Builder - ScanCard() { - Column({ space: 12 }) { - Row() { - Blank() - Stack() { - Text('') - .width(72) - .height(72) - .borderRadius(22) - .backgroundColor(SOFT) - this.ScanCorner(12, 12, true, true) - this.ScanCorner(32, 12, false, true) - this.ScanCorner(12, 32, true, false) - this.ScanCorner(32, 32, false, false) - } - .width(72) - .height(72) - Blank() - } - .width('100%') - .height(92) - - Text(RemoteI18n.t('connect.scanTitle')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .width('100%') - .textAlign(TextAlign.Center) - Text(RemoteI18n.t('connect.scanBody')) - .fontSize(13) - .lineHeight(20) - .fontColor(MUTED) - .width('100%') - .textAlign(TextAlign.Center) - } - .padding({ left: 18, right: 18, top: 26, bottom: 24 }) - .backgroundColor(CARD) - .borderRadius(16) - .width('100%') - .border({ width: 1, color: LINE }) - .onClick(() => { - this.onScanRemoteUrl(); - }) - } - - @Builder - ScanCorner(x: number, y: number, isLeft: boolean, isTop: boolean) { - Stack() { - Text('') - .width(42) - .height(4) - .borderRadius(2) - .backgroundColor(CONNECT_SCAN_YELLOW) - .position({ x: isLeft ? 0 : 22, y: isTop ? 0 : 60 }) - Text('') - .width(4) - .height(42) - .borderRadius(2) - .backgroundColor(CONNECT_SCAN_YELLOW) - .position({ x: isLeft ? 0 : 60, y: isTop ? 0 : 22 }) - } - .width(64) - .height(64) - .position({ x, y }) - } - - @Builder - RemoteUrlCard() { - Column({ space: 14 }) { - Row() { - Text(RemoteI18n.t('connect.userId')) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Blank() - Text(this.remoteUrl.trim().length > 0 ? RemoteI18n.t('connect.filled') : RemoteI18n.t('connect.remoteUrlShort')) - .fontSize(13) - .fontColor(MUTED) - .onClick(() => { - if (this.remoteUrl.trim().length > 0 || this.showRemoteUrlInput) { - this.onRemoteUrlInputVisibleChange(!this.showRemoteUrlInput); - } else { - this.onRemoteUrlInputVisibleChange(true); - this.onPasteRemoteUrl(); - } - }) - } - .width('100%') - - TextInput({ placeholder: RemoteI18n.t('connect.userPlaceholder'), text: this.displayUserIdInput() }) - .height(56) - .fontSize(15) - .backgroundColor(SOFT) - .borderRadius(14) - .padding({ left: 16, right: 16 }) - .border({ width: 1, color: LINE }) - .defaultFocus(false) - .onChange((value: string) => { - this.onUserIdChange(value); - }) - - if (this.showRemoteUrlInput) { - TextInput({ placeholder: RemoteI18n.t('connect.urlPlaceholder'), text: this.remoteUrl }) - .height(50) - .fontSize(13) - .backgroundColor(SOFT) - .borderRadius(14) - .padding(12) - .border({ width: 1, color: LINE }) - .defaultFocus(false) - .onChange((value: string) => { - this.onRemoteUrlChange(value); - }) - } - - Button(this.isBusy ? RemoteI18n.t('connect.connecting') : RemoteI18n.t('connect.connect')) - .width('100%') - .height(50) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(this.canConnect() ? PRIMARY_ACTION_TEXT : SUBTLE) - .backgroundColor(this.canConnect() ? PRIMARY_ACTION : SOFT) - .borderRadius(14) - .enabled(this.canConnect()) - .onClick(() => { - this.ensureUserId(); - this.onConnect(); - }) - } - .padding({ left: 18, right: 18, top: 18, bottom: 18 }) - .backgroundColor(CARD) - .borderRadius(16) - .width('100%') - .border({ width: 1, color: LINE }) - } - - @Builder - StatusCard() { - Column({ space: 8 }) { - Text(RemoteI18n.t('connect.statusTitle')) - .fontSize(16) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - .width('100%') - .margin({ bottom: 6 }) - this.DesktopStatus() - if (this.isConnectError() && this.failureHint().length > 0) { - Divider().color(LINE) - this.FailureHint() - } - } - .width('100%') - .padding(16) - .backgroundColor(CARD) - .borderRadius(16) - .border({ width: 1, color: LINE }) - } - - @Builder - FailureHint() { - Text(this.failureHint()) - .fontSize(12) - .lineHeight(18) - .fontColor(INK) - .width('100%') - .padding(12) - .backgroundColor(SOFT) - .borderRadius(14) - .border({ width: 1, color: LINE }) - } - - @Builder - DesktopStatus() { - List() { - ListItem() { - this.DesktopStatusContent() - } - .height(74) - .swipeAction(this.statusSwipeAction()) - } - .width('100%') - .height(74) - .scrollBar(BarState.Off) - .divider(null) - } - - @Builder - DesktopStatusContent() { - Row() { - Text('●') - .fontSize(12) - .fontColor(this.statusDotColor()) - Column({ space: 6 }) { - Text(this.statusTitle()) - .fontSize(14) - .fontWeight(FontWeight.Medium) - .fontColor(INK) - Text(this.statusDetail()) - .fontSize(12) - .fontColor(MUTED) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - if (this.remoteUrl.trim().length > 0) { - Text(this.desktopIdText()) - .fontSize(11) - .fontColor(SUBTLE) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - .margin({ left: 12 }) - if (this.isBusy) { - Blank() - Text('◌') - .fontSize(22) - .fontColor(INK) + ConnectManualPairingOverlay({ + remoteUrl: this.remoteUrl, + userIdInput: this.displayUserIdInput(), + password: this.accountPassword, + requiresAccountAuth: this.requiresAccountAuth, + canSubmit: this.canConnect(), + onRemoteUrlChange: this.onRemoteUrlChange, + onUserIdChange: this.onUserIdChange, + onPasswordChange: (value: string) => { this.accountPassword = value; }, + onCancel: () => this.closeManualPairing(), + onSubmit: () => { + this.ensureUserId(); + this.stopInlineScan(); + this.showManualPairing = false; + this.onConnect(this.accountPassword); } - } - .width('100%') - .height(74) - .backgroundColor(CARD) - .onClick(() => { - this.handleStatusClick(); }) } - @Builder - DeleteReveal() { - Text(RemoteI18n.t('connect.clear')) - .fontSize(13) - .fontColor(CARD) - .textAlign(TextAlign.Center) - .width(84) - .height(74) - .backgroundColor(RED) - .onClick(() => { - this.onClearPairing(); - }) - } - private statusDotColor(): ResourceColor { if (this.isConnected) { return GREEN; @@ -1002,13 +429,6 @@ export struct ConnectView { return SUBTLE; } - private statusTitle(): string { - if (this.remoteUrl.trim().length === 0) { - return RemoteI18n.t('connect.noDesktop'); - } - return this.desktopName || RemoteI18n.t('connect.targetDesktop'); - } - private statusDetail(): string { if (this.remoteUrl.trim().length === 0) { return RemoteI18n.t('connect.noDesktopDetail'); @@ -1028,13 +448,6 @@ export struct ConnectView { return RemoteI18n.t('connect.waitingDesktop'); } - private desktopIdText(): string { - if (this.desktopId.trim().length === 0) { - return RemoteI18n.t('connect.desktopIdUnavailable'); - } - return RemoteI18n.f('connect.desktopId', this.desktopId); - } - private displayUserIdInput(): string { if (this.requiresAccountAuth && this.accountUsername.length > 0 && this.userId.trim().length === 0) { return this.accountUsername; @@ -1045,24 +458,6 @@ export struct ConnectView { return this.userId; } - private statusSwipeAction(): SwipeActionOptions { - if (this.remoteUrl.trim().length === 0 || this.isBusy) { - return {}; - } - return { - end: { - builder: () => { - this.DeleteReveal(); - }, - actionAreaDistance: 84, - onAction: () => { - this.onClearPairing(); - } - }, - edgeEffect: SwipeEdgeEffect.None - }; - } - private handleStatusClick(): void { if (this.isBusy) { return; @@ -1093,6 +488,12 @@ export struct ConnectView { return this.displayUserIdInput().trim().length > 0 && this.accountPassword.length > 0; } + private closeManualPairing(): void { + this.stopInlineScan(); + this.showManualPairing = false; + this.resumeInlineScan(); + } + private currentStep(): string { if (this.pairingStep === 'account' && this.isAccountAuthenticated()) { return 'account'; @@ -1109,57 +510,6 @@ export struct ConnectView { return 'intro'; } - private async refreshAccountDevices(): Promise { - if (!this.isAccountAuthenticated() || this.accountDevicesBusy) return; - this.accountDevicesBusy = true; - this.accountDevicesError = ''; - try { - this.accountDevices = await this.cloudListDevices(); - } catch (err) { - this.accountDevicesError = err instanceof Error ? err.message : - RemoteI18n.t('remote.settings.deviceLoadFailed'); - } finally { - this.accountDevicesBusy = false; - if (!this.hasOnlineDesktopDevice()) { - this.otherConnectionMethodsExpanded = true; - } - } - } - - private desktopDevices(): CloudAccountDevice[] { - return this.accountDevices.filter((device: CloudAccountDevice): boolean => - device.deviceId !== this.deviceId && device.deviceName !== 'HarmonyOS Phone'); - } - - private canSelectAccountDevice(device: CloudAccountDevice): boolean { - return device.online && device.deviceId !== this.deviceId && this.switchingDeviceId.length === 0; - } - - private hasOnlineDesktopDevice(): boolean { - const devices = this.desktopDevices(); - for (let index = 0; index < devices.length; index += 1) { - if (devices[index].online) { - return true; - } - } - return false; - } - - private accountDeviceStatus(device: CloudAccountDevice): string { - if (device.deviceId === this.switchingDeviceId) { - return RemoteI18n.t('remote.settings.deviceConnecting'); - } - const presence = device.online ? RemoteI18n.t('remote.settings.deviceOnline') : - RemoteI18n.t('remote.settings.deviceOffline'); - if (device.deviceId === this.controlTargetDeviceId && this.connectionState === 'connected') { - return `${RemoteI18n.t('remote.settings.deviceControlling')} · ${presence}`; - } - if (device.deviceId === this.controlTargetDeviceId) { - return `${RemoteI18n.t('connect.deviceLastUsed')} · ${presence}`; - } - return presence; - } - private isAccountAuthenticated(): boolean { return this.accountUserId.trim().length > 0; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets new file mode 100644 index 0000000000..b8c7593144 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationLoadingState.ets @@ -0,0 +1,58 @@ +import { LINE, SOFT } from './Theme'; + +@ComponentV2 +export struct ConversationLoadingState { + @Param maxContentWidth: number = 0; + + build() { + Row() { + Column({ space: 18 }) { + this.AssistantSkeleton(78, '72%') + this.UserSkeleton(42, '46%') + this.AssistantSkeleton(112, '84%') + } + .width('100%') + .constraintSize({ maxWidth: this.maxContentWidth > 0 ? this.maxContentWidth : '100%' }) + .padding({ left: 22, right: 22, top: 28, bottom: 28 }) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Top) + } + + @Builder + private AssistantSkeleton(height: number, width: string) { + Row() { + Column({ space: 9 }) { + Text('').width('74%').height(10).backgroundColor(LINE).borderRadius(5) + Text('').width('92%').height(10).backgroundColor(LINE).borderRadius(5) + Text('').width('58%').height(10).backgroundColor(LINE).borderRadius(5) + } + .width(width) + .height(height) + .padding({ left: 14, right: 14, top: 14, bottom: 14 }) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Start) + .backgroundColor(SOFT) + .borderRadius(10) + Blank().layoutWeight(1) + } + .width('100%') + .height(height) + } + + @Builder + private UserSkeleton(height: number, width: string) { + Row() { + Blank().layoutWeight(1) + Text('') + .width(width) + .height(height) + .backgroundColor(SOFT) + .borderRadius(10) + } + .width('100%') + .height(height) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets new file mode 100644 index 0000000000..6a82568798 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets @@ -0,0 +1,94 @@ +import { ConversationIntent } from '../actions/ConversationIntent'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; +import { AppRoute } from '../navigation/AppRouteContract'; +import { FilePreviewPhase, FilePreviewState } from '../state/FilePreviewState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { RemotePageState } from '../state/RemotePageState'; +import { ComposerPresentation } from './ComposerBar'; +import { ConversationViewHost } from './ConversationViewHost'; +import { toConversationUiModelCatalog } from './ConversationUiModels'; +import { RemoteCreateSessionView } from './RemoteCreateSessionView'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from './remote/RemoteSurfaceHost'; +import { ConversationViewState } from '../state/ConversationViewState'; +import { PAGE_BG } from './Theme'; + +@ComponentV2 +export struct ConversationRouteSurface { + @Param route: AppRoute = AppRoute.ChatHome; + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param filePreviewState: FilePreviewState = new FilePreviewState(); + @Param remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Param showSidebarButton: boolean = true; + @Param showBackButton: boolean = false; + @Param showSidebarRestoreButton: boolean = false; + @Param useWidePresentation: boolean = false; + @Param contentHorizontalOffset: number = 0; + @Event onRestoreSidebar: () => void = () => {}; + + build() { + Column() { + if (this.route === AppRoute.RemoteHome) { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.CompactHome, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + onOpenSidebar: this.actions.onRemoteHome.openSidebar + }) + } else if (this.route === AppRoute.RemoteCreate) { + RemoteCreateSessionView({ + state: this.remoteCreateState, + presentation: this.useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Create, + isVoiceListening: this.remoteCreateState.isVoiceListening, + modelCatalog: toConversationUiModelCatalog(this.remotePageState.conversation.modelCatalog), + selectedModelId: this.remoteCreateState.selectedModelId, + showSidebarRestoreButton: this.showSidebarRestoreButton, + onRestoreSidebar: this.onRestoreSidebar, + onBack: this.actions.onRemoteCreate.back, + onToggleDeviceMenu: this.actions.onRemoteCreate.toggleDevices, + onToggleWorkspaceMenu: this.actions.onRemoteCreate.toggleWorkspaces, + onSelectDevice: this.actions.onRemoteCreate.selectDevice, + onSelectWorkspace: (workspace) => this.actions.onRemoteCreate.selectWorkspace(workspace?.path || ''), + onDraftChange: this.actions.onRemoteCreate.draftChanged, + onVoiceInput: this.actions.onRemoteCreate.voiceInput, + onSelectModel: this.actions.onRemoteCreate.selectModel, + onSend: this.actions.onRemoteCreate.send + }) + } else { + ConversationViewHost({ + viewState: ConversationViewState.project( + this.route, + this.remotePageState, + this.generalPageState, + this.actions.generalStatus() + ), + activeFilePreviewPath: this.route === AppRoute.RemoteChat && this.filePreviewState.visible ? + this.filePreviewState.target.remotePath : '', + activeFilePreviewLoading: this.route === AppRoute.RemoteChat && this.filePreviewState.visible && + this.filePreviewState.phase === FilePreviewPhase.Loading, + showSidebarButton: this.showSidebarButton, + showBackButton: this.showBackButton, + showSidebarRestoreButton: this.showSidebarRestoreButton, + composerPresentation: this.useWidePresentation ? ComposerPresentation.Floating : ComposerPresentation.Compact, + contentHorizontalOffset: this.contentHorizontalOffset, + onRestoreSidebar: this.onRestoreSidebar, + onIntent: (intent: ConversationIntent) => this.actions.onConversationIntent(this.route, intent) + }) + } + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets index f940c851e5..1c66b5a892 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationSourceSwitcher.ets @@ -2,10 +2,10 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConversationSource } from '../navigation/AppRouteContract'; import { CARD, INK, LINE, MUTED, SOFT } from './Theme'; -@Component +@ComponentV2 export struct ConversationSourceSwitcher { - @Prop activeSource: ConversationSource = ConversationSource.General; - onSelectSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; + @Param activeSource: ConversationSource = ConversationSource.General; + @Event onSelectSource: (source: ConversationSource) => void = (_source: ConversationSource) => {}; build() { Row({ space: 2 }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 6dbb9e2694..db3f0fc7f1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -6,6 +6,7 @@ import { ChatComposerCapabilities, REMOTE_CHAT_COMPOSER_CAPABILITIES } from './C import { ChatSurface } from './ChatSurface'; import { ChatStatusBar } from './ChatStatusBar'; import { ChatTimeline } from './ChatTimeline'; +import { ConversationLoadingState } from './ConversationLoadingState'; import { ConversationViewContract } from './ConversationViewContract'; import { ConversationUiModelCatalog, @@ -34,6 +35,7 @@ export struct ConversationView { @Param connectionState: string = 'connected'; @Param composerCapabilities: ChatComposerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; @Param isBusy: boolean = false; + @Param isLoadingConversation: boolean = false; @Param canStop: boolean = false; @Param hasMoreMessages: boolean = false; @Param timelineItems: ChatTimelineItem[] = []; @@ -110,7 +112,12 @@ export struct ConversationView { if (this.shouldShowStatusBar()) { this.ExecutionStatusBar() } - if (this.shouldShowSuggestions()) { + if (this.isLoadingConversation) { + ConversationLoadingState({ + maxContentWidth: this.composerPresentation === ComposerPresentation.Floating ? 800 : 0 + }) + .layoutWeight(1) + } else if (this.shouldShowSuggestions()) { Blank().layoutWeight(1) if (!this.isVoiceListening) { this.PromptArea() @@ -179,6 +186,7 @@ export struct ConversationView { workspaceBranch: this.workspaceBranch, desktopName: this.desktopName, showBackButton: this.showBackButton, + showSidebarButton: this.showSidebarButton, showSidebarRestoreButton: this.showSidebarRestoreButton, showActionsMenu: this.showHeaderActions, actionsMenu: () => { @@ -187,6 +195,9 @@ export struct ConversationView { onBack: () => { this.onBack(); }, + onOpenSidebar: () => { + this.onOpenSidebar(); + }, onRestoreSidebar: () => { this.onRestoreSidebar(); }, @@ -224,7 +235,7 @@ export struct ConversationView { timelineItems: this.visibleTimelineItems(), timelineRevision: this.timelineRevision, hasMoreMessages: this.hasMoreMessages, - isBusy: this.isBusy, + isBusy: this.isBusy || this.isLoadingConversation, connectionState: this.connectionState, statusText: this.statusText, downloadingFilePath: this.downloadingFilePath, @@ -675,7 +686,7 @@ export struct ConversationView { } private shouldShowStatusBar(): boolean { - return this.surface === ChatSurface.Remote && this.connectionState !== 'connected'; + return !this.isLoadingConversation && this.surface === ChatSurface.Remote && this.connectionState !== 'connected'; } private connectionColor(): ResourceColor { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets index 1c6bae8410..045054b74a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets @@ -4,7 +4,7 @@ import { ConversationIntent, ConversationIntents, ConversationIntentType -} from './ConversationIntent'; +} from '../actions/ConversationIntent'; import { ConversationUiQuestionAnswer } from './ConversationUiModels'; import { ComposerPresentation } from './ComposerBar'; @@ -32,6 +32,7 @@ export struct ConversationViewHost { connectionState: this.viewState.connectionState, composerCapabilities: this.viewState.composerCapabilities, isBusy: this.viewState.isBusy, + isLoadingConversation: this.viewState.isLoadingConversation, canStop: this.viewState.canStop, hasMoreMessages: this.viewState.hasMoreMessages, timelineItems: this.viewState.timelineItems, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets index d965d5b08e..f3e7176df9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewSettings.ets @@ -1,7 +1,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; import { RemoteLogger } from '../../services/RemoteLogger'; -import { ConversationSessionFilterPolicy } from '../state/ConversationSessionFilterPolicy'; +import { ConversationSessionFilterPolicy } from '../policy/ConversationSessionFilterPolicy'; import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; @ComponentV2 diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets index 7ad51ec5d1..a00d0ca784 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/CreateSessionSheet.ets @@ -1,17 +1,19 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, SOFT, SUBTLE } from './Theme'; -@Component +@ComponentV2 export struct CreateSessionSheet { - @Prop createAgentType: string = 'code'; - @Prop workspaceName: string = ''; - @Prop workspaceBranch: string = ''; - @Prop isBusy: boolean = false; - @Link sessionTitle: string; - @Link instruction: string; - onClose: () => void = () => {}; - onChooseWorkspace: () => void = () => {}; - onStart: () => void = () => {}; + @Param createAgentType: string = 'code'; + @Param workspaceName: string = ''; + @Param workspaceBranch: string = ''; + @Param isBusy: boolean = false; + @Param sessionTitle: string = ''; + @Param instruction: string = ''; + @Event onSessionTitleChange: (value: string) => void = (_value: string) => {}; + @Event onInstructionChange: (value: string) => void = (_value: string) => {}; + @Event onClose: () => void = () => {}; + @Event onChooseWorkspace: () => void = () => {}; + @Event onStart: () => void = () => {}; build() { Column() { @@ -123,7 +125,7 @@ export struct CreateSessionSheet { .border({ width: 1, color: LINE }) .defaultFocus(false) .onChange((value: string) => { - this.sessionTitle = value; + this.onSessionTitleChange(value); }) } .width('100%') @@ -146,7 +148,7 @@ export struct CreateSessionSheet { .border({ width: 1, color: LINE }) .defaultFocus(false) .onChange((value: string) => { - this.instruction = value; + this.onInstructionChange(value); }) } .width('100%') diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets index 72fb3c48cc..a4f939b8ca 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DefaultAccountAvatar.ets @@ -1,8 +1,8 @@ import { MUTED, SOFT } from './Theme'; -@Component +@ComponentV2 export struct DefaultAccountAvatar { - @Prop avatarSize: number = 34; + @Param avatarSize: number = 34; build() { Stack({ alignContent: Alignment.Center }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets index 06f477b898..139edbac82 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FileReferenceCard.ets @@ -1,17 +1,17 @@ import { CARD, FILE_LINK, INK, LINE, MUTED, SOFT } from './Theme'; -@Component +@ComponentV2 export struct FileReferenceCard { - @Prop path: string = ''; - @Prop label: string = ''; - @Prop status: string = ''; - @Prop previewLabel: string = ''; - @Prop buttonLabel: string = ''; - @Prop disabled: boolean = false; - @Prop selected: boolean = false; - @Prop previewLoading: boolean = false; - onPreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; - onDownload: (path: string) => void = (_path: string) => {}; + @Param path: string = ''; + @Param label: string = ''; + @Param status: string = ''; + @Param previewLabel: string = ''; + @Param buttonLabel: string = ''; + @Param disabled: boolean = false; + @Param selected: boolean = false; + @Param previewLoading: boolean = false; + @Event onPreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; + @Event onDownload: (path: string) => void = (_path: string) => {}; build() { Row({ space: 10 }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets index a088f54d88..0382f2db61 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/GeneralChatHeader.ets @@ -1,11 +1,13 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { CARD, INK, LINE, PAGE_BG } from './Theme'; +import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; import { CompactMenuButton } from './CompactMenuButton'; import { SidebarToggleButton } from './SidebarToggleButton'; @ComponentV2 export struct GeneralChatHeader { @Param title: string = ''; + /** Secondary context line. Empty keeps the single-line header. */ + @Param subtitle: string = ''; @Param showActions: boolean = false; @Param showSidebarButton: boolean = true; @Param showBackButton: boolean = false; @@ -21,23 +23,42 @@ export struct GeneralChatHeader { build() { Row({ space: 8 }) { this.LeadingControl() + this.TitleBlock() + this.TrailingControl() + } + .width('100%') + .height(this.hasSubtitle() ? 76 : 64) + .alignItems(VerticalAlign.Center) + .padding({ left: 16, right: 16, top: 8, bottom: 8 }) + .backgroundColor(PAGE_BG) + } + /** Mirrors the conversation header: title above a muted context line. */ + @Builder + private TitleBlock() { + Column({ space: 3 }) { Text(this.title || 'BitFun') - .fontSize(17) + .fontSize(this.hasSubtitle() ? 18 : 17) .fontWeight(FontWeight.Medium) .fontColor(INK) - .layoutWeight(1) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .textAlign(TextAlign.Center) - - this.TrailingControl() + if (this.hasSubtitle()) { + Text(this.subtitle) + .fontSize(14) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .textAlign(TextAlign.Center) + } } - .width('100%') - .height(64) - .alignItems(VerticalAlign.Center) - .padding({ left: 16, right: 16, top: 8, bottom: 8 }) - .backgroundColor(PAGE_BG) + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + } + + private hasSubtitle(): boolean { + return this.subtitle.length > 0; } @Builder diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets index 486aad6a67..5b7e8d76a8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets @@ -7,12 +7,12 @@ import { } from '../../services/MarkdownParser'; import { CARD, FILE_LINK, INK, LINE, MUTED, SOFT } from './Theme'; -@Component +@ComponentV2 export struct MarkdownContent { private readonly parseCache: MarkdownParseCache = new MarkdownParseCache(); - @Prop text: string = ''; - onCopyText: (text: string) => void = (_text: string) => {}; - onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; + @Param text: string = ''; + @Event onCopyText: (text: string) => void = (_text: string) => {}; + @Event onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; build() { Column({ space: 5 }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets index 3c92d19156..07996fbb89 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelServiceSettingsPanel.ets @@ -4,24 +4,24 @@ import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels' import { GENERAL_CHAT_LOCAL_MODEL_ID } from '../../services/general-chat/GeneralChatConfigStore'; import { ACCENT, CARD, GREEN, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT, SUBTLE } from './Theme'; -@Component +@ComponentV2 export struct ModelServiceSettingsPanel { private readonly contentScroller: Scroller = new Scroller(); private focusScrollTimerId: number = 0; private blurResetTimerId: number = 0; private previousKeyboardAvoidMode: KeyboardAvoidMode = KeyboardAvoidMode.OFFSET; - @Prop apiUrl: string = ''; - @Prop modelName: string = ''; - @Prop hasApiKey: boolean = false; - @Prop modelCatalog: RemoteModelCatalog = { + @Param apiUrl: string = ''; + @Param modelName: string = ''; + @Param hasApiKey: boolean = false; + @Param modelCatalog: RemoteModelCatalog = { version: 0, models: [], default_models: {} }; - @Prop selectedModelId: string = ''; - onClose: () => void = () => {}; - onSaved: (apiUrl: string, modelName: string, hasApiKey: boolean) => void = () => {}; - onTest: ( + @Param selectedModelId: string = ''; + @Event onClose: () => void = () => {}; + @Event onSaved: (apiUrl: string, modelName: string, hasApiKey: boolean) => void = () => {}; + @Event onTest: ( apiUrl: string, apiKey: string, modelName: string, @@ -32,7 +32,7 @@ export struct ModelServiceSettingsPanel { _modelName: string, _clearApiKey: boolean ) => ''; - onSave: ( + @Event onSave: ( apiUrl: string, apiKey: string, modelName: string, @@ -43,16 +43,16 @@ export struct ModelServiceSettingsPanel { _modelName: string, _clearApiKey: boolean ) => ''; - @State draftApiUrl: string = ''; - @State draftApiKey: string = ''; - @State draftModelName: string = ''; - @State clearApiKey: boolean = false; - @State isSaving: boolean = false; - @State isTesting: boolean = false; - @State feedbackText: string = ''; - @State feedbackIsError: boolean = false; - @State focusedFieldKind: string = ''; - @State showLocalEditor: boolean = false; + @Local draftApiUrl: string = ''; + @Local draftApiKey: string = ''; + @Local draftModelName: string = ''; + @Local clearApiKey: boolean = false; + @Local isSaving: boolean = false; + @Local isTesting: boolean = false; + @Local feedbackText: string = ''; + @Local feedbackIsError: boolean = false; + @Local focusedFieldKind: string = ''; + @Local showLocalEditor: boolean = false; aboutToAppear(): void { this.previousKeyboardAvoidMode = this.getUIContext().getKeyboardAvoidMode(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets index 67b5b38130..539ea93aaa 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteChatHeader.ets @@ -1,6 +1,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConversationUiSession } from './ConversationUiModels'; import { ACCENT, CARD, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION_TEXT, SOFT } from './Theme'; +import { CompactMenuButton } from './CompactMenuButton'; import { SidebarToggleButton } from './SidebarToggleButton'; @ComponentV2 @@ -14,10 +15,12 @@ export struct RemoteChatHeader { @Param workspaceBranch: string = ''; @Param desktopName: string = ''; @Param showBackButton: boolean = true; + @Param showSidebarButton: boolean = false; @Param showSidebarRestoreButton: boolean = false; @Param showActionsMenu: boolean = false; @BuilderParam actionsMenu: () => void = this.EmptyBuilder; @Event onBack: () => void = () => {}; + @Event onOpenSidebar: () => void = () => {}; @Event onRestoreSidebar: () => void = () => {}; @Event onOpenActions: () => void = () => {}; @Event onActionsMenuStateChange: (visible: boolean) => void = (_visible: boolean) => {}; @@ -100,6 +103,13 @@ export struct RemoteChatHeader { .onClick(() => { this.onBack(); }) + } else if (this.showSidebarButton) { + CompactMenuButton({ + controlSize: 44, + onOpen: () => { + this.onOpenSidebar(); + } + }) } else { Blank().width(44).height(44) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets index 9ed4245319..3396da0e91 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteControlSettingsSheet.ets @@ -5,45 +5,45 @@ import { RemotePermissionMode } from '../../model/RemoteModels'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; import { BitFunAccountLoginPage } from './BitFunAccountLoginPage'; -@Component +@ComponentV2 export struct RemoteControlSettingsSheet { - @Prop desktopName: string = ''; - @Prop desktopId: string = ''; - @Prop userId: string = ''; - @Prop accountUsername: string = ''; - @Prop @Watch('handleAccountUserChanged') accountUserId: string = ''; - @Prop deviceId: string = ''; - @Prop controlTargetType: string = 'none'; - @Prop controlTargetDeviceId: string = ''; - @Prop connectionState: string = 'idle'; - @Prop statusText: string = ''; - @Prop isBusy: boolean = false; - @Prop openAccountOnAppear: boolean = false; - onClose: () => void = () => {}; - onOpenAccount: () => void = () => {}; - onAddConnection: () => void = () => {}; - cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; - cloudSync: () => Promise = async (): Promise => '0'; - cloudLogout: () => Promise = async (): Promise => {}; - cloudListDevices: () => Promise = async (): Promise => []; - getPermissionMode: () => Promise = async (): Promise => 'ask'; - setPermissionMode: (mode: RemotePermissionMode) => Promise = + @Param desktopName: string = ''; + @Param desktopId: string = ''; + @Param userId: string = ''; + @Param accountUsername: string = ''; + @Param accountUserId: string = ''; + @Param deviceId: string = ''; + @Param controlTargetType: string = 'none'; + @Param controlTargetDeviceId: string = ''; + @Param connectionState: string = 'idle'; + @Param statusText: string = ''; + @Param isBusy: boolean = false; + @Param openAccountOnAppear: boolean = false; + @Event onClose: () => void = () => {}; + @Event onOpenAccount: () => void = () => {}; + @Event onAddConnection: () => void = () => {}; + @Event cloudLogin: (relayUrl: string, username: string, password: string) => Promise = async (_relayUrl: string, _username: string, _password: string): Promise => ''; + @Event cloudSync: () => Promise = async (): Promise => '0'; + @Event cloudLogout: () => Promise = async (): Promise => {}; + @Event cloudListDevices: () => Promise = async (): Promise => []; + @Event getPermissionMode: () => Promise = async (): Promise => 'ask'; + @Event setPermissionMode: (mode: RemotePermissionMode) => Promise = async (mode: RemotePermissionMode): Promise => mode; - onDisconnect: () => void = () => {}; - onReconnect: () => void = () => {}; - @State showProfile: boolean = false; - @State showLogin: boolean = false; - @State cloudSyncBusy: boolean = false; - @State cloudSyncStatus: string = ''; - @State accountDevices: CloudAccountDevice[] = []; - @State accountDevicesBusy: boolean = false; - @State accountDevicesError: string = ''; - @State permissionMode: RemotePermissionMode = 'ask'; - @State permissionModeBusy: boolean = false; - @State permissionModeLoaded: boolean = false; - @State permissionModeError: string = ''; - @State confirmFullAccess: boolean = false; - @State logoutBusy: boolean = false; + @Event onDisconnect: () => void = () => {}; + @Event onReconnect: () => void = () => {}; + @Local showProfile: boolean = false; + @Local showLogin: boolean = false; + @Local cloudSyncBusy: boolean = false; + @Local cloudSyncStatus: string = ''; + @Local accountDevices: CloudAccountDevice[] = []; + @Local accountDevicesBusy: boolean = false; + @Local accountDevicesError: string = ''; + @Local permissionMode: RemotePermissionMode = 'ask'; + @Local permissionModeBusy: boolean = false; + @Local permissionModeLoaded: boolean = false; + @Local permissionModeError: string = ''; + @Local confirmFullAccess: boolean = false; + @Local logoutBusy: boolean = false; aboutToAppear(): void { this.showProfile = this.openAccountOnAppear && this.isAccountAuthenticated(); @@ -855,6 +855,7 @@ export struct RemoteControlSettingsSheet { return this.accountUserId.trim().length > 0; } + @Monitor('accountUserId') private handleAccountUserChanged(): void { if (this.isAccountAuthenticated() && this.accountDevices.length === 0) { this.refreshAccountDevices(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets index d1d6b2d8c5..bfca15020c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteSessionList.ets @@ -3,9 +3,9 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { TimeFormat } from '../../services/TimeFormat'; import { CARD, INK, MUTED, SOFT } from './Theme'; import { SessionActionPresentation, SessionActionSurface } from './SessionActionSurface'; -import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../state/SessionActionPolicy'; +import { SessionActionCapabilities, SessionActionPolicy, SessionActionScope } from '../policy/SessionActionPolicy'; import { SessionDetailsView } from './SessionDetailsView'; -import { ConversationSessionFilterPolicy } from '../state/ConversationSessionFilterPolicy'; +import { ConversationSessionFilterPolicy } from '../policy/ConversationSessionFilterPolicy'; @ComponentV2 export struct RemoteSessionList { @@ -46,12 +46,18 @@ export struct RemoteSessionList { @Local showSessionActionSheet: boolean = false; @Local detailsSessionId: string = ''; @Local showSessionDetails: boolean = false; + @Local optimisticSelectedSessionId: string = ''; @Monitor('isBusy', 'workspacePath') onWorkspaceContextChanged(): void { this.createMenuPath = ''; } + @Monitor('selectedSessionId') + onSelectedSessionChanged(): void { + this.optimisticSelectedSessionId = ''; + } + build() { Column() { Scroll() { @@ -566,7 +572,7 @@ export struct RemoteSessionList { Text(item.title || RemoteI18n.t('sidebar.untitled')) .width('100%') .fontSize(15) - .fontWeight(this.selectedSessionId === item.id ? FontWeight.Medium : FontWeight.Regular) + .fontWeight(this.isSessionSelected(item.id) ? FontWeight.Medium : FontWeight.Regular) .fontColor(INK) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) @@ -587,9 +593,23 @@ export struct RemoteSessionList { .height(this.metadataText(item).length > 0 ? 56 : 46) .padding({ left: nested ? 0 : 10, right: 4 }) .alignItems(VerticalAlign.Center) - .backgroundColor(this.selectedSessionId === item.id ? SOFT : '#00000000') + .backgroundColor(this.isSessionSelected(item.id) ? SOFT : '#00000000') .borderRadius(10) + .onTouch((event: TouchEvent) => { + if (this.isBusy) { + return; + } + if (event.type === TouchType.Down) { + this.optimisticSelectedSessionId = item.id; + } else if (event.type === TouchType.Cancel) { + this.optimisticSelectedSessionId = ''; + } + }) .onClick(() => { + if (this.isBusy) { + return; + } + this.optimisticSelectedSessionId = item.id; this.onOpenSession(item); }) .gesture(LongPressGesture({ repeat: false }).onAction(() => this.openSessionActions(item))) @@ -610,6 +630,12 @@ export struct RemoteSessionList { }) } + private isSessionSelected(sessionId: string): boolean { + const selectedSessionId = this.optimisticSelectedSessionId.length > 0 ? + this.optimisticSelectedSessionId : this.selectedSessionId; + return selectedSessionId === sessionId; + } + @Builder private SessionMoreButton(item: RemoteSession) { Stack({ alignContent: Alignment.Center }) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets index a90edecec2..0a3cb71b96 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets @@ -4,23 +4,23 @@ import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; import { ModelServiceSettingsPanel } from './ModelServiceSettingsPanel'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; -@Component +@ComponentV2 export struct SettingsSheet { - @Prop generalChatApiUrl: string = ''; - @Prop generalChatModelName: string = ''; - @Prop hasGeneralChatApiKey: boolean = false; - @Prop generalChatModelCatalog: RemoteModelCatalog = { + @Param generalChatApiUrl: string = ''; + @Param generalChatModelName: string = ''; + @Param hasGeneralChatApiKey: boolean = false; + @Param generalChatModelCatalog: RemoteModelCatalog = { version: 0, models: [], default_models: {} }; - @Prop selectedGeneralChatModelId: string = ''; - @Prop accountUsername: string = ''; - @Prop authenticatedUserId: string = ''; - @Prop deviceId: string = ''; - onClose: () => void = () => {}; - onOpenAccount: () => void = () => {}; - onSaveGeneralChatConfig: ( + @Param selectedGeneralChatModelId: string = ''; + @Param accountUsername: string = ''; + @Param authenticatedUserId: string = ''; + @Param deviceId: string = ''; + @Event onClose: () => void = () => {}; + @Event onOpenAccount: () => void = () => {}; + @Event onSaveGeneralChatConfig: ( apiUrl: string, apiKey: string, modelName: string, @@ -31,7 +31,7 @@ export struct SettingsSheet { _modelName: string, _clearApiKey: boolean ) => ''; - onTestGeneralChatConfig: ( + @Event onTestGeneralChatConfig: ( apiUrl: string, apiKey: string, modelName: string, @@ -42,10 +42,10 @@ export struct SettingsSheet { _modelName: string, _clearApiKey: boolean ) => ''; - @State showModelService: boolean = false; - @State savedGeneralChatApiUrl: string = ''; - @State savedGeneralChatModelName: string = ''; - @State savedGeneralChatHasApiKey: boolean = false; + @Local showModelService: boolean = false; + @Local savedGeneralChatApiUrl: string = ''; + @Local savedGeneralChatModelName: string = ''; + @Local savedGeneralChatHasApiKey: boolean = false; aboutToAppear(): void { this.showModelService = false; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets new file mode 100644 index 0000000000..af5a2504d6 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarGlyphs.ets @@ -0,0 +1,151 @@ +import { CARD, GREEN, INK, MUTED } from './Theme'; + +@ComponentV2 +export struct SidebarGlyph { + @Param kind: string = ''; + @Param connectionState: string = ''; + + build() { + if (this.kind === 'session_more') { + this.MoreDots() + } else if (this.kind === 'remote') { + this.Remote() + } else if (this.kind === 'search') { + this.Search() + } else if (this.kind === 'notebook') { + this.Notebook() + } else if (this.kind === 'clock') { + this.Clock() + } else if (this.kind === 'apps') { + this.Apps() + } else if (this.kind === 'code_flower') { + this.CodeFlower() + } else if (this.kind === 'more') { + this.More() + } else if (this.kind === 'edit') { + this.Edit() + } else if (this.kind === 'settings') { + this.Settings() + } + } + + @Builder + private MoreDots() { + Row({ space: 3 }) { + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + Text('').width(3.5).height(3.5).backgroundColor(MUTED).borderRadius(2) + } + .height(8) + .alignItems(VerticalAlign.Center) + } + + @Builder + private Remote() { + Stack({ alignContent: Alignment.Center }) { + if (this.connectionState === 'connected' || this.connectionState === 'reconnecting') { + Image($r('app.media.remote_ref_sidebar_connected')) + .width(35).height(34).objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template).foregroundColor(INK) + Text('').width(8).height(8).backgroundColor(GREEN).borderRadius(4) + .position({ x: 24, y: 22 }) + } else { + Image($r('app.media.remote_logo')) + .width(34).height(34).objectFit(ImageFit.Contain) + .renderMode(ImageRenderMode.Template).foregroundColor(MUTED) + } + } + .width(35).height(34) + } + + @Builder + private Search() { + SymbolGlyph($r('sys.symbol.magnifyingglass')) + .fontSize(22).fontColor([INK]).width(24).height(24) + } + + @Builder + private Notebook() { + Stack() { + Text('').width(22).height(24).borderRadius(5).border({ width: 1.5, color: INK }) + .position({ x: 8, y: 5 }) + Text('').width(4).height(4).borderRadius(2).backgroundColor(INK) + .position({ x: 5, y: 11 }) + Text('').width(4).height(4).borderRadius(2).backgroundColor(INK) + .position({ x: 5, y: 20 }) + } + .width(34).height(34) + } + + @Builder + private Clock() { + Stack() { + Text('').width(26).height(26).borderRadius(13).border({ width: 1.5, color: INK }) + .position({ x: 4, y: 4 }) + Text('').width(1.5).height(9).backgroundColor(INK).borderRadius(2) + .position({ x: 18, y: 10 }) + Text('').width(9).height(1.5).backgroundColor(INK).borderRadius(2) + .position({ x: 18, y: 20 }) + } + .width(34).height(34) + } + + @Builder + private Apps() { + Column({ space: 8 }) { + Row({ space: 8 }) { this.AppDot(); this.AppDot(); } + Row({ space: 8 }) { this.AppDot(); this.AppDot(); } + } + .width(24).height(24) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + @Builder + private AppDot() { + Text('').width(8).height(8).borderRadius(4).backgroundColor(INK) + } + + @Builder + private CodeFlower() { + Stack() { + Text('').width(16).height(16).borderRadius(8).border({ width: 1.5, color: INK }) + .backgroundColor(CARD).position({ x: 9, y: 1 }) + Text('').width(16).height(16).borderRadius(8).border({ width: 1.5, color: INK }) + .backgroundColor(CARD).position({ x: 17, y: 9 }) + Text('').width(16).height(16).borderRadius(8).border({ width: 1.5, color: INK }) + .backgroundColor(CARD).position({ x: 9, y: 17 }) + Text('').width(16).height(16).borderRadius(8).border({ width: 1.5, color: INK }) + .backgroundColor(CARD).position({ x: 1, y: 9 }) + Text('').width(14).height(14).borderRadius(7).backgroundColor(CARD) + .position({ x: 10, y: 10 }) + } + .width(34).height(34) + } + + @Builder + private More() { + Row({ space: 5 }) { this.Dot(); this.Dot(); this.Dot(); } + .width(30).height(22) + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Center) + } + + @Builder + private Dot() { + Text('').width(5).height(5).borderRadius(3).backgroundColor(INK) + } + + @Builder + private Edit() { + SymbolGlyph($r('sys.symbol.square_and_pencil')) + .fontSize(22).fontColor([INK]).width(24).height(24) + } + + @Builder + private Settings() { + SymbolGlyph($r('sys.symbol.gearshape')) + .fontSize(22).fontColor([INK]).width(24).height(24) + } +} + diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets index 227cccd2a2..283e2698a0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/StreamingMarkdownContent.ets @@ -2,14 +2,14 @@ import { MarkdownContent } from './MarkdownContent'; const STREAMING_MARKDOWN_CACHE: Map = new Map(); -@Component +@ComponentV2 export struct StreamingMarkdownContent { - @Prop @Watch('handleTextChanged') text: string = ''; - @Prop @Watch('handleTextChanged') active: boolean = false; - @Prop @Watch('handleTextChanged') streamKey: string = ''; - onCopyText: (text: string) => void = (_text: string) => {}; - onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; - @State renderedText: string = ''; + @Param text: string = ''; + @Param active: boolean = false; + @Param streamKey: string = ''; + @Event onCopyText: (text: string) => void = (_text: string) => {}; + @Event onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; + @Local renderedText: string = ''; private targetText: string = ''; private timerId: number = 0; private frameIntervalMs: number = 40; @@ -42,6 +42,7 @@ export struct StreamingMarkdownContent { }) } + @Monitor('text', 'active', 'streamKey') private handleTextChanged(): void { if (!this.active) { this.clearTimer(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets index 6adc633b73..854c234a46 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/Theme.ets @@ -12,6 +12,8 @@ export const CONNECT_HERO_BG: ResourceColor = $r('app.color.connect_hero_bg'); export const CONNECT_HERO_ACCENT: ResourceColor = $r('app.color.connect_hero_accent'); export const CONNECT_HERO_SECONDARY: ResourceColor = $r('app.color.connect_hero_secondary'); export const CONNECT_HERO_SURFACE: ResourceColor = $r('app.color.connect_hero_surface'); +export const CONNECT_SCAN_ACCENT: ResourceColor = $r('app.color.connect_scan_accent'); +export const MODAL_SCRIM: ResourceColor = $r('app.color.modal_scrim'); export const SOFT: ResourceColor = $r('app.color.soft'); export const FLOATING_PANEL_BG: ResourceColor = $r('app.color.floating_panel_bg'); export const GREEN: ResourceColor = $r('app.color.green'); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets index 89bacf2425..42a9a75af5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ThinkingBlock.ets @@ -1,14 +1,14 @@ import { MUTED } from './Theme'; -@Component +@ComponentV2 export struct ThinkingBlock { - @Prop text: string = ''; - @Prop status: string = ''; - @Prop keepExpandedWhenDone: boolean = false; - @Prop streaming: boolean = false; - @Prop streamKey: string = ''; - onCopyText: (text: string) => void = (_text: string) => {}; - @State dotPhase: number = 0; + @Param text: string = ''; + @Param status: string = ''; + @Param keepExpandedWhenDone: boolean = false; + @Param streaming: boolean = false; + @Param streamKey: string = ''; + @Event onCopyText: (text: string) => void = (_text: string) => {}; + @Local dotPhase: number = 0; private dotTimerId: number = 0; aboutToAppear(): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolGlyphs.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolGlyphs.ets new file mode 100644 index 0000000000..cc029ab416 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolGlyphs.ets @@ -0,0 +1,40 @@ +import { MUTED } from './Theme'; + +@ComponentV2 +export struct ToolGlyph { + @Param kind: string = 'tool'; + @Param color: ResourceColor = MUTED; + + build() { + SymbolGlyph(this.symbol()) + .fontSize(this.isChevron() ? 18 : 14) + .fontColor([this.color]) + .width(this.isChevron() ? 14 : 15) + .height(this.isChevron() ? 14 : 15) + } + + private isChevron(): boolean { + return this.kind.indexOf('chevron_') === 0; + } + + private symbol(): Resource { + if (this.kind === 'search') return $r('sys.symbol.magnifyingglass'); + if (this.kind === 'document') return $r('sys.symbol.doc_text'); + if (this.kind === 'stack') return $r('sys.symbol.rectangle_stack'); + if (this.kind === 'question') return $r('sys.symbol.questionmark_circle'); + if (this.kind === 'todo') return $r('sys.symbol.list_checkmark'); + if (this.kind === 'task') return $r('sys.symbol.robot'); + if (this.kind === 'git') return $r('sys.symbol.arrow_triangle_merge'); + if (this.kind === 'delete') return $r('sys.symbol.trash'); + if (this.kind === 'diff') return $r('sys.symbol.doc_text_badge_magnifyingglass'); + if (this.kind === 'patch' || this.kind === 'command') return $r('sys.symbol.code_square'); + if (this.kind === 'create') return $r('sys.symbol.doc_text_badge_arrow_up'); + if (this.kind === 'mutate') return $r('sys.symbol.square_and_pencil'); + if (this.kind === 'folder') return $r('sys.symbol.folder'); + if (this.kind === 'web') return $r('sys.symbol.link'); + if (this.kind === 'chevron_right') return $r('sys.symbol.chevron_right'); + if (this.kind === 'chevron_up') return $r('sys.symbol.chevron_up'); + if (this.kind === 'chevron_down') return $r('sys.symbol.chevron_down'); + return $r('sys.symbol.wrench_and_screwdriver'); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets new file mode 100644 index 0000000000..9af587e2af --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolInteractionPanels.ets @@ -0,0 +1,171 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ConversationUiQuestionAnswer } from './ConversationUiModels'; +import { ACCENT, CARD, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; + +@ComponentV2 +export struct ToolConfirmationPanel { + @Param toolId: string = ''; + @Param defaultInputText: string = ''; + @Param hasEditableInput: boolean = false; + @Event onApproveTool: (toolId: string, updatedInput?: Object) => void = + (_toolId: string, _updatedInput?: Object) => {}; + @Event onRejectTool: (toolId: string) => void = (_toolId: string) => {}; + @Local inputText: string = ''; + @Local inputError: string = ''; + + aboutToAppear(): void { + this.inputText = this.defaultInputText; + } + + build() { + Column({ space: 8 }) { + if (this.hasEditableInput) { + this.InputEditor() + } + Row({ space: 8 }) { + Text(RemoteI18n.t('chat.approve')) + .fontSize(12) + .fontColor(PRIMARY_ACTION_TEXT) + .textAlign(TextAlign.Center) + .height(32) + .layoutWeight(1) + .backgroundColor(ACCENT) + .borderRadius(16) + .onClick(() => this.approve()) + Text(RemoteI18n.t('chat.reject')) + .fontSize(12) + .fontColor(INK) + .textAlign(TextAlign.Center) + .height(32) + .layoutWeight(1) + .backgroundColor(SOFT) + .borderRadius(16) + .border({ width: 1, color: LINE }) + .onClick(() => this.onRejectTool(this.toolId)) + } + .width('100%') + } + .width('100%') + .padding({ left: 30 }) + } + + @Builder + private InputEditor() { + Column({ space: 6 }) { + Row() { + Text(RemoteI18n.t('chat.toolInput')).fontSize(11).fontColor(MUTED) + Blank() + Text(RemoteI18n.t('chat.reset')) + .fontSize(11) + .fontColor(MUTED) + .onClick(() => { + this.inputText = this.defaultInputText; + this.inputError = ''; + }) + } + .width('100%') + TextArea({ placeholder: RemoteI18n.t('chat.editJsonInput'), text: this.inputText }) + .height(96) + .fontSize(12) + .fontColor(INK) + .lineHeight(17) + .backgroundColor(SOFT) + .borderRadius(14) + .padding(10) + .border({ width: 1, color: this.inputError.length > 0 ? RED : LINE }) + .defaultFocus(false) + .enabled(true) + .onChange((value: string) => { + this.inputText = value; + this.inputError = ''; + }) + if (this.inputError.length > 0) { + Text(this.inputError).fontSize(11).fontColor(RED) + } + } + .width('100%') + } + + private approve(): void { + if (this.toolId.length === 0) { + return; + } + if (!this.hasEditableInput) { + this.onApproveTool(this.toolId); + return; + } + const rawInput = this.inputText.trim(); + if (rawInput.length === 0) { + this.inputError = RemoteI18n.t('chat.jsonObjectRequired'); + return; + } + try { + const parsed = JSON.parse(rawInput) as Object; + if (parsed === null || Array.isArray(parsed)) { + this.inputError = RemoteI18n.t('chat.jsonObjectRequired'); + return; + } + this.inputError = ''; + this.onApproveTool(this.toolId, parsed); + } catch (_err) { + this.inputError = RemoteI18n.t('chat.jsonInvalid'); + } + } +} + +@ComponentV2 +export struct ToolQuestionAnswerPanel { + @Param toolId: string = ''; + @Param prompt: string = ''; + @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = + (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; + @Local answerText: string = ''; + + build() { + Column({ space: 8 }) { + Text(this.prompt) + .fontSize(12) + .lineHeight(17) + .fontColor(INK) + .width('100%') + TextArea({ placeholder: RemoteI18n.t('chat.answerPlaceholder'), text: this.answerText }) + .height(78) + .fontSize(13) + .backgroundColor(CARD) + .borderRadius(14) + .padding(12) + .border({ width: 1, color: LINE }) + .defaultFocus(false) + .enabled(true) + .onChange((value: string) => { this.answerText = value; }) + Row() { + Text(RemoteI18n.t('chat.submitAnswer')) + .fontSize(12) + .fontColor(this.canSubmit() ? PRIMARY_ACTION_TEXT : MUTED) + .textAlign(TextAlign.Center) + .height(32) + .layoutWeight(1) + .backgroundColor(this.canSubmit() ? ACCENT : SOFT) + .borderRadius(16) + .onClick(() => this.submit()) + } + .width('100%') + } + .width('100%') + .padding({ left: 30 }) + } + + private canSubmit(): boolean { + return this.toolId.length > 0 && this.answerText.trim().length > 0; + } + + private submit(): void { + if (!this.canSubmit()) { + return; + } + const answer = this.answerText.trim(); + const answers: ConversationUiQuestionAnswer = { answer, '0': answer }; + this.onAnswerQuestion(this.toolId, answers); + this.answerText = ''; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets index bd1316fb2c..bb8c2ed7e9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets @@ -1,7 +1,9 @@ import { ConversationUiQuestionAnswer, ConversationUiToolStatus } from './ConversationUiModels'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ToolFileReference, ToolFileReferenceResolver } from '../../services/ToolFileReferenceResolver'; -import { ACCENT, CARD, FILE_LINK, GREEN, INK, LINE, MUTED, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; +import { CARD, FILE_LINK, GREEN, INK, LINE, MUTED, RED, SOFT } from './Theme'; +import { ToolGlyph } from './ToolGlyphs'; +import { ToolConfirmationPanel, ToolQuestionAnswerPanel } from './ToolInteractionPanels'; interface QuestionPreview { header?: string; @@ -61,11 +63,6 @@ export struct ToolStatusList { (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; - @Local questionAnswerToolId: string = ''; - @Local questionAnswerText: string = ''; - @Local toolInputEditToolId: string = ''; - @Local toolInputEditText: string = ''; - @Local toolInputEditError: string = ''; @Local expanded: boolean = false; @Local expandedToolKey: string = ''; @@ -138,39 +135,20 @@ export struct ToolStatusList { } if (this.isPendingConfirmation(tool)) { - if (this.hasEditableToolInput(tool)) { - this.ToolInputEditor(tool) - } - Row({ space: 8 }) { - Text(RemoteI18n.t('chat.approve')) - .fontSize(12) - .fontColor(PRIMARY_ACTION_TEXT) - .textAlign(TextAlign.Center) - .height(32) - .layoutWeight(1) - .backgroundColor(ACCENT) - .borderRadius(16) - .onClick(() => { - this.approveToolWithInput(tool); - }) - Text(RemoteI18n.t('chat.reject')) - .fontSize(12) - .fontColor(INK) - .textAlign(TextAlign.Center) - .height(32) - .layoutWeight(1) - .backgroundColor(SOFT) - .borderRadius(16) - .border({ width: 1, color: LINE }) - .onClick(() => { - this.onRejectTool(tool.id || ''); - }) - } - .width('100%') - .padding({ left: 30 }) + ToolConfirmationPanel({ + toolId: tool.id || '', + defaultInputText: this.defaultToolInputText(tool), + hasEditableInput: this.hasEditableToolInput(tool), + onApproveTool: this.onApproveTool, + onRejectTool: this.onRejectTool + }) } if (this.isQuestionTool(tool)) { - this.QuestionAnswer(tool) + ToolQuestionAnswerPanel({ + toolId: tool.id || '', + prompt: this.questionPrompt(tool), + onAnswerQuestion: this.onAnswerQuestion + }) } if (this.isRunningTool(tool)) { Row() { @@ -260,122 +238,12 @@ export struct ToolStatusList { @Builder SummaryTypeSymbol(entry: ToolRenderEntry) { - if (entry.searchCount > 0 && entry.readCount === 0) { - SymbolGlyph($r('sys.symbol.magnifyingglass')) - .fontSize(14) - .fontColor([this.summaryTypeColor(entry)]) - .width(15) - .height(15) - } else if (entry.readCount > 0 && entry.searchCount === 0) { - SymbolGlyph($r('sys.symbol.doc_text')) - .fontSize(14) - .fontColor([this.summaryTypeColor(entry)]) - .width(15) - .height(15) - } else { - SymbolGlyph($r('sys.symbol.rectangle_stack')) - .fontSize(14) - .fontColor([this.summaryTypeColor(entry)]) - .width(15) - .height(15) - } + ToolGlyph({ kind: this.summaryGlyphKind(entry), color: this.summaryTypeColor(entry) }) } @Builder ToolTypeSymbol(tool: ConversationUiToolStatus) { - if (this.isQuestionLikeTool(tool)) { - SymbolGlyph($r('sys.symbol.questionmark_circle')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isTodoTool(tool)) { - SymbolGlyph($r('sys.symbol.list_checkmark')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isTaskTool(tool)) { - SymbolGlyph($r('sys.symbol.robot')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isGitTool(tool)) { - SymbolGlyph($r('sys.symbol.arrow_triangle_merge')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isDeleteTool(tool)) { - SymbolGlyph($r('sys.symbol.trash')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isDiffTool(tool)) { - SymbolGlyph($r('sys.symbol.doc_text_badge_magnifyingglass')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isPatchTool(tool)) { - SymbolGlyph($r('sys.symbol.code_square')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isFileCreateTool(tool)) { - SymbolGlyph($r('sys.symbol.doc_text_badge_arrow_up')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isFileMutationTool(tool)) { - SymbolGlyph($r('sys.symbol.square_and_pencil')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isFileReadTool(tool)) { - if (this.isDirectoryListTool(tool)) { - SymbolGlyph($r('sys.symbol.folder')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else { - SymbolGlyph($r('sys.symbol.doc_text')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } - } else if (this.isSearchTool(tool)) { - SymbolGlyph($r('sys.symbol.magnifyingglass')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isWebTool(tool)) { - SymbolGlyph($r('sys.symbol.link')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else if (this.isCommandTool(tool)) { - SymbolGlyph($r('sys.symbol.code_square')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } else { - SymbolGlyph($r('sys.symbol.wrench_and_screwdriver')) - .fontSize(14) - .fontColor([this.toolTypeColor(tool)]) - .width(15) - .height(15) - } + ToolGlyph({ kind: this.toolGlyphKind(tool), color: this.toolTypeColor(tool) }) } @Builder @@ -392,77 +260,9 @@ export struct ToolStatusList { .border({ width: 1, color: CARD }) } - @Builder - RunningDotsIcon() { - Row({ space: 2 }) { - Text('') - .width(3.5) - .height(3.5) - .borderRadius(2) - .backgroundColor(ACCENT) - Text('') - .width(3.5) - .height(3.5) - .borderRadius(2) - .backgroundColor(ACCENT) - Text('') - .width(3.5) - .height(3.5) - .borderRadius(2) - .backgroundColor(ACCENT) - } - .width(16) - .height(16) - .justifyContent(FlexAlign.Center) - } - - @Builder - AlertCircleIcon(color: string, mark: string) { - Text(mark) - .width(16) - .height(16) - .fontSize(10) - .fontColor(color) - .textAlign(TextAlign.Center) - .border({ width: 1.5, color }) - .borderRadius(8) - } - - @Builder - NeutralDotIcon() { - Stack() { - Text('') - .width(4) - .height(4) - .borderRadius(2) - .backgroundColor(MUTED) - .position({ x: 6, y: 6 }) - } - .width(16) - .height(16) - } - @Builder ChevronIcon(direction: string) { - if (direction === 'right') { - SymbolGlyph($r('sys.symbol.chevron_right')) - .fontSize(18) - .fontColor([MUTED]) - .width(14) - .height(14) - } else if (direction === 'up') { - SymbolGlyph($r('sys.symbol.chevron_up')) - .fontSize(18) - .fontColor([MUTED]) - .width(14) - .height(14) - } else { - SymbolGlyph($r('sys.symbol.chevron_down')) - .fontSize(18) - .fontColor([MUTED]) - .width(14) - .height(14) - } + ToolGlyph({ kind: `chevron_${direction}`, color: MUTED }) } @Builder @@ -483,99 +283,6 @@ export struct ToolStatusList { .padding({ left: 30, top: 2 }) } - @Builder - ToolInputEditor(tool: ConversationUiToolStatus) { - Column({ space: 6 }) { - Row() { - Text(RemoteI18n.t('chat.toolInput')) - .fontSize(11) - .fontColor(MUTED) - Blank() - Text(RemoteI18n.t('chat.reset')) - .fontSize(11) - .fontColor(MUTED) - .onClick(() => { - this.toolInputEditToolId = tool.id || ''; - this.toolInputEditText = this.defaultToolInputText(tool); - this.toolInputEditError = ''; - }) - } - .width('100%') - TextArea({ placeholder: RemoteI18n.t('chat.editJsonInput'), text: this.toolInputTextForTool(tool) }) - .height(96) - .fontSize(12) - .fontColor(INK) - .lineHeight(17) - .backgroundColor(SOFT) - .borderRadius(14) - .padding(10) - .border({ width: 1, color: this.toolInputErrorForTool(tool.id || '').length > 0 ? RED : LINE }) - .defaultFocus(false) - .enabled(true) - .onChange((value: string) => { - this.toolInputEditToolId = tool.id || ''; - this.toolInputEditText = value; - this.toolInputEditError = ''; - }) - if (this.toolInputErrorForTool(tool.id || '').length > 0) { - Text(this.toolInputErrorForTool(tool.id || '')) - .fontSize(11) - .fontColor(RED) - } - } - .width('100%') - .padding({ left: 30 }) - } - - @Builder - QuestionAnswer(tool: ConversationUiToolStatus) { - Column({ space: 8 }) { - Text(this.questionPrompt(tool)) - .fontSize(12) - .lineHeight(17) - .fontColor(INK) - .width('100%') - TextArea({ placeholder: RemoteI18n.t('chat.answerPlaceholder'), text: this.answerTextForTool(tool.id || '') }) - .height(78) - .fontSize(13) - .backgroundColor(CARD) - .borderRadius(14) - .padding(12) - .border({ width: 1, color: LINE }) - .defaultFocus(false) - .enabled(true) - .onChange((value: string) => { - this.questionAnswerToolId = tool.id || ''; - this.questionAnswerText = value; - }) - Row({ space: 8 }) { - Text(RemoteI18n.t('chat.submitAnswer')) - .fontSize(12) - .fontColor(this.canSubmitQuestion(tool.id || '') ? PRIMARY_ACTION_TEXT : MUTED) - .textAlign(TextAlign.Center) - .height(32) - .layoutWeight(1) - .backgroundColor(this.canSubmitQuestion(tool.id || '') ? ACCENT : SOFT) - .borderRadius(16) - .onClick(() => { - if (this.canSubmitQuestion(tool.id || '')) { - const answer = this.questionAnswerText.trim(); - const answers: ConversationUiQuestionAnswer = { - answer, - '0': answer - }; - this.onAnswerQuestion(tool.id || '', answers); - this.questionAnswerText = ''; - this.questionAnswerToolId = ''; - } - }) - } - .width('100%') - } - .width('100%') - .padding({ left: 30 }) - } - private displayStatus(status: string): string { const normalized = (status || '').toLowerCase(); if (normalized === 'running' || normalized === 'active') { @@ -758,18 +465,6 @@ export struct ToolStatusList { }; } - private hasCollapsibleTools(): boolean { - let runLength = 0; - return this.tools.some((tool: ConversationUiToolStatus) => { - if (this.shouldCollapseExploreTool(tool)) { - runLength += 1; - return runLength >= 2; - } - runLength = 0; - return false; - }); - } - private shouldCollapseExploreTool(tool: ConversationUiToolStatus): boolean { if (this.hasToolError(tool) || this.isPendingConfirmation(tool) || this.isQuestionTool(tool) || this.isRunningTool(tool)) { @@ -823,6 +518,29 @@ export struct ToolStatusList { return (tool.name || 'Tool').replace(/[\s-]/g, '_').toLowerCase(); } + private summaryGlyphKind(entry: ToolRenderEntry): string { + if (entry.searchCount > 0 && entry.readCount === 0) return 'search'; + if (entry.readCount > 0 && entry.searchCount === 0) return 'document'; + return 'stack'; + } + + private toolGlyphKind(tool: ConversationUiToolStatus): string { + if (this.isQuestionLikeTool(tool)) return 'question'; + if (this.isTodoTool(tool)) return 'todo'; + if (this.isTaskTool(tool)) return 'task'; + if (this.isGitTool(tool)) return 'git'; + if (this.isDeleteTool(tool)) return 'delete'; + if (this.isDiffTool(tool)) return 'diff'; + if (this.isPatchTool(tool)) return 'patch'; + if (this.isFileCreateTool(tool)) return 'create'; + if (this.isFileMutationTool(tool)) return 'mutate'; + if (this.isFileReadTool(tool)) return this.isDirectoryListTool(tool) ? 'folder' : 'document'; + if (this.isSearchTool(tool)) return 'search'; + if (this.isWebTool(tool)) return 'web'; + if (this.isCommandTool(tool)) return 'command'; + return 'tool'; + } + private isQuestionLikeTool(tool: ConversationUiToolStatus): boolean { const normalized = this.normalizedToolName(tool); return this.isQuestionTool(tool) || normalized === 'askuserquestion' || normalized === 'ask_user_question'; @@ -1280,50 +998,6 @@ export struct ToolStatusList { } } - private toolInputTextForTool(tool: ConversationUiToolStatus): string { - const toolId = tool.id || ''; - if (this.toolInputEditToolId === toolId) { - return this.toolInputEditText; - } - return this.defaultToolInputText(tool); - } - - private toolInputErrorForTool(toolId: string): string { - return this.toolInputEditToolId === toolId ? this.toolInputEditError : ''; - } - - private approveToolWithInput(tool: ConversationUiToolStatus): void { - const toolId = tool.id || ''; - if (toolId.length === 0) { - return; - } - if (!this.hasEditableToolInput(tool)) { - this.onApproveTool(toolId); - return; - } - - const rawInput = this.toolInputTextForTool(tool).trim(); - if (rawInput.length === 0) { - this.toolInputEditToolId = toolId; - this.toolInputEditError = RemoteI18n.t('chat.jsonObjectRequired'); - return; - } - - try { - const parsed = JSON.parse(rawInput) as Object; - if (parsed === null || Array.isArray(parsed)) { - this.toolInputEditToolId = toolId; - this.toolInputEditError = RemoteI18n.t('chat.jsonObjectRequired'); - return; - } - this.toolInputEditError = ''; - this.onApproveTool(toolId, parsed); - } catch (_err) { - this.toolInputEditToolId = toolId; - this.toolInputEditError = RemoteI18n.t('chat.jsonInvalid'); - } - } - private isRunningTool(tool: ConversationUiToolStatus): boolean { const status = (tool.status || '').toLowerCase(); return (status === 'running' || status === 'active') && (tool.id || '').length > 0; @@ -1387,16 +1061,6 @@ export struct ToolStatusList { return ''; } - private answerTextForTool(toolId: string): string { - return this.questionAnswerToolId === toolId ? this.questionAnswerText : ''; - } - - private canSubmitQuestion(toolId: string): boolean { - return toolId.length > 0 && - this.questionAnswerToolId === toolId && - this.questionAnswerText.trim().length > 0; - } - private toolKey(tool: ConversationUiToolStatus, index: number): string { const signature = this.toolSignature(tool); if (tool.id && tool.id.length > 0) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets new file mode 100644 index 0000000000..8db93d9fac --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets @@ -0,0 +1,319 @@ +import { RemoteUiState } from '../../services/RemoteUiState'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../actions/AppRootPresentationActions'; +import { WideLayoutGeometry } from '../layout/WideLayoutGeometry'; +import { AppRoute, ConversationSource } from '../navigation/AppRouteContract'; +import { FilePreviewLayout, FilePreviewPlacement } from '../policy/FilePreviewPlacementPolicy'; +import { AppShellState } from '../state/AppShellState'; +import { FilePreviewState } from '../state/FilePreviewState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { RemotePageState } from '../state/RemotePageState'; +import { AppSidebar } from './AppSidebar'; +import { ConversationRouteSurface } from './ConversationRouteSurface'; +import { FilePreviewSurface } from './FilePreviewSurface'; +import { + RemoteSurfaceHost, + RemoteSurfaceMode, + RemoteSurfaceState +} from './remote/RemoteSurfaceHost'; +import { SidebarToggleButton } from './SidebarToggleButton'; +import { FLOATING_PANEL_BG, LINE, PAGE_BG } from './Theme'; + +const WIDE_DETAIL_CONTENT_MAX_WIDTH: number = 920; + +@ComponentV2 +export struct WideConversationHost { + @Param route: AppRoute = AppRoute.ChatHome; + @Param shellState: AppShellState = new AppShellState(); + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); + @Param generalPageState: GeneralChatPageState = new GeneralChatPageState(); + @Param filePreviewState: FilePreviewState = new FilePreviewState(); + @Param remoteSurfaceState: RemoteSurfaceState = new RemoteSurfaceState(); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Param filePreviewLayout: FilePreviewLayout = new FilePreviewLayout(FilePreviewPlacement.Hidden); + @Param wideMasterPaneWidth: number = 0; + @Param wideMasterDetailGap: number = 0; + @Param wideDetailContentOffset: number = 0; + @Param wideDetailContentWidth: number = 0; + @Param wideCollapsedDetailContentOffset: number = 0; + @Param wideCollapsedDetailContentWidth: number = 0; + @Param wideMasterPaneCollapsed: boolean = false; + @Param wideMasterPaneMotionActive: boolean = false; + @Event onCollapseMasterPane: () => void = () => {}; + @Event onRestoreMasterPane: () => void = () => {}; + @Event onOpenRemoteViewSettings: () => void = () => {}; + + build() { + if (this.route === AppRoute.ChatHome || this.route === AppRoute.GeneralChat) { + this.GeneralChatContent(); + } else if (this.showsRemoteConversation() && + this.filePreviewLayout.placement === FilePreviewPlacement.WideFocusSplit) { + this.RemotePreviewFocusContent(); + } else if (this.showsRemoteConversation()) { + this.RemoteChatContent(); + } else if (this.route === AppRoute.RemoteHome) { + this.RemoteHomeContent(); + } else { + this.RemoteCreateContent(); + } + } + + @Builder + private GeneralChatContent() { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.MasterPane(ConversationSource.General, false) + this.MasterDetailGap() + } + this.ConversationDetail(false) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private RemoteHomeContent() { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.MasterPane(ConversationSource.Remote, false) + this.MasterDetailGap() + } + Column() { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Placeholder, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + wideMasterPaneCollapsed: this.wideMasterPaneCollapsed, + onRestoreSidebar: this.onRestoreMasterPane + }) + } + .layoutWeight(1).height('100%').backgroundColor(PAGE_BG) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private RemoteCreateContent() { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.MasterPane(ConversationSource.Remote, false) + this.MasterDetailGap() + } + this.ConversationDetail(false) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private MasterPane(source: ConversationSource, showSelectedSession: boolean) { + Column() { + Column() { + AppSidebar({ + sessions: source === ConversationSource.Remote ? [] : this.generalPageState.recentSessions(), + pinnedSessionId: this.generalPageState.pinnedSessionId(), + selectedSessionId: source === ConversationSource.Remote ? '' : + this.generalPageState.conversation.activeSession.sessionId, + connectionState: this.remotePageState.connectionState, + accountUserId: this.remotePageState.accountUserId, + activeSection: source === ConversationSource.Remote ? 'remote' : 'chat', + showConversationSourceSwitcher: true, + showCollapseButton: true, + showViewSettingsButton: source === ConversationSource.Remote, + showCustomContent: source === ConversationSource.Remote, + conversationSource: source, + contentSlot: () => { + this.RemoteMasterContent(showSelectedSession) + }, + onClose: this.actions.onSidebar.close, + onNewChat: source === ConversationSource.Remote ? + this.actions.onRemoteHome.createAssistant : this.actions.onSidebar.newChat, + onEnterCode: () => this.actions.onWideConversationSource(ConversationSource.Remote), + onConversationSource: this.actions.onWideConversationSource, + onCollapse: this.onCollapseMasterPane, + onOpenViewSettings: this.onOpenRemoteViewSettings, + onSearchQueryChange: (query: string) => { + if (source === ConversationSource.Remote) this.actions.onRemoteHome.queryChanged(query); + }, + onOpenSettings: source === ConversationSource.Remote ? + this.actions.onRemoteHome.openSettings : this.actions.onSidebar.settings, + onOpenAccount: this.actions.onSidebar.openAccount, + onOpenSession: this.actions.onSidebar.openSession, + onArchiveSession: this.actions.onSidebar.archive, + onExportSession: this.actions.onSidebar.exportSession, + onDeleteSession: this.actions.onSidebar.deleteSession + }) + } + .width('100%').height('100%').backgroundColor(FLOATING_PANEL_BG) + .borderRadius(18).clip(true) + .shadow({ radius: 24, color: '#14000000', offsetX: 4, offsetY: 8 }) + } + .width(WideLayoutGeometry.masterPaneWidth(this.filePreviewLayout, this.wideMasterPaneWidth)) + .height('100%').padding({ left: 10, right: 6, top: 10, bottom: 10 }).backgroundColor(PAGE_BG) + .transition(this.wideMasterPaneMotionActive ? + TransitionEffect.translate({ x: -28, y: 0 }).combine(TransitionEffect.opacity(0)) + .animation({ duration: 220, curve: Curve.EaseInOut }) : TransitionEffect.opacity(1)) + } + + @Builder + private RemoteMasterContent(showSelectedSession: boolean) { + RemoteSurfaceHost({ + mode: RemoteSurfaceMode.Master, + remotePageState: this.remotePageState, + presentationState: this.remoteSurfaceState, + actions: this.actions, + showSelectedSession, + compact: false + }) + } + + @Builder + private RemoteChatContent() { + if (this.filePreviewLayout.placement === FilePreviewPlacement.WideTriplePane) { + Row() { + this.MasterPane(ConversationSource.Remote, true) + this.PaneGap(this.filePreviewLayout.masterConversationGap) + this.ConversationDetail(false, this.filePreviewLayout.conversationPaneWidth) + this.PaneGap(this.filePreviewLayout.conversationPreviewGap) + this.FilePreviewPane(this.filePreviewLayout.previewPaneWidth) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } else { + Row() { + if (!this.wideMasterPaneCollapsed) { + this.MasterPane(ConversationSource.Remote, true) + this.MasterDetailGap() + } + this.ConversationDetail(false) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + } + + @Builder + private RemotePreviewFocusContent() { + Row() { + this.ConversationDetail(false, this.filePreviewLayout.conversationPaneWidth) + this.PaneGap(this.filePreviewLayout.conversationPreviewGap) + this.FilePreviewPane(this.filePreviewLayout.previewPaneWidth) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private FilePreviewPane(paneWidth: number) { + Column() { + FilePreviewSurface({ + state: this.filePreviewState, + remoteAvailable: RemoteUiState.canUseRemote(this.remotePageState.connectionState), + downloadPath: this.remotePageState.downloadingFilePath, + downloadedPath: this.remotePageState.downloadedFilePath, + downloadStatus: this.remotePageState.fileDownloadStatus, + onClose: this.actions.onFilePreview.close, + onRefresh: this.actions.onFilePreview.refresh, + onDownload: this.actions.onFilePreview.download, + onOpenLink: this.actions.onFilePreview.openLink + }) + } + .width(paneWidth).height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private ConversationDetail(showBackButton: boolean, paneWidth: number = 0) { + if (paneWidth > 0) { + Column() { + this.RouteSurface(showBackButton) + } + .width(paneWidth).height('100%').constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) + .backgroundColor(PAGE_BG) + } else { + Stack({ alignContent: Alignment.TopStart }) { + Row() { + if (this.currentDetailOffset() > 0) Blank().width(this.currentDetailOffset()) + Row() { + Column() { this.RouteSurface(showBackButton) } + .width('100%').height('100%').constraintSize({ maxWidth: WIDE_DETAIL_CONTENT_MAX_WIDTH }) + .backgroundColor(PAGE_BG) + } + .width(this.currentDetailWidth() > 0 ? this.currentDetailWidth() : '100%') + .height('100%').justifyContent(FlexAlign.Center) + if (this.currentDetailOffset() > 0) Blank().layoutWeight(1) + } + .width('100%').height('100%').justifyContent(FlexAlign.Center).backgroundColor(PAGE_BG) + + if (this.wideMasterPaneCollapsed) { + SidebarToggleButton({ restore: true, controlSize: 44, onToggle: this.onRestoreMasterPane }) + .position({ x: this.currentDetailOffset() + 12, y: 12 }).zIndex(2) + .transition(TransitionEffect.scale({ x: 0.9, y: 0.9 }).combine(TransitionEffect.opacity(0)) + .animation({ duration: 180, curve: Curve.EaseOut })) + } + } + .layoutWeight(1).height('100%').backgroundColor(PAGE_BG) + } + } + + @Builder + private RouteSurface(showBackButton: boolean) { + ConversationRouteSurface({ + route: this.route, + remotePageState: this.remotePageState, + remoteCreateState: this.remoteCreateState, + generalPageState: this.generalPageState, + filePreviewState: this.filePreviewState, + remoteSurfaceState: this.remoteSurfaceState, + actions: this.actions, + showSidebarButton: false, + showBackButton, + useWidePresentation: true, + contentHorizontalOffset: this.collapsedDetailVisualBias(), + onRestoreSidebar: this.onRestoreMasterPane + }) + } + + @Builder + private MasterDetailGap() { + if (this.wideMasterDetailGap > 0) { + Row() {}.width(this.wideMasterDetailGap).height('100%').backgroundColor(LINE) + } + } + + @Builder + private PaneGap(width: number) { + if (width > 0) { + Row() {}.width(width).height('100%').backgroundColor(LINE) + } + } + + private showsRemoteConversation(): boolean { + return this.route === AppRoute.RemoteChat; + } + + private currentDetailOffset(): number { + return WideLayoutGeometry.detailOffset( + this.wideMasterPaneCollapsed, + this.wideDetailContentOffset, + this.wideCollapsedDetailContentOffset + ); + } + + private currentDetailWidth(): number { + return WideLayoutGeometry.detailWidth( + this.wideMasterPaneCollapsed, + this.wideDetailContentWidth, + this.wideCollapsedDetailContentWidth + ); + } + + private collapsedDetailVisualBias(): number { + return WideLayoutGeometry.collapsedVisualBias( + this.wideMasterPaneCollapsed, + this.wideCollapsedDetailContentOffset, + this.wideCollapsedDetailContentWidth, + WIDE_DETAIL_CONTENT_MAX_WIDTH, + 72 + ); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets new file mode 100644 index 0000000000..11f1f41e09 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets @@ -0,0 +1,368 @@ +import { RemoteI18n } from '../../../i18n/RemoteI18n'; +import { RemoteSession } from '../../../model/RemoteModels'; +import { RemotePageState } from '../../state/RemotePageState'; +import { + AppRootPresentationActions, + emptyAppRootPresentationActions +} from '../../actions/AppRootPresentationActions'; +import { ConversationViewSettings } from '../ConversationViewSettings'; +import { GeneralChatHeader } from '../GeneralChatHeader'; +import { RemoteSessionList } from '../RemoteSessionList'; +import { RemoteSessionLoadingView } from '../RemoteSessionLoadingView'; +import { SidebarToggleButton } from '../SidebarToggleButton'; +import { SessionActionPresentation } from '../SessionActionSurface'; +import { CARD, GREEN, INK, LINE, MUTED, PAGE_BG, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED } from '../Theme'; + +export enum RemoteSurfaceMode { + Master = 'master', + CompactHome = 'compact_home', + Placeholder = 'placeholder', + Settings = 'settings' +} + +/** Shared presentation state for compact and wide Remote surfaces. */ +@ObservedV2 +export class RemoteSurfaceState { + @Trace sortMode: string = 'project'; + @Trace workspaceFilter: string = ''; + @Trace agentFilter: string = ''; + @Trace statusFilter: string = ''; + @Trace showWorkspaceMetadata: boolean = false; + @Trace showUpdatedMetadata: boolean = false; + @Trace showStatusMetadata: boolean = false; + + setSortMode(value: string): void { this.sortMode = value; } + setWorkspaceFilter(value: string): void { this.workspaceFilter = value; } + setAgentFilter(value: string): void { this.agentFilter = value; } + setStatusFilter(value: string): void { this.statusFilter = value; } + setWorkspaceMetadata(value: boolean): void { this.showWorkspaceMetadata = value; } + setUpdatedMetadata(value: boolean): void { this.showUpdatedMetadata = value; } + setStatusMetadata(value: boolean): void { this.showStatusMetadata = value; } +} + +@ComponentV2 +export struct RemoteSurfaceHost { + @Param mode: RemoteSurfaceMode = RemoteSurfaceMode.Master; + @Param remotePageState: RemotePageState = new RemotePageState(); + @Param presentationState: RemoteSurfaceState = new RemoteSurfaceState(); + @Param actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + @Param showSelectedSession: boolean = false; + @Param compact: boolean = false; + @Param wideMasterPaneCollapsed: boolean = false; + @Event onOpenSidebar: () => void = () => {}; + @Event onRestoreSidebar: () => void = () => {}; + @Event onCloseSettings: () => void = () => {}; + + build() { + if (this.mode === RemoteSurfaceMode.Master) { + this.MasterContent(); + } else if (this.mode === RemoteSurfaceMode.CompactHome) { + this.CompactHomeContent(); + } else if (this.mode === RemoteSurfaceMode.Placeholder) { + this.FlowPlaceholder(); + } else { + this.SettingsContent(); + } + } + + @Builder + private MasterContent() { + Column() { + this.StatusRow() + if (this.isInitialLoading()) { + RemoteSessionLoadingView() + } else if (this.canShowSessionList()) { + RemoteSessionList({ + sessions: this.remotePageState.visibleSessions(), + query: this.remotePageState.sessionQuery, + sortMode: this.presentationState.sortMode, + workspaceFilter: this.presentationState.workspaceFilter, + agentFilter: this.presentationState.agentFilter, + statusFilter: this.presentationState.statusFilter, + workspaceName: this.remotePageState.workspaceName, + workspacePath: this.remotePageState.workspacePath, + workspaceKind: this.remotePageState.workspaceKind, + recentWorkspaces: this.remotePageState.recentWorkspaces, + actionPresentation: SessionActionPresentation.Popover, + showWorkspaceMetadata: this.presentationState.showWorkspaceMetadata, + showUpdatedMetadata: this.presentationState.showUpdatedMetadata, + showStatusMetadata: this.presentationState.showStatusMetadata, + hasMoreSessions: this.remotePageState.hasMoreSessions, + isBusy: this.remotePageState.conversation.isBusy || this.remotePageState.isLoadingSessions, + selectedSessionId: this.remotePageState.pendingSessionId.length > 0 ? + this.remotePageState.pendingSessionId : + (this.showSelectedSession ? this.remotePageState.conversation.activeSession.sessionId : ''), + onCreate: () => this.createSession('code'), + onCreateAssistantSession: () => this.createAssistantSession(), + onCreateInWorkspace: (path: string, agentType: string) => this.createSessionInWorkspace(path, agentType), + onSelectWorkspace: (path: string) => this.actions.onRemoteHome.selectWorkspace(path), + onOpenSession: (session: RemoteSession) => this.openSession(session), + onDeleteSession: (session: RemoteSession) => this.actions.onRemoteHome.deleteSession(session), + onLoadMore: () => this.actions.onRemoteHome.loadMore() + }) + } else { + this.DisconnectedState() + } + } + .width('100%') + .height('100%') + .alignItems(HorizontalAlign.Start) + .padding({ bottom: 84 }) + } + + @Builder + private StatusRow() { + Row({ space: 6 }) { + this.StatusIndicator() + Text(this.statusText()) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + } + .width('100%') + .margin({ top: 16, bottom: 6 }) + .alignItems(VerticalAlign.Center) + } + + @Builder + private StatusIndicator() { + if (this.isInitialLoading()) { + LoadingProgress().width(14).height(14).color(MUTED) + } else { + Stack() { + Text('') + } + .width(7) + .height(7) + .backgroundColor(this.statusColor()) + .borderRadius(4) + } + } + + @Builder + private DisconnectedState() { + Column({ space: 12 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.desktop')).fontSize(42).fontColor([INK]) + } + .width(74) + .height(74) + .backgroundColor(CARD) + .borderRadius(24) + .border({ width: 1, color: LINE }) + Text(RemoteI18n.t('remote.connectTitle')) + .fontSize(18).fontWeight(FontWeight.Bold).fontColor(INK).textAlign(TextAlign.Center) + Text(RemoteI18n.t('remote.connectText')) + .fontSize(13).lineHeight(20).fontColor(MUTED).textAlign(TextAlign.Center) + Text(RemoteI18n.t('connect.connect')) + .width(136).height(44).fontSize(15).fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION).textAlign(TextAlign.Center).borderRadius(22) + .onClick(() => this.actions.onRemoteHome.connectWorkspace()) + } + .layoutWeight(1) + .width('100%') + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .padding({ left: 20, right: 20, bottom: 48 }) + } + + @Builder + private CompactHomeContent() { + Column() { + GeneralChatHeader({ + title: RemoteI18n.t('remote.title'), + subtitle: this.compactHeaderContext(), + showSidebarButton: true, + onOpenSidebar: this.onOpenSidebar + }) + if (this.canShowSessionList()) { + this.CompactEmptyState() + } else { + this.DisconnectedState() + } + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + @Builder + private CompactEmptyState() { + Column({ space: 10 }) { + if (this.isInitialLoading()) { + LoadingProgress().width(28).height(28).color(MUTED).margin({ bottom: 8 }) + } + Text(this.compactTitle()) + .fontSize(20).fontWeight(FontWeight.Bold).fontColor(INK).textAlign(TextAlign.Center) + Text(this.compactText()) + .fontSize(14).lineHeight(21).fontColor(MUTED).maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }).textAlign(TextAlign.Center) + .constraintSize({ maxWidth: 280 }) + Text(RemoteI18n.t('remote.startSession')) + .width(148).height(46).fontSize(15).fontWeight(FontWeight.Medium) + .fontColor(PRIMARY_ACTION_TEXT).backgroundColor(PRIMARY_ACTION) + .textAlign(TextAlign.Center).borderRadius(23).margin({ top: 12 }) + .onClick(() => this.actions.onRemoteHome.createAssistant()) + } + .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center).padding({ left: 24, right: 24, bottom: 56 }) + } + + @Builder + private FlowPlaceholder() { + Column() { + Row({ space: 8 }) { + if (this.wideMasterPaneCollapsed) { + SidebarToggleButton({ restore: true, controlSize: 48, onToggle: this.onRestoreSidebar }) + } else { + Blank().width(48).height(48) + } + Column({ space: 4 }) { + Text(RemoteI18n.t('remote.chats')).fontSize(20).fontWeight(FontWeight.Bold).fontColor(INK) + Text(this.desktopName()).fontSize(13).fontColor(MUTED).maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1).alignItems(HorizontalAlign.Center) + Blank().width(48).height(48) + } + .width('100%').height(76).padding({ left: 16, right: 16, top: 14, bottom: 12 }) + .border({ width: { bottom: 1 }, color: LINE }) + + Column({ space: 8 }) { + if (this.isInitialLoading()) { + LoadingProgress().width(28).height(28).color(MUTED).margin({ bottom: 8 }) + } + Text(this.placeholderTitle()).fontSize(22).fontWeight(FontWeight.Bold).fontColor(INK) + Text(this.statusText()).fontSize(14).fontColor(MUTED).maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center).padding({ left: 24, right: 24, bottom: 48 }) + } + .width('100%').height('100%').backgroundColor(PAGE_BG) + } + + @Builder + private SettingsContent() { + ConversationViewSettings({ + sessions: this.remotePageState.visibleSessions(), + workspaceName: this.remotePageState.workspaceName, + workspacePath: this.remotePageState.workspacePath, + workspaceKind: this.remotePageState.workspaceKind, + recentWorkspaces: this.remotePageState.recentWorkspaces, + sortMode: this.presentationState.sortMode, + workspaceFilter: this.presentationState.workspaceFilter, + agentFilter: this.presentationState.agentFilter, + statusFilter: this.presentationState.statusFilter, + showWorkspaceMetadata: this.presentationState.showWorkspaceMetadata, + showUpdatedMetadata: this.presentationState.showUpdatedMetadata, + showStatusMetadata: this.presentationState.showStatusMetadata, + onSortModeChange: (value: string) => this.presentationState.setSortMode(value), + onWorkspaceFilterChange: (value: string) => this.presentationState.setWorkspaceFilter(value), + onAgentFilterChange: (value: string) => this.presentationState.setAgentFilter(value), + onStatusFilterChange: (value: string) => this.presentationState.setStatusFilter(value), + onWorkspaceMetadataChange: (value: boolean) => this.presentationState.setWorkspaceMetadata(value), + onUpdatedMetadataChange: (value: boolean) => this.presentationState.setUpdatedMetadata(value), + onStatusMetadataChange: (value: boolean) => this.presentationState.setStatusMetadata(value), + onClose: this.onCloseSettings + }) + } + + private openSession(session: RemoteSession): void { + if (this.compact) { + this.actions.onSidebar.openSession(session); + } else { + this.actions.onRemoteHome.openSessionInPlace(session); + } + } + + private createSession(agentType: string): void { + if (this.compact) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.create(agentType); + } else { + this.actions.onRemoteHome.createInPlace(agentType); + } + } + + private createSessionInWorkspace(path: string, agentType: string): void { + if (this.compact) { + this.actions.onSidebar.close(); + this.actions.onRemoteHome.createInWorkspace(path, agentType); + } else { + this.actions.onRemoteHome.createInWorkspaceInPlace(path, agentType); + } + } + + private createAssistantSession(): void { + if (this.compact) { + this.actions.onSidebar.close(); + } + this.actions.onRemoteHome.createAssistant(); + } + + private canShowSessionList(): boolean { + return this.remotePageState.connectionState === 'connected' || + this.remotePageState.visibleSessions().length > 0 || + this.remotePageState.isLoadingHome || this.remotePageState.isLoadingSessions; + } + + private isInitialLoading(): boolean { + return this.remotePageState.isLoadingHome || this.isConnecting(); + } + + private isConnecting(): boolean { + return this.remotePageState.connectionState === 'parsing' || + this.remotePageState.connectionState === 'pairing' || + this.remotePageState.connectionState === 'reconnecting'; + } + + private statusText(): string { + if (this.remotePageState.conversation.statusText.length > 0) { + return this.remotePageState.conversation.statusText; + } + return this.desktopName(); + } + + private statusColor(): ResourceColor { + if (this.remotePageState.connectionState === 'connected') return GREEN; + if (this.remotePageState.connectionState === 'failed' || this.remotePageState.connectionState === 'disconnected') { + return RED; + } + return MUTED; + } + + /** + * Compact Remote Home names the bound desktop under the title, the same + * context the conversation header carries. Stays empty while disconnected so + * the connect state does not advertise a stale desktop. + */ + private compactHeaderContext(): string { + return this.canShowSessionList() ? this.remotePageState.desktopName : ''; + } + + private desktopName(): string { + return this.remotePageState.desktopName.length > 0 ? this.remotePageState.desktopName : + RemoteI18n.t('remote.settings.noDesktop'); + } + + private compactTitle(): string { + if (this.isInitialLoading()) return RemoteI18n.t('common.loading'); + return this.remotePageState.visibleSessions().length > 0 ? + RemoteI18n.t('remote.pickSession') : RemoteI18n.t('remote.emptyTitle'); + } + + private compactText(): string { + if (this.isInitialLoading()) return this.statusText(); + return this.remotePageState.visibleSessions().length > 0 ? + RemoteI18n.t('remote.pickSessionText') : RemoteI18n.t('remote.emptyText'); + } + + private placeholderTitle(): string { + if (this.isInitialLoading()) return RemoteI18n.t('common.loading'); + return this.remotePageState.visibleSessions().length > 0 ? '选择会话' : RemoteI18n.t('remote.emptyTitle'); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets new file mode 100644 index 0000000000..4b3de88883 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/layout/WideLayoutGeometry.ets @@ -0,0 +1,35 @@ +import { FilePreviewLayout, FilePreviewPlacement } from '../policy/FilePreviewPlacementPolicy'; + +/** Pure geometry helpers shared by wide conversation presentation paths. */ +export class WideLayoutGeometry { + static masterPaneWidth(layout: FilePreviewLayout, fallback: number): number { + return layout.placement === FilePreviewPlacement.WideTriplePane ? layout.masterPaneWidth : fallback; + } + + static detailOffset(collapsed: boolean, expandedOffset: number, collapsedOffset: number): number { + return collapsed ? collapsedOffset : expandedOffset; + } + + static detailWidth(collapsed: boolean, expandedWidth: number, collapsedWidth: number): number { + return collapsed ? collapsedWidth : expandedWidth; + } + + static collapsedVisualBias( + collapsed: boolean, + collapsedOffset: number, + collapsedWidth: number, + maxContentWidth: number, + maximumBias: number + ): number { + if (!collapsed || collapsedOffset > 0) { + return 0; + } + const availableMargin = (collapsedWidth - maxContentWidth) / 2; + return Math.min(maximumBias, Math.max(0, availableMargin)); + } + + static areaLength(value: Object): number { + const parsed = Number.parseFloat(`${value}`); + return Number.isNaN(parsed) ? 0 : parsed; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/AppRootRouteState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRootRouteState.ets similarity index 78% rename from src/apps/mobile/harmonyos/entry/src/main/ets/services/AppRootRouteState.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRootRouteState.ets index 417e97869e..58ca1bf03e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/AppRootRouteState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRootRouteState.ets @@ -1,8 +1,8 @@ -import { SelectedImageAttachment } from '../model/RemoteModels'; -import { AppRoute, AppRouteContract } from '../pages/navigation/AppRouteContract'; -import { GeneralChatPageState } from '../pages/state/GeneralChatPageState'; -import { RemotePageState } from '../pages/state/RemotePageState'; -import { VoiceInputRouteSnapshot } from './VoiceInputLifecycleController'; +import { SelectedImageAttachment } from '../../model/RemoteModels'; +import { VoiceInputRouteSnapshot } from '../../services/VoiceInputLifecycleController'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; +import { AppRoute, AppRouteContract } from './AppRouteContract'; /** Keeps route-dependent composer state mapping out of the root component. */ export class AppRootRouteState { @@ -11,16 +11,19 @@ export class AppRootRouteState { } static chatInput(route: AppRoute, general: GeneralChatPageState, remote: RemotePageState): string { - return AppRootRouteState.isGeneralComposerRoute(route) ? general.chatInput : remote.chatInput; + return AppRootRouteState.isGeneralComposerRoute(route) ? + general.conversation.chatInput : remote.conversation.chatInput; } static selectedImages(route: AppRoute, general: GeneralChatPageState, remote: RemotePageState): SelectedImageAttachment[] { - return AppRootRouteState.isGeneralComposerRoute(route) ? general.selectedImages : remote.selectedImages; + return AppRootRouteState.isGeneralComposerRoute(route) ? + general.conversation.selectedImages : remote.conversation.selectedImages; } static voiceListening(route: AppRoute, general: GeneralChatPageState, remote: RemotePageState): boolean { - return AppRootRouteState.isGeneralComposerRoute(route) ? general.isVoiceListening : remote.isVoiceListening; + return AppRootRouteState.isGeneralComposerRoute(route) ? + general.conversation.isVoiceListening : remote.conversation.isVoiceListening; } static setChatInput(route: AppRoute, value: string, general: GeneralChatPageState, remote: RemotePageState): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets index 0ca41eeb88..32a712b546 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/navigation/AppRouteContract.ets @@ -85,6 +85,10 @@ export class AppRouteContract { return new ChatRouteParam(sessionId); } + static remoteSessionDestination(sessionId: string): AppNavigationPathSpec { + return new AppNavigationPathSpec(AppRoute.RemoteChat, sessionId); + } + static pathSpec(currentRoute: AppRoute, route: AppRoute, sessionId: string = ''): AppNavigationPathSpec | undefined { if (currentRoute === route) { return undefined; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationLayoutPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationLayoutPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationLayoutPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationModelPresentationPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationModelPresentationPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationModelPresentationPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationSessionFilterPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationSessionFilterPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationSessionFilterPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ConversationSessionFilterPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewPlacementPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/FilePreviewPlacementPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewPlacementPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/FilePreviewPlacementPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SessionActionPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionActionPolicy.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SessionActionPolicy.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SessionActionPolicy.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets new file mode 100644 index 0000000000..7d34cede75 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets @@ -0,0 +1,390 @@ +import { RemoteSession } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { AppRootHostPort } from '../host/AppRootHostAdapter'; +import { + AppNavigationBackAction, + AppRoute, + AppRouteContract, + ConversationSource +} from '../navigation/AppRouteContract'; +import { + AppRootRuntimeComposition, + ConnectionState +} from './AppRootRuntimeComposition'; + +export class AppRootRuntime extends AppRootRuntimeComposition { + constructor(host: AppRootHostPort) { + super(host); + } + + async aboutToAppear(): Promise { + this.syncRemotePageSummary(); + await this.generalChatBootstrapController.restore(this.host.context()); + await this.settingsController.initializeCloudAccount(this.host.context()); + await this.settingsController.refreshModelCatalog(); + await this.restoreIdentity(); + } + + onPageShow(): void { + RemoteLogger.info(`page show state=${(this.remotePageState.connectionState as ConnectionState)} route=${this.appShellViewModel.currentRoute()}`); + this.remoteActivityViewModel.resume(); + } + + onPageHide(): void { + RemoteLogger.info(`page hide state=${(this.remotePageState.connectionState as ConnectionState)} route=${this.appShellViewModel.currentRoute()}`); + this.remoteActivityViewModel.invalidate(); + this.remoteConnectionCoordinator.invalidate(); + this.remotePageState.setBusy(false); + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + } + + aboutToDisappear(): void { + this.remoteActivityViewModel.invalidate(); + this.remoteConnectionCoordinator.invalidate(); + this.remotePageState.setBusy(false); + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + this.generalChatConversationViewModel.stop(true, 'failed'); + this.generalChatDraftLifecycleController.cancel(); + this.remoteFileDownloadController.cancel(); + this.filePreviewController.close(); + this.voiceInputLifecycleController.cancel(`${this.appShellViewModel.currentRoute()}`, () => { + this.conversationController.clearAllVoiceListening(); + }); + } + + isRemoteConversationContext(sessionId: string): boolean { + if (sessionId.length === 0 || this.remotePageState.activeSession.sessionId !== sessionId) { + return false; + } + return this.appShellViewModel.isRoute(AppRoute.RemoteChat) || this.appShellViewModel.isRoute(AppRoute.RemoteHome); + } + + handleNavigationBack(route: AppRoute): boolean { + if (this.filePreviewState.visible) { + this.filePreviewController.close(); + return true; + } + const action = this.appShellViewModel.backAction(route); + if (action === AppNavigationBackAction.CloseSidebar) { + this.closeAppSidebar(); + return true; + } + if (action === AppNavigationBackAction.CloseActiveChat) { + this.exitActiveChat(); + return true; + } + if (action === AppNavigationBackAction.PopRemoteHome) { + this.appShellViewModel.popRoute(AppRoute.ChatHome); + return true; + } + return false; + } + + handleRootBack(): boolean { + if (!this.filePreviewState.visible) { + return false; + } + this.filePreviewController.close(); + return true; + } + + + async restoreIdentity(): Promise { + if (this.remotePageState.controlTargetType === 'account_device') { + return; + } + await this.remoteConnectionController.restore(this.host.context()); + } + + async connect(autoReconnect: boolean = false, accountPassword: string = ''): Promise { + await this.remoteConnectionController.connect(autoReconnect, accountPassword); + await this.settingsController.persistDelegatedAccountSession(); + } + + async reconnect(): Promise { + if (this.remotePageState.controlTargetType === 'account_device') { + await this.settingsController.restoreCloudTarget( + this.remotePageState.controlTargetDeviceId, + this.remotePageState.controlTargetDeviceName + ); + return; + } + await this.remoteConnectionController.reconnect(); + } + + async disconnect(clearPairing: boolean): Promise { + this.filePreviewController.invalidate(); + await this.remoteConnectionController.disconnect(clearPairing); + } + + syncRemotePageSummary(): void { + if (this.remotePageState.statusText.length === 0) { + this.remotePageState.setStatusText(RemoteI18n.t('status.waitingConnection')); + } + if (this.remotePageState.workspaceName.length === 0) { + this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + } + } + + failRemoteConnection(err: Object): void { + this.remotePageState.setStatusText(ConnectionErrorPolicy.errorText(err)); + this.remotePageState.setConnectionState(ConnectionState.Failed); + this.remoteActivityViewModel.stopHeartbeat(); + } + + async selectWorkspace(path: string): Promise { + this.filePreviewController.close(); + await this.remoteWorkspaceViewModel.selectWorkspace(path); + } + + async selectAssistant(path: string): Promise { + this.filePreviewController.close(); + await this.remoteWorkspaceViewModel.selectAssistant(path); + } + + openAppSidebar(): void { + this.host.animate(230, () => { + this.appShellState.setSidebarVisible(true); + }); + } + + closeAppSidebar(): void { + this.host.animate(210, () => { + this.appShellState.setSidebarVisible(false); + }); + } + + enterCodeEntry(): void { + if (this.settingsController.hasCloudAccountSession() && this.remotePageState.accountUserId.trim().length > 0) { + this.appShellState.setConnectSheetVisible(true); + return; + } + if (RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState))) { + this.appShellState.setConnectSheetVisible(false); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + return; + } + this.appShellState.setConnectSheetVisible(true); + } + + async switchWideConversationSource(source: ConversationSource): Promise { + if (AppRouteContract.conversationSource(this.appShellViewModel.currentRoute()) === source) { + return; + } + if (this.conversationController.visibleVoiceListening()) { + await this.stopVoiceInput(false); + } + if (source === ConversationSource.General) { + this.remoteChatPollingLifecycleController.stop(); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); + return; + } + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + const activeRemoteSessionId = this.remotePageState.isConversationDismissed ? '' : + (this.remotePageState.activeSession.sessionId || ''); + const target = AppRouteContract.routeForConversationSource( + source, + RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState)), + activeRemoteSessionId + ); + this.appShellViewModel.replaceRouteWithoutAnimation( + target.name, + target.hasSessionParam() ? target.routeParam().sessionId : '' + ); + if (target.name === AppRoute.RemoteChat) { + this.conversationController.startRemotePolling(); + await this.conversationController.loadRemoteMessages(); + } + } + + /** + * Compact counterpart of switchWideConversationSource. Switching source is a + * change of context, not a command to start something: it resumes the session + * the user was last in, and otherwise rests on the Remote landing surface + * rather than opening the create composer for them. + */ + async switchCompactConversationSource(source: ConversationSource): Promise { + this.closeAppSidebar(); + if (AppRouteContract.conversationSource(this.appShellViewModel.currentRoute()) === source) { + return; + } + if (this.conversationController.visibleVoiceListening()) { + await this.stopVoiceInput(false); + } + if (source === ConversationSource.General) { + this.remoteChatPollingLifecycleController.stop(); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); + return; + } + this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); + const activeRemoteSessionId = RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState)) && + !this.remotePageState.isConversationDismissed ? + (this.remotePageState.activeSession.sessionId || '') : ''; + if (activeRemoteSessionId.length === 0) { + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + return; + } + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteChat, activeRemoteSessionId); + this.conversationController.startRemotePolling(); + await this.conversationController.loadRemoteMessages(); + } + + enterCompactLayout(): void { + const sessionId = this.remotePageState.activeSession.sessionId || ''; + if (this.appShellViewModel.isRoute(AppRoute.RemoteHome) && + !this.remotePageState.isConversationDismissed && sessionId.length > 0) { + this.appShellViewModel.pushRoute(AppRoute.RemoteChat, sessionId, false); + } + } + + /** + * Exit control for an open conversation. Leaving a remote conversation on a + * compact layout lands on Remote Home with no visible session list, so reveal + * the drawer that owns navigation there. Compact chats have no back button of + * their own, so this runs for the system back gesture. + */ + exitActiveChat(): void { + const revealSidebar = !this.appShellState.wideLayout && + this.appShellViewModel.isRoute(AppRoute.RemoteChat); + this.conversationController.closeActiveChat(); + if (revealSidebar) { + this.openAppSidebar(); + } + } + + openRemoteControlSettings(): void { + setTimeout(() => { + this.appShellState.openSettings('remote'); + }, 180); + } + + openAddConnectionFromSettings(): void { + this.appShellState.setSettingsVisible(false); + setTimeout(() => { + this.appShellState.setConnectSheetVisible(true); + }, 220); + } + + applyDiscoveredWorkspaceSessions(all: RemoteSession[]): void { + this.remoteWorkspaceSessions = all; + const current = this.remotePageState.sessions; + const extras = all.filter((item: RemoteSession) => item.workspacePath !== this.remotePageState.workspacePath); + this.remotePageState.setSessions(this.mergeSessions(current, extras), this.remotePageState.hasMoreSessions); + } + + mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[] { + const merged = primary.slice(); + extras.forEach((item: RemoteSession) => { + if (!merged.some((existing: RemoteSession) => existing.id === item.id)) { + merged.push(item); + } + }); + return merged; + } + + async toggleVoiceInput(): Promise { + const route = this.appShellViewModel.currentRoute(); + await this.voiceInputLifecycleController.toggle( + this.host.context(), this.conversationController.voiceInputSnapshot(route) + ); + } + + async stopVoiceInput(showStatus: boolean): Promise { + const route = this.appShellViewModel.currentRoute(); + await this.voiceInputLifecycleController.stop( + this.conversationController.voiceInputSnapshot(route), showStatus + ); + } + + showVoiceInputError(message: string): void { + const text = message.length > 0 ? message : RemoteI18n.t('errors.voiceInputUnavailable'); + this.conversationController.setVisibleStatusText(text); + this.host.showToast(text, 2600); + } + + async pickImages(): Promise { + if (this.conversationController.visibleBusy()) { + return; + } + const route = this.appShellViewModel.currentRoute(); + if (this.conversationController.visibleVoiceListening()) { + await this.stopVoiceInput(false); + } + try { + this.conversationController.setVisibleStatusText(RemoteI18n.t('status.pickImage')); + const picked = await this.imagePickerService.pickImages( + 3, + this.conversationController.visibleSelectedImages().length + ); + if (picked.length === 0) { + this.conversationController.setVisibleStatusText(RemoteI18n.t('status.noImageSelected')); + return; + } + this.conversationController.addSelectedImages(route, picked); + this.conversationController.setVisibleStatusText(RemoteI18n.f( + 'status.imagesSelected', + `${this.conversationController.visibleSelectedImages().length}` + )); + } catch (err) { + this.conversationController.setVisibleStatusText(ConnectionErrorPolicy.errorText(err)); + } + } + + currentActiveTurnId(): string { + if (!this.appShellViewModel.isGeneralChatVisible()) { + return this.conversationController.remoteActiveTurnId(); + } + const activeTurnMessage = this.generalChatPageState.activeTurnMessage; + if (activeTurnMessage.turnId && activeTurnMessage.turnId.length > 0) { + return activeTurnMessage.turnId; + } + const activePrefix = 'active-'; + if (activeTurnMessage.id.indexOf(activePrefix) === 0) { + return activeTurnMessage.id.slice(activePrefix.length); + } + return ''; + } + + hasRemoteBindingForResume(): boolean { + if (this.remotePageState.controlTargetType === 'account_device') { + return this.remotePageState.accountUserId.trim().length > 0 && + this.remotePageState.controlTargetDeviceId.trim().length > 0 && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Idle && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Disconnected; + } + return this.remotePageState.remoteUrl.trim().length > 0 && + this.remotePageState.userId.trim().length > 0 && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Idle && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Parsing && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Pairing && + (this.remotePageState.connectionState as ConnectionState) !== ConnectionState.Disconnected; + } + + async reconnectActiveRemote(): Promise { + if (this.remotePageState.controlTargetType !== 'account_device') { + await this.connect(true); + return; + } + const targetId = this.remotePageState.controlTargetDeviceId; + const device = (await this.settingsController.listCloudAccountDevices()) + .find((item: CloudAccountDevice): boolean => item.deviceId === targetId); + if (!device) { + throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); + } + await this.settingsController.selectCloudAccountDevice(device); + } + + hasRemoteBindingForCodeHome(): boolean { + return this.remotePageState.remoteUrl.trim().length > 0 && + this.remotePageState.userId.trim().length > 0 && + ((this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected || + (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Reconnecting || + (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Pairing || + (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Parsing); + } + +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets new file mode 100644 index 0000000000..c2d87b233f --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -0,0 +1,803 @@ +import { + ChatMessage, + RemoteModelCatalog, + RemotePermissionMode, + RemoteQuestionAnswerPayload, + RemoteSession, + SelectedImageAttachment, + SessionSummary, + WorkspaceInfo +} from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { ClipboardService } from '../../services/ClipboardService'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { ImagePickerService } from '../../services/ImagePickerService'; +import { + GeneralChatConfigSnapshot, + GeneralChatConfigStore +} from '../../services/general-chat/GeneralChatConfigStore'; +import { GeneralChatBootstrapController } from '../../services/general-chat/GeneralChatBootstrapController'; +import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; +import { GeneralChatController } from '../../services/general-chat/GeneralChatController'; +import { GeneralChatDraftController } from '../../services/general-chat/GeneralChatDraftController'; +import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; +import { GeneralChatStreamLifecycleController } from '../../services/general-chat/GeneralChatStreamLifecycleController'; +import { + GeneralChatSendResult, + GeneralChatStreamCallbacks +} from '../../services/general-chat/GeneralChatPort'; +import { MobileIdentityStore } from '../../services/MobileIdentityStore'; +import { CloudAccountClient, CloudAccountDevice } from '../../services/CloudAccountClient'; +import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; +import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; +import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; +import { RemoteChatPollingLifecycleController, RemoteChatPollingSnapshot } from '../../services/RemoteChatPollingLifecycleController'; +import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; +import { FilePreviewController } from '../viewmodel/FilePreviewController'; +import { SettingsController } from '../viewmodel/SettingsController'; +import { ConversationController } from '../viewmodel/ConversationController'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteModelController } from '../../services/RemoteModelController'; +import { RemotePairingPolicy } from '../../services/RemotePairingPolicy'; +import { RemoteSessionController } from '../../services/RemoteSessionController'; +import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { RemoteWorkspaceRepository } from '../../services/RemoteWorkspaceRepository'; +import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; +import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; +import { RemoteToolActionController } from '../../services/RemoteToolActionController'; +import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; +import { QrScanService } from '../../services/QrScanService'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { VoiceInputLifecycleController } from '../../services/VoiceInputLifecycleController'; +import { VoiceInputService } from '../../services/VoiceInputService'; +import { ConversationIntent } from '../actions/ConversationIntent'; +import { AppRootHostPort } from '../host/AppRootHostAdapter'; +import { AppRootPresentationActions } from '../actions/AppRootPresentationActions'; +import { + AppNavigationBackAction, + AppRoute, + AppRouteContract, + ConversationSource +} from '../navigation/AppRouteContract'; +import { AppShellState } from '../state/AppShellState'; +import { AppShellViewModel } from '../viewmodel/AppShellViewModel'; +import { RemoteActivityViewModel } from '../viewmodel/RemoteActivityViewModel'; +import { + RemoteConnectionController +} from '../viewmodel/RemoteConnectionController'; +import { ConversationIntentDispatcher } from '../actions/ConversationIntentDispatcher'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { ConversationViewModel } from '../viewmodel/ConversationViewModel'; +import { FilePreviewState } from '../state/FilePreviewState'; +import { FilePreviewRequest } from '../../model/FilePreviewTarget'; +import { RemoteWorkspaceViewModel } from '../viewmodel/RemoteWorkspaceViewModel'; +import { RemoteSessionViewModel } from '../viewmodel/RemoteSessionViewModel'; +import { GeneralChatConversationViewModel } from '../viewmodel/GeneralChatConversationViewModel'; +import { ModelProviderGeneralChatAdapter } from '../../services/general-chat/ModelProviderGeneralChatAdapter'; + +export enum ConnectionState { + Idle = 'idle', + Parsing = 'parsing', + Pairing = 'pairing', + Connected = 'connected', + Reconnecting = 'reconnecting', + Failed = 'failed', + Disconnected = 'disconnected' +} + +const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; +const GENERAL_CHAT_DRAFT_SAVE_DELAY_MS: number = 250; + +export abstract class AppRootRuntimeComposition { + readonly host: AppRootHostPort; + + constructor(host: AppRootHostPort) { + this.host = host; + } + + abstract applyDiscoveredWorkspaceSessions(all: RemoteSession[]): void; + abstract closeAppSidebar(): void; + abstract connect(autoReconnect?: boolean, accountPassword?: string): Promise; + abstract currentActiveTurnId(): string; + abstract disconnect(clearPairing: boolean): Promise; + abstract enterCodeEntry(): void; + abstract enterCompactLayout(): void; + abstract exitActiveChat(): void; + abstract failRemoteConnection(err: Object): void; + abstract handleNavigationBack(route: AppRoute): boolean; + abstract hasRemoteBindingForResume(): boolean; + abstract isRemoteConversationContext(sessionId: string): boolean; + abstract mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[]; + abstract openAddConnectionFromSettings(): void; + abstract openAppSidebar(): void; + abstract openRemoteControlSettings(): void; + abstract pickImages(): Promise; + abstract reconnect(): Promise; + abstract reconnectActiveRemote(): Promise; + abstract selectAssistant(path: string): Promise; + abstract selectWorkspace(path: string): Promise; + abstract showVoiceInputError(message: string): void; + abstract stopVoiceInput(showStatus: boolean): Promise; + abstract switchCompactConversationSource(source: ConversationSource): Promise; + abstract switchWideConversationSource(source: ConversationSource): Promise; + abstract toggleVoiceInput(): Promise; + + readonly sessionManager: RemoteSessionManager = new RemoteSessionManager(); + readonly workspaceRepository: RemoteWorkspaceRepository = + new RemoteWorkspaceRepository(this.sessionManager); + readonly workspaceCoordinator: RemoteWorkspaceCoordinator = + new RemoteWorkspaceCoordinator(this.workspaceRepository); + readonly remoteResumeGate: AsyncLifecycleGate = new AsyncLifecycleGate(); + readonly remoteConnectionGate: AsyncLifecycleGate = new AsyncLifecycleGate(); + readonly filePreviewState: FilePreviewState = new FilePreviewState(); + readonly identityStore: MobileIdentityStore = new MobileIdentityStore(); + readonly clipboardService: ClipboardService = new ClipboardService(); + readonly qrScanService: QrScanService = new QrScanService(); + readonly imagePickerService: ImagePickerService = new ImagePickerService(); + readonly remotePairingPolicy: RemotePairingPolicy = new RemotePairingPolicy(); + readonly remoteConnectionCoordinator: RemoteConnectionCoordinator = + new RemoteConnectionCoordinator( + this.sessionManager, + this.identityStore, + this.remotePairingPolicy, + this.remoteConnectionGate + ); + readonly generalChatPageState: GeneralChatPageState = new GeneralChatPageState(); + readonly remotePageState: RemotePageState = new RemotePageState(); + readonly remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); + readonly generalChatConfigStore: GeneralChatConfigStore = new GeneralChatConfigStore(); + readonly generalChatController: GeneralChatController = + GeneralChatController.createDefault(this.generalChatConfigStore); + readonly generalChatDraftController: GeneralChatDraftController = + new GeneralChatDraftController( + this.generalChatController, + GENERAL_CHAT_DRAFT_SAVE_DELAY_MS, + (err: Error) => { + RemoteLogger.warn(`general chat draft operation failed: ${ConnectionErrorPolicy.errorText(err)}`); + } + ); + readonly generalChatDraftLifecycleController: GeneralChatDraftLifecycleController = + new GeneralChatDraftLifecycleController( + this.generalChatDraftController, + GENERAL_CHAT_HOME_DRAFT_ID, + (): string => this.conversationController.visibleGeneralChatDraftId() + ); + readonly chatTimelineStore: ConversationViewModel = new ConversationViewModel(); + readonly generalChatCommandController: GeneralChatCommandController = + new GeneralChatCommandController( + this.generalChatController, + { + onSessions: (sessions: RemoteSession[]) => { + this.generalChatPageState.setSessions(sessions); + }, + onSessionPrepared: (sessionId: string) => { + this.conversationController.resetGeneralTimeline(sessionId); + this.remoteModelController.clearCatalog(); + }, + onActiveSession: (session: SessionSummary) => { + this.generalChatPageState.setActiveSession(session); + }, + onMessagesLoaded: (messages: ChatMessage[]) => { + this.chatTimelineStore.setPersistedMessages(messages); + this.conversationController.syncGeneralTimeline(); + }, + onClearComposer: () => { + this.generalChatPageState.clearComposer(); + }, + onChatInput: (text: string) => { + this.generalChatPageState.setChatInput(text); + }, + onStatusText: (statusText: string) => { + this.generalChatPageState.setStatus(statusText); + }, + onBusy: (isBusy: boolean) => { + this.generalChatPageState.setBusy(isBusy); + }, + onToast: (statusText: string) => { + this.conversationController.showHomeToast(statusText); + } + } + ); + readonly generalChatBootstrapController: GeneralChatBootstrapController = + new GeneralChatBootstrapController( + this.generalChatConfigStore, + this.generalChatCommandController, + this.generalChatDraftLifecycleController, + { + onConfigRestored: (snapshot: GeneralChatConfigSnapshot) => { + this.settingsController.apply(snapshot); + }, + onHomeDraftRestored: (text: string) => { + this.generalChatPageState.setChatInput(text); + }, + onStatusText: (statusText: string) => { + this.generalChatPageState.setStatus(statusText); + } + } + ); + readonly voiceInputService: VoiceInputService = new VoiceInputService(); + readonly remoteActivityLifecycleController: RemoteActivityLifecycleController = + new RemoteActivityLifecycleController(() => { + this.remoteActivityViewModel.checkConnectionHealth(); + }); + readonly remoteActivityViewModel: RemoteActivityViewModel = + new RemoteActivityViewModel( + this.remoteActivityLifecycleController, + this.remoteConnectionCoordinator, + this.remoteResumeGate, + { + isConnected: (): boolean => (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected, + isBusy: (): boolean => this.remotePageState.isBusy, + hasRemoteBinding: (): boolean => this.hasRemoteBindingForResume(), + isRemoteChat: (): boolean => this.appShellViewModel.isRoute(AppRoute.RemoteChat), + activeSession: (): SessionSummary => this.remotePageState.activeSession, + onConnectionState: (state: string): void => this.remotePageState.setConnectionState(state as ConnectionState), + onStatus: (status: string): void => this.remotePageState.setStatusText(status), + onConnectionError: async (err: Object): Promise => this.settingsController.handleRemoteConnectionError(err), + onStopHeartbeat: (): void => this.remoteActivityViewModel.stopHeartbeat(), + onStartPolling: (): void => this.conversationController.startRemotePolling(), + onStopPolling: (): void => this.remoteChatPollingLifecycleController.stop(), + onPoll: async (): Promise => { + await this.remoteChatPollingLifecycleController.pollNow(); + }, + onReconnect: async (): Promise => { + await this.reconnectActiveRemote(); + }, + onRestoreSession: async (session: SessionSummary): Promise => { + this.conversationController.applyRemoteActiveSession(session); + await this.conversationController.loadRemoteMessages(); + } + } + ); + readonly generalChatStreamLifecycleController: GeneralChatStreamLifecycleController = + new GeneralChatStreamLifecycleController(); + readonly remoteWorkspaceViewModel: RemoteWorkspaceViewModel = + new RemoteWorkspaceViewModel( + this.remotePageState, + this.workspaceCoordinator, + { + isRemoteAvailable: (): boolean => this.remoteConnectionController.ensureAvailable(), + isBusy: (): boolean => this.remotePageState.isBusy, + onBusy: (isBusy: boolean): void => { + this.remotePageState.setBusy(isBusy); + }, + onStatus: (statusText: string): void => { + this.remotePageState.setStatusText(statusText); + }, + onWorkspaceSelected: (workspace: WorkspaceInfo): void => { + this.remoteConnectionController.applyWorkspace(workspace); + this.remoteSessionController.clearSessions(); + }, + onSessionsDiscovered: (sessions: RemoteSession[]): void => { + this.applyDiscoveredWorkspaceSessions(sessions); + }, + onRefreshSessions: async (): Promise => { + await this.remoteSessionViewModel.refreshSessions(); + }, + onConnectionFailure: (error: Object): void => { + this.failRemoteConnection(error); + } + } + ); + remoteWorkspaceSessions: RemoteSession[] = []; + readonly appShellViewModel: AppShellViewModel = new AppShellViewModel(); + readonly appShellState: AppShellState = this.appShellViewModel.state; + readonly voiceInputLifecycleController: VoiceInputLifecycleController = + new VoiceInputLifecycleController( + this.voiceInputService, + { + currentInputText: (): string => this.conversationController.visibleChatInput(), + currentStatusText: (): string => this.conversationController.visibleStatusText(), + onInputText: (routeId: string, text: string) => { + this.conversationController.setChatInput(routeId as AppRoute, text); + }, + onListening: (routeId: string, isListening: boolean) => { + this.conversationController.setVoiceListening(routeId as AppRoute, isListening); + }, + onStatusText: (statusText: string) => { + this.conversationController.setVisibleStatusText(statusText); + }, + onError: (message: string) => { + this.showVoiceInputError(message); + } + } + ); + readonly remoteSessionController: RemoteSessionController = + new RemoteSessionController( + this.sessionManager, + 8, + { + onSessions: (sessions: RemoteSession[], hasMore: boolean) => { + const extras = this.remoteWorkspaceSessions.filter((item: RemoteSession) => { + return item.workspacePath !== this.remotePageState.workspacePath; + }); + this.remotePageState.setSessions(this.mergeSessions(sessions, extras), hasMore); + }, + onActiveSession: (session: SessionSummary) => { + this.conversationController.applyRemoteActiveSession(session); + }, + onStatusText: (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + onBusy: (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + }, + onLoading: (isLoading: boolean) => { + this.remotePageState.setLoading(isLoading); + }, + onSessionError: (errorText: string) => { + this.remotePageState.setError(errorText); + }, + onReconnecting: () => { + this.remotePageState.setConnectionState(ConnectionState.Reconnecting); + }, + onConnected: () => { + this.remotePageState.setConnectionState(ConnectionState.Connected); + }, + onConnectionFailed: (err: Object) => { + this.failRemoteConnection(err); + }, + onStartHeartbeat: () => { + this.remoteActivityViewModel.startHeartbeat(); + } + } + ); + readonly remoteChatCommandController: RemoteChatCommandController = + new RemoteChatCommandController( + this.sessionManager, + { + onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { + this.chatTimelineStore.setPersistedMessages(messages); + this.remotePageState.setHasMoreMessages(hasMoreMessages); + this.conversationController.syncRemoteTimeline(); + }, + onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { + this.conversationController.updateKnownMessageCount(pollVersion, knownMessageCount); + }, + onSendSucceeded: (turnId: string, pendingActiveId: string) => { + if (turnId.length > 0) { + this.chatTimelineStore.setLocalActiveTurn(turnId); + this.conversationController.syncRemoteTimeline(); + } else if (pendingActiveId.length > 0) { + this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); + this.conversationController.syncRemoteTimeline(); + } + this.remoteChatPollingLifecycleController.nudge(); + }, + onSendFailed: ( + rawText: string, + images: SelectedImageAttachment[], + localMessageId: string, + pendingActiveId: string + ) => { + this.remotePageState.setChatInput(rawText); + this.remotePageState.setSelectedImages(images); + this.chatTimelineStore.markOptimisticMessageFailed(localMessageId); + if (pendingActiveId.length > 0) { + this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); + } + this.conversationController.syncRemoteTimeline(); + }, + onActiveSession: (session: SessionSummary) => { + this.conversationController.applyRemoteActiveSession(session); + }, + onSessionTitleChanged: (sessionId: string, title: string) => { + this.remoteSessionController.updateSessionTitle(sessionId, title); + }, + onStatusText: (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + onBusy: (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + }, + onPollRequested: () => { + this.remoteChatPollingLifecycleController.pollNow(); + } + } + ); + readonly remoteFileDownloadController: RemoteFileDownloadController = + new RemoteFileDownloadController( + this.sessionManager, + (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string) => { + this.remotePageState.setDownloadStatus(downloadingFilePath, downloadedFilePath, fileDownloadStatus); + }, + () => { + this.remotePageState.clearDownloadingFilePath(); + }, + (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + } + ); + readonly filePreviewController: FilePreviewController = + new FilePreviewController( + this.sessionManager, + this.filePreviewState, + { + remoteAvailable: (): boolean => RemoteUiState.canUseRemote((this.remotePageState.connectionState as ConnectionState)), + activeSession: (): SessionSummary => this.remotePageState.conversation.activeSession, + workspacePath: (): string => this.remotePageState.workspacePath, + openExternalLink: async (reference: string): Promise => + this.host.openExternalLink ? await this.host.openExternalLink(reference) : false, + onGeneralStatus: (statusText: string): void => this.generalChatPageState.setStatus(statusText), + onRemoteStatus: (statusText: string): void => this.remotePageState.setStatusText(statusText) + } + ); + readonly remoteToolActionController: RemoteToolActionController = + new RemoteToolActionController( + this.sessionManager, + (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + }, + () => { + this.remoteChatPollingLifecycleController.pollNow(); + } + ); + readonly remoteChatPollingLifecycleController: RemoteChatPollingLifecycleController = + new RemoteChatPollingLifecycleController( + this.sessionManager, + { + canPoll: (sessionId: string) => { + return this.remotePageState.activeSession.sessionId === sessionId && + this.isRemoteConversationContext(sessionId) && + this.remoteConnectionController.ensureAvailable(); + }, + onSnapshot: (snapshot: RemoteChatPollingSnapshot) => { + this.conversationController.applyRemoteSnapshot(snapshot); + }, + onError: (error: Object) => { + this.remotePageState.setStatusText(ConnectionErrorPolicy.errorText(error)); + } + } + ); + readonly remoteModelController: RemoteModelController = + new RemoteModelController( + this.sessionManager, + this.identityStore, + (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { + this.conversationController.updateKnownModelCatalogVersion(knownModelCatalogVersion); + this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); + }, + (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { + this.conversationController.updateKnownModelCatalogVersion(knownModelCatalogVersion); + this.chatTimelineStore.setModelCatalog(modelCatalog, selectedModelId); + this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); + }, + (statusText: string) => { + this.remotePageState.setStatusText(statusText); + }, + (isBusy: boolean) => { + this.remotePageState.setBusy(isBusy); + } + ); + readonly remoteSessionViewModel: RemoteSessionViewModel = + new RemoteSessionViewModel( + this.remotePageState, + this.remoteSessionController, + this.remoteChatCommandController, + this.remoteModelController, + this.remoteFileDownloadController, + { + remoteAvailable: (): boolean => this.remoteConnectionController.ensureAvailable(), + isConnected: (): boolean => (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected, + isBusy: (): boolean => this.remotePageState.isBusy, + onBusy: (busy: boolean): void => this.remotePageState.setBusy(busy), + onRouteChat: (sessionId: string): void => this.conversationController.routeCreatedRemoteSession(sessionId), + onRouteHome: (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), + onStopPolling: (): void => this.remoteChatPollingLifecycleController.stop(), + onStartPolling: (): void => this.conversationController.startRemotePolling(), + onResetTimeline: (sessionId: string): void => this.conversationController.resetRemoteTimeline(sessionId), + onClearRemoteFiles: (): void => this.remoteFileDownloadController.clear(), + onKnownStateReset: (): void => this.conversationController.resetKnownRemoteState(), + onLoadModelCatalog: async (sessionId: string): Promise => { + await this.conversationController.loadRemoteModelCatalog(sessionId); + }, + onLoadActiveMessages: async (): Promise => { + await this.conversationController.loadRemoteMessages(); + }, + onRefreshSessions: async (): Promise => { + await this.remoteSessionController.refresh( + this.remotePageState.sessionQuery, + this.remotePageState.sessionFilter, + this.remoteConnectionController.ensureAvailable(), + (this.remotePageState.connectionState as ConnectionState) === ConnectionState.Connected + ); + }, + onSelectWorkspace: async (path: string): Promise => { + await this.selectWorkspace(path); + } + } + ); + readonly generalChatConversationViewModel: GeneralChatConversationViewModel = + new GeneralChatConversationViewModel( + this.generalChatPageState, + this.generalChatCommandController, + this.generalChatDraftLifecycleController, + this.generalChatStreamLifecycleController, + this.chatTimelineStore, + { + isVisible: (sessionId: string): boolean => this.generalChatPageState.activeSession.sessionId === sessionId && + this.appShellViewModel.isGeneralChatVisible(), + currentActiveTurnId: (): string => this.currentActiveTurnId(), + latestUserMessageText: (): string => this.conversationController.latestUserMessageText(), + syncTimeline: (): void => this.conversationController.syncGeneralTimeline(), + refreshSessions: (): void => this.generalChatCommandController.refreshSessions() + } + ); + readonly remoteConnectionController: RemoteConnectionController = + new RemoteConnectionController( + this.remotePageState, + this.identityStore, + this.remotePairingPolicy, + this.remoteConnectionCoordinator, + this.remoteSessionController, + this.remoteModelController, + this.remoteFileDownloadController, + this.clipboardService, + this.qrScanService, + (sessionId: string): void => this.conversationController.resetRemoteTimeline(sessionId), + (): void => this.conversationController.resetKnownRemoteState(), + (): void => this.remoteActivityViewModel.startHeartbeat(), + (): void => this.remoteActivityViewModel.stopHeartbeat(), + (): void => this.remoteChatPollingLifecycleController.stop(), + async (): Promise => { + await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); + }, + (route: AppRoute): void => this.appShellViewModel.replaceRouteWithoutAnimation(route), + (): void => this.appShellState.setConnectSheetVisible(false), + (): void => this.appShellState.setConnectSheetVisible(true) + ); + readonly settingsController: SettingsController = + new SettingsController( + this.generalChatConfigStore, + this.generalChatPageState, + { + probeConfiguration: async (apiUrl: string, apiKey: string, modelName: string): Promise => { + await ModelProviderGeneralChatAdapter.probeConfiguration(apiUrl, apiKey, modelName); + } + }, + { + client: new CloudAccountClient(), + sessionStore: new CloudAccountSessionStore(), + sessionManager: this.sessionManager, + remoteState: this.remotePageState, + hooks: { + deviceId: (): string => this.remoteConnectionController.getDeviceId(), + remoteAvailable: (): boolean => this.remoteConnectionController.ensureAvailable(), + invalidatePreview: (): void => this.filePreviewController.invalidate(), + invalidateRemoteActivity: (): void => this.remoteActivityViewModel.invalidate(), + invalidateRemoteConnection: (): void => this.remoteConnectionCoordinator.invalidate(), + stopPolling: (): void => this.remoteChatPollingLifecycleController.stop(), + stopHeartbeat: (): void => this.remoteActivityViewModel.stopHeartbeat(), + startHeartbeat: (): void => this.remoteActivityViewModel.startHeartbeat(), + resetTimeline: (): void => this.conversationController.resetRemoteTimeline(''), + resetKnownRemoteState: (): void => this.conversationController.resetKnownRemoteState(), + closeSettings: (): void => this.appShellState.setSettingsVisible(false), + closeConnectSheet: (): void => this.appShellState.setConnectSheetVisible(false), + navigateRemoteHome: (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), + loadRecentWorkspaces: async (): Promise => { + await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); + } + } + } + ); + readonly conversationController: ConversationController = + new ConversationController( + this.generalChatPageState, + this.remotePageState, + this.remoteCreateState, + { currentRoute: (): AppRoute => this.appShellViewModel.currentRoute() }, + { + timeline: this.chatTimelineStore, + chat: this.remoteChatCommandController, + polling: this.remoteChatPollingLifecycleController, + models: this.remoteModelController, + files: this.remoteFileDownloadController, + tools: this.remoteToolActionController, + connection: this.remoteConnectionController, + imagePicker: this.imagePickerService, + clipboard: this.clipboardService, + sessions: this.remoteSessionViewModel, + sessionManager: this.sessionManager, + workspace: this.workspaceCoordinator, + settings: this.settingsController, + appShell: this.appShellViewModel, + filePreview: this.filePreviewController, + generalCommands: this.generalChatCommandController, + generalConversation: this.generalChatConversationViewModel, + generalDrafts: this.generalChatDraftLifecycleController, + hooks: { + isConversationContext: (sessionId: string): boolean => this.isRemoteConversationContext(sessionId), + isFilePreviewVisible: (): boolean => this.filePreviewState.visible, + stopVoiceInput: async (): Promise => this.stopVoiceInput(false), + showToast: (message: string): boolean => this.host.showToast(message, 2600), + selectAssistantWorkspace: async (path: string): Promise => { + await this.selectAssistant(path); + } + } + } + ); + readonly conversationIntentDispatcher: ConversationIntentDispatcher = + new ConversationIntentDispatcher({ + openSidebar: (): void => this.openAppSidebar(), + back: (): void => this.exitActiveChat(), + newRemoteSession: (): void => { this.conversationController.createRemoteSession('code'); }, + newGeneralSession: (): void => this.conversationController.prepareNewGeneralChat(), + activeGeneralSession: (): RemoteSession => this.conversationController.activeGeneralChatAsRemoteSession(), + activeGeneralSessionId: (): string => this.generalChatPageState.activeSession.sessionId, + isGeneralBusy: (): boolean => this.generalChatPageState.isBusy, + isPinned: (sessionId: string): boolean => this.generalChatPageState.pinnedSessionId() === sessionId, + pin: async (session: RemoteSession, pinned: boolean, busy: boolean): Promise => { + await this.generalChatCommandController.pinSession(session, pinned, busy); + }, + archive: async (session: RemoteSession): Promise => { + await this.conversationController.archiveHomeSession(session, true); + }, + delete: async (session: RemoteSession): Promise => { + await this.conversationController.deleteHomeSession(session); + this.conversationController.prepareNewGeneralChat(); + }, + showToast: (text: string): void => this.conversationController.showHomeToast(text), + uploadedFileCount: (): number => this.conversationController.activeGeneralUploadedFileCount(), + stop: async (): Promise => { await this.conversationController.stopVisibleTask(); }, + loadOlder: async (): Promise => { await this.conversationController.loadOlderRemoteMessages(); }, + approve: async (id: string, input?: Object): Promise => { + await this.conversationController.approveRemoteTool(id, input); + }, + reject: async (id: string): Promise => { await this.conversationController.rejectRemoteTool(id); }, + cancel: async (id: string): Promise => { await this.conversationController.cancelRemoteTool(id); }, + answer: async (id: string, answers: RemoteQuestionAnswerPayload): Promise => { + await this.conversationController.answerRemoteQuestion(id, answers); + }, + rename: async (title: string): Promise => { + await this.conversationController.renameVisibleSession(title); + }, + copy: async (text: string): Promise => { await this.conversationController.copyRemoteMessage(text); }, + retry: async (text: string): Promise => { await this.conversationController.retryVisibleMessage(text); }, + selectModel: async (id: string): Promise => { await this.conversationController.selectVisibleModel(id); }, + pickImages: async (): Promise => { await this.pickImages(); }, + removeImage: (id: string): void => this.conversationController.removeSelectedImage(this.appShellViewModel.currentRoute(), id), + openFilePreview: (route: AppRoute, request: FilePreviewRequest): void => + this.filePreviewController.open(route, request), + downloadFile: (path: string): void => this.conversationController.downloadVisibleFile(path), + send: async (): Promise => { await this.conversationController.sendVisibleMessage(); }, + voiceInput: async (): Promise => { await this.toggleVoiceInput(); }, + inputChanged: (route: AppRoute, value: string): void => + this.conversationController.onVisibleChatInputChange(route, value) + }); + readonly presentationActions: AppRootPresentationActions = { + onNavigationBack: (route: AppRoute): boolean => this.handleNavigationBack(route), + onConversationIntent: (route: AppRoute, intent: ConversationIntent): void => + this.conversationIntentDispatcher.dispatch(route, intent), + onCloseSidebar: (): void => this.closeAppSidebar(), + onWideConversationSource: (source: ConversationSource): void => { + this.switchWideConversationSource(source); + }, + onCompactConversationSource: (source: ConversationSource): void => { + this.switchCompactConversationSource(source); + }, + onCompactLayoutEntered: (): void => this.enterCompactLayout(), + onLayoutModeChanged: (wideLayout: boolean): void => this.appShellState.setWideLayout(wideLayout), + onRemoteHome: { + openSidebar: (): void => this.openAppSidebar(), + connectWorkspace: (): void => this.enterCodeEntry(), + addConnection: (): void => this.appShellState.setConnectSheetVisible(true), + openSettings: (): void => this.openRemoteControlSettings(), + refresh: (): void => { this.remoteSessionViewModel.refreshSessions(); }, + showWorkspaces: (): void => { this.remoteWorkspaceViewModel.toggleRecentWorkspaces(); }, + showAssistants: (): void => { this.remoteWorkspaceViewModel.toggleAssistants(); }, + selectWorkspace: (path: string): void => { this.selectWorkspace(path); }, + selectAssistant: (path: string): void => { this.selectAssistant(path); }, + cancelWorkspace: (): void => this.remotePageState.setWorkspacePickerVisible(false), + cancelAssistant: (): void => this.remotePageState.setAssistantPickerVisible(false), + queryChanged: (query: string): void => this.remotePageState.setQuery(query), + search: (): void => { this.remoteSessionViewModel.refreshSessions(); }, + loadMore: (): void => { this.remoteSessionViewModel.loadMoreSessions(); }, + reconnect: (): void => { this.reconnect(); }, + disconnect: (): void => { this.disconnect(false); }, + clearPairing: (): void => { this.disconnect(true); }, + create: (agentType: string): void => { this.conversationController.createRemoteSession(agentType); }, + createInPlace: (agentType: string): void => { + this.conversationController.createRemoteSession(agentType, true); + }, + createAssistant: (): void => { this.conversationController.openRemoteCreateSession(); }, + createInWorkspace: (path: string, agentType: string): void => { + this.conversationController.createRemoteSessionInWorkspace(path, agentType); + }, + createInWorkspaceInPlace: (path: string, agentType: string): void => { + this.conversationController.createRemoteSessionInWorkspace(path, agentType, true); + }, + openSession: (session: RemoteSession): void => this.conversationController.openHomeSession(session), + openSessionInPlace: (session: RemoteSession): void => this.conversationController.openHomeSession(session, true), + deleteSession: (session: RemoteSession): void => { this.conversationController.deleteHomeSession(session); } + }, + onRemoteCreate: { + back: (): void => this.conversationController.closeRemoteCreateSession(), + toggleDevices: (): void => { this.conversationController.toggleRemoteCreateDevices(); }, + toggleWorkspaces: (): void => { this.conversationController.toggleRemoteCreateWorkspaces(); }, + selectDevice: (device: CloudAccountDevice): void => { + this.conversationController.selectRemoteCreateDevice(device); + }, + selectWorkspace: (path: string): void => this.conversationController.selectRemoteCreateWorkspace(path), + draftChanged: (value: string): void => this.remoteCreateState.setDraft(value), + voiceInput: async (): Promise => { await this.toggleVoiceInput(); }, + selectModel: (modelId: string): void => this.remoteCreateState.setSelectedModelId(modelId), + send: (): void => { this.conversationController.submitRemoteCreateSession(); } + }, + onSidebar: { + close: (): void => this.closeAppSidebar(), + newChat: (): void => { this.closeAppSidebar(); this.conversationController.prepareNewGeneralChat(); }, + enterCode: (): void => { this.closeAppSidebar(); this.enterCodeEntry(); }, + settings: (): void => { this.closeAppSidebar(); this.appShellState.openSettings('general'); }, + openAccount: (): void => { + this.closeAppSidebar(); + setTimeout(() => this.appShellState.openSettings('account'), 180); + }, + openSession: (session: RemoteSession): void => { + this.closeAppSidebar(); + this.conversationController.openHomeSession(session); + }, + archive: (session: RemoteSession, archived: boolean): void => { + this.conversationController.archiveHomeSession(session, archived); + }, + exportSession: (session: RemoteSession): void => { this.conversationController.exportHomeSession(session); }, + deleteSession: (session: RemoteSession): void => { this.conversationController.deleteHomeSession(session); } + }, + onSettings: { + close: (): void => this.appShellState.leaveSettings(), + addConnection: (): void => this.openAddConnectionFromSettings(), + disconnect: (): void => { this.disconnect(false); }, + reconnect: (): void => { this.reconnect(); }, + openAccount: (): void => { this.appShellState.openSettings('account'); }, + cloudLogin: (relayUrl: string, username: string, password: string): Promise => + this.settingsController.loginCloudAccount(relayUrl, username, password), + cloudSync: (): Promise => this.settingsController.syncCloudAccount(), + cloudLogout: (): Promise => this.settingsController.logoutCloudAccount(), + cloudListDevices: (): Promise => this.settingsController.listCloudAccountDevices(), + getPermissionMode: (): Promise => this.settingsController.getRemotePermissionMode(), + setPermissionMode: (mode: RemotePermissionMode): Promise => + this.settingsController.setRemotePermissionMode(mode), + testGeneral: async (url: string, key: string, model: string, clear: boolean): Promise => + this.settingsController.test(url, key, model, clear), + saveGeneral: async (url: string, key: string, model: string, clear: boolean): Promise => + this.settingsController.save(url, key, model, clear) + }, + onConnect: { + back: (): void => this.appShellState.setConnectSheetVisible(false), + connect: (password?: string): void => { + // Keep connection progress on the same RemoteHome surface as the connected state. + this.appShellState.setConnectSheetVisible(false); + this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + this.connect(false, password || ''); + }, + clearPairing: (): void => { this.appShellState.setConnectSheetVisible(false); this.disconnect(true); }, + urlChanged: (url: string): void => { this.remotePageState.setRemoteUrl(url); this.remoteConnectionController.projectRemoteUrl(url); }, + userChanged: (user: string): void => this.remotePageState.setUserId(user), + detected: (url: string): boolean => this.remoteConnectionController.handleDetectedUrl(url), + inputVisible: (visible: boolean): void => this.remotePageState.setRemoteUrlInputVisible(visible), + paste: (): void => { this.remoteConnectionController.paste(); }, + scan: (): void => { this.remoteConnectionController.scan(this.host.context()); }, + cloudListDevices: (): Promise => this.settingsController.listCloudAccountDevices(), + cloudSelectDevice: (device: CloudAccountDevice): Promise => + this.settingsController.selectCloudAccountDevice(device) + }, + onFilePreview: { + close: (): void => this.filePreviewController.close(), + refresh: (): void => this.filePreviewController.refresh(), + download: (path: string): void => this.conversationController.downloadVisibleFile(path), + openLink: (reference: string, label: string): void => this.filePreviewController.openLink(reference, label) + }, + generalStatus: (): string => this.conversationController.generalChatHomeStatusText() + }; + readonly navigationStack: NavPathStack = this.appShellViewModel.navigationStack; + + +} + diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets deleted file mode 100644 index b4b50b623b..0000000000 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppRootRuntime.ets +++ /dev/null @@ -1,2608 +0,0 @@ -import { - ChatMessage, - RecentWorkspaceEntry, - RemoteModelCatalog, - RemotePermissionMode, - RemoteImageContext, - RemoteQuestionAnswerPayload, - RemoteSession, - SelectedImageAttachment, - SessionSummary, - WorkspaceInfo -} from '../../model/RemoteModels'; -import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { ClipboardService } from '../../services/ClipboardService'; -import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; -import { ChatTimelineState } from '../../services/ChatTimelineStore'; -import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; -import { ImagePickerService } from '../../services/ImagePickerService'; -import { - GeneralChatConfigSnapshot, - GeneralChatConfigStore, - GeneralChatConfigUpdate, - GeneralChatConfigValidator, - GeneralChatModelSelectionPolicy -} from '../../services/general-chat/GeneralChatConfigStore'; -import { GeneralChatBootstrapController } from '../../services/general-chat/GeneralChatBootstrapController'; -import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; -import { GeneralChatController } from '../../services/general-chat/GeneralChatController'; -import { GeneralChatDraftController } from '../../services/general-chat/GeneralChatDraftController'; -import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; -import { GeneralChatCloudConfigPolicy } from '../../services/general-chat/GeneralChatCloudConfigPolicy'; -import { GeneralChatStreamLifecycleController } from '../../services/general-chat/GeneralChatStreamLifecycleController'; -import { - GeneralChatServiceState, - GeneralChatServiceStatus -} from '../../services/general-chat/GeneralChatServiceState'; -import { - GeneralChatSendResult, - GeneralChatStreamCallbacks -} from '../../services/general-chat/GeneralChatPort'; -import { MobileIdentityStore } from '../../services/MobileIdentityStore'; -import { CloudAccountClient, CloudAccountDevice, CloudAccountRequestError, CloudAccountSession } from '../../services/CloudAccountClient'; -import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; -import { Encoding } from '../../services/Encoding'; -import { AppRootRouteState } from '../../services/AppRootRouteState'; -import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; -import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; -import { - RemoteChatPollingCursor, - RemoteChatPollingLifecycleController, - RemoteChatPollingSnapshot -} from '../../services/RemoteChatPollingLifecycleController'; -import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; -import { FileReferenceKind, FileTargetResolver } from '../../services/FileTargetResolver'; -import { RemoteFilePreviewController } from '../../services/RemoteFilePreviewController'; -import { RemoteLogger } from '../../services/RemoteLogger'; -import { RemoteModelController } from '../../services/RemoteModelController'; -import { RemotePairingPolicy } from '../../services/RemotePairingPolicy'; -import { RemoteSessionController } from '../../services/RemoteSessionController'; -import { RemoteSessionManager } from '../../services/RemoteSessionManager'; -import { RemoteWorkspaceRepository } from '../../services/RemoteWorkspaceRepository'; -import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; -import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; -import { RemoteToolActionController } from '../../services/RemoteToolActionController'; -import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; -import { QrScanService } from '../../services/QrScanService'; -import { RemoteUiState } from '../../services/RemoteUiState'; -import { - VoiceInputLifecycleController, - VoiceInputRouteSnapshot -} from '../../services/VoiceInputLifecycleController'; -import { VoiceInputService } from '../../services/VoiceInputService'; -import { ConversationIntent } from '../components/ConversationIntent'; -import { AppRootHostPort } from '../host/AppRootHostAdapter'; -import { - AppRootPresentation, - AppRootPresentationActions, - ConnectPresentationActions, - FilePreviewPresentationActions, - RemoteCreatePresentationActions, - RemoteHomePresentationActions, - SettingsPresentationActions, - SidebarPresentationActions -} from '../components/AppRootPresentation'; -import { - AppNavigationBackAction, - AppRoute, - AppRouteContract, - ConversationSource -} from '../navigation/AppRouteContract'; -import { AppShellState } from './AppShellState'; -import { AppShellViewModel } from './AppShellViewModel'; -import { - RemoteActivityViewModel, - RemoteActivityViewModelHooks -} from './RemoteActivityViewModel'; -import { - RemoteConnectionViewModel -} from './RemoteConnectionViewModel'; -import { - ConversationIntentDispatcher, - ConversationIntentDispatcherHooks -} from './ConversationIntentDispatcher'; -import { GeneralChatPageState } from './GeneralChatPageState'; -import { RemotePageState } from './RemotePageState'; -import { RemoteCreateSessionState } from './RemoteCreateSessionState'; -import { ConversationViewModel } from './ConversationViewModel'; -import { FilePreviewState } from './FilePreviewState'; -import { FilePreviewRequest, FilePreviewTargetContext } from './FilePreviewTarget'; -import { - RemoteWorkspaceViewModel, - RemoteWorkspaceViewModelHooks -} from './RemoteWorkspaceViewModel'; -import { - RemoteSessionViewModel, - RemoteSessionViewModelHooks -} from './RemoteSessionViewModel'; -import { - GeneralChatConversationViewModel, - GeneralChatConversationViewModelHooks -} from './GeneralChatConversationViewModel'; -import { ModelProviderGeneralChatAdapter } from '../../services/general-chat/ModelProviderGeneralChatAdapter'; - -enum ConnectionState { - Idle = 'idle', - Parsing = 'parsing', - Pairing = 'pairing', - Connected = 'connected', - Reconnecting = 'reconnecting', - Failed = 'failed', - Disconnected = 'disconnected' -} - -const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; -const GENERAL_CHAT_DRAFT_SAVE_DELAY_MS: number = 250; - -export class AppRootRuntime { - readonly host: AppRootHostPort; - - constructor(host: AppRootHostPort) { - this.host = host; - } - - readonly sessionManager: RemoteSessionManager = new RemoteSessionManager(); - readonly workspaceRepository: RemoteWorkspaceRepository = - new RemoteWorkspaceRepository(this.sessionManager); - readonly workspaceCoordinator: RemoteWorkspaceCoordinator = - new RemoteWorkspaceCoordinator(this.workspaceRepository); - readonly remoteResumeGate: AsyncLifecycleGate = new AsyncLifecycleGate(); - readonly remoteConnectionGate: AsyncLifecycleGate = new AsyncLifecycleGate(); - readonly filePreviewState: FilePreviewState = new FilePreviewState(); - private controlTargetEpoch: number = 1; - private remoteCreateWorkspaceLoadVersion: number = 0; - readonly identityStore: MobileIdentityStore = new MobileIdentityStore(); - readonly cloudAccountClient: CloudAccountClient = new CloudAccountClient(); - readonly cloudAccountSessionStore: CloudAccountSessionStore = new CloudAccountSessionStore(); - private cloudAccountSession?: CloudAccountSession; - private cloudAccountRelayUrl: string = ''; - readonly clipboardService: ClipboardService = new ClipboardService(); - readonly qrScanService: QrScanService = new QrScanService(); - readonly imagePickerService: ImagePickerService = new ImagePickerService(); - readonly remotePairingPolicy: RemotePairingPolicy = new RemotePairingPolicy(); - readonly remoteConnectionCoordinator: RemoteConnectionCoordinator = - new RemoteConnectionCoordinator( - this.sessionManager, - this.identityStore, - this.remotePairingPolicy, - this.remoteConnectionGate - ); - readonly generalChatConfigStore: GeneralChatConfigStore = new GeneralChatConfigStore(); - readonly generalChatController: GeneralChatController = - GeneralChatController.createDefault(this.generalChatConfigStore); - readonly generalChatDraftController: GeneralChatDraftController = - new GeneralChatDraftController( - this.generalChatController, - GENERAL_CHAT_DRAFT_SAVE_DELAY_MS, - (err: Error) => { - RemoteLogger.warn(`general chat draft operation failed: ${ConnectionErrorPolicy.errorText(err)}`); - } - ); - readonly generalChatDraftLifecycleController: GeneralChatDraftLifecycleController = - new GeneralChatDraftLifecycleController( - this.generalChatDraftController, - GENERAL_CHAT_HOME_DRAFT_ID, - (): string => this.visibleGeneralChatDraftId() - ); - readonly chatTimelineStore: ConversationViewModel = new ConversationViewModel(); - readonly generalChatCommandController: GeneralChatCommandController = - new GeneralChatCommandController( - this.generalChatController, - { - onSessions: (sessions: RemoteSession[]) => { - this.generalChatPageState.setSessions(sessions); - }, - onSessionPrepared: (sessionId: string) => { - this.resetGeneralChatTimeline(sessionId); - this.remoteModelController.clearCatalog(); - }, - onActiveSession: (session: SessionSummary) => { - this.generalChatPageState.setActiveSession(session); - }, - onMessagesLoaded: (messages: ChatMessage[]) => { - this.chatTimelineStore.setPersistedMessages(messages); - this.syncGeneralChatTimelineFromStore(); - }, - onClearComposer: () => { - this.generalChatPageState.clearComposer(); - }, - onChatInput: (text: string) => { - this.generalChatPageState.setChatInput(text); - }, - onStatusText: (statusText: string) => { - this.generalChatPageState.setStatus(statusText); - }, - onBusy: (isBusy: boolean) => { - this.generalChatPageState.setBusy(isBusy); - }, - onToast: (statusText: string) => { - this.showHomeToast(statusText); - } - } - ); - readonly generalChatBootstrapController: GeneralChatBootstrapController = - new GeneralChatBootstrapController( - this.generalChatConfigStore, - this.generalChatCommandController, - this.generalChatDraftLifecycleController, - { - onConfigRestored: (snapshot: GeneralChatConfigSnapshot) => { - this.applyGeneralChatConfig(snapshot); - }, - onHomeDraftRestored: (text: string) => { - this.generalChatPageState.setChatInput(text); - }, - onStatusText: (statusText: string) => { - this.generalChatPageState.setStatus(statusText); - } - } - ); - readonly voiceInputService: VoiceInputService = new VoiceInputService(); - readonly remoteActivityLifecycleController: RemoteActivityLifecycleController = - new RemoteActivityLifecycleController(() => { - this.checkConnectionHealth(); - }); - readonly remoteActivityViewModel: RemoteActivityViewModel = - new RemoteActivityViewModel( - this.remoteActivityLifecycleController, - this.remoteConnectionCoordinator, - this.remoteResumeGate, - new RemoteActivityViewModelHooks( - (): boolean => this.connectionState === ConnectionState.Connected, - (): boolean => this.isBusy, - (): boolean => this.hasRemoteBindingForResume(), - (): boolean => this.isRoute(AppRoute.RemoteChat), - (): SessionSummary => this.activeSession, - (state: string): void => this.setRemoteConnectionState(state as ConnectionState), - (status: string): void => this.setRemoteStatusText(status), - async (err: Object): Promise => this.handleRemoteConnectionError(err), - (): void => this.stopHeartbeat(), - (): void => this.startPolling(), - (): void => this.stopPolling(), - async (): Promise => { - await this.pollActiveSession(); - }, - async (): Promise => { - await this.reconnectActiveRemote(); - }, - async (session: SessionSummary): Promise => { - this.applyRemoteActiveSession(session); - await this.loadActiveMessages(); - } - ) - ); - isSyncingAfterTurn: boolean = false; - knownPollVersion: number = 0; - knownModelCatalogVersion: number = 0; - knownRemoteMessageCount: number = 0; - readonly generalChatStreamLifecycleController: GeneralChatStreamLifecycleController = - new GeneralChatStreamLifecycleController(); - readonly generalChatPageState: GeneralChatPageState = new GeneralChatPageState(); - readonly remotePageState: RemotePageState = new RemotePageState(); - readonly remoteCreateState: RemoteCreateSessionState = new RemoteCreateSessionState(); - readonly remoteWorkspaceViewModel: RemoteWorkspaceViewModel = - new RemoteWorkspaceViewModel( - this.remotePageState, - this.workspaceCoordinator, - new RemoteWorkspaceViewModelHooks( - (): boolean => this.ensureRemoteAvailable(), - (): boolean => this.isBusy, - (isBusy: boolean): void => { - this.setRemoteBusy(isBusy); - }, - (statusText: string): void => { - this.setRemoteStatusText(statusText); - }, - (workspace: WorkspaceInfo): void => { - this.applyWorkspace(workspace); - this.remoteSessionController.clearSessions(); - }, - (sessions: RemoteSession[]): void => { - this.applyDiscoveredWorkspaceSessions(sessions); - }, - async (): Promise => { - await this.refreshSessions(); - }, - (error: Object): void => { - this.failRemoteConnection(error); - } - ) - ); - remoteWorkspaceSessions: RemoteSession[] = []; - readonly appShellViewModel: AppShellViewModel = new AppShellViewModel(); - readonly appShellState: AppShellState = this.appShellViewModel.state; - readonly voiceInputLifecycleController: VoiceInputLifecycleController = - new VoiceInputLifecycleController( - this.voiceInputService, - { - currentInputText: (): string => this.visibleChatInput(), - currentStatusText: (): string => this.visibleStatusText(), - onInputText: (routeId: string, text: string) => { - this.setChatInputForRoute(routeId as AppRoute, text); - }, - onListening: (routeId: string, isListening: boolean) => { - this.setVoiceListeningForRoute(routeId as AppRoute, isListening); - }, - onStatusText: (statusText: string) => { - this.setVisibleStatusText(statusText); - }, - onError: (message: string) => { - this.showVoiceInputError(message); - } - } - ); - readonly remoteSessionController: RemoteSessionController = - new RemoteSessionController( - this.sessionManager, - 8, - { - onSessions: (sessions: RemoteSession[], hasMore: boolean) => { - const extras = this.remoteWorkspaceSessions.filter((item: RemoteSession) => { - return item.workspacePath !== this.workspacePath; - }); - this.remotePageState.setSessions(this.mergeSessions(sessions, extras), hasMore); - }, - onActiveSession: (session: SessionSummary) => { - this.applyRemoteActiveSession(session); - }, - onStatusText: (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - onBusy: (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - }, - onLoading: (isLoading: boolean) => { - this.remotePageState.setLoading(isLoading); - }, - onSessionError: (errorText: string) => { - this.remotePageState.setError(errorText); - }, - onReconnecting: () => { - this.setRemoteConnectionState(ConnectionState.Reconnecting); - }, - onConnected: () => { - this.setRemoteConnectionState(ConnectionState.Connected); - }, - onConnectionFailed: (err: Object) => { - this.failRemoteConnection(err); - }, - onStartHeartbeat: () => { - this.startHeartbeat(); - } - } - ); - readonly remoteChatCommandController: RemoteChatCommandController = - new RemoteChatCommandController( - this.sessionManager, - { - onMessagesLoaded: (messages: ChatMessage[], hasMoreMessages: boolean) => { - this.chatTimelineStore.setPersistedMessages(messages); - this.remotePageState.setHasMoreMessages(hasMoreMessages); - this.syncChatTimelineFromStore(); - }, - onMessageCountKnown: (pollVersion: number, knownMessageCount: number) => { - this.knownRemoteMessageCount = knownMessageCount; - this.updateChatPollingCursor(pollVersion, knownMessageCount); - }, - onSendSucceeded: (turnId: string, pendingActiveId: string) => { - if (turnId.length > 0) { - this.chatTimelineStore.setLocalActiveTurn(turnId); - this.syncChatTimelineFromStore(); - } else if (pendingActiveId.length > 0) { - this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); - this.syncChatTimelineFromStore(); - } - this.nudgeChatPolling(); - }, - onSendFailed: ( - rawText: string, - images: SelectedImageAttachment[], - localMessageId: string, - pendingActiveId: string - ) => { - this.remotePageState.setChatInput(rawText); - this.remotePageState.setSelectedImages(images); - this.chatTimelineStore.markOptimisticMessageFailed(localMessageId); - if (pendingActiveId.length > 0) { - this.chatTimelineStore.clearPendingActiveTurn(pendingActiveId); - } - this.syncChatTimelineFromStore(); - }, - onActiveSession: (session: SessionSummary) => { - this.applyRemoteActiveSession(session); - }, - onSessionTitleChanged: (sessionId: string, title: string) => { - this.remoteSessionController.updateSessionTitle(sessionId, title); - }, - onStatusText: (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - onBusy: (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - }, - onPollRequested: () => { - this.pollActiveSession(); - } - } - ); - readonly remoteFileDownloadController: RemoteFileDownloadController = - new RemoteFileDownloadController( - this.sessionManager, - (downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string) => { - this.remotePageState.setDownloadStatus(downloadingFilePath, downloadedFilePath, fileDownloadStatus); - }, - () => { - this.remotePageState.clearDownloadingFilePath(); - }, - (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - } - ); - readonly remoteFilePreviewController: RemoteFilePreviewController = - new RemoteFilePreviewController( - this.sessionManager, - this.filePreviewState, - (): boolean => RemoteUiState.canUseRemote(this.connectionState), - (): number => this.controlTargetEpoch - ); - readonly remoteToolActionController: RemoteToolActionController = - new RemoteToolActionController( - this.sessionManager, - (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - }, - () => { - this.pollActiveSession(); - } - ); - readonly remoteChatPollingLifecycleController: RemoteChatPollingLifecycleController = - new RemoteChatPollingLifecycleController( - this.sessionManager, - { - canPoll: (sessionId: string) => { - return this.activeSession.sessionId === sessionId && - this.isRemoteConversationContext(sessionId) && - this.ensureRemoteAvailable(); - }, - onSnapshot: (snapshot: RemoteChatPollingSnapshot) => { - this.applyChatSessionSnapshot(snapshot); - }, - onError: (error: Object) => { - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(error)); - } - } - ); - readonly remoteModelController: RemoteModelController = - new RemoteModelController( - this.sessionManager, - this.identityStore, - (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { - this.knownModelCatalogVersion = knownModelCatalogVersion; - this.remoteChatPollingLifecycleController.updateKnownModelCatalogVersion(knownModelCatalogVersion); - this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); - }, - (modelCatalog: RemoteModelCatalog, selectedModelId: string, knownModelCatalogVersion: number) => { - this.knownModelCatalogVersion = knownModelCatalogVersion; - this.remoteChatPollingLifecycleController.updateKnownModelCatalogVersion(knownModelCatalogVersion); - this.chatTimelineStore.setModelCatalog(modelCatalog, selectedModelId); - this.remotePageState.setModelCatalog(modelCatalog, selectedModelId); - }, - (statusText: string) => { - this.setRemoteStatusText(statusText); - }, - (isBusy: boolean) => { - this.setRemoteBusy(isBusy); - } - ); - readonly remoteSessionViewModel: RemoteSessionViewModel = - new RemoteSessionViewModel( - this.remotePageState, - this.remoteSessionController, - this.remoteChatCommandController, - this.remoteModelController, - this.remoteFileDownloadController, - new RemoteSessionViewModelHooks( - (): boolean => this.ensureRemoteAvailable(), - (): boolean => this.connectionState === ConnectionState.Connected, - (): boolean => this.isBusy, - (busy: boolean): void => this.setRemoteBusy(busy), - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId), - (): void => this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome), - (): void => this.stopPolling(), - (): void => this.startPolling(), - (sessionId: string): void => this.resetChatTimeline(sessionId), - (): void => this.remoteFileDownloadController.clear(), - (): void => { - this.knownPollVersion = 0; - this.knownModelCatalogVersion = 0; - this.knownRemoteMessageCount = 0; - }, - async (sessionId: string): Promise => { - await this.remoteModelController.loadCatalog( - sessionId, - this.ensureRemoteAvailable(), - (activeSessionId: string): boolean => { - return this.isRemoteConversationContext(activeSessionId); - } - ); - }, - async (): Promise => { - const sessionId = this.activeSession.sessionId || ''; - await this.remoteChatCommandController.loadMessages( - sessionId, - (activeSessionId: string): boolean => { - return this.isRemoteConversationContext(activeSessionId); - } - ); - }, - async (): Promise => { - await this.remoteSessionController.refresh( - this.remotePageState.sessionQuery, - this.remotePageState.sessionFilter, - this.ensureRemoteAvailable(), - this.connectionState === ConnectionState.Connected - ); - }, - async (path: string): Promise => { - await this.selectWorkspace(path); - } - ) - ); - readonly generalChatConversationViewModel: GeneralChatConversationViewModel = - new GeneralChatConversationViewModel( - this.generalChatPageState, - this.generalChatCommandController, - this.generalChatDraftLifecycleController, - this.generalChatStreamLifecycleController, - this.chatTimelineStore, - new GeneralChatConversationViewModelHooks( - (sessionId: string): boolean => this.generalChatPageState.activeSession.sessionId === sessionId && - this.isGeneralChatVisible(), - (): string => this.currentActiveTurnId(), - (): string => this.latestUserMessageText(), - (): void => this.syncGeneralChatTimelineFromStore(), - (): void => this.generalChatCommandController.refreshSessions() - ) - ); - readonly remoteConnectionViewModel: RemoteConnectionViewModel = - new RemoteConnectionViewModel( - this.remotePageState, - this.identityStore, - this.remotePairingPolicy, - this.remoteConnectionCoordinator, - this.remoteSessionController, - this.remoteModelController, - this.remoteFileDownloadController, - this.clipboardService, - this.qrScanService, - (sessionId: string): void => this.resetChatTimeline(sessionId), - (): void => { - this.knownPollVersion = 0; - this.knownModelCatalogVersion = 0; - this.knownRemoteMessageCount = 0; - }, - (): void => this.startHeartbeat(), - (): void => this.stopHeartbeat(), - (): void => this.stopPolling(), - async (): Promise => { - await this.loadRecentWorkspacesInBackground(); - }, - (route: AppRoute): void => this.appShellViewModel.replaceRouteWithoutAnimation(route), - (): void => this.appShellState.setConnectSheetVisible(false), - (): void => this.appShellState.setConnectSheetVisible(true) - ); - readonly conversationIntentDispatcher: ConversationIntentDispatcher = - new ConversationIntentDispatcher(new ConversationIntentDispatcherHooks( - (): void => this.openAppSidebar(), - (): void => this.closeActiveChat(), - (): void => { this.createSession('code'); }, - (): void => this.prepareNewGeneralChat(), - (): RemoteSession => this.activeGeneralChatAsRemoteSession(), - (): string => this.generalChatPageState.activeSession.sessionId, - (): boolean => this.generalChatPageState.isBusy, - (sessionId: string): boolean => this.generalChatPageState.pinnedSessionId() === sessionId, - async (session: RemoteSession, pinned: boolean, busy: boolean): Promise => { - await this.generalChatCommandController.pinSession(session, pinned, busy); - }, - async (session: RemoteSession): Promise => { await this.archiveHomeSession(session, true); }, - async (session: RemoteSession): Promise => { - await this.deleteHomeSession(session); - this.prepareNewGeneralChat(); - }, - (text: string): void => this.showHomeToast(text), - (): number => this.activeGeneralUploadedFileCount(), - async (): Promise => { await this.stopActiveChatTask(); }, - async (): Promise => { await this.loadOlderMessages(); }, - async (id: string, input?: Object): Promise => { await this.approveTool(id, input); }, - async (id: string): Promise => { await this.rejectTool(id); }, - async (id: string): Promise => { await this.cancelTool(id); }, - async (id: string, answers: RemoteQuestionAnswerPayload): Promise => { - await this.answerQuestion(id, answers); - }, - async (title: string): Promise => { await this.renameVisibleSession(title); }, - async (text: string): Promise => { await this.copyMessage(text); }, - async (text: string): Promise => { await this.retryVisibleMessage(text); }, - async (id: string): Promise => { await this.selectModel(id); }, - async (): Promise => { await this.pickImages(); }, - (id: string): void => this.removeSelectedImage(id), - (route: AppRoute, request: FilePreviewRequest): void => this.openFilePreview(route, request), - (path: string): void => this.downloadVisibleFile(path), - async (): Promise => { await this.sendVisibleChatMessage(); }, - async (): Promise => { await this.toggleVoiceInput(); }, - (route: AppRoute, value: string): void => this.onVisibleChatInputChange(route, value) - )); - readonly presentationActions: AppRootPresentationActions = new AppRootPresentationActions( - (route: AppRoute): boolean => this.handleNavigationBack(route), - (route: AppRoute, intent: ConversationIntent): void => this.handleConversationIntent(route, intent), - (): void => this.closeAppSidebar(), - (source: ConversationSource): void => { this.switchWideConversationSource(source); }, - (source: ConversationSource): void => { this.switchCompactConversationSource(source); }, - (): void => this.enterCompactLayout(), - new RemoteHomePresentationActions( - (): void => this.openAppSidebar(), (): void => this.enterCodeEntry(), (): void => this.openAddConnection(), - (): void => this.openRemoteControlSettings(), (): void => { this.refreshSessions(); }, - (): void => { this.showRecentWorkspaces(); }, (): void => { this.showAssistants(); }, - (path: string): void => { this.selectWorkspace(path); }, (path: string): void => { this.selectAssistant(path); }, - (): void => this.remotePageState.setWorkspacePickerVisible(false), - (): void => this.remotePageState.setAssistantPickerVisible(false), - (query: string): void => this.remotePageState.setQuery(query), (): void => { this.refreshSessions(); }, - (): void => { this.loadMoreSessions(); }, (): void => { this.reconnect(); }, - (): void => { this.disconnect(false); }, (): void => { this.disconnect(true); }, - (agentType: string): void => { this.createSession(agentType); }, - (agentType: string): void => { this.createSession(agentType, true); }, - (): void => { this.openRemoteCreateSession(); }, - (path: string, agentType: string): void => { this.createSessionInWorkspace(path, agentType); }, - (path: string, agentType: string): void => { this.createSessionInWorkspace(path, agentType, true); }, - (session: RemoteSession): void => this.openHomeSession(session), - (session: RemoteSession): void => this.openHomeSessionInPlace(session), - (session: RemoteSession): void => { this.deleteHomeSession(session); } - ), - new RemoteCreatePresentationActions( - (): void => this.closeRemoteCreateSession(), - (): void => { this.toggleRemoteCreateDevices(); }, - (): void => { this.toggleRemoteCreateWorkspaces(); }, - (device: CloudAccountDevice): void => { this.selectRemoteCreateDevice(device); }, - (path: string): void => this.selectRemoteCreateWorkspace(path), - (value: string): void => this.remoteCreateState.setDraft(value), - async (): Promise => { await this.toggleVoiceInput(); }, - (modelId: string): void => this.selectRemoteCreateModel(modelId), - (): void => { this.submitRemoteCreateSession(); } - ), - new SidebarPresentationActions( - (): void => this.closeAppSidebar(), - (): void => { this.closeAppSidebar(); this.prepareNewGeneralChat(); }, - (): void => { this.closeAppSidebar(); this.enterCodeEntry(); }, - (): void => { this.closeAppSidebar(); this.appShellState.openSettings('general'); }, - (): void => { - this.closeAppSidebar(); - setTimeout(() => this.appShellState.openSettings('account'), 180); - }, - (session: RemoteSession): void => { this.closeAppSidebar(); this.openHomeSession(session); }, - (session: RemoteSession, archived: boolean): void => { this.archiveHomeSession(session, archived); }, - (session: RemoteSession): void => { this.exportHomeSession(session); }, - (session: RemoteSession): void => { this.deleteHomeSession(session); } - ), - new SettingsPresentationActions( - (): void => this.appShellState.leaveSettings(), - (): void => this.openAddConnectionFromSettings(), (): void => { this.disconnect(false); }, - (): void => { this.reconnect(); }, - (): void => { - this.appShellState.openSettings('account'); - }, - (relayUrl: string, username: string, password: string): Promise => - this.loginCloudAccount(relayUrl, username, password), - (): Promise => this.syncCloudAccount(), - (): Promise => this.logoutCloudAccount(), - (): Promise => this.listCloudAccountDevices(), - (): Promise => this.getRemotePermissionMode(), - (mode: RemotePermissionMode): Promise => this.setRemotePermissionMode(mode), - async (url: string, key: string, model: string, clear: boolean): Promise => - this.testGeneralChatConfig(url, key, model, clear), - async (url: string, key: string, model: string, clear: boolean): Promise => - this.saveGeneralChatConfig(url, key, model, clear) - ), - new ConnectPresentationActions( - (): void => this.appShellState.setConnectSheetVisible(false), - (password?: string): void => { - // Keep connection progress on the same RemoteHome surface as the - // connected state instead of showing a separate loading sheet. - this.appShellState.setConnectSheetVisible(false); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - this.connect(false, password || ''); - }, - (): void => { this.appShellState.setConnectSheetVisible(false); this.disconnect(true); }, - (url: string): void => { this.setRemoteUrl(url); this.applyRemotePairingProjection(url); }, - (user: string): void => this.setRemoteUserId(user), - (url: string): boolean => this.handleDetectedRemoteUrl(url), - (visible: boolean): void => this.setRemoteUrlInputVisible(visible), - (): void => { this.pasteRemoteUrl(); }, (): void => { this.scanRemoteUrl(); }, - (): Promise => this.listCloudAccountDevices(), - (device: CloudAccountDevice): Promise => this.selectCloudAccountDevice(device) - ), - new FilePreviewPresentationActions( - (): void => this.closeFilePreview(), - (): void => this.refreshFilePreview(), - (path: string): void => this.downloadVisibleFile(path), - (reference: string, label: string): void => this.openFilePreviewLink(reference, label) - ), - (): string => this.generalChatHomeStatusText() - ); - readonly navigationStack: NavPathStack = this.appShellViewModel.navigationStack; - - - get remoteUrl(): string { return this.remotePageState.remoteUrl; } - get userId(): string { return this.remotePageState.userId; } - get authenticatedUserId(): string { return this.remotePageState.authenticatedUserId; } - get statusText(): string { return this.remotePageState.statusText; } - get connectionState(): ConnectionState { return this.remotePageState.connectionState as ConnectionState; } - get connectionFailureKind(): string { return this.remotePageState.connectionFailureKind; } - get isBusy(): boolean { return this.remotePageState.isBusy; } - get showRemoteUrlInput(): boolean { return this.remotePageState.showRemoteUrlInput; } - get workspaceName(): string { return this.remotePageState.workspaceName; } - get workspacePath(): string { return this.remotePageState.workspacePath; } - get workspaceBranch(): string { return this.remotePageState.workspaceBranch; } - get workspaceKind(): string { return this.remotePageState.workspaceKind; } - get assistantId(): string { return this.remotePageState.assistantId; } - get desktopName(): string { return this.remotePageState.desktopName; } - get desktopId(): string { return this.remotePageState.desktopId; } - get activeSession(): SessionSummary { return this.remotePageState.activeSession; } - get messages(): ChatMessage[] { return this.remotePageState.persistedMessages; } - get pendingMessages(): ChatMessage[] { return this.remotePageState.optimisticMessages; } - get activeTurnMessage(): ChatMessage { return this.remotePageState.activeTurnMessage; } - get timelineItems(): ChatTimelineItem[] { return this.remotePageState.timelineItems; } - get hasMoreMessages(): boolean { return this.remotePageState.hasMoreMessages; } - - async aboutToAppear(): Promise { - this.syncRemotePageSummary(); - await this.generalChatBootstrapController.restore(this.host.context()); - await this.cloudAccountSessionStore.init(this.host.context()); - await this.restoreCloudAccountSession(); - await this.refreshGeneralChatModelCatalog(); - await this.restoreIdentity(); - } - - onPageShow(): void { - RemoteLogger.info(`page show state=${this.connectionState} route=${this.currentRoute()}`); - this.resumeRemoteActivity(); - } - - onPageHide(): void { - RemoteLogger.info(`page hide state=${this.connectionState} route=${this.currentRoute()}`); - this.remoteActivityViewModel.invalidate(); - this.remoteConnectionCoordinator.invalidate(); - this.setRemoteBusy(false); - this.persistVisibleGeneralChatDraft(); - } - - aboutToDisappear(): void { - this.remoteActivityViewModel.invalidate(); - this.remoteConnectionCoordinator.invalidate(); - this.setRemoteBusy(false); - this.persistVisibleGeneralChatDraft(); - this.stopGeneralChatStream(true, 'failed'); - this.generalChatDraftLifecycleController.cancel(); - this.remoteFileDownloadController.cancel(); - this.remoteFilePreviewController.close(); - this.voiceInputLifecycleController.cancel(`${this.currentRoute()}`, () => { - this.setAllVoiceListening(false); - }); - } - - currentRoute(): AppRoute { - return this.appShellViewModel.currentRoute(); - } - - isGeneralComposerRoute(route: AppRoute): boolean { - return AppRootRouteState.isGeneralComposerRoute(route); - } - - visibleChatInput(): string { - if (this.currentRoute() === AppRoute.RemoteCreate) { - return this.remoteCreateState.draft; - } - return AppRootRouteState.chatInput(this.currentRoute(), this.generalChatPageState, this.remotePageState); - } - - visibleSelectedImages(): SelectedImageAttachment[] { - return AppRootRouteState.selectedImages(this.currentRoute(), this.generalChatPageState, this.remotePageState); - } - - visibleVoiceListening(): boolean { - if (this.currentRoute() === AppRoute.RemoteCreate) { - return this.remoteCreateState.isVoiceListening; - } - return AppRootRouteState.voiceListening(this.currentRoute(), this.generalChatPageState, this.remotePageState); - } - - setChatInputForRoute(route: AppRoute, value: string): void { - if (route === AppRoute.RemoteCreate) { - this.remoteCreateState.setDraft(value); - return; - } - AppRootRouteState.setChatInput(route, value, this.generalChatPageState, this.remotePageState); - } - - setSelectedImagesForRoute(route: AppRoute, images: SelectedImageAttachment[]): void { - AppRootRouteState.setSelectedImages(route, images, this.generalChatPageState, this.remotePageState); - } - - addSelectedImagesForRoute(route: AppRoute, images: SelectedImageAttachment[]): void { - AppRootRouteState.addSelectedImages(route, images, this.generalChatPageState, this.remotePageState); - } - - removeSelectedImageForRoute(route: AppRoute, imageId: string): void { - AppRootRouteState.removeSelectedImage(route, imageId, this.generalChatPageState, this.remotePageState); - } - - clearComposerForRoute(route: AppRoute): void { - AppRootRouteState.clearComposer(route, this.generalChatPageState, this.remotePageState); - } - - setVoiceListeningForRoute(route: AppRoute, isVoiceListening: boolean): void { - if (route === AppRoute.RemoteCreate) { - this.remoteCreateState.isVoiceListening = isVoiceListening; - return; - } - AppRootRouteState.setVoiceListening( - route, - isVoiceListening, - this.generalChatPageState, - this.remotePageState - ); - } - - setAllVoiceListening(isVoiceListening: boolean): void { - this.generalChatPageState.setVoiceListening(isVoiceListening); - this.remotePageState.setVoiceListening(isVoiceListening); - } - - voiceInputSnapshot(route: AppRoute = this.currentRoute()): VoiceInputRouteSnapshot { - if (route === AppRoute.RemoteCreate) { - return { - routeId: `${route}`, - isListening: this.remoteCreateState.isVoiceListening, - isBusy: this.remoteCreateState.isSubmitting, - inputText: this.remoteCreateState.draft, - selectedImageCount: 0 - }; - } - return AppRootRouteState.snapshot( - route, - this.visibleChatBusy(), - this.generalChatPageState, - this.remotePageState - ); - } - - isRoute(route: AppRoute): boolean { - return this.appShellViewModel.isRoute(route); - } - - isGeneralChatVisible(): boolean { - return this.appShellViewModel.isGeneralChatVisible(); - } - - pushRoute(route: AppRoute, sessionId: string = ''): void { - this.appShellViewModel.pushRoute(route, sessionId); - } - - replaceRoute(route: AppRoute, sessionId: string = ''): void { - this.appShellViewModel.replaceRoute(route, sessionId); - } - - popRoute(fallback: AppRoute): void { - this.appShellViewModel.popRoute(fallback); - } - - private routeCreatedRemoteSession(sessionId: string): void { - this.closeFilePreview(); - if (this.isRoute(AppRoute.RemoteCreate)) { - this.appShellViewModel.replaceCurrentRoute(AppRoute.RemoteChat, sessionId); - return; - } - this.pushRoute(AppRoute.RemoteChat, sessionId); - } - - private routeRemoteSessionInPlace(_sessionId: string): void { - this.closeFilePreview(); - if (this.isRoute(AppRoute.RemoteHome) || this.isRoute(AppRoute.RemoteChat)) { - return; - } - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - } - - private isRemoteConversationContext(sessionId: string): boolean { - if (sessionId.length === 0 || this.activeSession.sessionId !== sessionId) { - return false; - } - return this.isRoute(AppRoute.RemoteChat) || this.isRoute(AppRoute.RemoteHome); - } - - handleNavigationBack(route: AppRoute): boolean { - if (this.filePreviewState.visible) { - this.closeFilePreview(); - return true; - } - const action = this.appShellViewModel.backAction(route); - if (action === AppNavigationBackAction.CloseSidebar) { - this.closeAppSidebar(); - return true; - } - if (action === AppNavigationBackAction.CloseActiveChat) { - this.closeActiveChat(); - return true; - } - if (action === AppNavigationBackAction.PopRemoteHome) { - this.popRoute(AppRoute.ChatHome); - return true; - } - return false; - } - - handleRootBack(): boolean { - if (!this.filePreviewState.visible) { - return false; - } - this.closeFilePreview(); - return true; - } - - - handleConversationIntent(route: AppRoute, intent: ConversationIntent): void { - this.conversationIntentDispatcher.dispatch(route, intent); - } - - - async saveGeneralChatConfig( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ): Promise { - const update: GeneralChatConfigUpdate = { - apiUrl, - apiKey, - modelName, - clearApiKey - }; - try { - const validationError = await this.validateGeneralChatConfig(update); - if (validationError.length > 0) { - return validationError; - } - if (!update.clearApiKey) { - const probeError = await this.probeGeneralChatConfig(update); - if (probeError.length > 0) { - return probeError; - } - } - const catalogBeforeSave = await this.generalChatConfigStore.modelCatalog(); - const snapshot = await this.generalChatConfigStore.save(update); - if (GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel(catalogBeforeSave)) { - await this.generalChatConfigStore.selectLocalModel(); - } - this.applyGeneralChatConfig(snapshot); - await this.refreshGeneralChatModelCatalog(); - return ''; - } catch (err) { - return ConnectionErrorPolicy.errorText(err); - } - } - - async testGeneralChatConfig( - apiUrl: string, - apiKey: string, - modelName: string, - clearApiKey: boolean - ): Promise { - const update: GeneralChatConfigUpdate = { - apiUrl, - apiKey, - modelName, - clearApiKey - }; - try { - const validationError = await this.validateGeneralChatConfig(update); - if (validationError.length > 0) { - return validationError; - } - if (update.clearApiKey) { - return RemoteI18n.t('settings.modelService.testNeedsKey'); - } - return await this.probeGeneralChatConfig(update); - } catch (err) { - return ConnectionErrorPolicy.errorText(err); - } - } - - private async validateGeneralChatConfig(update: GeneralChatConfigUpdate): Promise { - const snapshot = await this.generalChatConfigStore.snapshot(); - return GeneralChatConfigValidator.validate(update, snapshot.hasApiKey); - } - - private async probeGeneralChatConfig(update: GeneralChatConfigUpdate): Promise { - const apiKey = await this.effectiveGeneralChatApiKey(update); - if (apiKey.length === 0) { - return RemoteI18n.t('settings.modelService.apiKeyRequired'); - } - try { - await ModelProviderGeneralChatAdapter.probeConfiguration(update.apiUrl, apiKey, update.modelName); - return ''; - } catch (err) { - return ConnectionErrorPolicy.errorText(err); - } - } - - private async effectiveGeneralChatApiKey(update: GeneralChatConfigUpdate): Promise { - const directKey = update.apiKey.trim(); - if (directKey.length > 0) { - return directKey; - } - if (update.clearApiKey) { - return ''; - } - return (await this.generalChatConfigStore.accessToken()).trim(); - } - - applyGeneralChatConfig(snapshot: GeneralChatConfigSnapshot): void { - this.generalChatPageState.setConfiguration( - snapshot.apiUrl, - snapshot.modelName, - snapshot.hasApiKey, - GeneralChatServiceStatus.fromConfiguration(snapshot.apiUrl, snapshot.modelName, snapshot.hasApiKey) - ); - } - - private async refreshGeneralChatModelCatalog(): Promise { - const catalog = await this.generalChatConfigStore.modelCatalog(); - const selectedModelId = catalog.session_model_id || catalog.default_models.primary || ''; - this.generalChatPageState.setModelCatalog(catalog, selectedModelId); - const active = await this.generalChatConfigStore.activeSnapshot(); - this.generalChatPageState.setServiceState( - GeneralChatServiceStatus.fromConfiguration(active.apiUrl, active.modelName, active.hasApiKey) - ); - } - - async restoreIdentity(): Promise { - if (this.remotePageState.controlTargetType === 'account_device') { - return; - } - await this.remoteConnectionViewModel.restore(this.host.context()); - } - - async connect(autoReconnect: boolean = false, accountPassword: string = ''): Promise { - await this.remoteConnectionViewModel.connect(autoReconnect, accountPassword); - await this.persistDelegatedAccountSession(); - } - - private async persistDelegatedAccountSession(): Promise { - if (this.cloudAccountSession) { - return; - } - const delegated = this.sessionManager.delegatedAccountSession(); - if (!delegated) { - return; - } - this.cloudAccountSession = delegated.session; - this.cloudAccountRelayUrl = delegated.relayUrl; - await this.cloudAccountSessionStore.save({ - relayUrl: delegated.relayUrl, - username: delegated.session.userId, - token: delegated.session.token, - userId: delegated.session.userId, - masterKey: Encoding.bytesToBase64(delegated.session.masterKey) - }); - this.remotePageState.setAccountUserId(delegated.session.userId); - this.remotePageState.setAccountUsername(delegated.session.userId); - RemoteLogger.info('delegated account session persisted after room pairing'); - } - - async reconnect(): Promise { - if (this.remotePageState.controlTargetType === 'account_device') { - await this.restoreCloudTarget( - this.remotePageState.controlTargetDeviceId, - this.remotePageState.controlTargetDeviceName - ); - return; - } - await this.remoteConnectionViewModel.reconnect(); - } - - async disconnect(clearPairing: boolean): Promise { - this.invalidateFilePreviewTarget(); - await this.remoteConnectionViewModel.disconnect(clearPairing); - } - - async pasteRemoteUrl(): Promise { - await this.remoteConnectionViewModel.paste(); - } - - async scanRemoteUrl(): Promise { - await this.remoteConnectionViewModel.scan(this.host.context()); - } - - handleDetectedRemoteUrl(remoteUrl: string): boolean { - return this.remoteConnectionViewModel.handleDetectedUrl(remoteUrl); - } - - applyWorkspace(workspace: WorkspaceInfo): void { - this.remoteConnectionViewModel.applyWorkspace(workspace); - } - - applyRemotePairingProjection(remoteUrl: string): void { - this.remoteConnectionViewModel.projectRemoteUrl(remoteUrl); - } - - ensureRemoteAvailable(): boolean { - return this.remoteConnectionViewModel.ensureAvailable(); - } - - setRemoteConnectionState(connectionState: ConnectionState): void { - this.remotePageState.setConnectionState(connectionState); - } - - setRemoteUrl(remoteUrl: string): void { - this.remotePageState.setRemoteUrl(remoteUrl); - } - - setRemoteUserId(userId: string): void { - this.remotePageState.setUserId(userId); - } - - setRemoteAuthenticatedUserId(authenticatedUserId: string): void { - this.remotePageState.setAuthenticatedUserId(authenticatedUserId); - } - - setRemoteStatusText(statusText: string): void { - this.remotePageState.setStatusText(statusText); - } - - setRemoteConnectionFailureKind(connectionFailureKind: string): void { - this.remotePageState.setConnectionFailureKind(connectionFailureKind); - } - - setRemoteBusy(isBusy: boolean): void { - this.remotePageState.setBusy(isBusy); - } - - setRemoteUrlInputVisible(visible: boolean): void { - this.remotePageState.setRemoteUrlInputVisible(visible); - } - - syncRemotePageSummary(): void { - if (this.remotePageState.statusText.length === 0) { - this.remotePageState.setStatusText(RemoteI18n.t('status.waitingConnection')); - } - if (this.remotePageState.workspaceName.length === 0) { - this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); - } - } - - failRemoteConnection(err: Object): void { - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); - this.setRemoteConnectionState(ConnectionState.Failed); - this.stopHeartbeat(); - } - - async showRecentWorkspaces(): Promise { - await this.remoteWorkspaceViewModel.toggleRecentWorkspaces(); - } - - async showAssistants(): Promise { - await this.remoteWorkspaceViewModel.toggleAssistants(); - } - - async selectWorkspace(path: string): Promise { - this.closeFilePreview(); - await this.remoteWorkspaceViewModel.selectWorkspace(path); - } - - async selectAssistant(path: string): Promise { - this.closeFilePreview(); - await this.remoteWorkspaceViewModel.selectAssistant(path); - } - - async refreshSessions(): Promise { - await this.remoteSessionViewModel.refreshSessions(); - } - - async loadMoreSessions(): Promise { - await this.remoteSessionViewModel.loadMoreSessions(); - } - - setSessionFilter(filter: string): void { - this.remoteSessionViewModel.setFilter(filter); - } - - visibleChatBusy(): boolean { - return this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat) ? - this.generalChatPageState.isBusy : this.remotePageState.isBusy; - } - - visibleStatusText(): string { - return this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat) ? - this.generalChatPageState.statusText : this.remotePageState.statusText; - } - - setVisibleStatusText(statusText: string): void { - if (this.isRoute(AppRoute.ChatHome) || this.isRoute(AppRoute.GeneralChat)) { - this.generalChatPageState.setStatus(statusText); - return; - } - this.setRemoteStatusText(statusText); - } - - openAppSidebar(): void { - this.host.animate(230, () => { - this.appShellState.setSidebarVisible(true); - }); - } - - closeAppSidebar(): void { - this.host.animate(210, () => { - this.appShellState.setSidebarVisible(false); - }); - } - - enterCodeEntry(): void { - if (this.cloudAccountSession && this.remotePageState.accountUserId.trim().length > 0) { - this.appShellState.setConnectSheetVisible(true); - return; - } - if (RemoteUiState.canUseRemote(this.connectionState)) { - this.appShellState.setConnectSheetVisible(false); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - return; - } - this.appShellState.setConnectSheetVisible(true); - } - - async switchWideConversationSource(source: ConversationSource): Promise { - if (AppRouteContract.conversationSource(this.currentRoute()) === source) { - return; - } - if (this.visibleVoiceListening()) { - await this.stopVoiceInput(false); - } - if (source === ConversationSource.General) { - this.stopPolling(); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - return; - } - this.persistVisibleGeneralChatDraft(); - const activeRemoteSessionId = this.remotePageState.activeSession.sessionId || ''; - const target = AppRouteContract.routeForConversationSource( - source, - RemoteUiState.canUseRemote(this.connectionState), - activeRemoteSessionId - ); - const hasActiveRemoteConversation = target.name === AppRoute.RemoteChat; - this.appShellViewModel.replaceRouteWithoutAnimation( - hasActiveRemoteConversation ? AppRoute.RemoteHome : target.name - ); - if (hasActiveRemoteConversation) { - this.startPolling(); - await this.loadActiveMessages(); - } - } - - /** - * Compact counterpart of switchWideConversationSource. Switching source is a - * change of context, not a command to start something: it resumes the session - * the user was last in, and otherwise rests on the Remote landing surface - * rather than opening the create composer for them. - */ - async switchCompactConversationSource(source: ConversationSource): Promise { - this.closeAppSidebar(); - if (AppRouteContract.conversationSource(this.currentRoute()) === source) { - return; - } - if (this.visibleVoiceListening()) { - await this.stopVoiceInput(false); - } - if (source === ConversationSource.General) { - this.stopPolling(); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - return; - } - this.persistVisibleGeneralChatDraft(); - const activeRemoteSessionId = RemoteUiState.canUseRemote(this.connectionState) ? - (this.remotePageState.activeSession.sessionId || '') : ''; - if (activeRemoteSessionId.length === 0) { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - return; - } - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteChat, activeRemoteSessionId); - this.startPolling(); - await this.loadActiveMessages(); - } - - private enterCompactLayout(): void { - const sessionId = this.remotePageState.activeSession.sessionId || ''; - if (this.isRoute(AppRoute.RemoteHome) && sessionId.length > 0) { - this.appShellViewModel.pushRoute(AppRoute.RemoteChat, sessionId, false); - } - } - - openAddConnection(): void { - this.appShellState.setConnectSheetVisible(true); - } - - openRemoteControlSettings(): void { - setTimeout(() => { - this.appShellState.openSettings('remote'); - }, 180); - } - - openAddConnectionFromSettings(): void { - this.appShellState.setSettingsVisible(false); - setTimeout(() => { - this.openAddConnection(); - }, 220); - } - - async loginCloudAccount(relayUrl: string, username: string, password: string): Promise { - RemoteLogger.info('cloud account UI login requested'); - const session = await this.cloudAccountClient.login(relayUrl, username, password, this.identityStoreSnapshotInstallId()); - this.applyCloudAccountSession(session, relayUrl, username); - await this.cloudAccountSessionStore.save({ - relayUrl: relayUrl.trim(), username: username.trim(), token: session.token, userId: session.userId, - masterKey: Encoding.bytesToBase64(session.masterKey) - }); - await this.loadGeneralChatAccountModels(session, relayUrl); - RemoteLogger.info('cloud account credentials persisted, refreshing account devices'); - RemoteLogger.info(`cloud account login success user=${session.userId}`); - return session.userId; - } - - private async restoreCloudAccountSession(): Promise { - try { - const persisted = await this.cloudAccountSessionStore.load(); - if (!persisted) return; - const session: CloudAccountSession = { - token: persisted.token, - userId: persisted.userId, - masterKey: Encoding.base64ToBytes(persisted.masterKey) - }; - this.applyCloudAccountSession(session, persisted.relayUrl, persisted.username || session.userId); - await this.loadGeneralChatAccountModels(session, persisted.relayUrl); - } catch (err) { - RemoteLogger.warn(`cloud account restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); - await this.cloudAccountSessionStore.clear(); - } - } - - private async loadGeneralChatAccountModels(session: CloudAccountSession, relayUrl: string): Promise { - this.generalChatConfigStore.replaceAccountModels([]); - try { - const blob = await this.cloudAccountClient.fetchSettings(relayUrl, session); - if (!blob) { - this.generalChatConfigStore.replaceAccountModels([]); - await this.refreshGeneralChatModelCatalog(); - RemoteLogger.info('cloud model catalog is empty'); - return; - } - const models = GeneralChatCloudConfigPolicy.models(blob.plaintext); - this.generalChatConfigStore.replaceAccountModels(models); - await this.refreshGeneralChatModelCatalog(); - RemoteLogger.info(`cloud model catalog loaded count=${models.length} version=${blob.version}`); - } catch (err) { - await this.refreshGeneralChatModelCatalog(); - RemoteLogger.warn(`cloud model catalog load failed: ${err instanceof Error ? err.message : 'unknown error'}`); - } - } - - async syncCloudAccount(): Promise { - if (!this.cloudAccountSession || this.cloudAccountRelayUrl.length === 0) { - throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); - } - let bundles: Object[]; - try { - bundles = await this.cloudAccountClient.fetchSessions(this.cloudAccountRelayUrl, this.cloudAccountSession, 0); - } catch (err) { - if (err instanceof CloudAccountRequestError && err.statusCode === 401) { - await this.expireCloudAccountSession(); - throw new Error(RemoteI18n.t('remote.settings.accountExpired')); - } - throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.accountSyncFailed')); - } - await this.loadGeneralChatAccountModels(this.cloudAccountSession, this.cloudAccountRelayUrl); - RemoteLogger.info(`cloud account backup sync completed count=${bundles.length}`); - return String(bundles.length); - } - - protected applyCloudAccountSession(session: CloudAccountSession, relayUrl: string, username: string): void { - this.cloudAccountSession = session; - this.cloudAccountRelayUrl = relayUrl.trim(); - this.remotePageState.setAccountUserId(session.userId); - this.remotePageState.setAccountUsername(username.trim()); - } - - async logoutCloudAccount(): Promise { - this.invalidateFilePreviewTarget(); - if (this.remotePageState.controlTargetType === 'account_device') { - this.remoteActivityViewModel.invalidate(); - this.stopPolling(); - this.stopHeartbeat(); - this.sessionManager.reset(); - this.remotePageState.clearActiveSession(); - this.remotePageState.setSessions([], false); - this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); - this.remotePageState.setConnectionState(ConnectionState.Disconnected); - this.remotePageState.setAuthenticatedUserId(''); - } - this.cloudAccountSession = undefined; - this.cloudAccountRelayUrl = ''; - this.generalChatConfigStore.replaceAccountModels([]); - await this.refreshGeneralChatModelCatalog(); - await this.cloudAccountSessionStore.clear(); - this.remotePageState.setAccountUserId(''); - this.remotePageState.setAccountUsername(''); - this.remotePageState.clearControlTarget(); - RemoteLogger.info('cloud account logout success'); - } - - async listCloudAccountDevices(): Promise { - if (!this.cloudAccountSession || this.cloudAccountRelayUrl.length === 0) { - return []; - } - try { - return await this.cloudAccountClient.listDevices(this.cloudAccountRelayUrl, this.cloudAccountSession); - } catch (err) { - if (err instanceof CloudAccountRequestError && err.statusCode === 401) { - await this.expireCloudAccountSession(); - throw new Error(RemoteI18n.t('remote.settings.accountExpired')); - } - if (err instanceof CloudAccountRequestError && - (err.statusCode === 404 || err.statusCode === 503 || err.statusCode === 504)) { - throw new Error(RemoteI18n.t('remote.settings.deviceUnavailable')); - } - throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.deviceLoadFailed')); - } - } - - async getRemotePermissionMode(): Promise { - if (!this.ensureRemoteAvailable()) { - throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); - } - return this.sessionManager.getPermissionMode(); - } - - async setRemotePermissionMode(mode: RemotePermissionMode): Promise { - if (!this.ensureRemoteAvailable()) { - throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); - } - return this.sessionManager.setPermissionMode(mode); - } - - private async restoreCloudTarget(targetDeviceId: string, targetDeviceName: string): Promise { - const targetId = targetDeviceId.trim(); - if (targetId.length === 0) { - return; - } - try { - const devices = await this.listCloudAccountDevices(); - const target = devices.find((device: CloudAccountDevice): boolean => device.deviceId === targetId); - if (!target || !target.online) { - const targetName = target?.deviceName || targetDeviceName || targetId; - this.remotePageState.setControlTarget('account_device', targetId, targetName); - this.remotePageState.setDesktopIdentity(targetName, targetId); - this.remotePageState.setConnectionState(ConnectionState.Failed); - this.remotePageState.setStatusText(RemoteI18n.t('remote.settings.deviceUnavailable')); - return; - } - await this.selectCloudAccountDevice({ - deviceId: target.deviceId, - deviceName: target.deviceName || targetDeviceName || target.deviceId, - online: target.online, - lastSeenAt: target.lastSeenAt - }); - } catch (err) { - RemoteLogger.warn(`cloud target restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); - } - } - - private async expireCloudAccountSession(): Promise { - this.invalidateFilePreviewTarget(); - this.cloudAccountSession = undefined; - this.cloudAccountRelayUrl = ''; - await this.cloudAccountSessionStore.clear(); - this.remotePageState.setAccountUserId(''); - this.remotePageState.setAccountUsername(''); - if (this.remotePageState.controlTargetType === 'account_device') { - this.remoteActivityViewModel.invalidate(); - this.stopPolling(); - this.stopHeartbeat(); - this.sessionManager.reset(); - this.remotePageState.clearActiveSession(); - this.remotePageState.setSessions([], false); - this.remotePageState.clearControlTarget(); - this.remotePageState.setConnectionState(ConnectionState.Disconnected); - } - } - - private async handleRemoteConnectionError(err: Object): Promise { - if (this.remotePageState.controlTargetType !== 'account_device' || - !(err instanceof CloudAccountRequestError) || err.statusCode !== 401) { - return false; - } - await this.expireCloudAccountSession(); - this.remotePageState.setStatusText(RemoteI18n.t('remote.settings.accountExpired')); - return true; - } - - async selectCloudAccountDevice(device: CloudAccountDevice, navigateHome: boolean = true): Promise { - if (!device.online) { - throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); - } - if (!this.cloudAccountSession || this.cloudAccountRelayUrl.length === 0) { - throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); - } - const deviceId = device.deviceId.trim(); - if (deviceId.length === 0 || deviceId === this.remoteConnectionViewModel.getDeviceId()) { - return; - } - if (deviceId === this.remotePageState.controlTargetDeviceId && - this.connectionState === ConnectionState.Connected) { - this.appShellState.setConnectSheetVisible(false); - if (navigateHome) { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - } - return; - } - this.invalidateFilePreviewTarget(); - this.remoteActivityViewModel.invalidate(); - this.remoteConnectionCoordinator.invalidate(); - this.stopPolling(); - this.stopHeartbeat(); - // Do not keep presenting the previous device while the new account device - // is being handshaken. Clear its projection before the async connect. - this.remotePageState.setConnectionState(ConnectionState.Reconnecting); - this.remotePageState.setLoadingHome(true); - this.remotePageState.clearControlTarget(); - this.remotePageState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); - this.remotePageState.setBusy(true); - this.remotePageState.setStatusText(RemoteI18n.t('remote.settings.deviceConnecting')); - this.remotePageState.clearActiveSession(); - this.resetChatTimeline(''); - this.knownPollVersion = 0; - this.knownModelCatalogVersion = 0; - this.knownRemoteMessageCount = 0; - this.remotePageState.setSessions([], false); - try { - const initialSync = await this.sessionManager.connectAccountDevice( - this.cloudAccountClient, - this.cloudAccountRelayUrl, - this.cloudAccountSession, - deviceId - ); - this.remotePageState.setControlTarget('account_device', deviceId, device.deviceName); - this.remotePageState.setDesktopIdentity(device.deviceName, deviceId); - this.remotePageState.setWorkspace( - initialSync.workspace.name, - initialSync.workspace.path, - initialSync.workspace.assistantId || '', - initialSync.workspace.gitBranch, - initialSync.workspace.workspaceKind || 'normal' - ); - this.remotePageState.setSessions(initialSync.sessions, initialSync.hasMoreSessions); - this.remotePageState.setAuthenticatedUserId(initialSync.authenticatedUserId); - this.remotePageState.setConnectionState(ConnectionState.Connected); - this.remotePageState.setStatusText(RemoteI18n.t('connection.connected')); - this.appShellState.setSettingsVisible(false); - this.appShellState.setConnectSheetVisible(false); - if (navigateHome) { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.RemoteHome); - } - await this.cloudAccountSessionStore.save({ - relayUrl: this.cloudAccountRelayUrl, - username: this.remotePageState.accountUsername, - token: this.cloudAccountSession.token, - userId: this.cloudAccountSession.userId, - masterKey: Encoding.bytesToBase64(this.cloudAccountSession.masterKey), - targetDeviceId: deviceId, - targetDeviceName: device.deviceName - }); - this.startHeartbeat(); - await this.loadRecentWorkspacesInBackground(); - this.remotePageState.setLoadingHome(false); - } catch (err) { - if (err instanceof CloudAccountRequestError && err.statusCode === 401) { - await this.expireCloudAccountSession(); - } - this.remotePageState.clearControlTarget(); - this.remotePageState.setConnectionState(ConnectionState.Failed); - const message = ConnectionErrorPolicy.errorText(err); - this.remotePageState.setStatusText(message); - this.sessionManager.reset(); - throw new Error(message); - } finally { - this.remotePageState.setLoadingHome(false); - this.remotePageState.setBusy(false); - } - } - - private identityStoreSnapshotInstallId(): string { - return this.remoteConnectionViewModel.getDeviceId(); - } - - openHomeSession(session: RemoteSession): void { - this.closeFilePreview(); - if (session.agentType === 'chat') { - this.openGeneralSession(session); - return; - } - this.openSession(session); - } - - openHomeSessionInPlace(session: RemoteSession): void { - this.closeFilePreview(); - if (session.agentType === 'chat') { - this.openGeneralSession(session); - return; - } - this.openSession(session, true); - } - - async deleteHomeSession(session: RemoteSession): Promise { - if (session.agentType !== 'chat') { - await this.deleteSession(session); - return; - } - await this.generalChatCommandController.deleteSession(session, this.generalChatPageState.isBusy); - } - - activeGeneralChatAsRemoteSession(): RemoteSession { - const active = this.generalChatPageState.activeSession; - return { - id: active.sessionId, - title: active.title, - agentType: 'chat', - status: 'ready', - updatedAt: '', - createdAt: '', - messageCount: this.generalChatPageState.timelineItems.length, - workspacePath: active.workspacePath - }; - } - - activeGeneralUploadedFileCount(): number { - let count = 0; - this.generalChatPageState.timelineItems.forEach((item: ChatTimelineItem) => { - if (item.message && item.message.images) { - count += item.message.images.length; - } - }); - return count; - } - - async archiveHomeSession(session: RemoteSession, archived: boolean): Promise { - await this.generalChatCommandController.archiveSession(session, archived, this.generalChatPageState.isBusy); - } - - async exportHomeSession(session: RemoteSession): Promise { - await this.generalChatCommandController.exportSession( - session, - this.generalChatPageState.isBusy, - async (text: string): Promise => { - await this.clipboardService.writeText(text); - } - ); - } - - async openGeneralSession(item: RemoteSession): Promise { - if (this.generalChatPageState.isBusy) { - return; - } - this.stopPolling(); - this.stopGeneralChatStream(false); - await this.generalChatCommandController.openSession( - item, - this.generalChatPageState.isBusy, - async (sessionId: string): Promise => { - return this.generalChatDraftLifecycleController.restore(sessionId); - }, - (_sessionId: string) => { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - } - ); - } - - async startGeneralChat(text: string): Promise { - const trimmed = text.trim(); - if (trimmed.length === 0 || this.generalChatPageState.isBusy) { - return; - } - this.stopPolling(); - this.stopGeneralChatStream(false); - this.generalChatDraftLifecycleController.cancel(); - const created = await this.generalChatCommandController.createSession( - trimmed, - this.generalChatPageState.isBusy, - async (): Promise => { - await this.generalChatDraftLifecycleController.clearHomeNow(); - }, - (_sessionId: string) => { - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - } - ); - if (!created) { - return; - } - await this.sendGeneralChatMessage(); - } - - async sendVisibleChatMessage(): Promise { - if (this.isGeneralChatVisible()) { - if ((this.generalChatPageState.activeSession.sessionId || '').length === 0) { - this.startVisibleGeneralChat(); - return; - } - await this.sendGeneralChatMessage(); - return; - } - await this.sendChatMessage(); - } - - async stopActiveChatTask(): Promise { - if (this.isGeneralChatVisible()) { - this.stopGeneralChatStream(true); - return; - } - await this.stopActiveTask(); - } - - closeActiveChat(): void { - this.closeFilePreview(); - this.stopVoiceInput(false); - if (this.isRoute(AppRoute.GeneralChat)) { - this.persistVisibleGeneralChatDraft(); - this.stopGeneralChatStream(true); - this.popRoute(AppRoute.ChatHome); - this.restoreGeneralChatDraft(GENERAL_CHAT_HOME_DRAFT_ID); - return; - } - this.stopPolling(); - this.popRoute(AppRoute.RemoteHome); - } - - async renameVisibleSession(title: string): Promise { - if (this.isGeneralChatVisible()) { - await this.generalChatCommandController.renameActiveSession( - this.generalChatPageState.activeSession, - title - ); - return; - } - await this.renameActiveSession(title); - } - - async retryVisibleMessage(text: string): Promise { - if (this.isGeneralChatVisible()) { - const sessionId = this.generalChatPageState.activeSession.sessionId || ''; - const prepared = await this.generalChatCommandController.retryMessage( - sessionId, - text, - this.generalChatPageState.isBusy - ); - if (prepared) { - await this.sendGeneralChatMessage(); - } - return; - } - this.retryMessage(text); - } - - downloadVisibleFile(path: string): void { - if (this.isGeneralChatVisible()) { - this.generalChatPageState.setStatus(RemoteI18n.t('generalChat.fileDownloadMock')); - return; - } - this.downloadFile(path); - } - - openFilePreview(route: AppRoute, request: FilePreviewRequest): void { - const context = new FilePreviewTargetContext( - this.remotePageState.activeSession.sessionId, - this.remotePageState.activeSession.workspacePath || this.remotePageState.workspacePath, - this.controlTargetEpoch - ); - const resolution = FileTargetResolver.resolve(request.reference, request.label, context); - if (resolution.kind === FileReferenceKind.HttpUrl) { - void this.openExternalLink(route, request.reference); - return; - } - if (route !== AppRoute.RemoteChat) { - this.generalChatPageState.setStatus(RemoteI18n.t('generalChat.filePreviewUnavailable')); - return; - } - if (resolution.kind !== FileReferenceKind.RemoteWorkspaceFile || !resolution.target) { - return; - } - void this.remoteFilePreviewController.open(resolution.target); - } - - private async openExternalLink(route: AppRoute, reference: string): Promise { - const opened = this.host.openExternalLink ? await this.host.openExternalLink(reference) : false; - if (!opened) { - if (AppRouteContract.isGeneralComposerRoute(route)) { - this.generalChatPageState.setStatus(RemoteI18n.t('errors.operationFailed')); - } else { - this.setRemoteStatusText(RemoteI18n.t('errors.operationFailed')); - } - } - } - - closeFilePreview(): void { - this.remoteFilePreviewController.close(); - } - - refreshFilePreview(): void { - void this.remoteFilePreviewController.refresh(); - } - - openFilePreviewLink(reference: string, label: string): void { - this.openFilePreview(AppRoute.RemoteChat, new FilePreviewRequest(reference, label)); - } - - invalidateFilePreviewTarget(): void { - this.controlTargetEpoch += 1; - this.remoteFilePreviewController.close(); - } - - async createSession(agentType: string, inPlace: boolean = false): Promise { - this.closeFilePreview(); - await this.remoteSessionViewModel.createSession( - agentType, - '', - inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) - ); - } - - openRemoteCreateSession(): void { - if (!this.ensureRemoteAvailable()) { - return; - } - const deviceId = this.remotePageState.controlTargetDeviceId || this.remotePageState.desktopId; - const deviceName = this.remotePageState.controlTargetDeviceName || this.remotePageState.desktopName; - this.remoteCreateState.prepare(deviceId, deviceName, this.remotePageState.selectedModelId); - if (deviceId.length > 0) { - this.remoteCreateState.setDevices([{ - deviceId, - deviceName: deviceName || deviceId, - online: true - }]); - } - this.remoteCreateState.setWorkspaces(this.remotePageState.recentWorkspaces); - this.pushRoute(AppRoute.RemoteCreate); - this.loadRemoteCreateChoices(); - this.loadRemoteCreateModelCatalog(); - } - - closeRemoteCreateSession(): void { - this.remoteCreateWorkspaceLoadVersion += 1; - this.stopVoiceInput(false); - this.remoteCreateState.closeMenu(); - this.popRoute(AppRoute.RemoteHome); - } - - async loadRemoteCreateChoices(): Promise { - await Promise.all([ - this.loadRemoteCreateDevices(), - this.loadRemoteCreateWorkspaces() - ]); - } - - async loadRemoteCreateModelCatalog(): Promise { - if (this.remotePageState.modelCatalog.models.length > 0) { - return; - } - try { - const catalog = await this.sessionManager.getModelCatalog(); - const selectedModelId = RemoteUiState.selectedModelIdForCatalog( - catalog, - this.remotePageState.selectedModelId - ); - this.remotePageState.setModelCatalog(catalog, selectedModelId); - this.remoteCreateState.setSelectedModelId(selectedModelId); - } catch (_err) { - // Model selection remains hidden when the remote does not expose a catalog. - } - } - - async loadRemoteCreateDevices(): Promise { - this.remoteCreateState.isLoadingDevices = this.remoteCreateState.devices.length === 0; - try { - const phoneDeviceId = this.remoteConnectionViewModel.getDeviceId(); - const accountDevices = await this.listCloudAccountDevices(); - const devices = accountDevices.filter((device: CloudAccountDevice): boolean => - device.online && device.deviceId !== phoneDeviceId - ); - const currentId = this.remoteCreateState.selectedDeviceId; - if (currentId.length > 0 && !devices.some((device: CloudAccountDevice): boolean => device.deviceId === currentId)) { - devices.unshift({ - deviceId: currentId, - deviceName: this.remoteCreateState.selectedDeviceName || currentId, - online: true - }); - } - this.remoteCreateState.setDevices(devices); - } catch (err) { - const currentId = this.remoteCreateState.selectedDeviceId; - if (currentId.length > 0) { - this.remoteCreateState.setDevices([{ - deviceId: currentId, - deviceName: this.remoteCreateState.selectedDeviceName || currentId, - online: true - }]); - } else { - this.remoteCreateState.setDevices([]); - } - this.remoteCreateState.errorText = RemoteI18n.t('remote.create.deviceLoadFailed'); - } - } - - async loadRemoteCreateWorkspaces(): Promise { - const loadVersion = ++this.remoteCreateWorkspaceLoadVersion; - const deviceId = this.remoteCreateState.selectedDeviceId; - this.remoteCreateState.isLoadingWorkspaces = this.remoteCreateState.workspaces.length === 0; - try { - const workspaces = await this.workspaceCoordinator.recentWorkspaces(); - if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || - deviceId !== this.remoteCreateState.selectedDeviceId) { - return; - } - this.remoteCreateState.setWorkspaces(workspaces); - } catch (err) { - if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || - deviceId !== this.remoteCreateState.selectedDeviceId) { - return; - } - this.remoteCreateState.setWorkspaces([]); - this.remoteCreateState.errorText = RemoteI18n.t('remote.create.workspaceLoadFailed'); - } - } - - toggleRemoteCreateDevices(): void { - this.remoteCreateState.toggleMenu('devices'); - if (this.remoteCreateState.openMenu === 'devices' && this.remoteCreateState.devices.length === 0) { - this.loadRemoteCreateDevices(); - } - } - - toggleRemoteCreateWorkspaces(): void { - this.remoteCreateState.toggleMenu('workspaces'); - if (this.remoteCreateState.openMenu === 'workspaces' && this.remoteCreateState.workspaces.length === 0) { - this.loadRemoteCreateWorkspaces(); - } - } - - async selectRemoteCreateDevice(device: CloudAccountDevice): Promise { - if (device.deviceId === this.remoteCreateState.selectedDeviceId) { - this.remoteCreateState.closeMenu(); - return; - } - const draft = this.remoteCreateState.draft; - this.remoteCreateState.closeMenu(); - this.remoteCreateState.isLoadingWorkspaces = true; - try { - await this.selectCloudAccountDevice(device, false); - this.remoteCreateState.selectDevice(device); - this.remoteCreateState.setDraft(draft); - await this.loadRemoteCreateWorkspaces(); - } catch (err) { - this.remoteCreateState.isLoadingWorkspaces = false; - this.remoteCreateState.errorText = err instanceof Error ? err.message : - RemoteI18n.t('remote.settings.deviceSwitchFailed'); - } - } - - selectRemoteCreateWorkspace(path: string): void { - const workspace = this.remoteCreateState.workspaces.find((item: RecentWorkspaceEntry): boolean => item.path === path); - this.remoteCreateState.selectWorkspace(workspace); - } - - selectRemoteCreateModel(modelId: string): void { - this.remoteCreateState.setSelectedModelId(modelId); - } - - async submitRemoteCreateSession(): Promise { - const instruction = this.remoteCreateState.draft.trim(); - if (instruction.length === 0 || this.remoteCreateState.isSubmitting || !this.ensureRemoteAvailable()) { - return; - } - const context = this.remoteCreateState.submissionContext(); - const activeDeviceId = this.remotePageState.controlTargetDeviceId || this.remotePageState.desktopId; - if (context.deviceId.length === 0 || context.deviceId !== activeDeviceId) { - this.remoteCreateState.errorText = RemoteI18n.t('remote.create.deviceMismatch'); - return; - } - this.remoteCreateState.isSubmitting = true; - this.remoteCreateState.errorText = ''; - this.remoteCreateState.closeMenu(); - try { - if (context.workspacePath.length > 0) { - await this.remoteSessionViewModel.createSessionInWorkspace( - context.workspacePath, - this.workspacePath, - instruction, - context.agentType, - undefined, - this.remoteCreateState.selectedModelId - ); - } else { - await this.remoteSessionViewModel.createSession( - context.agentType, - instruction, - undefined, - this.remoteCreateState.selectedModelId - ); - } - if (this.isRoute(AppRoute.RemoteCreate)) { - this.remoteCreateState.errorText = this.statusText || RemoteI18n.t('remote.create.submitFailed'); - } - } catch (err) { - this.remoteCreateState.errorText = err instanceof Error ? err.message : - RemoteI18n.t('remote.create.submitFailed'); - } finally { - this.remoteCreateState.isSubmitting = false; - } - } - - async createSessionInWorkspace( - path: string, - agentType: string = 'code', - inPlace: boolean = false - ): Promise { - this.closeFilePreview(); - await this.remoteSessionViewModel.createSessionInWorkspace( - path, - this.workspacePath, - '', - agentType, - inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) - ); - } - - applyDiscoveredWorkspaceSessions(all: RemoteSession[]): void { - this.remoteWorkspaceSessions = all; - const current = this.remotePageState.sessions; - const extras = all.filter((item: RemoteSession) => item.workspacePath !== this.workspacePath); - this.remotePageState.setSessions(this.mergeSessions(current, extras), this.remotePageState.hasMoreSessions); - } - - async loadRecentWorkspacesInBackground(): Promise { - await this.remoteWorkspaceViewModel.loadRecentWorkspacesInBackground(); - } - - mergeSessions(primary: RemoteSession[], extras: RemoteSession[]): RemoteSession[] { - const merged = primary.slice(); - extras.forEach((item: RemoteSession) => { - if (!merged.some((existing: RemoteSession) => existing.id === item.id)) { - merged.push(item); - } - }); - return merged; - } - - async openSession(item: RemoteSession, inPlace: boolean = false): Promise { - this.closeFilePreview(); - await this.remoteSessionViewModel.openSession( - item, - this.workspacePath, - inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : - (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) - ); - } - - applyRemoteActiveSession(session: SessionSummary): void { - const current = this.remotePageState.activeSession; - if (this.filePreviewState.visible && - (current.sessionId !== session.sessionId || current.workspacePath !== session.workspacePath)) { - this.closeFilePreview(); - } - this.remotePageState.setActiveSession(session); - } - - async deleteSession(item: RemoteSession): Promise { - await this.remoteSessionViewModel.deleteSession(item, this.workspacePath); - } - - async loadActiveMessages(): Promise { - await this.remoteSessionViewModel.loadActiveMessages((activeSessionId: string): boolean => { - return this.isRemoteConversationContext(activeSessionId); - }); - } - - async loadModelCatalog(sessionId: string): Promise { - await this.remoteSessionViewModel.loadModelCatalog(sessionId, (activeSessionId: string): boolean => { - return this.isRemoteConversationContext(activeSessionId); - }); - } - - async selectModel(modelId: string): Promise { - if (this.isGeneralChatVisible()) { - if (await this.generalChatConfigStore.selectModel(modelId)) { - await this.refreshGeneralChatModelCatalog(); - } - return; - } - await this.remoteSessionViewModel.selectModel(modelId); - } - - async loadOlderMessages(): Promise { - await this.remoteSessionViewModel.loadOlderMessages(this.knownPollVersion); - } - - async sendGeneralChatMessage(): Promise { - await this.generalChatConversationViewModel.sendMessage(); - return; - } - - stopGeneralChatStream(cancelled: boolean, finalStatus: string = 'cancelled'): void { - this.generalChatConversationViewModel.stop(cancelled, finalStatus); - return; - } - - async sendChatMessage(): Promise { - if (this.remotePageState.isVoiceListening) { - await this.stopVoiceInput(false); - } - const rawText = this.remotePageState.chatInput.trim(); - const images = this.remotePageState.selectedImages.slice(); - const text = rawText.length > 0 ? rawText : (images.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); - const sessionId = this.activeSession.sessionId || ''; - if ((!text && images.length === 0) || !sessionId || this.isBusy) { - return; - } - if (!this.ensureRemoteAvailable()) { - return; - } - this.remotePageState.clearComposer(); - const localMessage = RemoteUiState.localUserMessage(text, images); - this.chatTimelineStore.appendOptimisticMessage(localMessage); - const pendingActiveId = this.chatTimelineStore.setPendingActiveTurn(localMessage.id); - this.syncChatTimelineFromStore(); - RemoteLogger.info(`chat send queued session=${this.shortSessionId(sessionId)} pending=${pendingActiveId}`); - this.startPolling(); - this.nudgeChatPolling(); - const imageContexts: RemoteImageContext[] = images.length > 0 ? this.imagePickerService.toRemoteContexts(images) : []; - await this.remoteChatCommandController.sendPreparedMessage( - sessionId, - text, - this.activeSession.agentType, - rawText, - images, - imageContexts, - localMessage.id, - pendingActiveId, - this.isBusy, - true - ); - } - - async toggleVoiceInput(): Promise { - const route = this.currentRoute(); - await this.voiceInputLifecycleController.toggle(this.host.context(), this.voiceInputSnapshot(route)); - } - - async stopVoiceInput(showStatus: boolean): Promise { - const route = this.currentRoute(); - await this.voiceInputLifecycleController.stop(this.voiceInputSnapshot(route), showStatus); - } - - showVoiceInputError(message: string): void { - const text = message.length > 0 ? message : RemoteI18n.t('errors.voiceInputUnavailable'); - this.setVisibleStatusText(text); - this.host.showToast(text, 2600); - } - - async pickImages(): Promise { - if (this.visibleChatBusy()) { - return; - } - const route = this.currentRoute(); - if (this.visibleVoiceListening()) { - await this.stopVoiceInput(false); - } - try { - this.setVisibleStatusText(RemoteI18n.t('status.pickImage')); - const picked = await this.imagePickerService.pickImages(3, this.visibleSelectedImages().length); - if (picked.length === 0) { - this.setVisibleStatusText(RemoteI18n.t('status.noImageSelected')); - return; - } - this.addSelectedImagesForRoute(route, picked); - this.setVisibleStatusText(RemoteI18n.f('status.imagesSelected', `${this.visibleSelectedImages().length}`)); - } catch (err) { - this.setVisibleStatusText(ConnectionErrorPolicy.errorText(err)); - } - } - - removeSelectedImage(imageId: string): void { - this.removeSelectedImageForRoute(this.currentRoute(), imageId); - } - - startVisibleGeneralChat(): void { - const rawText = this.generalChatPageState.chatInput.trim(); - const text = rawText.length > 0 ? rawText : - (this.generalChatPageState.selectedImages.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); - if (text.length === 0 || this.generalChatPageState.isBusy) { - return; - } - if (this.generalChatPageState.serviceState === GeneralChatServiceState.Unconfigured) { - const statusText = GeneralChatServiceStatus.userMessage(this.generalChatPageState.serviceState); - this.generalChatPageState.setStatus(statusText); - this.showHomeToast(statusText); - return; - } - this.startGeneralChat(text); - } - - generalChatHomeStatusText(): string { - if (this.generalChatPageState.serviceState === GeneralChatServiceState.Ready || - this.generalChatPageState.serviceState === GeneralChatServiceState.Sending || - this.generalChatPageState.serviceState === GeneralChatServiceState.Streaming) { - return ''; - } - return GeneralChatServiceStatus.userMessage( - this.generalChatPageState.serviceState, - this.generalChatPageState.statusText - ); - } - - prepareNewGeneralChat(): void { - this.stopVoiceInput(false); - this.stopGeneralChatStream(true); - this.generalChatDraftLifecycleController.clearHome(); - this.generalChatPageState.clearComposer(); - this.generalChatPageState.clearActiveSession(); - this.resetGeneralChatTimeline(''); - this.appShellViewModel.replaceRouteWithoutAnimation(AppRoute.ChatHome); - } - - onVisibleChatInputChange(route: AppRoute, value: string): void { - this.setChatInputForRoute(route, value); - if (!this.isGeneralComposerRoute(route)) { - return; - } - this.generalChatDraftLifecycleController.scheduleVisible(value); - } - - visibleGeneralChatDraftId(): string { - if (this.isGeneralChatVisible()) { - return this.generalChatPageState.activeSession.sessionId || GENERAL_CHAT_HOME_DRAFT_ID; - } - return ''; - } - - persistVisibleGeneralChatDraft(): void { - this.generalChatDraftLifecycleController.persistVisible(this.generalChatPageState.chatInput); - } - - async restoreGeneralChatDraft(draftId: string): Promise { - this.generalChatPageState.setChatInput(await this.generalChatDraftLifecycleController.restore(draftId)); - } - - latestUserMessageText(): string { - if (this.isGeneralChatVisible()) { - return this.generalChatPageState.latestUserMessageText(); - } - const candidates = this.messages.concat(this.pendingMessages); - for (let index = candidates.length - 1; index >= 0; index--) { - if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { - return candidates[index].text; - } - } - return ''; - } - - showHomeToast(message: string): void { - if (!this.host.showToast(message, 2600)) { - this.setVisibleStatusText(message); - } - } - - async stopActiveTask(): Promise { - const sessionId = this.activeSession.sessionId || ''; - if (!sessionId) { - return; - } - await this.remoteChatCommandController.stopTask( - sessionId, - this.activeTurnMessage.id, - this.currentActiveTurnId(), - this.ensureRemoteAvailable() - ); - } - - async renameActiveSession(title: string): Promise { - const nextTitle = title.trim(); - if ( - !this.activeSession.sessionId || - nextTitle.length === 0 || - nextTitle === this.activeSession.title || - this.isBusy - ) { - return; - } - await this.remoteChatCommandController.renameActiveSession( - this.activeSession, - nextTitle, - this.isBusy, - this.ensureRemoteAvailable() - ); - } - - async copyMessage(text: string): Promise { - if (text.trim().length === 0) { - return; - } - try { - await this.clipboardService.writeText(text); - this.setRemoteStatusText(RemoteI18n.t('status.messageCopied')); - } catch (err) { - this.setRemoteStatusText(ConnectionErrorPolicy.errorText(err)); - } - } - - async downloadFile(path: string): Promise { - const sessionId = this.activeSession.sessionId || ''; - await this.remoteFileDownloadController.download(path, sessionId, this.isBusy, this.ensureRemoteAvailable()); - } - - retryMessage(text: string): void { - if (this.isBusy) { - return; - } - if (!this.ensureRemoteAvailable()) { - return; - } - this.remotePageState.setChatInput(text); - this.sendChatMessage(); - } - - async approveTool(toolId: string, updatedInput?: Object): Promise { - await this.remoteToolActionController.approve( - toolId, - this.activeSession.sessionId || '', - this.ensureRemoteAvailable(), - updatedInput - ); - } - - async rejectTool(toolId: string): Promise { - await this.remoteToolActionController.reject( - toolId, - this.activeSession.sessionId || '', - this.ensureRemoteAvailable() - ); - } - - async cancelTool(toolId: string): Promise { - await this.remoteToolActionController.cancel( - toolId, - this.activeSession.sessionId || '', - this.ensureRemoteAvailable() - ); - } - - async answerQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { - await this.remoteToolActionController.answer( - toolId, - this.activeSession.sessionId || '', - this.ensureRemoteAvailable(), - answers - ); - } - - resetChatTimeline(sessionId: string): void { - this.chatTimelineStore.reset(sessionId); - this.knownPollVersion = 0; - this.syncChatTimelineFromStore(); - } - - resetGeneralChatTimeline(sessionId: string): void { - this.chatTimelineStore.reset(sessionId); - this.syncGeneralChatTimelineFromStore(); - } - - syncChatTimelineFromStore(): void { - const state: ChatTimelineState = this.chatTimelineStore.snapshotState(); - const projectedItems: ChatTimelineItem[] = this.projectedTimelineItems(); - this.remotePageState.setTimelineProjection( - state.persistedMessages, - state.optimisticMessages, - state.activeTurn || RemoteUiState.emptyActiveTurn(), - this.hasMoreMessages, - projectedItems - ); - this.remotePageState.setModelCatalog(state.modelCatalog, state.selectedModelId); - } - - syncGeneralChatTimelineFromStore(): void { - const state: ChatTimelineState = this.chatTimelineStore.snapshotState(); - const projectedItems = this.chatTimelineStore.viewState(false); - this.generalChatPageState.setTimelineProjection( - state.persistedMessages, - state.optimisticMessages, - state.activeTurn || RemoteUiState.emptyActiveTurn(), - false, - projectedItems - ); - const itemSummary = projectedItems.map((item: ChatTimelineItem) => { - const message = item.message; - return `${item.type}:${item.id}:${message ? message.status : ''}:${message ? message.text.length : 0}`; - }).join(','); - RemoteLogger.info(`general chat projection revision=${this.generalChatPageState.timelineRevision} persisted=${state.persistedMessages.length} active=${state.activeTurn ? state.activeTurn.id : 'none'} items=${itemSummary}`); - } - - startPolling(): void { - this.remoteChatPollingLifecycleController.startActiveSession({ - sessionId: this.activeSession.sessionId || '', - cursor: this.currentChatPollingCursor(), - activeTurn: this.activeTurnMessage - }); - } - - stopPolling(): void { - this.remoteChatPollingLifecycleController.stop(); - } - - nudgeChatPolling(): void { - this.remoteChatPollingLifecycleController.nudge(); - } - - async pollActiveSession(): Promise { - await this.remoteChatPollingLifecycleController.pollNow(); - } - - currentChatPollingCursor(): RemoteChatPollingCursor { - return { - pollVersion: this.knownPollVersion, - knownMessageCount: this.knownRemoteMessageCount, - knownModelCatalogVersion: this.knownModelCatalogVersion - }; - } - - updateChatPollingCursor(pollVersion: number, knownMessageCount: number): void { - this.knownPollVersion = pollVersion; - this.knownRemoteMessageCount = knownMessageCount; - this.remoteChatPollingLifecycleController.updateCursor({ - pollVersion, - knownMessageCount, - knownModelCatalogVersion: this.knownModelCatalogVersion - }); - } - - applyChatSessionSnapshot(snapshot: RemoteChatPollingSnapshot): void { - if (!this.isRemoteConversationContext(snapshot.sessionId)) { - return; - } - this.chatTimelineStore.applySnapshot(snapshot); - this.syncChatTimelineFromStore(); - this.knownPollVersion = snapshot.cursor.pollVersion; - this.knownModelCatalogVersion = snapshot.cursor.knownModelCatalogVersion; - this.knownRemoteMessageCount = snapshot.cursor.knownMessageCount; - if (snapshot.title.length > 0) { - this.remotePageState.setActiveSession({ - sessionId: this.activeSession.sessionId, - title: snapshot.title, - workspacePath: this.activeSession.workspacePath, - agentType: this.activeSession.agentType - }); - } - if (snapshot.modelCatalog) { - this.remoteModelController.applyCatalog(snapshot.modelCatalog); - } - this.setRemoteStatusText(this.hasRunningActiveTurn() - ? RemoteI18n.t('status.desktopProcessing') - : RemoteI18n.t('status.messagesSynced')); - if (snapshot.shouldSyncAfterTurnEnded) { - this.syncAfterTurnEnded(); - } - } - - hasRunningActiveTurn(): boolean { - return this.activeTurnMessage.id.length > 0 && - (this.activeTurnMessage.status || '').toLowerCase() === 'active'; - } - - currentActiveTurnId(): string { - const activeTurnMessage = this.isGeneralChatVisible() ? - this.generalChatPageState.activeTurnMessage : this.activeTurnMessage; - if (activeTurnMessage.turnId && activeTurnMessage.turnId.length > 0) { - return activeTurnMessage.turnId; - } - const activePrefix = 'active-'; - if (activeTurnMessage.id.indexOf(activePrefix) === 0) { - return activeTurnMessage.id.slice(activePrefix.length); - } - return ''; - } - - projectedTimelineItems(): ChatTimelineItem[] { - return this.chatTimelineStore.viewState(this.hasMoreMessages); - } - - startHeartbeat(): void { - this.remoteActivityViewModel.startHeartbeat(); - } - - stopHeartbeat(): void { - this.remoteActivityViewModel.stopHeartbeat(); - } - - async checkConnectionHealth(): Promise { - await this.remoteActivityViewModel.checkConnectionHealth(); - } - - resumeRemoteActivity(): void { - this.remoteActivityViewModel.resume(); - } - - hasRemoteBindingForResume(): boolean { - if (this.remotePageState.controlTargetType === 'account_device') { - return this.remotePageState.accountUserId.trim().length > 0 && - this.remotePageState.controlTargetDeviceId.trim().length > 0 && - this.connectionState !== ConnectionState.Idle && - this.connectionState !== ConnectionState.Disconnected; - } - return this.remoteUrl.trim().length > 0 && - this.userId.trim().length > 0 && - this.connectionState !== ConnectionState.Idle && - this.connectionState !== ConnectionState.Parsing && - this.connectionState !== ConnectionState.Pairing && - this.connectionState !== ConnectionState.Disconnected; - } - - private async reconnectActiveRemote(): Promise { - if (this.remotePageState.controlTargetType !== 'account_device') { - await this.connect(true); - return; - } - const targetId = this.remotePageState.controlTargetDeviceId; - const device = (await this.listCloudAccountDevices()).find((item: CloudAccountDevice): boolean => item.deviceId === targetId); - if (!device) { - throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); - } - await this.selectCloudAccountDevice(device); - } - - hasRemoteBindingForCodeHome(): boolean { - return this.remoteUrl.trim().length > 0 && - this.userId.trim().length > 0 && - (this.connectionState === ConnectionState.Connected || - this.connectionState === ConnectionState.Reconnecting || - this.connectionState === ConnectionState.Pairing || - this.connectionState === ConnectionState.Parsing); - } - - shortSessionId(sessionId: string): string { - if (sessionId.length <= 8) { - return sessionId; - } - return sessionId.slice(0, 4) + '...' + sessionId.slice(sessionId.length - 4); - } - - async syncAfterTurnEnded(): Promise { - if (this.isSyncingAfterTurn) { - return; - } - this.isSyncingAfterTurn = true; - try { - await this.loadActiveMessages(); - } finally { - this.isSyncingAfterTurn = false; - } - } - -} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets index b9009f449f..e34e22c5d1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellState.ets @@ -12,12 +12,22 @@ export class AppShellState { @Trace showSettings: boolean = false; @Trace settingsMode: string = 'general'; @Trace showConnectSheet: boolean = false; + /** + * Mirror of the resolved master-detail layout mode. Only the presentation + * layer measures the viewport, so runtime logic that must branch on compact + * versus wide reads it from here. + */ + @Trace wideLayout: boolean = false; private accountReturnMode: string = ''; setSidebarVisible(visible: boolean): void { this.showSidebar = visible; } + setWideLayout(wide: boolean): void { + this.wideLayout = wide; + } + setSettingsVisible(visible: boolean): void { this.showSettings = visible; if (!visible) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets new file mode 100644 index 0000000000..f86b14dd1c --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationCoreState.ets @@ -0,0 +1,191 @@ +import { + ChatMessage, + RemoteModelCatalog, + RemoteModelConfig, + RemoteSession, + SelectedImageAttachment, + SessionSummary +} from '../../model/RemoteModels'; +import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; +import { RemoteUiState } from '../../services/RemoteUiState'; + +/** Shared observable state for General Chat and Remote Chat conversations. */ +@ObservedV2 +export class ConversationCoreState { + @Trace sessions: RemoteSession[] = []; + @Trace activeSession: SessionSummary; + @Trace persistedMessages: ChatMessage[] = []; + @Trace optimisticMessages: ChatMessage[] = []; + @Trace activeTurnMessage: ChatMessage = RemoteUiState.emptyActiveTurn(); + @Trace hasMoreMessages: boolean = false; + @Trace timelineItems: ChatTimelineItem[] = []; + @Trace timelineRevision: number = 0; + @Trace isBusy: boolean = false; + @Trace modelCatalog: RemoteModelCatalog = RemoteUiState.emptyModelCatalog(); + @Trace selectedModelId: string = ''; + @Trace statusText: string = ''; + @Trace chatInput: string = ''; + @Trace selectedImages: SelectedImageAttachment[] = []; + @Trace isVoiceListening: boolean = false; + private readonly defaultAgentType: string; + private readonly timelineRevisionTracker: ChatTimelineRevisionTracker = new ChatTimelineRevisionTracker(); + + constructor(defaultAgentType: string) { + this.defaultAgentType = defaultAgentType; + this.activeSession = ConversationCoreState.emptySession(defaultAgentType); + } + + setSessions(sessions: RemoteSession[]): void { + this.sessions = sessions.slice(); + } + + setActiveSession(session: SessionSummary): void { + this.activeSession = { + sessionId: session.sessionId, + title: session.title, + workspacePath: session.workspacePath, + agentType: this.defaultAgentType === 'chat' ? 'chat' : session.agentType, + initialTurnId: session.initialTurnId + }; + } + + clearActiveSession(): void { + this.activeSession = ConversationCoreState.emptySession(this.defaultAgentType); + this.clearTimeline(); + } + + setTimelineProjection( + persistedMessages: ChatMessage[], + optimisticMessages: ChatMessage[], + activeTurnMessage: ChatMessage, + hasMoreMessages: boolean, + timelineItems: ChatTimelineItem[] + ): void { + this.timelineRevision = this.timelineRevisionTracker.update(timelineItems); + this.persistedMessages = persistedMessages.slice(); + this.optimisticMessages = optimisticMessages.slice(); + this.activeTurnMessage = activeTurnMessage.id.length > 0 ? + ConversationCoreState.copyMessage(activeTurnMessage) : + RemoteUiState.emptyActiveTurn(); + this.hasMoreMessages = hasMoreMessages; + this.timelineItems = timelineItems.slice(); + } + + setHasMoreMessages(hasMoreMessages: boolean): void { + this.hasMoreMessages = hasMoreMessages; + } + + clearTimeline(): void { + this.persistedMessages = []; + this.optimisticMessages = []; + this.activeTurnMessage = RemoteUiState.emptyActiveTurn(); + this.hasMoreMessages = false; + this.timelineItems = []; + this.timelineRevision = this.timelineRevisionTracker.reset(); + } + + setBusy(isBusy: boolean): void { + this.isBusy = isBusy; + } + + setModelCatalog(modelCatalog: RemoteModelCatalog, selectedModelId: string): void { + this.modelCatalog = ConversationCoreState.copyModelCatalog(modelCatalog); + this.selectedModelId = selectedModelId; + } + + setStatusText(statusText: string): void { + this.statusText = statusText; + } + + setChatInput(chatInput: string): void { + this.chatInput = chatInput; + } + + setSelectedImages(selectedImages: SelectedImageAttachment[]): void { + this.selectedImages = selectedImages.slice(); + } + + addSelectedImages(selectedImages: SelectedImageAttachment[]): void { + this.selectedImages = this.selectedImages.concat(selectedImages); + } + + removeSelectedImage(imageId: string): void { + this.selectedImages = this.selectedImages.filter((image: SelectedImageAttachment) => image.id !== imageId); + } + + clearComposer(): void { + this.chatInput = ''; + this.selectedImages = []; + } + + setVoiceListening(isVoiceListening: boolean): void { + this.isVoiceListening = isVoiceListening; + } + + hasRunningActiveTurn(): boolean { + return this.activeTurnMessage.id.length > 0 && + (this.activeTurnMessage.status || '').toLowerCase() === 'active'; + } + + latestUserMessageText(): string { + const candidates = this.persistedMessages.concat(this.optimisticMessages); + for (let index = candidates.length - 1; index >= 0; index--) { + if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { + return candidates[index].text; + } + } + return ''; + } + + private static emptySession(agentType: string): SessionSummary { + return { + sessionId: '', + title: '', + workspacePath: '', + agentType + }; + } + + private static copyMessage(message: ChatMessage): ChatMessage { + return { + id: message.id, + role: message.role, + text: message.text, + status: message.status, + renderVersion: message.renderVersion, + turnId: message.turnId, + detail: message.detail, + timestamp: message.timestamp, + thinking: message.thinking, + tools: message.tools ? message.tools.slice() : undefined, + items: message.items ? message.items.slice() : undefined, + images: message.images ? message.images.slice() : undefined + }; + } + + private static copyModelCatalog(modelCatalog: RemoteModelCatalog): RemoteModelCatalog { + return { + version: modelCatalog.version, + models: modelCatalog.models.map((model): RemoteModelConfig => { + return { + id: model.id, + name: model.name, + provider: model.provider, + base_url: model.base_url, + model_name: model.model_name, + context_window: model.context_window, + enabled: model.enabled, + capabilities: model.capabilities.slice(), + reasoning: model.reasoning + }; + }), + default_models: { + primary: modelCatalog.default_models.primary, + fast: modelCatalog.default_models.fast, + search: modelCatalog.default_models.search, + image_understanding: modelCatalog.default_models.image_understanding + }, + session_model_id: modelCatalog.session_model_id + }; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets index a7db3241bc..a555f25aa0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewState.ets @@ -18,6 +18,7 @@ import { toConversationUiSession } from '../components/ConversationUiModels'; import { AppRoute } from '../navigation/AppRouteContract'; +import { ConversationCoreState } from './ConversationCoreState'; import { GeneralChatPageState } from './GeneralChatPageState'; import { RemotePageState } from './RemotePageState'; @@ -32,6 +33,7 @@ export class ConversationViewState { connectionState: string = 'idle'; composerCapabilities: ChatComposerCapabilities = GENERAL_CHAT_COMPOSER_CAPABILITIES; isBusy: boolean = false; + isLoadingConversation: boolean = false; canStop: boolean = false; hasMoreMessages: boolean = false; timelineItems: ChatTimelineItem[] = []; @@ -63,49 +65,43 @@ export class ConversationViewState { } private static remote(remote: RemotePageState): ConversationViewState { - const state = new ConversationViewState(); - state.activeSession = toConversationUiSession(remote.activeSession); + const state = ConversationViewState.fromCore(remote.conversation); state.surface = ChatSurface.Remote; state.desktopName = remote.desktopName; state.workspaceBranch = remote.workspaceBranch; - state.statusText = remote.statusText; state.connectionState = remote.connectionState; + state.isLoadingConversation = remote.isLoadingConversation; state.composerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; - state.isBusy = remote.isBusy; - state.canStop = remote.hasRunningActiveTurn(); - state.hasMoreMessages = remote.hasMoreMessages; - state.timelineItems = remote.timelineItems; - state.timelineRevision = remote.timelineRevision; state.showSuggestionsWhenEmpty = false; - state.modelCatalog = toConversationUiModelCatalog(remote.modelCatalog); - state.selectedModelId = remote.selectedModelId; state.downloadingFilePath = remote.downloadingFilePath; state.downloadedFilePath = remote.downloadedFilePath; state.fileDownloadStatus = remote.fileDownloadStatus; - state.selectedImages = remote.selectedImages.map((image) => toConversationUiSelectedImage(image)); - state.isVoiceListening = remote.isVoiceListening; - state.chatInput = remote.chatInput; return state; } private static general(general: GeneralChatPageState, inlineStatus: string): ConversationViewState { - const state = new ConversationViewState(); - state.activeSession = toConversationUiSession(general.activeSession); - state.statusText = general.statusText; + const state = ConversationViewState.fromCore(general.conversation); state.inlineStatusText = inlineStatus; state.connectionState = GeneralChatServiceStatus.connectionState(general.serviceState); - state.isBusy = general.isBusy; - state.canStop = general.hasRunningActiveTurn(); - state.hasMoreMessages = general.hasMoreMessages; - state.timelineItems = general.timelineItems; - state.timelineRevision = general.timelineRevision; - state.modelCatalog = toConversationUiModelCatalog(general.modelCatalog); - state.selectedModelId = general.selectedModelId; - state.isSessionPinned = general.activeSession.sessionId.length > 0 && - general.pinnedSessionId() === general.activeSession.sessionId; - state.selectedImages = general.selectedImages.map((image) => toConversationUiSelectedImage(image)); - state.isVoiceListening = general.isVoiceListening; - state.chatInput = general.chatInput; + state.isSessionPinned = general.conversation.activeSession.sessionId.length > 0 && + general.pinnedSessionId() === general.conversation.activeSession.sessionId; + return state; + } + + private static fromCore(core: ConversationCoreState): ConversationViewState { + const state = new ConversationViewState(); + state.activeSession = toConversationUiSession(core.activeSession); + state.statusText = core.statusText; + state.isBusy = core.isBusy; + state.canStop = core.hasRunningActiveTurn(); + state.hasMoreMessages = core.hasMoreMessages; + state.timelineItems = core.timelineItems; + state.timelineRevision = core.timelineRevision; + state.modelCatalog = toConversationUiModelCatalog(core.modelCatalog); + state.selectedModelId = core.selectedModelId; + state.selectedImages = core.selectedImages.map((image) => toConversationUiSelectedImage(image)); + state.isVoiceListening = core.isVoiceListening; + state.chatInput = core.chatInput; return state; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets index 88351095ff..7d51e1a0a7 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/FilePreviewState.ets @@ -1,4 +1,4 @@ -import { FilePreviewTarget } from './FilePreviewTarget'; +import { FilePreviewTarget } from '../../model/FilePreviewTarget'; export enum FilePreviewPhase { Idle = 'idle', diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets index 5c4db437fe..a6bcb8bd0f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatPageState.ets @@ -5,50 +5,44 @@ import { SelectedImageAttachment, SessionSummary } from '../../model/RemoteModels'; -import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; import { GeneralChatServiceState } from '../../services/general-chat/GeneralChatServiceState'; -import { RemoteUiState } from '../../services/RemoteUiState'; +import { ConversationCoreState } from './ConversationCoreState'; @ObservedV2 export class GeneralChatPageState { - @Trace activeSession: SessionSummary = GeneralChatPageState.emptySession(); - @Trace sessions: RemoteSession[] = []; - @Trace persistedMessages: ChatMessage[] = []; - @Trace optimisticMessages: ChatMessage[] = []; - @Trace activeTurnMessage: ChatMessage = RemoteUiState.emptyActiveTurn(); - @Trace hasMoreMessages: boolean = false; - @Trace timelineItems: ChatTimelineItem[] = []; - @Trace timelineRevision: number = 0; - @Trace isBusy: boolean = false; + @Trace conversation: ConversationCoreState = new ConversationCoreState('chat'); @Trace serviceState: GeneralChatServiceState = GeneralChatServiceState.Unconfigured; @Trace apiUrl: string = ''; @Trace modelName: string = ''; @Trace hasApiKey: boolean = false; - @Trace modelCatalog: RemoteModelCatalog = RemoteUiState.emptyModelCatalog(); - @Trace selectedModelId: string = ''; - @Trace statusText: string = ''; - @Trace chatInput: string = ''; - @Trace selectedImages: SelectedImageAttachment[] = []; - @Trace isVoiceListening: boolean = false; - private readonly timelineRevisionTracker: ChatTimelineRevisionTracker = new ChatTimelineRevisionTracker(); + + get activeSession(): SessionSummary { return this.conversation.activeSession; } + get sessions(): RemoteSession[] { return this.conversation.sessions; } + get persistedMessages(): ChatMessage[] { return this.conversation.persistedMessages; } + get optimisticMessages(): ChatMessage[] { return this.conversation.optimisticMessages; } + get activeTurnMessage(): ChatMessage { return this.conversation.activeTurnMessage; } + get hasMoreMessages(): boolean { return this.conversation.hasMoreMessages; } + get timelineItems(): ChatTimelineItem[] { return this.conversation.timelineItems; } + get timelineRevision(): number { return this.conversation.timelineRevision; } + get isBusy(): boolean { return this.conversation.isBusy; } + get modelCatalog(): RemoteModelCatalog { return this.conversation.modelCatalog; } + get selectedModelId(): string { return this.conversation.selectedModelId; } + get statusText(): string { return this.conversation.statusText; } + get chatInput(): string { return this.conversation.chatInput; } + get selectedImages(): SelectedImageAttachment[] { return this.conversation.selectedImages; } + get isVoiceListening(): boolean { return this.conversation.isVoiceListening; } setActiveSession(session: SessionSummary): void { - this.activeSession = { - sessionId: session.sessionId, - title: session.title, - workspacePath: session.workspacePath, - agentType: 'chat', - initialTurnId: session.initialTurnId - }; + this.conversation.setActiveSession(session); } clearActiveSession(): void { - this.activeSession = GeneralChatPageState.emptySession(); - this.clearTimeline(); + this.conversation.clearActiveSession(); } setSessions(sessions: RemoteSession[]): void { - this.sessions = sessions.slice(); + this.conversation.setSessions(sessions); } setTimelineProjection( @@ -58,27 +52,21 @@ export class GeneralChatPageState { hasMoreMessages: boolean, timelineItems: ChatTimelineItem[] ): void { - this.timelineRevision = this.timelineRevisionTracker.update(timelineItems); - this.persistedMessages = persistedMessages.slice(); - this.optimisticMessages = optimisticMessages.slice(); - this.activeTurnMessage = activeTurnMessage.id.length > 0 ? - GeneralChatPageState.copyMessage(activeTurnMessage) : - RemoteUiState.emptyActiveTurn(); - this.hasMoreMessages = hasMoreMessages; - this.timelineItems = timelineItems.slice(); + this.conversation.setTimelineProjection( + persistedMessages, + optimisticMessages, + activeTurnMessage, + hasMoreMessages, + timelineItems + ); } clearTimeline(): void { - this.persistedMessages = []; - this.optimisticMessages = []; - this.activeTurnMessage = RemoteUiState.emptyActiveTurn(); - this.hasMoreMessages = false; - this.timelineItems = []; - this.timelineRevision = this.timelineRevisionTracker.reset(); + this.conversation.clearTimeline(); } setBusy(isBusy: boolean): void { - this.isBusy = isBusy; + this.conversation.setBusy(isBusy); } setConfiguration( @@ -98,46 +86,39 @@ export class GeneralChatPageState { } setModelCatalog(modelCatalog: RemoteModelCatalog, selectedModelId: string): void { - this.modelCatalog = { - version: modelCatalog.version, - models: modelCatalog.models.slice(), - default_models: modelCatalog.default_models, - session_model_id: modelCatalog.session_model_id - }; - this.selectedModelId = selectedModelId; + this.conversation.setModelCatalog(modelCatalog, selectedModelId); } setStatus(statusText: string): void { - this.statusText = statusText; + this.conversation.setStatusText(statusText); } setChatInput(chatInput: string): void { - this.chatInput = chatInput; + this.conversation.setChatInput(chatInput); } setSelectedImages(selectedImages: SelectedImageAttachment[]): void { - this.selectedImages = selectedImages.slice(); + this.conversation.setSelectedImages(selectedImages); } addSelectedImages(selectedImages: SelectedImageAttachment[]): void { - this.selectedImages = this.selectedImages.concat(selectedImages); + this.conversation.addSelectedImages(selectedImages); } removeSelectedImage(imageId: string): void { - this.selectedImages = this.selectedImages.filter((image: SelectedImageAttachment) => image.id !== imageId); + this.conversation.removeSelectedImage(imageId); } clearComposer(): void { - this.chatInput = ''; - this.selectedImages = []; + this.conversation.clearComposer(); } setVoiceListening(isVoiceListening: boolean): void { - this.isVoiceListening = isVoiceListening; + this.conversation.setVoiceListening(isVoiceListening); } recentSessions(): RemoteSession[] { - const recent = this.sessions.slice(); + const recent = this.conversation.sessions.slice(); recent.sort((first: RemoteSession, second: RemoteSession) => { if ((first.pinned === true) !== (second.pinned === true)) { return first.pinned === true ? -1 : 1; @@ -148,53 +129,20 @@ export class GeneralChatPageState { } pinnedSessionId(): string { - const pinned = this.sessions.find((session: RemoteSession) => session.pinned === true); + const pinned = this.conversation.sessions.find((session: RemoteSession) => session.pinned === true); return pinned ? pinned.id : ''; } hasRunningActiveTurn(): boolean { - return this.activeTurnMessage.id.length > 0 && - (this.activeTurnMessage.status || '').toLowerCase() === 'active'; + return this.conversation.hasRunningActiveTurn(); } latestUserMessageText(): string { - const candidates = this.persistedMessages.concat(this.optimisticMessages); - for (let index = candidates.length - 1; index >= 0; index--) { - if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { - return candidates[index].text; - } - } - return ''; + return this.conversation.latestUserMessageText(); } private sessionTimeValue(value: string): number { const parsed = new Date(value).getTime(); return Number.isNaN(parsed) ? 0 : parsed; } - - private static emptySession(): SessionSummary { - return { - sessionId: '', - title: '', - workspacePath: '', - agentType: 'chat' - }; - } - - private static copyMessage(message: ChatMessage): ChatMessage { - return { - id: message.id, - role: message.role, - text: message.text, - status: message.status, - renderVersion: message.renderVersion, - turnId: message.turnId, - detail: message.detail, - timestamp: message.timestamp, - thinking: message.thinking, - tools: message.tools ? message.tools.slice() : undefined, - items: message.items ? message.items.slice() : undefined, - images: message.images ? message.images.slice() : undefined - }; - } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets index 27e2d77f75..cb77d09d48 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteCreateSessionState.ets @@ -94,8 +94,18 @@ export class RemoteCreateSessionState { this.errorText = ''; } + /** + * The desktop binds every Claw session to its assistant workspace and ignores + * the requested workspace_path, so a picked workspace only holds when it is + * paired with the code agent. No workspace means the chat option, which is + * what Claw is for. + */ submissionContext(): RemoteCreateSessionContext { - return new RemoteCreateSessionContext(this.selectedDeviceId, this.selectedWorkspacePath); + return new RemoteCreateSessionContext( + this.selectedDeviceId, + this.selectedWorkspacePath, + this.selectedWorkspacePath.length > 0 ? 'code' : 'Claw' + ); } clearWorkspace(): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets index 8af7b5da7f..3284ff69fb 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemotePageState.ets @@ -3,13 +3,13 @@ import { ChatMessage, RecentWorkspaceEntry, RemoteModelCatalog, - RemoteModelConfig, RemoteSession, SelectedImageAttachment, SessionSummary } from '../../model/RemoteModels'; -import { ChatTimelineItem, ChatTimelineRevisionTracker } from '../../services/ChatTimelineProjector'; +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; import { RemoteUiState } from '../../services/RemoteUiState'; +import { ConversationCoreState } from './ConversationCoreState'; /** * Observable projection for the Remote home page. @@ -17,6 +17,7 @@ import { RemoteUiState } from '../../services/RemoteUiState'; */ @ObservedV2 export class RemotePageState { + @Trace conversation: ConversationCoreState = new ConversationCoreState('code'); @Trace desktopName: string = ''; @Trace desktopId: string = ''; @Trace remoteUrl: string = ''; @@ -28,10 +29,8 @@ export class RemotePageState { @Trace controlTargetType: string = 'none'; @Trace controlTargetDeviceId: string = ''; @Trace controlTargetDeviceName: string = ''; - @Trace statusText: string = ''; @Trace connectionState: string = 'idle'; @Trace connectionFailureKind: string = ''; - @Trace isBusy: boolean = false; @Trace isLoadingHome: boolean = false; @Trace showRemoteUrlInput: boolean = false; @Trace workspaceName: string = ''; @@ -43,28 +42,32 @@ export class RemotePageState { @Trace assistants: AssistantEntry[] = []; @Trace showWorkspacePicker: boolean = false; @Trace showAssistantPicker: boolean = false; - @Trace sessions: RemoteSession[] = []; - @Trace activeSession: SessionSummary = RemotePageState.emptySession(); - @Trace persistedMessages: ChatMessage[] = []; - @Trace optimisticMessages: ChatMessage[] = []; - @Trace activeTurnMessage: ChatMessage = RemoteUiState.emptyActiveTurn(); - @Trace hasMoreMessages: boolean = false; - @Trace timelineItems: ChatTimelineItem[] = []; - @Trace timelineRevision: number = 0; - @Trace modelCatalog: RemoteModelCatalog = RemoteUiState.emptyModelCatalog(); - @Trace selectedModelId: string = ''; @Trace downloadingFilePath: string = ''; @Trace downloadedFilePath: string = ''; @Trace fileDownloadStatus: string = ''; - @Trace chatInput: string = ''; - @Trace selectedImages: SelectedImageAttachment[] = []; - @Trace isVoiceListening: boolean = false; @Trace sessionQuery: string = ''; @Trace sessionFilter: string = 'all'; @Trace hasMoreSessions: boolean = false; @Trace isLoadingSessions: boolean = false; + @Trace isLoadingConversation: boolean = false; + @Trace pendingSessionId: string = ''; + @Trace isConversationDismissed: boolean = false; @Trace sessionErrorText: string = ''; - private readonly timelineRevisionTracker: ChatTimelineRevisionTracker = new ChatTimelineRevisionTracker(); + get activeSession(): SessionSummary { return this.conversation.activeSession; } + get sessions(): RemoteSession[] { return this.conversation.sessions; } + get persistedMessages(): ChatMessage[] { return this.conversation.persistedMessages; } + get optimisticMessages(): ChatMessage[] { return this.conversation.optimisticMessages; } + get activeTurnMessage(): ChatMessage { return this.conversation.activeTurnMessage; } + get hasMoreMessages(): boolean { return this.conversation.hasMoreMessages; } + get timelineItems(): ChatTimelineItem[] { return this.conversation.timelineItems; } + get timelineRevision(): number { return this.conversation.timelineRevision; } + get isBusy(): boolean { return this.conversation.isBusy; } + get modelCatalog(): RemoteModelCatalog { return this.conversation.modelCatalog; } + get selectedModelId(): string { return this.conversation.selectedModelId; } + get statusText(): string { return this.conversation.statusText; } + get chatInput(): string { return this.conversation.chatInput; } + get selectedImages(): SelectedImageAttachment[] { return this.conversation.selectedImages; } + get isVoiceListening(): boolean { return this.conversation.isVoiceListening; } setQuery(query: string): void { this.sessionQuery = query; @@ -125,7 +128,7 @@ export class RemotePageState { } setStatusText(statusText: string): void { - this.statusText = statusText; + this.conversation.setStatusText(statusText); } setConnectionState(connectionState: string): void { @@ -137,7 +140,7 @@ export class RemotePageState { } setBusy(isBusy: boolean): void { - this.isBusy = isBusy; + this.conversation.setBusy(isBusy); } setLoadingHome(isLoadingHome: boolean): void { @@ -184,24 +187,20 @@ export class RemotePageState { } setSessions(sessions: RemoteSession[], hasMore: boolean): void { - this.sessions = sessions.slice(); + this.conversation.setSessions(sessions); this.hasMoreSessions = hasMore; this.sessionErrorText = ''; } setActiveSession(session: SessionSummary): void { - this.activeSession = { - sessionId: session.sessionId, - title: session.title, - workspacePath: session.workspacePath, - agentType: session.agentType, - initialTurnId: session.initialTurnId - }; + this.conversation.setActiveSession(session); } clearActiveSession(): void { - this.activeSession = RemotePageState.emptySession(); - this.clearTimeline(); + this.conversation.clearActiveSession(); + this.isLoadingConversation = false; + this.pendingSessionId = ''; + this.isConversationDismissed = false; this.setModelCatalog(RemoteUiState.emptyModelCatalog(), ''); } @@ -212,32 +211,25 @@ export class RemotePageState { hasMoreMessages: boolean, timelineItems: ChatTimelineItem[] ): void { - this.timelineRevision = this.timelineRevisionTracker.update(timelineItems); - this.persistedMessages = persistedMessages.slice(); - this.optimisticMessages = optimisticMessages.slice(); - this.activeTurnMessage = activeTurnMessage.id.length > 0 ? - RemotePageState.copyMessage(activeTurnMessage) : - RemoteUiState.emptyActiveTurn(); - this.hasMoreMessages = hasMoreMessages; - this.timelineItems = timelineItems.slice(); + this.conversation.setTimelineProjection( + persistedMessages, + optimisticMessages, + activeTurnMessage, + hasMoreMessages, + timelineItems + ); } setHasMoreMessages(hasMoreMessages: boolean): void { - this.hasMoreMessages = hasMoreMessages; + this.conversation.setHasMoreMessages(hasMoreMessages); } clearTimeline(): void { - this.persistedMessages = []; - this.optimisticMessages = []; - this.activeTurnMessage = RemoteUiState.emptyActiveTurn(); - this.hasMoreMessages = false; - this.timelineItems = []; - this.timelineRevision = this.timelineRevisionTracker.reset(); + this.conversation.clearTimeline(); } setModelCatalog(modelCatalog: RemoteModelCatalog, selectedModelId: string): void { - this.modelCatalog = RemotePageState.copyModelCatalog(modelCatalog); - this.selectedModelId = selectedModelId; + this.conversation.setModelCatalog(modelCatalog, selectedModelId); } setDownloadStatus(downloadingFilePath: string, downloadedFilePath: string, fileDownloadStatus: string): void { @@ -257,43 +249,57 @@ export class RemotePageState { } setChatInput(chatInput: string): void { - this.chatInput = chatInput; + this.conversation.setChatInput(chatInput); } setSelectedImages(selectedImages: SelectedImageAttachment[]): void { - this.selectedImages = selectedImages.slice(); + this.conversation.setSelectedImages(selectedImages); } addSelectedImages(selectedImages: SelectedImageAttachment[]): void { - this.selectedImages = this.selectedImages.concat(selectedImages); + this.conversation.addSelectedImages(selectedImages); } removeSelectedImage(imageId: string): void { - this.selectedImages = this.selectedImages.filter((image: SelectedImageAttachment) => image.id !== imageId); + this.conversation.removeSelectedImage(imageId); } clearComposer(): void { - this.chatInput = ''; - this.selectedImages = []; + this.conversation.clearComposer(); } setVoiceListening(isVoiceListening: boolean): void { - this.isVoiceListening = isVoiceListening; + this.conversation.setVoiceListening(isVoiceListening); } setLoading(loading: boolean): void { this.isLoadingSessions = loading; } + setConversationLoading(loading: boolean): void { + this.isLoadingConversation = loading; + } + + setPendingSessionId(sessionId: string): void { + this.pendingSessionId = sessionId; + } + + setConversationDismissed(dismissed: boolean): void { + this.isConversationDismissed = dismissed; + } + setError(errorText: string): void { this.sessionErrorText = errorText; this.isLoadingSessions = false; } clear(): void { - this.sessions = []; + this.conversation.setSessions([]); this.hasMoreSessions = false; this.isLoadingSessions = false; + this.isLoadingConversation = false; + this.pendingSessionId = ''; + this.isConversationDismissed = false; this.isLoadingHome = false; this.sessionErrorText = ''; } @@ -306,7 +312,7 @@ export class RemotePageState { visibleSessions(): RemoteSession[] { const query = this.sessionQuery.trim().toLowerCase(); - return this.sessions.filter((item: RemoteSession) => { + return this.conversation.sessions.filter((item: RemoteSession) => { if (item.id.length === 0 || item.status === 'archived') { return false; } @@ -315,59 +321,6 @@ export class RemotePageState { } hasRunningActiveTurn(): boolean { - return this.activeTurnMessage.id.length > 0 && - (this.activeTurnMessage.status || '').toLowerCase() === 'active'; - } - - private static emptySession(): SessionSummary { - return { - sessionId: '', - title: '', - workspacePath: '', - agentType: 'code' - }; - } - - private static copyMessage(message: ChatMessage): ChatMessage { - return { - id: message.id, - role: message.role, - text: message.text, - status: message.status, - renderVersion: message.renderVersion, - turnId: message.turnId, - detail: message.detail, - timestamp: message.timestamp, - thinking: message.thinking, - tools: message.tools ? message.tools.slice() : undefined, - items: message.items ? message.items.slice() : undefined, - images: message.images ? message.images.slice() : undefined - }; - } - - private static copyModelCatalog(modelCatalog: RemoteModelCatalog): RemoteModelCatalog { - return { - version: modelCatalog.version, - models: modelCatalog.models.map((model): RemoteModelConfig => { - return { - id: model.id, - name: model.name, - provider: model.provider, - base_url: model.base_url, - model_name: model.model_name, - context_window: model.context_window, - enabled: model.enabled, - capabilities: model.capabilities.slice(), - reasoning: model.reasoning - }; - }), - default_models: { - primary: modelCatalog.default_models.primary, - fast: modelCatalog.default_models.fast, - search: modelCatalog.default_models.search, - image_understanding: modelCatalog.default_models.image_understanding - }, - session_model_id: modelCatalog.session_model_id - }; + return this.conversation.hasRunningActiveTurn(); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets similarity index 98% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets index 53bf57347e..2b723f14d5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/AppShellViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/AppShellViewModel.ets @@ -4,7 +4,7 @@ import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; -import { AppShellState } from './AppShellState'; +import { AppShellState } from '../state/AppShellState'; /** Owns application navigation and global overlay state. */ export class AppShellViewModel { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets new file mode 100644 index 0000000000..b43aa96612 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets @@ -0,0 +1,1038 @@ +import { + RecentWorkspaceEntry, + RemoteImageContext, + RemoteQuestionAnswerPayload, + RemoteSession, + SessionSummary, + SelectedImageAttachment +} from '../../model/RemoteModels'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { ChatTimelineItem } from '../../services/ChatTimelineProjector'; +import { ChatTimelineState } from '../../services/ChatTimelineStore'; +import { ClipboardService } from '../../services/ClipboardService'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { ImagePickerService } from '../../services/ImagePickerService'; +import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; +import { GeneralChatConversationViewModel } from './GeneralChatConversationViewModel'; +import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; +import { + GeneralChatServiceState, + GeneralChatServiceStatus +} from '../../services/general-chat/GeneralChatServiceState'; +import { RemoteChatCommandController } from '../../services/RemoteChatCommandController'; +import { + RemoteChatPollingCursor, + RemoteChatPollingLifecycleController, + RemoteChatPollingSnapshot +} from '../../services/RemoteChatPollingLifecycleController'; +import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteModelController } from '../../services/RemoteModelController'; +import { RemoteToolActionController } from '../../services/RemoteToolActionController'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; +import { VoiceInputRouteSnapshot } from '../../services/VoiceInputLifecycleController'; +import { AppRootRouteState } from '../navigation/AppRootRouteState'; +import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemoteCreateSessionState } from '../state/RemoteCreateSessionState'; +import { RemotePageState } from '../state/RemotePageState'; +import { ConversationViewModel } from './ConversationViewModel'; +import { AppShellViewModel } from './AppShellViewModel'; +import { FilePreviewController } from './FilePreviewController'; +import { RemoteConnectionController } from './RemoteConnectionController'; +import { RemoteSessionViewModel } from './RemoteSessionViewModel'; +import { SettingsController } from './SettingsController'; +const GENERAL_CHAT_HOME_DRAFT_ID: string = 'new-chat'; + +export interface ConversationControllerHooks { + readonly currentRoute: () => AppRoute; +} + +export interface RemoteConversationHooks { + readonly isConversationContext: (sessionId: string) => boolean; + readonly isFilePreviewVisible: () => boolean; + readonly stopVoiceInput: () => Promise; + readonly showToast: (message: string) => boolean; + readonly selectAssistantWorkspace: (path: string) => Promise; +} + +export interface RemoteConversationDependencies { + readonly timeline: ConversationViewModel; + readonly chat: RemoteChatCommandController; + readonly polling: RemoteChatPollingLifecycleController; + readonly models: RemoteModelController; + readonly files: RemoteFileDownloadController; + readonly tools: RemoteToolActionController; + readonly connection: RemoteConnectionController; + readonly imagePicker: ImagePickerService; + readonly clipboard: ClipboardService; + readonly sessions: RemoteSessionViewModel; + readonly sessionManager: RemoteSessionManager; + readonly workspace: RemoteWorkspaceCoordinator; + readonly settings: SettingsController; + readonly appShell: AppShellViewModel; + readonly filePreview: FilePreviewController; + readonly generalCommands: GeneralChatCommandController; + readonly generalConversation: GeneralChatConversationViewModel; + readonly generalDrafts: GeneralChatDraftLifecycleController; + readonly hooks: RemoteConversationHooks; +} + +/** Owns route-dependent composer and voice presentation state. */ +export class ConversationController { + private readonly general: GeneralChatPageState; + private readonly remote: RemotePageState; + private readonly remoteCreate: RemoteCreateSessionState; + private readonly hooks: ConversationControllerHooks; + private readonly remoteRuntime?: RemoteConversationDependencies; + private knownPollVersionValue: number = 0; + private knownModelCatalogVersion: number = 0; + private knownRemoteMessageCount: number = 0; + private isSyncingAfterTurn: boolean = false; + private remoteCreateWorkspaceLoadVersion: number = 0; + + constructor( + general: GeneralChatPageState, + remote: RemotePageState, + remoteCreate: RemoteCreateSessionState, + hooks: ConversationControllerHooks, + remoteRuntime?: RemoteConversationDependencies + ) { + this.general = general; + this.remote = remote; + this.remoteCreate = remoteCreate; + this.hooks = hooks; + this.remoteRuntime = remoteRuntime; + } + + visibleChatInput(): string { + const route = this.hooks.currentRoute(); + return route === AppRoute.RemoteCreate ? this.remoteCreate.draft : + AppRootRouteState.chatInput(route, this.general, this.remote); + } + + visibleSelectedImages(): SelectedImageAttachment[] { + return AppRootRouteState.selectedImages(this.hooks.currentRoute(), this.general, this.remote); + } + + visibleVoiceListening(): boolean { + const route = this.hooks.currentRoute(); + return route === AppRoute.RemoteCreate ? this.remoteCreate.isVoiceListening : + AppRootRouteState.voiceListening(route, this.general, this.remote); + } + + setChatInput(route: AppRoute, value: string): void { + if (route === AppRoute.RemoteCreate) { + this.remoteCreate.setDraft(value); + return; + } + AppRootRouteState.setChatInput(route, value, this.general, this.remote); + } + + addSelectedImages(route: AppRoute, images: SelectedImageAttachment[]): void { + AppRootRouteState.addSelectedImages(route, images, this.general, this.remote); + } + + removeSelectedImage(route: AppRoute, imageId: string): void { + AppRootRouteState.removeSelectedImage(route, imageId, this.general, this.remote); + } + + setVoiceListening(route: AppRoute, isVoiceListening: boolean): void { + if (route === AppRoute.RemoteCreate) { + this.remoteCreate.isVoiceListening = isVoiceListening; + return; + } + AppRootRouteState.setVoiceListening(route, isVoiceListening, this.general, this.remote); + } + + clearAllVoiceListening(): void { + this.general.setVoiceListening(false); + this.remote.setVoiceListening(false); + this.remoteCreate.isVoiceListening = false; + } + + visibleBusy(): boolean { + return this.isGeneralComposerRoute(this.hooks.currentRoute()) ? + this.general.isBusy : this.remote.isBusy; + } + + visibleStatusText(): string { + return this.isGeneralComposerRoute(this.hooks.currentRoute()) ? + this.general.statusText : this.remote.statusText; + } + + setVisibleStatusText(statusText: string): void { + if (this.isGeneralComposerRoute(this.hooks.currentRoute())) { + this.general.setStatus(statusText); + return; + } + this.remote.setStatusText(statusText); + } + + voiceInputSnapshot(route: AppRoute): VoiceInputRouteSnapshot { + if (route === AppRoute.RemoteCreate) { + return { + routeId: `${route}`, + isListening: this.remoteCreate.isVoiceListening, + isBusy: this.remoteCreate.isSubmitting, + inputText: this.remoteCreate.draft, + selectedImageCount: 0 + }; + } + return AppRootRouteState.snapshot(route, this.visibleBusy(), this.general, this.remote); + } + + isGeneralComposerRoute(route: AppRoute): boolean { + return AppRouteContract.isGeneralComposerRoute(route); + } + + knownPollVersion(): number { + return this.knownPollVersionValue; + } + + resetKnownRemoteState(): void { + this.knownPollVersionValue = 0; + this.knownModelCatalogVersion = 0; + this.knownRemoteMessageCount = 0; + } + + updateKnownMessageCount(pollVersion: number, knownMessageCount: number): void { + this.knownRemoteMessageCount = knownMessageCount; + this.updateChatPollingCursor(pollVersion, knownMessageCount); + } + + updateKnownModelCatalogVersion(version: number): void { + this.knownModelCatalogVersion = version; + this.requireRemoteRuntime().polling.updateKnownModelCatalogVersion(version); + } + + async loadRemoteMessages(): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.chat.loadMessages( + this.remote.activeSession.sessionId || '', + runtime.hooks.isConversationContext + ); + } + + async loadRemoteModelCatalog(sessionId: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.models.loadCatalog( + sessionId, + runtime.connection.ensureAvailable(), + runtime.hooks.isConversationContext + ); + } + + async selectRemoteModel(modelId: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.models.selectModel( + modelId, + this.remote.activeSession.sessionId || '', + this.remote.isBusy, + runtime.connection.ensureAvailable() + ); + } + + async loadOlderRemoteMessages(): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.chat.loadOlderMessages( + this.remote.activeSession.sessionId || '', + this.knownPollVersionValue, + this.remote.hasMoreMessages, + this.remote.isBusy + ); + } + + async sendRemoteMessage(): Promise { + const runtime = this.requireRemoteRuntime(); + if (this.remote.isVoiceListening) { + await runtime.hooks.stopVoiceInput(); + } + const rawText = this.remote.chatInput.trim(); + const images = this.remote.selectedImages.slice(); + const text = rawText.length > 0 ? rawText : + (images.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); + const sessionId = this.remote.activeSession.sessionId || ''; + if ((!text && images.length === 0) || !sessionId || this.remote.isBusy || + !runtime.connection.ensureAvailable()) { + return; + } + this.remote.clearComposer(); + const localMessage = RemoteUiState.localUserMessage(text, images); + runtime.timeline.appendOptimisticMessage(localMessage); + const pendingActiveId = runtime.timeline.setPendingActiveTurn(localMessage.id); + this.syncRemoteTimeline(); + RemoteLogger.info(`chat send queued session=${this.shortSessionId(sessionId)} pending=${pendingActiveId}`); + this.startRemotePolling(); + runtime.polling.nudge(); + const imageContexts: RemoteImageContext[] = images.length > 0 ? + runtime.imagePicker.toRemoteContexts(images) : []; + await runtime.chat.sendPreparedMessage( + sessionId, + text, + this.remote.activeSession.agentType, + rawText, + images, + imageContexts, + localMessage.id, + pendingActiveId, + this.remote.isBusy, + true + ); + } + + async stopRemoteTask(): Promise { + const runtime = this.requireRemoteRuntime(); + const sessionId = this.remote.activeSession.sessionId || ''; + if (!sessionId) { + return; + } + await runtime.chat.stopTask( + sessionId, + this.remote.activeTurnMessage.id, + this.remoteActiveTurnId(), + runtime.connection.ensureAvailable() + ); + } + + async renameRemoteSession(title: string): Promise { + const runtime = this.requireRemoteRuntime(); + const nextTitle = title.trim(); + if (!this.remote.activeSession.sessionId || nextTitle.length === 0 || + nextTitle === this.remote.activeSession.title || this.remote.isBusy) { + return; + } + await runtime.chat.renameActiveSession( + this.remote.activeSession, + nextTitle, + this.remote.isBusy, + runtime.connection.ensureAvailable() + ); + } + + async copyRemoteMessage(text: string): Promise { + if (text.trim().length === 0) { + return; + } + try { + await this.requireRemoteRuntime().clipboard.writeText(text); + this.remote.setStatusText(RemoteI18n.t('status.messageCopied')); + } catch (err) { + this.remote.setStatusText(ConnectionErrorPolicy.errorText(err)); + } + } + + async downloadRemoteFile(path: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.files.download( + path, + this.remote.activeSession.sessionId || '', + this.remote.isBusy, + runtime.connection.ensureAvailable() + ); + } + + retryRemoteMessage(text: string): void { + if (this.remote.isBusy || !this.requireRemoteRuntime().connection.ensureAvailable()) { + return; + } + this.remote.setChatInput(text); + this.sendRemoteMessage(); + } + + async approveRemoteTool(toolId: string, updatedInput?: Object): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.tools.approve( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable(), updatedInput + ); + } + + async rejectRemoteTool(toolId: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.tools.reject( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable() + ); + } + + async cancelRemoteTool(toolId: string): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.tools.cancel( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable() + ); + } + + async answerRemoteQuestion(toolId: string, answers: RemoteQuestionAnswerPayload): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.tools.answer( + toolId, this.remote.activeSession.sessionId || '', runtime.connection.ensureAvailable(), answers + ); + } + + resetRemoteTimeline(sessionId: string): void { + const runtime = this.requireRemoteRuntime(); + runtime.timeline.reset(sessionId); + this.knownPollVersionValue = 0; + this.syncRemoteTimeline(); + } + + syncRemoteTimeline(): void { + const runtime = this.requireRemoteRuntime(); + const state: ChatTimelineState = runtime.timeline.snapshotState(); + this.remote.setTimelineProjection( + state.persistedMessages, + state.optimisticMessages, + state.activeTurn || RemoteUiState.emptyActiveTurn(), + this.remote.hasMoreMessages, + runtime.timeline.viewState(this.remote.hasMoreMessages) + ); + this.remote.setModelCatalog(state.modelCatalog, state.selectedModelId); + } + + startRemotePolling(): void { + this.requireRemoteRuntime().polling.startActiveSession({ + sessionId: this.remote.activeSession.sessionId || '', + cursor: this.currentChatPollingCursor(), + activeTurn: this.remote.activeTurnMessage + }); + } + + applyRemoteSnapshot(snapshot: RemoteChatPollingSnapshot): void { + const runtime = this.requireRemoteRuntime(); + if (!runtime.hooks.isConversationContext(snapshot.sessionId)) { + return; + } + runtime.timeline.applySnapshot(snapshot); + this.syncRemoteTimeline(); + this.knownPollVersionValue = snapshot.cursor.pollVersion; + this.knownModelCatalogVersion = snapshot.cursor.knownModelCatalogVersion; + this.knownRemoteMessageCount = snapshot.cursor.knownMessageCount; + if (snapshot.title.length > 0) { + this.remote.setActiveSession({ + sessionId: this.remote.activeSession.sessionId, + title: snapshot.title, + workspacePath: this.remote.activeSession.workspacePath, + agentType: this.remote.activeSession.agentType + }); + } + if (snapshot.modelCatalog) { + runtime.models.applyCatalog(snapshot.modelCatalog); + } + this.remote.setStatusText(this.hasRunningRemoteTurn() + ? RemoteI18n.t('status.desktopProcessing') + : RemoteI18n.t('status.messagesSynced')); + if (snapshot.shouldSyncAfterTurnEnded) { + this.syncAfterRemoteTurnEnded(); + } + } + + hasRunningRemoteTurn(): boolean { + return this.remote.activeTurnMessage.id.length > 0 && + (this.remote.activeTurnMessage.status || '').toLowerCase() === 'active'; + } + + remoteActiveTurnId(): string { + const active = this.remote.activeTurnMessage; + if (active.turnId && active.turnId.length > 0) { + return active.turnId; + } + return active.id.indexOf('active-') === 0 ? active.id.slice('active-'.length) : ''; + } + + projectedRemoteTimelineItems(): ChatTimelineItem[] { + return this.requireRemoteRuntime().timeline.viewState(this.remote.hasMoreMessages); + } + + async createRemoteSession(agentType: string, inPlace: boolean = false): Promise { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + await runtime.sessions.createSession( + agentType, + '', + inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : + (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) + ); + } + + openRemoteCreateSession(): void { + const runtime = this.requireRemoteRuntime(); + if (!runtime.connection.ensureAvailable()) { + return; + } + const deviceId = this.remote.controlTargetDeviceId || this.remote.desktopId; + const deviceName = this.remote.controlTargetDeviceName || this.remote.desktopName; + this.remoteCreate.prepare(deviceId, deviceName, this.remote.selectedModelId); + if (deviceId.length > 0) { + this.remoteCreate.setDevices([{ + deviceId, + deviceName: deviceName || deviceId, + online: true + }]); + } + this.remoteCreate.setWorkspaces(this.remote.recentWorkspaces); + runtime.appShell.pushRoute(AppRoute.RemoteCreate); + this.loadRemoteCreateChoices(); + this.loadRemoteCreateModelCatalog(); + } + + closeRemoteCreateSession(): void { + const runtime = this.requireRemoteRuntime(); + this.remoteCreateWorkspaceLoadVersion += 1; + runtime.hooks.stopVoiceInput(); + this.remoteCreate.closeMenu(); + runtime.appShell.popRoute(AppRoute.RemoteHome); + } + + async loadRemoteCreateChoices(): Promise { + await Promise.all([ + this.loadRemoteCreateDevices(), + this.loadRemoteCreateWorkspaces() + ]); + } + + async loadRemoteCreateModelCatalog(): Promise { + const runtime = this.requireRemoteRuntime(); + if (this.remote.modelCatalog.models.length > 0) { + return; + } + try { + const catalog = await runtime.sessionManager.getModelCatalog(); + const selectedModelId = RemoteUiState.selectedModelIdForCatalog(catalog, this.remote.selectedModelId); + this.remote.setModelCatalog(catalog, selectedModelId); + this.remoteCreate.setSelectedModelId(selectedModelId); + } catch (_err) { + // Model selection remains hidden when the remote does not expose a catalog. + } + } + + async loadRemoteCreateDevices(): Promise { + const runtime = this.requireRemoteRuntime(); + this.remoteCreate.isLoadingDevices = this.remoteCreate.devices.length === 0; + try { + const phoneDeviceId = runtime.connection.getDeviceId(); + const accountDevices = await runtime.settings.listCloudAccountDevices(); + const devices = accountDevices.filter((device: CloudAccountDevice): boolean => + device.online && device.deviceId !== phoneDeviceId + ); + const currentId = this.remoteCreate.selectedDeviceId; + if (currentId.length > 0 && + !devices.some((device: CloudAccountDevice): boolean => device.deviceId === currentId)) { + devices.unshift({ + deviceId: currentId, + deviceName: this.remoteCreate.selectedDeviceName || currentId, + online: true + }); + } + this.remoteCreate.setDevices(devices); + } catch (_err) { + const currentId = this.remoteCreate.selectedDeviceId; + if (currentId.length > 0) { + this.remoteCreate.setDevices([{ + deviceId: currentId, + deviceName: this.remoteCreate.selectedDeviceName || currentId, + online: true + }]); + } else { + this.remoteCreate.setDevices([]); + } + this.remoteCreate.errorText = RemoteI18n.t('remote.create.deviceLoadFailed'); + } + } + + async loadRemoteCreateWorkspaces(): Promise { + const runtime = this.requireRemoteRuntime(); + const loadVersion = ++this.remoteCreateWorkspaceLoadVersion; + const deviceId = this.remoteCreate.selectedDeviceId; + this.remoteCreate.isLoadingWorkspaces = this.remoteCreate.workspaces.length === 0; + try { + const workspaces = await runtime.workspace.recentWorkspaces(); + if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || + deviceId !== this.remoteCreate.selectedDeviceId) { + return; + } + this.remoteCreate.setWorkspaces(workspaces); + } catch (_err) { + if (loadVersion !== this.remoteCreateWorkspaceLoadVersion || + deviceId !== this.remoteCreate.selectedDeviceId) { + return; + } + this.remoteCreate.setWorkspaces([]); + this.remoteCreate.errorText = RemoteI18n.t('remote.create.workspaceLoadFailed'); + } + } + + toggleRemoteCreateDevices(): void { + this.remoteCreate.toggleMenu('devices'); + if (this.remoteCreate.openMenu === 'devices' && this.remoteCreate.devices.length === 0) { + this.loadRemoteCreateDevices(); + } + } + + toggleRemoteCreateWorkspaces(): void { + this.remoteCreate.toggleMenu('workspaces'); + if (this.remoteCreate.openMenu === 'workspaces' && this.remoteCreate.workspaces.length === 0) { + this.loadRemoteCreateWorkspaces(); + } + } + + async selectRemoteCreateDevice(device: CloudAccountDevice): Promise { + const runtime = this.requireRemoteRuntime(); + if (device.deviceId === this.remoteCreate.selectedDeviceId) { + this.remoteCreate.closeMenu(); + return; + } + const draft = this.remoteCreate.draft; + this.remoteCreate.closeMenu(); + this.remoteCreate.isLoadingWorkspaces = true; + try { + await runtime.settings.selectCloudAccountDevice(device, false); + this.remoteCreate.selectDevice(device); + this.remoteCreate.setDraft(draft); + await this.loadRemoteCreateWorkspaces(); + } catch (err) { + this.remoteCreate.isLoadingWorkspaces = false; + this.remoteCreate.errorText = err instanceof Error ? err.message : + RemoteI18n.t('remote.settings.deviceSwitchFailed'); + } + } + + selectRemoteCreateWorkspace(path: string): void { + const workspace = this.remoteCreate.workspaces + .find((item: RecentWorkspaceEntry): boolean => item.path === path); + this.remoteCreate.selectWorkspace(workspace); + } + + async submitRemoteCreateSession(): Promise { + const runtime = this.requireRemoteRuntime(); + const instruction = this.remoteCreate.draft.trim(); + if (instruction.length === 0 || this.remoteCreate.isSubmitting || !runtime.connection.ensureAvailable()) { + return; + } + const context = this.remoteCreate.submissionContext(); + const activeDeviceId = this.remote.controlTargetDeviceId || this.remote.desktopId; + if (context.deviceId.length === 0 || context.deviceId !== activeDeviceId) { + this.remoteCreate.errorText = RemoteI18n.t('remote.create.deviceMismatch'); + return; + } + this.remoteCreate.isSubmitting = true; + this.remoteCreate.errorText = ''; + this.remoteCreate.closeMenu(); + try { + if (context.workspacePath.length > 0) { + await runtime.sessions.createSessionInWorkspace( + context.workspacePath, + this.remote.workspacePath, + instruction, + context.agentType, + undefined, + this.remoteCreate.selectedModelId + ); + } else { + await this.bindAssistantWorkspace(); + await runtime.sessions.createSession( + context.agentType, + instruction, + undefined, + this.remoteCreate.selectedModelId + ); + } + if (runtime.appShell.isRoute(AppRoute.RemoteCreate)) { + this.remoteCreate.errorText = this.remote.statusText || RemoteI18n.t('remote.create.submitFailed'); + } + } catch (err) { + this.remoteCreate.errorText = err instanceof Error ? err.message : + RemoteI18n.t('remote.create.submitFailed'); + } finally { + this.remoteCreate.isSubmitting = false; + } + } + + /** + * The chat option creates a Claw session, and the desktop always binds those + * to its assistant workspace. Follow it there first, otherwise the app stays + * bound to the code workspace it was on and the new chat is listed, titled + * and file-scoped as if it had been created inside that workspace. + */ + private async bindAssistantWorkspace(): Promise { + const runtime = this.requireRemoteRuntime(); + if (this.remote.workspaceKind === 'assistant') { + return; + } + try { + const assistants = await runtime.workspace.assistants(); + if (assistants.length === 0) { + return; + } + await runtime.hooks.selectAssistantWorkspace(assistants[0].path); + } catch (err) { + RemoteLogger.warn(`assistant workspace bind failed: ${String(err)}`); + } + } + + async createRemoteSessionInWorkspace( + path: string, + agentType: string = 'code', + inPlace: boolean = false + ): Promise { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + await runtime.sessions.createSessionInWorkspace( + path, + this.remote.workspacePath, + '', + agentType, + inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : + (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) + ); + } + + async openRemoteSession(item: RemoteSession, inPlace: boolean = false): Promise { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + await runtime.sessions.openSession( + item, + this.remote.workspacePath, + inPlace ? (sessionId: string): void => this.routeRemoteSessionInPlace(sessionId) : + (sessionId: string): void => this.routeCreatedRemoteSession(sessionId) + ); + } + + applyRemoteActiveSession(session: SessionSummary): void { + const runtime = this.requireRemoteRuntime(); + const current = this.remote.activeSession; + if (runtime.hooks.isFilePreviewVisible() && + (current.sessionId !== session.sessionId || current.workspacePath !== session.workspacePath)) { + runtime.filePreview.close(); + } + this.remote.setActiveSession(session); + } + + async deleteRemoteSession(item: RemoteSession): Promise { + await this.requireRemoteRuntime().sessions.deleteSession(item, this.remote.workspacePath); + } + + openHomeSession(session: RemoteSession, inPlace: boolean = false): void { + this.requireRemoteRuntime().filePreview.close(); + if (session.agentType === 'chat') { + this.openGeneralSession(session); + return; + } + this.openRemoteSession(session, inPlace); + } + + async deleteHomeSession(session: RemoteSession): Promise { + if (session.agentType !== 'chat') { + await this.deleteRemoteSession(session); + return; + } + await this.requireRemoteRuntime().generalCommands.deleteSession(session, this.general.isBusy); + } + + activeGeneralChatAsRemoteSession(): RemoteSession { + const active = this.general.activeSession; + return { + id: active.sessionId, + title: active.title, + agentType: 'chat', + status: 'ready', + updatedAt: '', + createdAt: '', + messageCount: this.general.timelineItems.length, + workspacePath: active.workspacePath + }; + } + + activeGeneralUploadedFileCount(): number { + let count = 0; + this.general.timelineItems.forEach((item: ChatTimelineItem) => { + if (item.message && item.message.images) { + count += item.message.images.length; + } + }); + return count; + } + + async archiveHomeSession(session: RemoteSession, archived: boolean): Promise { + await this.requireRemoteRuntime().generalCommands.archiveSession(session, archived, this.general.isBusy); + } + + async exportHomeSession(session: RemoteSession): Promise { + const runtime = this.requireRemoteRuntime(); + await runtime.generalCommands.exportSession( + session, + this.general.isBusy, + async (text: string): Promise => runtime.clipboard.writeText(text) + ); + } + + async openGeneralSession(item: RemoteSession): Promise { + const runtime = this.requireRemoteRuntime(); + if (this.general.isBusy) { + return; + } + runtime.polling.stop(); + runtime.generalConversation.stop(false); + await runtime.generalCommands.openSession( + item, + this.general.isBusy, + async (sessionId: string): Promise => runtime.generalDrafts.restore(sessionId), + (_sessionId: string): void => runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome) + ); + } + + async startGeneralChat(text: string): Promise { + const runtime = this.requireRemoteRuntime(); + const trimmed = text.trim(); + if (trimmed.length === 0 || this.general.isBusy) { + return; + } + runtime.polling.stop(); + runtime.generalConversation.stop(false); + runtime.generalDrafts.cancel(); + const created = await runtime.generalCommands.createSession( + trimmed, + this.general.isBusy, + async (): Promise => runtime.generalDrafts.clearHomeNow(), + (_sessionId: string): void => runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome) + ); + if (created) { + await runtime.generalConversation.sendMessage(); + } + } + + async sendVisibleMessage(): Promise { + const runtime = this.requireRemoteRuntime(); + if (runtime.appShell.isGeneralChatVisible()) { + if ((this.general.activeSession.sessionId || '').length === 0) { + this.startVisibleGeneralChat(); + return; + } + await runtime.generalConversation.sendMessage(); + return; + } + await this.sendRemoteMessage(); + } + + async stopVisibleTask(): Promise { + const runtime = this.requireRemoteRuntime(); + if (runtime.appShell.isGeneralChatVisible()) { + runtime.generalConversation.stop(true); + return; + } + await this.stopRemoteTask(); + } + + closeActiveChat(): void { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + runtime.hooks.stopVoiceInput(); + if (runtime.appShell.isRoute(AppRoute.GeneralChat)) { + runtime.generalDrafts.persistVisible(this.general.chatInput); + runtime.generalConversation.stop(true); + runtime.appShell.popRoute(AppRoute.ChatHome); + this.restoreGeneralChatDraft(GENERAL_CHAT_HOME_DRAFT_ID); + return; + } + runtime.polling.stop(); + this.remote.setConversationDismissed(true); + runtime.appShell.replaceRouteWithoutAnimation(AppRoute.RemoteHome); + } + + async renameVisibleSession(title: string): Promise { + const runtime = this.requireRemoteRuntime(); + if (runtime.appShell.isGeneralChatVisible()) { + await runtime.generalCommands.renameActiveSession(this.general.activeSession, title); + return; + } + await this.renameRemoteSession(title); + } + + async retryVisibleMessage(text: string): Promise { + const runtime = this.requireRemoteRuntime(); + if (runtime.appShell.isGeneralChatVisible()) { + const prepared = await runtime.generalCommands.retryMessage( + this.general.activeSession.sessionId || '', text, this.general.isBusy + ); + if (prepared) { + await runtime.generalConversation.sendMessage(); + } + return; + } + this.retryRemoteMessage(text); + } + + downloadVisibleFile(path: string): void { + if (this.requireRemoteRuntime().appShell.isGeneralChatVisible()) { + this.general.setStatus(RemoteI18n.t('generalChat.fileDownloadMock')); + return; + } + this.downloadRemoteFile(path); + } + + async selectVisibleModel(modelId: string): Promise { + const runtime = this.requireRemoteRuntime(); + if (runtime.appShell.isGeneralChatVisible()) { + await runtime.settings.selectModel(modelId); + return; + } + await this.selectRemoteModel(modelId); + } + + startVisibleGeneralChat(): void { + const rawText = this.general.chatInput.trim(); + const text = rawText.length > 0 ? rawText : + (this.general.selectedImages.length > 0 ? RemoteI18n.t('chat.analyzeImageDefault') : ''); + if (text.length === 0 || this.general.isBusy) { + return; + } + if (this.general.serviceState === GeneralChatServiceState.Unconfigured) { + const statusText = GeneralChatServiceStatus.userMessage(this.general.serviceState); + this.general.setStatus(statusText); + this.showHomeToast(statusText); + return; + } + this.startGeneralChat(text); + } + + generalChatHomeStatusText(): string { + if (this.general.serviceState === GeneralChatServiceState.Ready || + this.general.serviceState === GeneralChatServiceState.Sending || + this.general.serviceState === GeneralChatServiceState.Streaming) { + return ''; + } + return GeneralChatServiceStatus.userMessage(this.general.serviceState, this.general.statusText); + } + + prepareNewGeneralChat(): void { + const runtime = this.requireRemoteRuntime(); + runtime.hooks.stopVoiceInput(); + runtime.generalConversation.stop(true); + runtime.generalDrafts.clearHome(); + this.general.clearComposer(); + this.general.clearActiveSession(); + this.resetGeneralTimeline(''); + runtime.appShell.replaceRouteWithoutAnimation(AppRoute.ChatHome); + } + + onVisibleChatInputChange(route: AppRoute, value: string): void { + this.setChatInput(route, value); + if (this.isGeneralComposerRoute(route)) { + this.requireRemoteRuntime().generalDrafts.scheduleVisible(value); + } + } + + visibleGeneralChatDraftId(): string { + return this.requireRemoteRuntime().appShell.isGeneralChatVisible() ? + this.general.activeSession.sessionId || GENERAL_CHAT_HOME_DRAFT_ID : ''; + } + + async restoreGeneralChatDraft(draftId: string): Promise { + this.general.setChatInput(await this.requireRemoteRuntime().generalDrafts.restore(draftId)); + } + + latestUserMessageText(): string { + if (this.requireRemoteRuntime().appShell.isGeneralChatVisible()) { + return this.general.latestUserMessageText(); + } + const candidates = this.remote.persistedMessages.concat(this.remote.optimisticMessages); + for (let index = candidates.length - 1; index >= 0; index--) { + if (candidates[index].role === 'user' && candidates[index].text.trim().length > 0) { + return candidates[index].text; + } + } + return ''; + } + + resetGeneralTimeline(sessionId: string): void { + this.requireRemoteRuntime().timeline.reset(sessionId); + this.syncGeneralTimeline(); + } + + syncGeneralTimeline(): void { + const runtime = this.requireRemoteRuntime(); + const state: ChatTimelineState = runtime.timeline.snapshotState(); + const projectedItems = runtime.timeline.viewState(false); + this.general.setTimelineProjection( + state.persistedMessages, + state.optimisticMessages, + state.activeTurn || RemoteUiState.emptyActiveTurn(), + false, + projectedItems + ); + const itemSummary = projectedItems.map((item: ChatTimelineItem) => { + const message = item.message; + return `${item.type}:${item.id}:${message ? message.status : ''}:${message ? message.text.length : 0}`; + }).join(','); + RemoteLogger.info(`general chat projection revision=${this.general.timelineRevision} persisted=${state.persistedMessages.length} active=${state.activeTurn ? state.activeTurn.id : 'none'} items=${itemSummary}`); + } + + showHomeToast(message: string): void { + const runtime = this.requireRemoteRuntime(); + if (!runtime.hooks.showToast(message)) { + this.setVisibleStatusText(message); + } + } + + private currentChatPollingCursor(): RemoteChatPollingCursor { + return { + pollVersion: this.knownPollVersionValue, + knownMessageCount: this.knownRemoteMessageCount, + knownModelCatalogVersion: this.knownModelCatalogVersion + }; + } + + private updateChatPollingCursor(pollVersion: number, knownMessageCount: number): void { + this.knownPollVersionValue = pollVersion; + this.knownRemoteMessageCount = knownMessageCount; + this.requireRemoteRuntime().polling.updateCursor({ + pollVersion, + knownMessageCount, + knownModelCatalogVersion: this.knownModelCatalogVersion + }); + } + + private async syncAfterRemoteTurnEnded(): Promise { + if (this.isSyncingAfterTurn) { + return; + } + this.isSyncingAfterTurn = true; + try { + await this.loadRemoteMessages(); + } finally { + this.isSyncingAfterTurn = false; + } + } + + private shortSessionId(sessionId: string): string { + return sessionId.length <= 8 ? sessionId : + sessionId.slice(0, 4) + '...' + sessionId.slice(sessionId.length - 4); + } + + routeCreatedRemoteSession(sessionId: string): void { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + this.remote.setConversationDismissed(false); + if (runtime.appShell.isRoute(AppRoute.RemoteCreate)) { + runtime.appShell.replaceCurrentRoute(AppRoute.RemoteChat, sessionId); + return; + } + runtime.appShell.pushRoute(AppRoute.RemoteChat, sessionId); + } + + private routeRemoteSessionInPlace(sessionId: string): void { + const runtime = this.requireRemoteRuntime(); + runtime.filePreview.close(); + this.remote.setConversationDismissed(false); + const target = AppRouteContract.remoteSessionDestination(sessionId); + runtime.appShell.replaceRouteWithoutAnimation(target.name, target.routeParam().sessionId); + } + + private requireRemoteRuntime(): RemoteConversationDependencies { + if (!this.remoteRuntime) { + throw new Error('Remote conversation dependencies are not configured.'); + } + return this.remoteRuntime; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationViewModel.ets similarity index 100% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationViewModel.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/FilePreviewController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/FilePreviewController.ets new file mode 100644 index 0000000000..fc8300b1f6 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/FilePreviewController.ets @@ -0,0 +1,92 @@ +import { FilePreviewRequest, FilePreviewTargetContext } from '../../model/FilePreviewTarget'; +import { SessionSummary } from '../../model/RemoteModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { FileReferenceKind, FileTargetResolver } from '../../services/FileTargetResolver'; +import { RemoteWorkspaceFileClient } from '../../services/RemoteWorkspaceFileClient'; +import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; +import { FilePreviewState } from '../state/FilePreviewState'; +import { RemoteFilePreviewController } from './RemoteFilePreviewController'; + +export interface FilePreviewControllerHooks { + readonly remoteAvailable: () => boolean; + readonly activeSession: () => SessionSummary; + readonly workspacePath: () => string; + readonly openExternalLink: (reference: string) => Promise; + readonly onGeneralStatus: (statusText: string) => void; + readonly onRemoteStatus: (statusText: string) => void; +} + +/** Owns file-preview routing, target validity and the underlying remote file load. */ +export class FilePreviewController { + private readonly state: FilePreviewState; + private readonly hooks: FilePreviewControllerHooks; + private readonly loader: RemoteFilePreviewController; + private controlTargetEpoch: number = 1; + + constructor( + client: RemoteWorkspaceFileClient, + state: FilePreviewState, + hooks: FilePreviewControllerHooks + ) { + this.state = state; + this.hooks = hooks; + this.loader = new RemoteFilePreviewController( + client, + state, + hooks.remoteAvailable, + (): number => this.controlTargetEpoch + ); + } + + open(route: AppRoute, request: FilePreviewRequest): void { + const activeSession = this.hooks.activeSession(); + const context = new FilePreviewTargetContext( + activeSession.sessionId, + activeSession.workspacePath || this.hooks.workspacePath(), + this.controlTargetEpoch + ); + const resolution = FileTargetResolver.resolve(request.reference, request.label, context); + if (resolution.kind === FileReferenceKind.HttpUrl) { + void this.openExternalLink(route, request.reference); + return; + } + if (route !== AppRoute.RemoteChat) { + this.hooks.onGeneralStatus(RemoteI18n.t('generalChat.filePreviewUnavailable')); + return; + } + if (resolution.kind !== FileReferenceKind.RemoteWorkspaceFile || !resolution.target) { + return; + } + void this.loader.open(resolution.target); + } + + close(): void { + this.loader.close(); + } + + refresh(): void { + void this.loader.refresh(); + } + + openLink(reference: string, label: string): void { + this.open(AppRoute.RemoteChat, new FilePreviewRequest(reference, label)); + } + + invalidate(): void { + this.controlTargetEpoch += 1; + this.loader.close(); + } + + private async openExternalLink(route: AppRoute, reference: string): Promise { + const opened = await this.hooks.openExternalLink(reference); + if (opened) { + return; + } + const statusText = RemoteI18n.t('errors.operationFailed'); + if (AppRouteContract.isGeneralComposerRoute(route)) { + this.hooks.onGeneralStatus(statusText); + return; + } + this.hooks.onRemoteStatus(statusText); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatConversationViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/GeneralChatConversationViewModel.ets similarity index 95% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatConversationViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/GeneralChatConversationViewModel.ets index cd489b440c..0248180436 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/GeneralChatConversationViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/GeneralChatConversationViewModel.ets @@ -8,7 +8,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; import { Encoding } from '../../services/Encoding'; import { ConversationViewModel } from './ConversationViewModel'; -import { GeneralChatPageState } from './GeneralChatPageState'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { GeneralChatCommandController } from '../../services/general-chat/GeneralChatCommandController'; import { GeneralChatDraftLifecycleController } from '../../services/general-chat/GeneralChatDraftLifecycleController'; import { GeneralChatStreamLifecycleController } from '../../services/general-chat/GeneralChatStreamLifecycleController'; @@ -22,26 +22,12 @@ import { } from '../../services/general-chat/GeneralChatPort'; import { RemoteLogger } from '../../services/RemoteLogger'; -export class GeneralChatConversationViewModelHooks { +export interface GeneralChatConversationViewModelHooks { readonly isVisible: (sessionId: string) => boolean; readonly currentActiveTurnId: () => string; readonly latestUserMessageText: () => string; readonly syncTimeline: () => void; readonly refreshSessions: () => void; - - constructor( - isVisible: (sessionId: string) => boolean, - currentActiveTurnId: () => string, - latestUserMessageText: () => string, - syncTimeline: () => void, - refreshSessions: () => void - ) { - this.isVisible = isVisible; - this.currentActiveTurnId = currentActiveTurnId; - this.latestUserMessageText = latestUserMessageText; - this.syncTimeline = syncTimeline; - this.refreshSessions = refreshSessions; - } } /** Owns General Chat stream state and publishes all updates through ConversationViewModel. */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteActivityViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets similarity index 78% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteActivityViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets index fd029d22ec..8228df45dd 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteActivityViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteActivityViewModel.ets @@ -5,7 +5,7 @@ import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; import { RemoteActivityLifecycleController } from '../../services/RemoteActivityLifecycleController'; import { RemoteConnectionCoordinator } from '../../services/RemoteConnectionCoordinator'; -export class RemoteActivityViewModelHooks { +export interface RemoteActivityViewModelHooks { readonly isConnected: () => boolean; readonly isBusy: () => boolean; readonly hasRemoteBinding: () => boolean; @@ -20,38 +20,6 @@ export class RemoteActivityViewModelHooks { readonly onPoll: () => Promise; readonly onReconnect: () => Promise; readonly onRestoreSession: (session: SessionSummary) => Promise; - - constructor( - isConnected: () => boolean, - isBusy: () => boolean, - hasRemoteBinding: () => boolean, - isRemoteChat: () => boolean, - activeSession: () => SessionSummary, - onConnectionState: (state: string) => void, - onStatus: (status: string) => void, - onConnectionError: (err: Object) => Promise, - onStopHeartbeat: () => void, - onStartPolling: () => void, - onStopPolling: () => void, - onPoll: () => Promise, - onReconnect: () => Promise, - onRestoreSession: (session: SessionSummary) => Promise - ) { - this.isConnected = isConnected; - this.isBusy = isBusy; - this.hasRemoteBinding = hasRemoteBinding; - this.isRemoteChat = isRemoteChat; - this.activeSession = activeSession; - this.onConnectionState = onConnectionState; - this.onStatus = onStatus; - this.onConnectionError = onConnectionError; - this.onStopHeartbeat = onStopHeartbeat; - this.onStartPolling = onStartPolling; - this.onStopPolling = onStopPolling; - this.onPoll = onPoll; - this.onReconnect = onReconnect; - this.onRestoreSession = onRestoreSession; - } } /** Owns foreground recovery, heartbeat health checks, and idempotent resume cancellation. */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteConnectionViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets similarity index 99% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteConnectionViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets index a15d5ca378..40e66955c8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteConnectionViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteConnectionController.ets @@ -16,7 +16,7 @@ import { RemoteSessionController } from '../../services/RemoteSessionController' import { RemoteUiState } from '../../services/RemoteUiState'; import { QrScanService } from '../../services/QrScanService'; import { RemoteConnectionCoordinator, RemoteConnectionRequest } from '../../services/RemoteConnectionCoordinator'; -import { RemotePageState } from './RemotePageState'; +import { RemotePageState } from '../state/RemotePageState'; import { AppRoute } from '../navigation/AppRouteContract'; import { RemoteLogger } from '../../services/RemoteLogger'; @@ -30,7 +30,7 @@ export enum RemoteConnectionState { Disconnected = 'disconnected' } -export class RemoteConnectionViewModel { +export class RemoteConnectionController { private readonly pageState: RemotePageState; private readonly identity: MobileIdentityStore; private readonly pairing: RemotePairingPolicy; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteFilePreviewController.ets similarity index 95% rename from src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteFilePreviewController.ets index a3299361f5..c2de266168 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteFilePreviewController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteFilePreviewController.ets @@ -1,15 +1,15 @@ -import { FileInfo, ReadFileChunkResult, ReadFileResult } from '../model/RemoteModels'; +import { FileInfo, ReadFileChunkResult, ReadFileResult } from '../../model/RemoteModels'; import { FilePreviewPhase, FilePreviewRendererKind, FilePreviewState -} from '../pages/state/FilePreviewState'; -import { FilePreviewTarget } from '../pages/state/FilePreviewTarget'; -import { RemoteI18n } from '../i18n/RemoteI18n'; -import { Encoding } from './Encoding'; -import { FilePreviewErrorPolicy } from './FilePreviewErrorPolicy'; -import { FilePreviewPolicy } from './FilePreviewPolicy'; -import { RemoteWorkspaceFileClient } from './RemoteWorkspaceFileClient'; +} from '../state/FilePreviewState'; +import { FilePreviewTarget } from '../../model/FilePreviewTarget'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { Encoding } from '../../services/Encoding'; +import { FilePreviewErrorPolicy } from '../../services/FilePreviewErrorPolicy'; +import { FilePreviewPolicy } from '../../services/FilePreviewPolicy'; +import { RemoteWorkspaceFileClient } from '../../services/RemoteWorkspaceFileClient'; export class RemoteFilePreviewController { private readonly client: RemoteWorkspaceFileClient; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets similarity index 74% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets index fbedef6d7d..42ab00f630 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteSessionViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteSessionViewModel.ets @@ -4,9 +4,9 @@ import { RemoteChatCommandController } from '../../services/RemoteChatCommandCon import { RemoteModelController } from '../../services/RemoteModelController'; import { RemoteSessionController } from '../../services/RemoteSessionController'; import { RemoteFileDownloadController } from '../../services/RemoteFileDownloadController'; -import { RemotePageState } from './RemotePageState'; +import { RemotePageState } from '../state/RemotePageState'; -export class RemoteSessionViewModelHooks { +export interface RemoteSessionViewModelHooks { readonly remoteAvailable: () => boolean; readonly isConnected: () => boolean; readonly isBusy: () => boolean; @@ -22,40 +22,6 @@ export class RemoteSessionViewModelHooks { readonly onLoadActiveMessages: () => Promise; readonly onRefreshSessions: () => Promise; readonly onSelectWorkspace: (path: string) => Promise; - - constructor( - remoteAvailable: () => boolean, - isConnected: () => boolean, - isBusy: () => boolean, - onBusy: (busy: boolean) => void, - onRouteChat: (sessionId: string) => void, - onRouteHome: () => void, - onStopPolling: () => void, - onStartPolling: () => void, - onResetTimeline: (sessionId: string) => void, - onClearRemoteFiles: () => void, - onKnownStateReset: () => void, - onLoadModelCatalog: (sessionId: string) => Promise, - onLoadActiveMessages: () => Promise, - onRefreshSessions: () => Promise, - onSelectWorkspace: (path: string) => Promise - ) { - this.remoteAvailable = remoteAvailable; - this.isConnected = isConnected; - this.isBusy = isBusy; - this.onBusy = onBusy; - this.onRouteChat = onRouteChat; - this.onRouteHome = onRouteHome; - this.onStopPolling = onStopPolling; - this.onStartPolling = onStartPolling; - this.onResetTimeline = onResetTimeline; - this.onClearRemoteFiles = onClearRemoteFiles; - this.onKnownStateReset = onKnownStateReset; - this.onLoadModelCatalog = onLoadModelCatalog; - this.onLoadActiveMessages = onLoadActiveMessages; - this.onRefreshSessions = onRefreshSessions; - this.onSelectWorkspace = onSelectWorkspace; - } } /** Owns remote session commands and their page lifecycle effects. */ @@ -158,26 +124,38 @@ export class RemoteSessionViewModel { currentWorkspacePath: string, onRouteChat: (sessionId: string) => void = this.hooks.onRouteChat ): Promise { - await this.sessions.open( - item, - item.workspacePath || currentWorkspacePath, - this.hooks.isBusy(), - this.hooks.remoteAvailable(), - async (session: SessionSummary): Promise => { - this.hooks.onStopPolling(); - this.hooks.onResetTimeline(item.id); - this.hooks.onKnownStateReset(); - this.pageState.setHasMoreMessages(false); - this.files.clear(); - this.pageState.clearComposer(); - onRouteChat(item.id); - await this.hooks.onLoadModelCatalog(item.id); - await this.hooks.onLoadActiveMessages(); - if (this.pageState.activeSession.sessionId === session.sessionId) { - this.hooks.onStartPolling(); + const isBusy = this.hooks.isBusy(); + const remoteAvailable = this.hooks.remoteAvailable(); + if (isBusy || item.id.length === 0 || !remoteAvailable) { + return; + } + this.pageState.setPendingSessionId(item.id); + this.pageState.setConversationLoading(true); + onRouteChat(item.id); + try { + await this.sessions.open( + item, + item.workspacePath || currentWorkspacePath, + false, + true, + async (session: SessionSummary): Promise => { + this.hooks.onStopPolling(); + this.hooks.onResetTimeline(item.id); + this.hooks.onKnownStateReset(); + this.pageState.setHasMoreMessages(false); + this.files.clear(); + this.pageState.clearComposer(); + await this.hooks.onLoadModelCatalog(item.id); + await this.hooks.onLoadActiveMessages(); + if (this.pageState.activeSession.sessionId === session.sessionId) { + this.hooks.onStartPolling(); + } } - } - ); + ); + } finally { + this.pageState.setConversationLoading(false); + this.pageState.setPendingSessionId(''); + } } async deleteSession(item: RemoteSession, currentWorkspacePath: string): Promise { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteWorkspaceViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets similarity index 86% rename from src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteWorkspaceViewModel.ets rename to src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets index 87b9818dd8..f6d3d475ac 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/RemoteWorkspaceViewModel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteWorkspaceViewModel.ets @@ -2,9 +2,9 @@ import { RecentWorkspaceEntry, RemoteSession, WorkspaceInfo } from '../../model/ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteWorkspaceCoordinator } from '../../services/RemoteWorkspaceCoordinator'; -import { RemotePageState } from './RemotePageState'; +import { RemotePageState } from '../state/RemotePageState'; -export class RemoteWorkspaceViewModelHooks { +export interface RemoteWorkspaceViewModelHooks { readonly isRemoteAvailable: () => boolean; readonly isBusy: () => boolean; readonly onBusy: (isBusy: boolean) => void; @@ -13,26 +13,6 @@ export class RemoteWorkspaceViewModelHooks { readonly onSessionsDiscovered: (sessions: RemoteSession[]) => void; readonly onRefreshSessions: () => Promise; readonly onConnectionFailure: (error: Object) => void; - - constructor( - isRemoteAvailable: () => boolean, - isBusy: () => boolean, - onBusy: (isBusy: boolean) => void, - onStatus: (statusText: string) => void, - onWorkspaceSelected: (workspace: WorkspaceInfo) => void, - onSessionsDiscovered: (sessions: RemoteSession[]) => void, - onRefreshSessions: () => Promise, - onConnectionFailure: (error: Object) => void - ) { - this.isRemoteAvailable = isRemoteAvailable; - this.isBusy = isBusy; - this.onBusy = onBusy; - this.onStatus = onStatus; - this.onWorkspaceSelected = onWorkspaceSelected; - this.onSessionsDiscovered = onSessionsDiscovered; - this.onRefreshSessions = onRefreshSessions; - this.onConnectionFailure = onConnectionFailure; - } } /** Owns the workspace/assistant picker workflows and their presentation state. */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets new file mode 100644 index 0000000000..02e90e9f0b --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets @@ -0,0 +1,523 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CloudAccountDevice, CloudAccountRequestError, CloudAccountSession, CloudAccountClient } from '../../services/CloudAccountClient'; +import { CloudAccountSessionStore } from '../../services/CloudAccountSessionStore'; +import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; +import { Encoding } from '../../services/Encoding'; +import { + GeneralChatConfigSnapshot, + GeneralChatConfigStore, + GeneralChatConfigUpdate, + GeneralChatConfigValidator, + GeneralChatModelSelectionPolicy +} from '../../services/general-chat/GeneralChatConfigStore'; +import { GeneralChatCloudConfigPolicy } from '../../services/general-chat/GeneralChatCloudConfigPolicy'; +import { GeneralChatServiceStatus } from '../../services/general-chat/GeneralChatServiceState'; +import { RemoteLogger } from '../../services/RemoteLogger'; +import { RemoteSessionManager } from '../../services/RemoteSessionManager'; +import { RemotePermissionMode } from '../../model/RemoteModels'; +import { GeneralChatPageState } from '../state/GeneralChatPageState'; +import { RemotePageState } from '../state/RemotePageState'; + +export interface SettingsControllerHooks { + readonly probeConfiguration: (apiUrl: string, apiKey: string, modelName: string) => Promise; +} + +export interface CloudAccountSettingsHooks { + readonly deviceId: () => string; + readonly remoteAvailable: () => boolean; + readonly invalidatePreview: () => void; + readonly invalidateRemoteActivity: () => void; + readonly invalidateRemoteConnection: () => void; + readonly stopPolling: () => void; + readonly stopHeartbeat: () => void; + readonly startHeartbeat: () => void; + readonly resetTimeline: () => void; + readonly resetKnownRemoteState: () => void; + readonly closeSettings: () => void; + readonly closeConnectSheet: () => void; + readonly navigateRemoteHome: () => void; + readonly loadRecentWorkspaces: () => Promise; +} + +export interface CloudAccountSettingsDependencies { + readonly client: CloudAccountClient; + readonly sessionStore: CloudAccountSessionStore; + readonly sessionManager: RemoteSessionManager; + readonly remoteState: RemotePageState; + readonly hooks: CloudAccountSettingsHooks; +} + +/** Owns general-chat model service settings and their presentation projection. */ +export class SettingsController { + private readonly store: GeneralChatConfigStore; + private readonly state: GeneralChatPageState; + private readonly hooks: SettingsControllerHooks; + private readonly cloud?: CloudAccountSettingsDependencies; + private cloudSession?: CloudAccountSession; + private cloudRelayUrl: string = ''; + + constructor( + store: GeneralChatConfigStore, + state: GeneralChatPageState, + hooks: SettingsControllerHooks, + cloud?: CloudAccountSettingsDependencies + ) { + this.store = store; + this.state = state; + this.hooks = hooks; + this.cloud = cloud; + } + + async save( + apiUrl: string, + apiKey: string, + modelName: string, + clearApiKey: boolean + ): Promise { + const update = this.update(apiUrl, apiKey, modelName, clearApiKey); + try { + const validationError = await this.validate(update); + if (validationError.length > 0) { + return validationError; + } + if (!update.clearApiKey) { + const probeError = await this.probe(update); + if (probeError.length > 0) { + return probeError; + } + } + const catalogBeforeSave = await this.store.modelCatalog(); + const snapshot = await this.store.save(update); + if (GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel(catalogBeforeSave)) { + await this.store.selectLocalModel(); + } + this.apply(snapshot); + await this.refreshModelCatalog(); + return ''; + } catch (err) { + return ConnectionErrorPolicy.errorText(err); + } + } + + async test( + apiUrl: string, + apiKey: string, + modelName: string, + clearApiKey: boolean + ): Promise { + const update = this.update(apiUrl, apiKey, modelName, clearApiKey); + try { + const validationError = await this.validate(update); + if (validationError.length > 0) { + return validationError; + } + if (update.clearApiKey) { + return RemoteI18n.t('settings.modelService.testNeedsKey'); + } + return await this.probe(update); + } catch (err) { + return ConnectionErrorPolicy.errorText(err); + } + } + + apply(snapshot: GeneralChatConfigSnapshot): void { + this.state.setConfiguration( + snapshot.apiUrl, + snapshot.modelName, + snapshot.hasApiKey, + GeneralChatServiceStatus.fromConfiguration(snapshot.apiUrl, snapshot.modelName, snapshot.hasApiKey) + ); + } + + async refreshModelCatalog(): Promise { + const catalog = await this.store.modelCatalog(); + const selectedModelId = catalog.session_model_id || catalog.default_models.primary || ''; + this.state.setModelCatalog(catalog, selectedModelId); + const active = await this.store.activeSnapshot(); + this.state.setServiceState( + GeneralChatServiceStatus.fromConfiguration(active.apiUrl, active.modelName, active.hasApiKey) + ); + } + + async selectModel(modelId: string): Promise { + if (!await this.store.selectModel(modelId)) { + return false; + } + await this.refreshModelCatalog(); + return true; + } + + async initializeCloudAccount(context: Context): Promise { + const cloud = this.requireCloud(); + await cloud.sessionStore.init(context); + await this.restoreCloudAccountSession(); + } + + hasCloudAccountSession(): boolean { + return this.cloudSession !== undefined; + } + + async persistDelegatedAccountSession(): Promise { + if (this.cloudSession) { + return; + } + const cloud = this.requireCloud(); + const delegated = cloud.sessionManager.delegatedAccountSession(); + if (!delegated) { + return; + } + this.applyCloudAccountSession(delegated.session, delegated.relayUrl, delegated.session.userId); + await cloud.sessionStore.save({ + relayUrl: delegated.relayUrl, + username: delegated.session.userId, + token: delegated.session.token, + userId: delegated.session.userId, + masterKey: Encoding.bytesToBase64(delegated.session.masterKey) + }); + RemoteLogger.info('delegated account session persisted after room pairing'); + } + + async loginCloudAccount(relayUrl: string, username: string, password: string): Promise { + const cloud = this.requireCloud(); + RemoteLogger.info('cloud account UI login requested'); + const session = await cloud.client.login(relayUrl, username, password, cloud.hooks.deviceId()); + this.applyCloudAccountSession(session, relayUrl, username); + await cloud.sessionStore.save({ + relayUrl: relayUrl.trim(), username: username.trim(), token: session.token, userId: session.userId, + masterKey: Encoding.bytesToBase64(session.masterKey) + }); + await this.loadGeneralChatAccountModels(session, relayUrl); + RemoteLogger.info('cloud account credentials persisted, refreshing account devices'); + RemoteLogger.info(`cloud account login success user=${session.userId}`); + return session.userId; + } + + async syncCloudAccount(): Promise { + const cloud = this.requireCloud(); + const session = this.cloudSession; + if (!session || this.cloudRelayUrl.length === 0) { + throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); + } + let bundles: Object[]; + try { + bundles = await cloud.client.fetchSessions(this.cloudRelayUrl, session, 0); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 401) { + await this.expireCloudAccountSession(); + throw new Error(RemoteI18n.t('remote.settings.accountExpired')); + } + throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.accountSyncFailed')); + } + await this.loadGeneralChatAccountModels(session, this.cloudRelayUrl); + RemoteLogger.info(`cloud account backup sync completed count=${bundles.length}`); + return String(bundles.length); + } + + applyCloudAccountSession(session: CloudAccountSession, relayUrl: string, username: string): void { + const remoteState = this.requireCloud().remoteState; + this.cloudSession = session; + this.cloudRelayUrl = relayUrl.trim(); + remoteState.setAccountUserId(session.userId); + remoteState.setAccountUsername(username.trim()); + } + + async logoutCloudAccount(): Promise { + const cloud = this.requireCloud(); + cloud.hooks.invalidatePreview(); + if (cloud.remoteState.controlTargetType === 'account_device') { + this.resetAccountDeviceConnection(true); + } + this.cloudSession = undefined; + this.cloudRelayUrl = ''; + this.store.replaceAccountModels([]); + await this.refreshModelCatalog(); + await cloud.sessionStore.clear(); + cloud.remoteState.setAccountUserId(''); + cloud.remoteState.setAccountUsername(''); + cloud.remoteState.clearControlTarget(); + RemoteLogger.info('cloud account logout success'); + } + + async listCloudAccountDevices(): Promise { + const cloud = this.requireCloud(); + const session = this.cloudSession; + if (!session || this.cloudRelayUrl.length === 0) { + return []; + } + try { + return await cloud.client.listDevices(this.cloudRelayUrl, session); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 401) { + await this.expireCloudAccountSession(); + throw new Error(RemoteI18n.t('remote.settings.accountExpired')); + } + if (err instanceof CloudAccountRequestError && + (err.statusCode === 404 || err.statusCode === 503 || err.statusCode === 504)) { + throw new Error(RemoteI18n.t('remote.settings.deviceUnavailable')); + } + throw new Error(err instanceof Error ? err.message : RemoteI18n.t('remote.settings.deviceLoadFailed')); + } + } + + async getRemotePermissionMode(): Promise { + const cloud = this.requireCloud(); + if (!cloud.hooks.remoteAvailable()) { + throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); + } + return cloud.sessionManager.getPermissionMode(); + } + + async setRemotePermissionMode(mode: RemotePermissionMode): Promise { + const cloud = this.requireCloud(); + if (!cloud.hooks.remoteAvailable()) { + throw new Error(RemoteI18n.t('remote.permissions.connectionRequired')); + } + return cloud.sessionManager.setPermissionMode(mode); + } + + async restoreCloudTarget(targetDeviceId: string, targetDeviceName: string): Promise { + const targetId = targetDeviceId.trim(); + if (targetId.length === 0) { + return; + } + const remoteState = this.requireCloud().remoteState; + try { + const devices = await this.listCloudAccountDevices(); + const target = devices.find((device: CloudAccountDevice): boolean => device.deviceId === targetId); + if (!target || !target.online) { + const targetName = target?.deviceName || targetDeviceName || targetId; + remoteState.setControlTarget('account_device', targetId, targetName); + remoteState.setDesktopIdentity(targetName, targetId); + remoteState.setConnectionState('failed'); + remoteState.setStatusText(RemoteI18n.t('remote.settings.deviceUnavailable')); + return; + } + await this.selectCloudAccountDevice({ + deviceId: target.deviceId, + deviceName: target.deviceName || targetDeviceName || target.deviceId, + online: target.online, + lastSeenAt: target.lastSeenAt + }); + } catch (err) { + RemoteLogger.warn(`cloud target restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); + } + } + + async handleRemoteConnectionError(err: Object): Promise { + const remoteState = this.requireCloud().remoteState; + if (remoteState.controlTargetType !== 'account_device' || + !(err instanceof CloudAccountRequestError) || err.statusCode !== 401) { + return false; + } + await this.expireCloudAccountSession(); + remoteState.setStatusText(RemoteI18n.t('remote.settings.accountExpired')); + return true; + } + + async selectCloudAccountDevice(device: CloudAccountDevice, navigateHome: boolean = true): Promise { + const cloud = this.requireCloud(); + const session = this.cloudSession; + if (!device.online) { + throw new Error(RemoteI18n.t('remote.settings.deviceOffline')); + } + if (!session || this.cloudRelayUrl.length === 0) { + throw new Error(RemoteI18n.t('remote.settings.accountNotSignedIn')); + } + const deviceId = device.deviceId.trim(); + if (deviceId.length === 0 || deviceId === cloud.hooks.deviceId()) { + return; + } + if (deviceId === cloud.remoteState.controlTargetDeviceId && cloud.remoteState.connectionState === 'connected') { + cloud.hooks.closeConnectSheet(); + if (navigateHome) { + cloud.hooks.navigateRemoteHome(); + } + return; + } + this.prepareAccountDeviceConnection(); + try { + const initialSync = await cloud.sessionManager.connectAccountDevice( + cloud.client, + this.cloudRelayUrl, + session, + deviceId + ); + cloud.remoteState.setControlTarget('account_device', deviceId, device.deviceName); + cloud.remoteState.setDesktopIdentity(device.deviceName, deviceId); + cloud.remoteState.setWorkspace( + initialSync.workspace.name, + initialSync.workspace.path, + initialSync.workspace.assistantId || '', + initialSync.workspace.gitBranch, + initialSync.workspace.workspaceKind || 'normal' + ); + cloud.remoteState.setSessions(initialSync.sessions, initialSync.hasMoreSessions); + cloud.remoteState.setAuthenticatedUserId(initialSync.authenticatedUserId); + cloud.remoteState.setConnectionState('connected'); + cloud.remoteState.setStatusText(RemoteI18n.t('connection.connected')); + cloud.hooks.closeSettings(); + cloud.hooks.closeConnectSheet(); + if (navigateHome) { + cloud.hooks.navigateRemoteHome(); + } + await cloud.sessionStore.save({ + relayUrl: this.cloudRelayUrl, + username: cloud.remoteState.accountUsername, + token: session.token, + userId: session.userId, + masterKey: Encoding.bytesToBase64(session.masterKey), + targetDeviceId: deviceId, + targetDeviceName: device.deviceName + }); + cloud.hooks.startHeartbeat(); + await cloud.hooks.loadRecentWorkspaces(); + } catch (err) { + if (err instanceof CloudAccountRequestError && err.statusCode === 401) { + await this.expireCloudAccountSession(); + } + cloud.remoteState.clearControlTarget(); + cloud.remoteState.setConnectionState('failed'); + const message = ConnectionErrorPolicy.errorText(err); + cloud.remoteState.setStatusText(message); + cloud.sessionManager.reset(); + throw new Error(message); + } finally { + cloud.remoteState.setLoadingHome(false); + cloud.remoteState.setBusy(false); + } + } + + private update( + apiUrl: string, + apiKey: string, + modelName: string, + clearApiKey: boolean + ): GeneralChatConfigUpdate { + return { apiUrl, apiKey, modelName, clearApiKey }; + } + + private async validate(update: GeneralChatConfigUpdate): Promise { + const snapshot = await this.store.snapshot(); + return GeneralChatConfigValidator.validate(update, snapshot.hasApiKey); + } + + private async probe(update: GeneralChatConfigUpdate): Promise { + const apiKey = await this.effectiveApiKey(update); + if (apiKey.length === 0) { + return RemoteI18n.t('settings.modelService.apiKeyRequired'); + } + try { + await this.hooks.probeConfiguration(update.apiUrl, apiKey, update.modelName); + return ''; + } catch (err) { + return ConnectionErrorPolicy.errorText(err); + } + } + + private async effectiveApiKey(update: GeneralChatConfigUpdate): Promise { + const directKey = update.apiKey.trim(); + if (directKey.length > 0) { + return directKey; + } + if (update.clearApiKey) { + return ''; + } + return (await this.store.accessToken()).trim(); + } + + private async restoreCloudAccountSession(): Promise { + const cloud = this.requireCloud(); + try { + const persisted = await cloud.sessionStore.load(); + if (!persisted) { + return; + } + const session: CloudAccountSession = { + token: persisted.token, + userId: persisted.userId, + masterKey: Encoding.base64ToBytes(persisted.masterKey) + }; + this.applyCloudAccountSession(session, persisted.relayUrl, persisted.username || session.userId); + await this.loadGeneralChatAccountModels(session, persisted.relayUrl); + } catch (err) { + RemoteLogger.warn(`cloud account restore failed: ${err instanceof Error ? err.message : 'unknown error'}`); + await cloud.sessionStore.clear(); + } + } + + private async loadGeneralChatAccountModels(session: CloudAccountSession, relayUrl: string): Promise { + const cloud = this.requireCloud(); + this.store.replaceAccountModels([]); + try { + const blob = await cloud.client.fetchSettings(relayUrl, session); + if (!blob) { + this.store.replaceAccountModels([]); + await this.refreshModelCatalog(); + RemoteLogger.info('cloud model catalog is empty'); + return; + } + const models = GeneralChatCloudConfigPolicy.models(blob.plaintext); + this.store.replaceAccountModels(models); + await this.refreshModelCatalog(); + RemoteLogger.info(`cloud model catalog loaded count=${models.length} version=${blob.version}`); + } catch (err) { + await this.refreshModelCatalog(); + RemoteLogger.warn(`cloud model catalog load failed: ${err instanceof Error ? err.message : 'unknown error'}`); + } + } + + private async expireCloudAccountSession(): Promise { + const cloud = this.requireCloud(); + cloud.hooks.invalidatePreview(); + this.cloudSession = undefined; + this.cloudRelayUrl = ''; + await cloud.sessionStore.clear(); + cloud.remoteState.setAccountUserId(''); + cloud.remoteState.setAccountUsername(''); + if (cloud.remoteState.controlTargetType === 'account_device') { + this.resetAccountDeviceConnection(false); + } + } + + private prepareAccountDeviceConnection(): void { + const cloud = this.requireCloud(); + cloud.hooks.invalidatePreview(); + cloud.hooks.invalidateRemoteActivity(); + cloud.hooks.invalidateRemoteConnection(); + cloud.hooks.stopPolling(); + cloud.hooks.stopHeartbeat(); + cloud.remoteState.setConnectionState('reconnecting'); + cloud.remoteState.setLoadingHome(true); + cloud.remoteState.clearControlTarget(); + cloud.remoteState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + cloud.remoteState.setBusy(true); + cloud.remoteState.setStatusText(RemoteI18n.t('remote.settings.deviceConnecting')); + cloud.remoteState.clearActiveSession(); + cloud.hooks.resetTimeline(); + cloud.hooks.resetKnownRemoteState(); + cloud.remoteState.setSessions([], false); + } + + private resetAccountDeviceConnection(clearWorkspace: boolean): void { + const cloud = this.requireCloud(); + cloud.hooks.invalidateRemoteActivity(); + cloud.hooks.stopPolling(); + cloud.hooks.stopHeartbeat(); + cloud.sessionManager.reset(); + cloud.remoteState.clearActiveSession(); + cloud.remoteState.setSessions([], false); + if (clearWorkspace) { + cloud.remoteState.setWorkspace(RemoteI18n.t('status.notConnected'), '', '', '', 'normal'); + cloud.remoteState.setAuthenticatedUserId(''); + } + cloud.remoteState.clearControlTarget(); + cloud.remoteState.setConnectionState('disconnected'); + } + + private requireCloud(): CloudAccountSettingsDependencies { + if (!this.cloud) { + throw new Error('Cloud account settings dependencies are not configured.'); + } + return this.cloud; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets index a83708868e..1d438f4309 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/FileTargetResolver.ets @@ -1,4 +1,4 @@ -import { FilePreviewTarget, FilePreviewTargetContext } from '../pages/state/FilePreviewTarget'; +import { FilePreviewTarget, FilePreviewTargetContext } from '../model/FilePreviewTarget'; import { RemoteUiState } from './RemoteUiState'; export enum FileReferenceKind { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets index a891801a75..528e3bf957 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MessageFileReferenceProjector.ets @@ -1,4 +1,4 @@ -import { FilePreviewTargetContext } from '../pages/state/FilePreviewTarget'; +import { FilePreviewTargetContext } from '../model/FilePreviewTarget'; import { FileReferenceKind, FileTargetResolver } from './FileTargetResolver'; import { MarkdownParser, diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json b/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json index 124f69ff32..22d8c6438e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json @@ -60,6 +60,14 @@ "name": "connect_hero_surface", "value": "#F8FAFF" }, + { + "name": "connect_scan_accent", + "value": "#FFD021" + }, + { + "name": "modal_scrim", + "value": "#99000000" + }, { "name": "soft", "value": "#F4F3F0" diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json b/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json index 39e3e9d2c5..9252b40cea 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json @@ -60,6 +60,14 @@ "name": "connect_hero_surface", "value": "#252522" }, + { + "name": "connect_scan_accent", + "value": "#FFD021" + }, + { + "name": "modal_scrim", + "value": "#99000000" + }, { "name": "soft", "value": "#2D2C28" diff --git a/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets index 1af9cec0d6..8db8af9f1a 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/AppRootLifecycleUnit.test.ets @@ -1,8 +1,9 @@ import { describe, expect, it } from '@ohos/hypium'; +import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; import { AppRootHostPort } from '../main/ets/pages/host/AppRootHostAdapter'; import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; -import { AppRootRuntime } from '../main/ets/pages/state/AppRootRuntime'; -import { FilePreviewRequest, FilePreviewTarget } from '../main/ets/pages/state/FilePreviewTarget'; +import { AppRootRuntime } from '../main/ets/pages/runtime/AppRootRuntime'; +import { FilePreviewRequest, FilePreviewTarget } from '../main/ets/model/FilePreviewTarget'; class FakeAppRootHost implements AppRootHostPort { externalLinks: string[] = []; @@ -30,41 +31,36 @@ class FakeAppRootHost implements AppRootHostPort { } class TestAppRootRuntime extends AppRootRuntime { - stopGeneralChatStreamCalls: number = 0; - constructor(host: AppRootHostPort = new FakeAppRootHost()) { super(host); } - - stopGeneralChatStream(cancelled: boolean, finalStatus: string = 'cancelled'): void { - this.stopGeneralChatStreamCalls += 1; - super.stopGeneralChatStream(cancelled, finalStatus); - } } export default function appRootLifecycleUnitTest() { describe('AppRootRuntime page hide lifecycle', () => { it('keeps backgrounded general chat running on page hide', 0, () => { const runtime = new TestAppRootRuntime(); + runtime.generalChatStreamLifecycleController.begin('session-1'); runtime.onPageHide(); - expect(runtime.stopGeneralChatStreamCalls).assertEqual(0); + expect(runtime.generalChatStreamLifecycleController.hasActiveStream()).assertTrue(); }); it('still performs general chat cleanup when the app truly disappears', 0, () => { const runtime = new TestAppRootRuntime(); + runtime.generalChatStreamLifecycleController.begin('session-1'); runtime.aboutToDisappear(); - expect(runtime.stopGeneralChatStreamCalls).assertEqual(1); + expect(runtime.generalChatStreamLifecycleController.hasActiveStream()).assertFalse(); }); it('routes HTTP Markdown links through the host without opening file preview', 0, async () => { const host = new FakeAppRootHost(); const runtime = new TestAppRootRuntime(host); - runtime.openFilePreview( + runtime.filePreviewController.open( AppRoute.ChatHome, new FilePreviewRequest('https://example.com/docs', 'docs') ); @@ -75,6 +71,22 @@ export default function appRootLifecycleUnitTest() { expect(runtime.filePreviewState.visible).assertFalse(); }); + it('reports external-link failures through the active conversation surface', 0, async () => { + const host = new FakeAppRootHost(); + host.externalLinkResult = false; + const runtime = new TestAppRootRuntime(host); + + runtime.filePreviewController.open( + AppRoute.ChatHome, + new FilePreviewRequest('https://example.com/failure', 'failure') + ); + await new Promise((resolve: () => void) => setTimeout(resolve, 0)); + + expect(runtime.generalChatPageState.conversation.statusText) + .assertEqual(RemoteI18n.t('errors.operationFailed')); + expect(runtime.remotePageState.conversation.statusText).assertEqual(''); + }); + it('closes preview before applying conversation navigation back', 0, () => { const runtime = new TestAppRootRuntime(); runtime.filePreviewState.begin(new FilePreviewTarget( @@ -92,7 +104,7 @@ export default function appRootLifecycleUnitTest() { 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 1 )); - runtime.invalidateFilePreviewTarget(); + runtime.filePreviewController.invalidate(); expect(runtime.filePreviewState.visible).assertFalse(); }); @@ -109,7 +121,7 @@ export default function appRootLifecycleUnitTest() { 'README.md', 'README.md', 'README.md', 'session-1', '/workspace', 1 )); - runtime.applyRemoteActiveSession({ + runtime.conversationController.applyRemoteActiveSession({ sessionId: 'session-1', title: 'Renamed session', workspacePath: '/workspace', @@ -117,7 +129,7 @@ export default function appRootLifecycleUnitTest() { }); expect(runtime.filePreviewState.visible).assertTrue(); - runtime.applyRemoteActiveSession({ + runtime.conversationController.applyRemoteActiveSession({ sessionId: 'session-2', title: 'Session 2', workspacePath: '/workspace', diff --git a/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets index 060199acea..0d4d2e8560 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/AppRootRuntimeStartupUnit.test.ets @@ -1,8 +1,7 @@ import { describe, expect, it } from '@ohos/hypium'; import { AppRootHostPort } from '../main/ets/pages/host/AppRootHostAdapter'; import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; -import { AppRootRuntime } from '../main/ets/pages/state/AppRootRuntime'; -import { CloudAccountSession } from '../main/ets/services/CloudAccountClient'; +import { AppRootRuntime } from '../main/ets/pages/runtime/AppRootRuntime'; class FakeAppRootHost implements AppRootHostPort { attach(_context: Context, _uiContext: UIContext): void { @@ -21,21 +20,11 @@ class FakeAppRootHost implements AppRootHostPort { } } -class TestAppRootRuntime extends AppRootRuntime { - constructor() { - super(new FakeAppRootHost()); - } - - applySession(session: CloudAccountSession, relayUrl: string, username: string): void { - this.applyCloudAccountSession(session, relayUrl, username); - } -} - export default function appRootRuntimeStartupUnitTest() { describe('AppRootRuntime startup restore', () => { it('applies cloud credentials without selecting a remote target', 0, () => { - const runtime = new TestAppRootRuntime(); - runtime.applySession({ + const runtime = new AppRootRuntime(new FakeAppRootHost()); + runtime.settingsController.applyCloudAccountSession({ token: 'token-1', userId: 'user-1', masterKey: new Uint8Array(32) @@ -45,7 +34,7 @@ export default function appRootRuntimeStartupUnitTest() { expect(runtime.remotePageState.accountUsername).assertEqual('alice'); expect(runtime.remotePageState.controlTargetType).assertEqual('none'); expect(runtime.remotePageState.controlTargetDeviceId).assertEqual(''); - expect(runtime.currentRoute()).assertEqual(AppRoute.ChatHome); + expect(runtime.appShellViewModel.currentRoute()).assertEqual(AppRoute.ChatHome); }); }); } diff --git a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets index 1139637387..4161ebe9fd 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ArchitectureUnit.test.ets @@ -7,8 +7,9 @@ import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; import { AppRoute } from '../main/ets/pages/navigation/AppRouteContract'; import { ChatMessage } from '../main/ets/model/RemoteModels'; -import { AppShellViewModel } from '../main/ets/pages/state/AppShellViewModel'; +import { AppShellViewModel } from '../main/ets/pages/viewmodel/AppShellViewModel'; import { AppNavigationBackAction } from '../main/ets/pages/navigation/AppRouteContract'; +import { WideLayoutGeometry } from '../main/ets/pages/layout/WideLayoutGeometry'; export default function architectureUnitTest() { describe('MobileArchitecture', () => { @@ -91,5 +92,15 @@ export default function architectureUnitTest() { expect(shell.currentRoute()).assertEqual(AppRoute.RemoteHome); expect(shell.navigationStack.getAllPathName().length).assertEqual(1); }); + + it('keeps wide layout geometry pure and deterministic', 0, () => { + expect(WideLayoutGeometry.detailOffset(false, 24, 8)).assertEqual(24); + expect(WideLayoutGeometry.detailOffset(true, 24, 8)).assertEqual(8); + expect(WideLayoutGeometry.detailWidth(true, 900, 1200)).assertEqual(1200); + expect(WideLayoutGeometry.collapsedVisualBias(true, 0, 1100, 920, 72)).assertEqual(72); + expect(WideLayoutGeometry.collapsedVisualBias(false, 0, 1100, 920, 72)).assertEqual(0); + expect(WideLayoutGeometry.areaLength('1080')).assertEqual(1080); + expect(WideLayoutGeometry.areaLength('invalid')).assertEqual(0); + }); }); } diff --git a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets index 1c6a270efd..57de0bb8df 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/ConversationStateUnit.test.ets @@ -80,9 +80,12 @@ import { } from '../main/ets/services/VoiceInputLifecycleController'; import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/VoiceInputService'; import { AppShellState } from '../main/ets/pages/state/AppShellState'; +import { ConversationCoreState } from '../main/ets/pages/state/ConversationCoreState'; import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; +import { RemoteCreateSessionState } from '../main/ets/pages/state/RemoteCreateSessionState'; import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; +import { ConversationController } from '../main/ets/pages/viewmodel/ConversationController'; import { GENERAL_CHAT_COMPOSER_CAPABILITIES, REMOTE_CHAT_COMPOSER_CAPABILITIES @@ -564,6 +567,81 @@ export default function conversationStateUnitTest() { }); }); + describe('ConversationController', () => { + it('keeps composer state isolated while the visible route changes', 0, () => { + const general = new GeneralChatPageState(); + const remote = new RemotePageState(); + const remoteCreate = new RemoteCreateSessionState(); + let route = AppRoute.ChatHome; + const controller = new ConversationController( + general, + remote, + remoteCreate, + { currentRoute: (): AppRoute => route } + ); + + controller.setChatInput(AppRoute.ChatHome, 'general draft'); + controller.setChatInput(AppRoute.RemoteChat, 'remote draft'); + controller.setChatInput(AppRoute.RemoteCreate, 'create draft'); + + expect(controller.visibleChatInput()).assertEqual('general draft'); + route = AppRoute.RemoteChat; + expect(controller.visibleChatInput()).assertEqual('remote draft'); + route = AppRoute.RemoteCreate; + expect(controller.visibleChatInput()).assertEqual('create draft'); + expect(general.chatInput).assertEqual('general draft'); + expect(remote.chatInput).assertEqual('remote draft'); + }); + + it('clears voice state for every conversation surface on teardown', 0, () => { + const general = new GeneralChatPageState(); + const remote = new RemotePageState(); + const remoteCreate = new RemoteCreateSessionState(); + const controller = new ConversationController( + general, + remote, + remoteCreate, + { currentRoute: (): AppRoute => AppRoute.RemoteCreate } + ); + controller.setVoiceListening(AppRoute.ChatHome, true); + controller.setVoiceListening(AppRoute.RemoteChat, true); + controller.setVoiceListening(AppRoute.RemoteCreate, true); + + controller.clearAllVoiceListening(); + + expect(general.isVoiceListening).assertFalse(); + expect(remote.isVoiceListening).assertFalse(); + expect(remoteCreate.isVoiceListening).assertFalse(); + }); + }); + + describe('ConversationCoreState', () => { + it('owns shared conversation data while keeping product surfaces isolated', 0, () => { + const general = new ConversationCoreState('chat'); + const remote = new ConversationCoreState('code'); + general.setActiveSession({ + sessionId: 'general-core', title: 'General', workspacePath: '', agentType: 'code' + }); + remote.setActiveSession({ + sessionId: 'remote-core', title: 'Remote', workspacePath: '/workspace', agentType: 'code' + }); + general.setChatInput('general draft'); + remote.setChatInput('remote draft'); + general.setBusy(true); + + expect(general.activeSession.agentType).assertEqual('chat'); + expect(remote.activeSession.agentType).assertEqual('code'); + expect(general.chatInput).assertEqual('general draft'); + expect(remote.chatInput).assertEqual('remote draft'); + expect(remote.isBusy).assertFalse(); + + general.clearActiveSession(); + expect(general.activeSession.sessionId).assertEqual(''); + expect(remote.activeSession.sessionId).assertEqual('remote-core'); + expect(remote.chatInput).assertEqual('remote draft'); + }); + }); + describe('GeneralChatPageState', () => { it('projects configuration, busy state, and status text', 0, () => { const state = new GeneralChatPageState(); @@ -797,6 +875,9 @@ export default function conversationStateUnitTest() { }); state.setTimelineProjection([userMessage], [], activeTurn, false, timelineItems); state.setModelCatalog(modelCatalog, 'model-a'); + state.setConversationLoading(true); + state.setPendingSessionId('remote-2'); + state.setConversationDismissed(true); timelineItems.length = 0; expect(state.activeSession.sessionId).assertEqual('remote-1'); @@ -805,6 +886,14 @@ export default function conversationStateUnitTest() { expect(state.hasRunningActiveTurn()).assertTrue(); expect(state.modelCatalog.version).assertEqual(2); expect(state.selectedModelId).assertEqual('model-a'); + expect(state.isLoadingConversation).assertTrue(); + expect(state.pendingSessionId).assertEqual('remote-2'); + expect(state.isConversationDismissed).assertTrue(); + + state.clearActiveSession(); + expect(state.pendingSessionId).assertEqual(''); + expect(state.isLoadingConversation).assertFalse(); + expect(state.isConversationDismissed).assertFalse(); }); it('copies nested remote session projection and replaces streaming turn snapshots', 0, () => { @@ -932,6 +1021,7 @@ export default function conversationStateUnitTest() { remote.setActiveSession({ sessionId: 'remote-session', title: 'Remote', workspacePath: '/repo', agentType: 'code' }); + remote.setConversationLoading(true); const general = new GeneralChatPageState(); general.setChatInput('general draft'); general.setActiveSession({ @@ -942,6 +1032,7 @@ export default function conversationStateUnitTest() { expect(remoteProjection.surface).assertEqual(ChatSurface.Remote); expect(remoteProjection.chatInput).assertEqual('remote draft'); expect(remoteProjection.activeSession.sessionId).assertEqual('remote-session'); + expect(remoteProjection.isLoadingConversation).assertTrue(); const generalProjection = ConversationViewState.project(AppRoute.ChatHome, remote, general, 'Configure model'); expect(generalProjection.surface).assertEqual(ChatSurface.General); diff --git a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets index 3fbd4c6b56..97a364d5b6 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LifecycleUnit.test.ets @@ -189,6 +189,18 @@ export default function lifecycleUnitTest() { expect(state.showSettings).assertFalse(); expect(state.showConnectSheet).assertFalse(); }); + + it('mirrors the resolved layout mode for runtime branching', 0, () => { + const state = new AppShellState(); + + expect(state.wideLayout).assertFalse(); + state.setWideLayout(true); + expect(state.wideLayout).assertTrue(); + + state.setSidebarVisible(true); + state.closeGlobalSurfaces(); + expect(state.wideLayout).assertTrue(); + }); }); describe('AsyncLifecycleGate', () => { diff --git a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets index eaf64f2b06..471cc8ef7b 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/RemoteControllersUnit.test.ets @@ -76,7 +76,7 @@ import { MessageFileReferenceProjectionCache, MessageFileReferenceProjector } from '../main/ets/services/MessageFileReferenceProjector'; -import { RemoteFilePreviewController } from '../main/ets/services/RemoteFilePreviewController'; +import { RemoteFilePreviewController } from '../main/ets/pages/viewmodel/RemoteFilePreviewController'; import { RemoteHeartbeatController, RemoteHeartbeatScheduler } from '../main/ets/services/RemoteHeartbeatController'; import { RemoteModelClient, @@ -107,18 +107,18 @@ import { FilePreviewRendererKind, FilePreviewState } from '../main/ets/pages/state/FilePreviewState'; -import { FilePreviewTarget, FilePreviewTargetContext } from '../main/ets/pages/state/FilePreviewTarget'; +import { FilePreviewTarget, FilePreviewTargetContext } from '../main/ets/model/FilePreviewTarget'; import { FilePreviewPlacement, FilePreviewPlacementPolicy -} from '../main/ets/pages/state/FilePreviewPlacementPolicy'; +} from '../main/ets/pages/policy/FilePreviewPlacementPolicy'; import { ConversationLayoutCrease, ConversationLayoutPolicy -} from '../main/ets/pages/state/ConversationLayoutPolicy'; -import { SessionActionPolicy, SessionActionScope } from '../main/ets/pages/state/SessionActionPolicy'; -import { ConversationSessionFilterPolicy } from '../main/ets/pages/state/ConversationSessionFilterPolicy'; -import { ConversationModelPresentationPolicy } from '../main/ets/pages/state/ConversationModelPresentationPolicy'; +} from '../main/ets/pages/policy/ConversationLayoutPolicy'; +import { SessionActionPolicy, SessionActionScope } from '../main/ets/pages/policy/SessionActionPolicy'; +import { ConversationSessionFilterPolicy } from '../main/ets/pages/policy/ConversationSessionFilterPolicy'; +import { ConversationModelPresentationPolicy } from '../main/ets/pages/policy/ConversationModelPresentationPolicy'; import { GENERAL_CHAT_COMPOSER_CAPABILITIES, REMOTE_CHAT_COMPOSER_CAPABILITIES @@ -788,7 +788,7 @@ export default function remoteControllersUnitTest() { expect(state.selectedWorkspaceName).assertEqual('BitFun'); }); - it('freezes the selected creation target and keeps workspace chats as Claw sessions', 0, () => { + it('freezes the selected creation target and pairs a workspace with the code agent', 0, () => { const state = new RemoteCreateSessionState(); state.prepare('desktop-b', 'Desktop B'); state.setWorkspaces([{ @@ -802,6 +802,17 @@ export default function remoteControllersUnitTest() { expect(context.deviceId).assertEqual('desktop-b'); expect(context.workspacePath).assertEqual('/workspace/BitFun'); + expect(context.agentType).assertEqual('code'); + }); + + it('keeps the chat option on the assistant agent so the desktop binds its assistant workspace', 0, () => { + const state = new RemoteCreateSessionState(); + state.prepare('desktop-b', 'Desktop B'); + state.selectWorkspace(undefined); + + const context = state.submissionContext(); + + expect(context.workspacePath).assertEqual(''); expect(context.agentType).assertEqual('Claw'); }); }); @@ -1677,6 +1688,14 @@ export default function remoteControllersUnitTest() { expect(remoteChat.name).assertEqual(AppRoute.RemoteChat); expect(remoteChat.routeParam().sessionId).assertEqual('remote-1'); }); + + it('routes an in-place remote session selection to an explicit chat destination', 0, () => { + const target = AppRouteContract.remoteSessionDestination('remote-session-1'); + + expect(target.name).assertEqual(AppRoute.RemoteChat); + expect(target.hasSessionParam()).assertTrue(); + expect(target.routeParam().sessionId).assertEqual('remote-session-1'); + }); }); describe('SessionActionPolicy', () => { diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets index e6b32f7f91..d083ef6550 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -20,6 +20,7 @@ import { GeneralChatCommandClient, GeneralChatCommandController } from '../main/ import { GeneralChatConfigSnapshot, GeneralChatConfigStore, + GeneralChatConfigUpdate, GeneralChatConfigValidator, GeneralChatModelSelectionPolicy } from '../main/ets/services/general-chat/GeneralChatConfigStore'; @@ -84,6 +85,7 @@ import { import { VoiceInputCallbacks, VoiceInputService } from '../main/ets/services/VoiceInputService'; import { AppShellState } from '../main/ets/pages/state/AppShellState'; import { GeneralChatPageState } from '../main/ets/pages/state/GeneralChatPageState'; +import { SettingsController } from '../main/ets/pages/viewmodel/SettingsController'; import { RemotePageState } from '../main/ets/pages/state/RemotePageState'; import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState'; import { @@ -217,6 +219,54 @@ function modelProviderRecordedResponse(statusCode: number, body: string): ModelP return response; } +class InMemorySettingsConfigStore extends GeneralChatConfigStore { + snapshotResult: GeneralChatConfigSnapshot = { + apiUrl: '', + modelName: '', + hasApiKey: false + }; + accessTokenResult: string = ''; + modelCatalogResults: RemoteModelCatalog[] = []; + modelCatalogCalls: number = 0; + saveRequests: GeneralChatConfigUpdate[] = []; + selectLocalModelCalls: number = 0; + + async snapshot(): Promise { + return this.snapshotResult; + } + + async save(update: GeneralChatConfigUpdate): Promise { + this.saveRequests.push(update); + this.snapshotResult = { + apiUrl: update.apiUrl.trim(), + modelName: update.modelName.trim(), + hasApiKey: !update.clearApiKey + }; + return this.snapshotResult; + } + + async accessToken(): Promise { + return this.accessTokenResult; + } + + async modelCatalog(): Promise { + const index = Math.min(this.modelCatalogCalls, this.modelCatalogResults.length - 1); + this.modelCatalogCalls += 1; + if (index >= 0) { + return this.modelCatalogResults[index]; + } + return { version: 1, models: [], default_models: {} }; + } + + async selectLocalModel(): Promise { + this.selectLocalModelCalls += 1; + } + + async activeSnapshot(): Promise { + return this.snapshotResult; + } +} + export default function transportAndGeneralChatUnitTest() { describe('RemoteDescriptorParser', () => { it('parses hash route URLs', 0, () => { @@ -748,6 +798,66 @@ export default function transportAndGeneralChatUnitTest() { }); }); + describe('SettingsController', () => { + it('tests model configuration with the stored key when the form keeps it unchanged', 0, async () => { + const store = new InMemorySettingsConfigStore(); + store.snapshotResult = { + apiUrl: 'https://chat.example.com', + modelName: 'model-a', + hasApiKey: true + }; + store.accessTokenResult = ' stored-key '; + let probedApiKey = ''; + const controller = new SettingsController(store, new GeneralChatPageState(), { + probeConfiguration: async (_apiUrl: string, apiKey: string, _modelName: string): Promise => { + probedApiKey = apiKey; + } + }); + + const error = await controller.test('https://chat.example.com', '', 'model-a', false); + + expect(error).assertEqual(''); + expect(probedApiKey).assertEqual('stored-key'); + }); + + it('saves the first local model and projects its catalog into page state', 0, async () => { + const store = new InMemorySettingsConfigStore(); + store.modelCatalogResults = [ + { version: 1, models: [], default_models: {} }, + { + version: 2, + models: [{ + id: 'local-general-chat', + name: 'model-a', + provider: 'local', + base_url: 'https://chat.example.com', + model_name: 'model-a', + enabled: true, + capabilities: ['text_chat'] + }], + default_models: { primary: 'local-general-chat' }, + session_model_id: 'local-general-chat' + } + ]; + const state = new GeneralChatPageState(); + const controller = new SettingsController(store, state, { + probeConfiguration: async (_apiUrl: string, _apiKey: string, _modelName: string): Promise => { + } + }); + + const error = await controller.save( + 'https://chat.example.com', 'new-key', 'model-a', false + ); + + expect(error).assertEqual(''); + expect(store.saveRequests.length).assertEqual(1); + expect(store.selectLocalModelCalls).assertEqual(1); + expect(state.apiUrl).assertEqual('https://chat.example.com'); + expect(state.conversation.selectedModelId).assertEqual('local-general-chat'); + expect(state.serviceState).assertEqual(GeneralChatServiceState.Ready); + }); + }); + describe('GeneralChatModelSelectionPolicy', () => { it('keeps an existing cloud selection when a local model is saved', 0, () => { const shouldActivateLocal = GeneralChatModelSelectionPolicy.shouldActivateSavedLocalModel({ diff --git a/src/apps/relay-server/Cargo.toml b/src/apps/relay-server/Cargo.toml index 4cc219e763..0deb7e6a68 100644 --- a/src/apps/relay-server/Cargo.toml +++ b/src/apps/relay-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitfun-relay-server" -version = "0.2.16" # x-release-please-version +version = "0.2.17" # x-release-please-version authors = ["BitFun Team"] edition = "2021" description = "BitFun standalone Remote Connect relay server" diff --git a/src/apps/server/src/bootstrap.rs b/src/apps/server/src/bootstrap.rs index 94817d50be..d521b1817b 100644 --- a/src/apps/server/src/bootstrap.rs +++ b/src/apps/server/src/bootstrap.rs @@ -38,6 +38,10 @@ pub(crate) struct ServerAppState { pub(crate) async fn initialize(workspace: Option) -> anyhow::Result> { log::info!("Initializing BitFun server core services"); + bitfun_core::agentic::system::select_agentic_system_profile( + bitfun_core::agentic::system::DeliveryProfile::ProductFull, + )?; + // 1. Global config config::initialize_global_config().await?; let config_service = config::get_global_config_service().await?; diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs index 4fb106d322..753fa5fb3a 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs @@ -18,6 +18,11 @@ use bitfun_runtime_ports::{ }; use serde_json::{json, Map}; +#[test] +fn shared_runtime_protocol_stays_at_version_17() { + assert_eq!(PROTOCOL_VERSION, 17); +} + #[test] fn protocol_rejects_unknown_fields_and_operations() { let unknown_field = diff --git a/src/crates/adapters/ai-adapters/Cargo.toml b/src/crates/adapters/ai-adapters/Cargo.toml index 22fd440f80..6a54278c89 100644 --- a/src/crates/adapters/ai-adapters/Cargo.toml +++ b/src/crates/adapters/ai-adapters/Cargo.toml @@ -24,7 +24,7 @@ futures = { workspace = true } fs2 = { workspace = true, optional = true } libc = { workspace = true, optional = true } log = { workspace = true } -reqwest = { workspace = true, features = ["rustls"] } +reqwest = { workspace = true, features = ["rustls", "socks"] } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true, optional = true } diff --git a/src/crates/adapters/ai-adapters/src/client/http.rs b/src/crates/adapters/ai-adapters/src/client/http.rs index 607e411ec4..3477831804 100644 --- a/src/crates/adapters/ai-adapters/src/client/http.rs +++ b/src/crates/adapters/ai-adapters/src/client/http.rs @@ -63,9 +63,9 @@ pub(crate) fn create_http_client( } } -fn build_proxy(config: &ProxyConfig) -> Result { - let mut proxy = - Proxy::all(&config.url).map_err(|e| anyhow!("Failed to create proxy: {}", e))?; +pub(crate) fn build_proxy(config: &ProxyConfig) -> Result { + let proxy_url = normalize_proxy_url(&config.url); + let mut proxy = Proxy::all(&proxy_url).map_err(|e| anyhow!("Failed to create proxy: {}", e))?; if let (Some(username), Some(password)) = (&config.username, &config.password) { if !username.is_empty() && !password.is_empty() { @@ -76,3 +76,58 @@ fn build_proxy(config: &ProxyConfig) -> Result { Ok(proxy) } + +fn normalize_proxy_url(url: &str) -> String { + let trimmed = url.trim(); + if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("http://{trimmed}") + } +} + +#[cfg(test)] +mod tests { + use super::{build_proxy, normalize_proxy_url}; + use crate::types::ProxyConfig; + + #[test] + fn normalizes_bare_host_and_port_to_http_proxy_url() { + assert_eq!( + normalize_proxy_url("127.0.0.1:7897"), + "http://127.0.0.1:7897" + ); + } + + #[test] + fn preserves_explicit_proxy_scheme() { + assert_eq!( + normalize_proxy_url("socks5://127.0.0.1:1080"), + "socks5://127.0.0.1:1080" + ); + } + + #[test] + fn accepts_bare_host_and_port_proxy_configuration() { + let config = ProxyConfig { + enabled: true, + url: "127.0.0.1:7897".to_string(), + username: None, + password: None, + }; + + assert!(build_proxy(&config).is_ok()); + } + + #[test] + fn accepts_explicit_socks5_proxy_configuration() { + let config = ProxyConfig { + enabled: true, + url: "socks5://127.0.0.1:1080".to_string(), + username: None, + password: None, + }; + + assert!(build_proxy(&config).is_ok()); + } +} diff --git a/src/crates/adapters/ai-adapters/src/subscription_auth/antigravity.rs b/src/crates/adapters/ai-adapters/src/subscription_auth/antigravity.rs index 0a05db26f7..36b9b4094f 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/antigravity.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/antigravity.rs @@ -7,7 +7,9 @@ //! `opencode-antigravity-auth`. use super::store::{self, StoredCredential}; -use super::{jwt, oauth_server, pkce, pkce::Pkce, ResolvedCredential, StartedLogin}; +use super::{ + jwt, oauth_server, pkce, pkce::Pkce, ResolvedCredential, StartedLogin, SubscriptionHttpOptions, +}; use anyhow::{anyhow, Context, Result}; use serde::Deserialize; use std::collections::HashMap; @@ -107,15 +109,17 @@ fn build_authorize_url(pkce: &Pkce, state: &str, redirect_uri: &str) -> String { format!("{AUTHORIZE_URL}?{query}") } -fn http_client() -> Result { - reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .context("build antigravity http client") +fn http_client(options: &SubscriptionHttpOptions) -> Result { + super::build_http_client(options, "Antigravity") } -async fn exchange_code(code: &str, verifier: &str, redirect_uri: &str) -> Result { - let client = http_client()?; +async fn exchange_code( + code: &str, + verifier: &str, + redirect_uri: &str, + options: &SubscriptionHttpOptions, +) -> Result { + let client = http_client(options)?; let secret = client_secret(); let params = [ ("grant_type", "authorization_code"), @@ -143,8 +147,8 @@ async fn exchange_code(code: &str, verifier: &str, redirect_uri: &str) -> Result .context("parse antigravity token response") } -async fn refresh(refresh_token: &str) -> Result { - let client = http_client()?; +async fn refresh(refresh_token: &str, options: &SubscriptionHttpOptions) -> Result { + let client = http_client(options)?; let secret = client_secret(); let params = [ ("grant_type", "refresh_token"), @@ -223,6 +227,7 @@ async fn persist_tokens(tokens: TokenResponse, expected_revision: u64) -> Result pub(crate) async fn begin_login( cancel: CancellationToken, expected_revision: u64, + options: SubscriptionHttpOptions, ) -> Result { let pkce = Pkce::generate(); let state = pkce::random_state(); @@ -242,7 +247,7 @@ pub(crate) async fn begin_login( .get("code") .cloned() .ok_or_else(|| anyhow!("antigravity callback missing code"))?; - exchange_code(&code, &verifier, &redirect_uri).await + exchange_code(&code, &verifier, &redirect_uri, &options).await }, move |tokens| persist_tokens(tokens, expected_revision), ) @@ -259,7 +264,7 @@ pub(crate) async fn begin_login( /// Ensures the stored access token is fresh, refreshing it when needed. Returns /// the current `(access, expires_ms)`. -async fn ensure_fresh() -> Result<(String, i64)> { +async fn ensure_fresh(options: &SubscriptionHttpOptions) -> Result<(String, i64)> { let snapshot = store::load_entry_with_revision(STORE_KEY).await?; let entry = snapshot .credential @@ -279,7 +284,7 @@ async fn ensure_fresh() -> Result<(String, i64)> { return Ok((access, expires)); } - let refreshed = refresh(&refresh_token).await?; + let refreshed = refresh(&refresh_token, options).await?; let new_access = refreshed .access_token .clone() @@ -299,14 +304,38 @@ async fn ensure_fresh() -> Result<(String, i64)> { }, ) .await?; - super::require_current_store_revision(super::SubscriptionProvider::Antigravity, outcome)?; - log::info!("antigravity subscription tokens refreshed"); - Ok((new_access, new_expires)) + match outcome { + store::ConditionalCommitOutcome::Committed { .. } => { + log::info!("antigravity subscription tokens refreshed"); + Ok((new_access, new_expires)) + } + store::ConditionalCommitOutcome::Conflict { current_revision } => { + let current = super::load_current_store_after_conflict( + super::SubscriptionProvider::Antigravity, + current_revision, + ) + .await?; + match current.credential { + Some(StoredCredential::Oauth { + access, expires, .. + }) if expires > now_ms() => { + log::info!( + "antigravity refresh reused tokens committed by a concurrent refresh" + ); + Ok((access, expires)) + } + _ => Err(super::store_revision_conflict( + super::SubscriptionProvider::Antigravity, + current_revision, + )), + } + } + } } /// Resolves the runtime credential (refreshing tokens if required). -pub(crate) async fn resolve() -> Result { - let (access, expires) = ensure_fresh().await?; +pub(crate) async fn resolve(options: &SubscriptionHttpOptions) -> Result { + let (access, expires) = ensure_fresh(options).await?; let (ua_platform, meta_platform) = platform_tokens(); let mut headers = HashMap::new(); headers.insert( diff --git a/src/crates/adapters/ai-adapters/src/subscription_auth/codex.rs b/src/crates/adapters/ai-adapters/src/subscription_auth/codex.rs index d7eb86c2cb..4499c00318 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/codex.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/codex.rs @@ -5,7 +5,9 @@ //! `chatgpt.com/backend-api/codex/responses`. use super::store::{self, StoredCredential}; -use super::{jwt, oauth_server, pkce::Pkce, ResolvedCredential, StartedLogin}; +use super::{ + jwt, oauth_server, pkce::Pkce, ResolvedCredential, StartedLogin, SubscriptionHttpOptions, +}; use anyhow::{anyhow, Context, Result}; use serde::Deserialize; use std::collections::HashMap; @@ -63,15 +65,17 @@ fn build_authorize_url(pkce: &Pkce, state: &str, redirect_uri: &str) -> String { format!("{ISSUER}/oauth/authorize?{query}") } -fn http_client() -> Result { - reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .context("build codex http client") +fn http_client(options: &SubscriptionHttpOptions) -> Result { + super::build_http_client(options, "Codex") } -async fn exchange_code(code: &str, verifier: &str, redirect_uri: &str) -> Result { - let client = http_client()?; +async fn exchange_code( + code: &str, + verifier: &str, + redirect_uri: &str, + options: &SubscriptionHttpOptions, +) -> Result { + let client = http_client(options)?; let params = [ ("grant_type", "authorization_code"), ("code", code), @@ -95,8 +99,8 @@ async fn exchange_code(code: &str, verifier: &str, redirect_uri: &str) -> Result resp.json().await.context("parse codex token response") } -async fn refresh(refresh_token: &str) -> Result { - let client = http_client()?; +async fn refresh(refresh_token: &str, options: &SubscriptionHttpOptions) -> Result { + let client = http_client(options)?; let params = [ ("grant_type", "refresh_token"), ("refresh_token", refresh_token), @@ -171,6 +175,7 @@ async fn persist_tokens(tokens: TokenResponse, expected_revision: u64) -> Result pub(crate) async fn begin_login( cancel: CancellationToken, expected_revision: u64, + options: SubscriptionHttpOptions, ) -> Result { let pkce = Pkce::generate(); let state = super::pkce::random_state(); @@ -190,7 +195,7 @@ pub(crate) async fn begin_login( .get("code") .cloned() .ok_or_else(|| anyhow!("codex callback missing code"))?; - exchange_code(&code, &verifier, &redirect_uri).await + exchange_code(&code, &verifier, &redirect_uri, &options).await }, move |tokens| persist_tokens(tokens, expected_revision), ) @@ -207,7 +212,7 @@ pub(crate) async fn begin_login( /// Ensures the stored access token is fresh, refreshing it when needed. Returns /// the current `(access, account_id, expires_ms)`. -async fn ensure_fresh() -> Result<(String, Option, i64)> { +async fn ensure_fresh(options: &SubscriptionHttpOptions) -> Result<(String, Option, i64)> { let snapshot = store::load_entry_with_revision(STORE_KEY).await?; let entry = snapshot .credential @@ -227,7 +232,7 @@ async fn ensure_fresh() -> Result<(String, Option, i64)> { return Ok((access, account_id, expires)); } - let refreshed = refresh(&refresh_token).await?; + let refreshed = refresh(&refresh_token, options).await?; let new_access = refreshed .access_token .clone() @@ -248,9 +253,34 @@ async fn ensure_fresh() -> Result<(String, Option, i64)> { }, ) .await?; - super::require_current_store_revision(super::SubscriptionProvider::Codex, outcome)?; - log::info!("codex subscription tokens refreshed"); - Ok((new_access, new_account_id, new_expires)) + match outcome { + store::ConditionalCommitOutcome::Committed { .. } => { + log::info!("codex subscription tokens refreshed"); + Ok((new_access, new_account_id, new_expires)) + } + store::ConditionalCommitOutcome::Conflict { current_revision } => { + let current = super::load_current_store_after_conflict( + super::SubscriptionProvider::Codex, + current_revision, + ) + .await?; + match current.credential { + Some(StoredCredential::Oauth { + access, + expires, + account_id, + .. + }) if expires > now_ms() => { + log::info!("codex refresh reused tokens committed by a concurrent refresh"); + Ok((access, account_id, expires)) + } + _ => Err(super::store_revision_conflict( + super::SubscriptionProvider::Codex, + current_revision, + )), + } + } + } } async fn resolve_codex_cli_version() -> Option { @@ -286,8 +316,8 @@ fn parse_codex_cli_version(output: &str) -> Option { } /// Resolves the runtime credential (refreshing tokens if required). -pub(crate) async fn resolve() -> Result { - let (access, account_id, expires) = ensure_fresh().await?; +pub(crate) async fn resolve(options: &SubscriptionHttpOptions) -> Result { + let (access, account_id, expires) = ensure_fresh(options).await?; let mut headers = HashMap::new(); if let Some(account) = account_id { headers.insert("ChatGPT-Account-ID".to_string(), account); diff --git a/src/crates/adapters/ai-adapters/src/subscription_auth/mod.rs b/src/crates/adapters/ai-adapters/src/subscription_auth/mod.rs index 1d32e5bbb5..1985945c17 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/mod.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/mod.rs @@ -18,7 +18,8 @@ pub mod store; pub use store::{set_store_path_for_test, StoredCredential}; -use anyhow::{anyhow, Result}; +use crate::types::ProxyConfig; +use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::pin::Pin; @@ -39,6 +40,25 @@ pub enum SubscriptionProvider { Opencode, } +/// Transport policy shared by subscription-auth requests. +/// +/// The proxy is owned because login flows keep these options in a background +/// future while token refresh and credential resolution only borrow them. +#[derive(Debug, Clone, Default)] +pub struct SubscriptionHttpOptions { + proxy_config: Option, + skip_ssl_verify: bool, +} + +impl SubscriptionHttpOptions { + pub fn new(proxy_config: Option, skip_ssl_verify: bool) -> Self { + Self { + proxy_config, + skip_ssl_verify, + } + } +} + impl SubscriptionProvider { /// All providers, in display order. pub const ALL: [SubscriptionProvider; 3] = [Self::Codex, Self::Antigravity, Self::Opencode]; @@ -246,6 +266,46 @@ fn validate_session_id(session_id: &str) -> Result<()> { .map_err(|_| anyhow!("subscription login session_id must be a valid UUID")) } +/// Builds the HTTP client used by proxy-aware subscription-auth requests. +/// +/// Subscription providers perform token exchange, refresh, or account +/// discovery outside the normal AI request client, so they must receive the +/// same explicit proxy configuration from the host. Keep environment proxy +/// discovery disabled to match the main AI client, which is controlled by +/// `ai.proxy`. +pub(crate) fn build_http_client( + options: &SubscriptionHttpOptions, + provider: &str, +) -> Result { + let mut builder = reqwest::Client::builder() + .tls_backend_rustls() + .timeout(Duration::from_secs(30)) + .danger_accept_invalid_certs(options.skip_ssl_verify); + + if options.skip_ssl_verify { + log::warn!( + "SSL certificate verification disabled for {provider} subscription authentication" + ); + } + + if let Some(proxy_config) = options + .proxy_config + .as_ref() + .filter(|config| config.enabled && !config.url.trim().is_empty()) + { + let proxy = crate::client::http::build_proxy(proxy_config) + .map_err(|error| anyhow!("build {provider} subscription proxy: {error}"))?; + builder = builder.proxy(proxy); + log::info!("Using configured proxy for {provider} subscription authentication"); + } else { + builder = builder.no_proxy(); + } + + builder + .build() + .with_context(|| format!("build {provider} subscription http client")) +} + /// Per-provider commit barrier for login cancellation/replacement and logout. /// Refresh deliberately does not hold this across an external request: its /// durable revision CAS lets logout commit immediately and reject stale tokens. @@ -299,13 +359,41 @@ pub(crate) fn require_current_store_revision( ) -> Result { match outcome { store::ConditionalCommitOutcome::Committed { revision } => Ok(revision), - store::ConditionalCommitOutcome::Conflict { current_revision } => Err(anyhow!( - "{} credentials changed in another BitFun process (current revision {current_revision}); retry the operation", - provider.display_label() - )), + store::ConditionalCommitOutcome::Conflict { current_revision } => { + Err(store_revision_conflict(provider, current_revision)) + } } } +pub(crate) fn store_revision_conflict( + provider: SubscriptionProvider, + current_revision: u64, +) -> anyhow::Error { + anyhow!( + "{} credentials changed in another BitFun process (current revision {current_revision}); retry the operation", + provider.display_label() + ) +} + +/// Reloads the credential after a refresh lost its conditional commit race. +/// +/// The revision returned by the CAS conflict is the revision observed while +/// holding the store lock. Reloading after releasing that lock gives the +/// caller the credential that won the race, which may be safely reused when it +/// is still valid. +pub(crate) async fn load_current_store_after_conflict( + provider: SubscriptionProvider, + current_revision: u64, +) -> Result { + let current = store::load_entry_with_revision(provider.key()).await?; + log::debug!( + "{} refresh commit lost CAS at revision {current_revision}; reloaded current revision {}", + provider.display_label(), + current.revision + ); + Ok(current) +} + fn build_account( provider: SubscriptionProvider, entry: Option<&StoredCredential>, @@ -398,6 +486,15 @@ pub async fn list_accounts() -> Vec { pub async fn start_login( provider: SubscriptionProvider, session_id: String, +) -> Result { + start_login_with_options(provider, session_id, SubscriptionHttpOptions::default()).await +} + +/// Starts a subscription login with an explicit transport policy. +pub async fn start_login_with_options( + provider: SubscriptionProvider, + session_id: String, + options: SubscriptionHttpOptions, ) -> Result { validate_session_id(&session_id)?; let cancel = CancellationToken::new(); @@ -433,16 +530,18 @@ pub async fn start_login( // The placeholder above makes cancellation visible even while a provider // is still binding its callback listener or requesting a device code. - let begin = async { + let begin_cancel = cancel.clone(); + let begin = async move { match provider { SubscriptionProvider::Codex => { - codex::begin_login(cancel.clone(), expected_revision).await + codex::begin_login(begin_cancel.clone(), expected_revision, options.clone()).await } SubscriptionProvider::Antigravity => { - antigravity::begin_login(cancel.clone(), expected_revision).await + antigravity::begin_login(begin_cancel.clone(), expected_revision, options.clone()) + .await } SubscriptionProvider::Opencode => { - opencode::begin_login(cancel.clone(), expected_revision).await + opencode::begin_login(begin_cancel.clone(), expected_revision, options).await } } }; @@ -690,10 +789,18 @@ pub async fn logout(provider: SubscriptionProvider) -> Result Result { + resolve_with_options(provider, &SubscriptionHttpOptions::default()).await +} + +/// Resolves a subscription credential with an explicit transport policy. +pub async fn resolve_with_options( + provider: SubscriptionProvider, + options: &SubscriptionHttpOptions, +) -> Result { match provider { - SubscriptionProvider::Codex => codex::resolve().await, - SubscriptionProvider::Antigravity => antigravity::resolve().await, - SubscriptionProvider::Opencode => opencode::resolve().await, + SubscriptionProvider::Codex => codex::resolve(options).await, + SubscriptionProvider::Antigravity => antigravity::resolve(options).await, + SubscriptionProvider::Opencode => opencode::resolve(options).await, } } @@ -701,15 +808,32 @@ pub async fn resolve(provider: SubscriptionProvider) -> Result Result { - opencode::resolve_for(plan, format).await + resolve_opencode_with_options(plan, format, &SubscriptionHttpOptions::default()).await +} + +/// Resolves an OpenCode credential with an explicit transport policy. +pub async fn resolve_opencode_with_options( + plan: OpenCodePlan, + format: &str, + options: &SubscriptionHttpOptions, +) -> Result { + opencode::resolve_for(plan, format, options).await } /// Forces a resolve (which refreshes and saves), then returns the account entry. pub async fn refresh_account(provider: SubscriptionProvider) -> Result { + refresh_account_with_options(provider, &SubscriptionHttpOptions::default()).await +} + +/// Refreshes a subscription account with an explicit transport policy. +pub async fn refresh_account_with_options( + provider: SubscriptionProvider, + options: &SubscriptionHttpOptions, +) -> Result { match provider { - SubscriptionProvider::Opencode => opencode::refresh_profile().await?, + SubscriptionProvider::Opencode => opencode::refresh_profile(options).await?, _ => { - resolve(provider).await?; + resolve_with_options(provider, options).await?; } } Ok(account_snapshot(provider).await) diff --git a/src/crates/adapters/ai-adapters/src/subscription_auth/opencode.rs b/src/crates/adapters/ai-adapters/src/subscription_auth/opencode.rs index a10376a860..b488e8359d 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/opencode.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/opencode.rs @@ -7,7 +7,7 @@ use super::store::{self, StoredCredential}; use super::{ OpenCodePlan, ResolvedCredential, StartedLogin, SubscriptionApiOffering, - SubscriptionOfferingModel, + SubscriptionHttpOptions, SubscriptionOfferingModel, }; use anyhow::{anyhow, Context, Result}; use serde::Deserialize; @@ -117,19 +117,16 @@ struct OpenCodeRoute { format: &'static str, } -fn http_client() -> Result { - reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .context("build opencode http client") +fn http_client(options: &SubscriptionHttpOptions) -> Result { + super::build_http_client(options, "OpenCode") } fn now_ms() -> i64 { chrono::Utc::now().timestamp_millis() } -async fn request_device_code() -> Result { - let client = http_client()?; +async fn request_device_code(options: &SubscriptionHttpOptions) -> Result { + let client = http_client(options)?; let resp = client .post(format!("{SERVER}/auth/device/code")) .json(&serde_json::json!({ "client_id": CLIENT_ID })) @@ -154,8 +151,8 @@ enum DevicePoll { } /// One poll attempt against the device-token endpoint. -async fn poll_once(device_code: &str) -> Result { - let client = http_client()?; +async fn poll_once(device_code: &str, options: &SubscriptionHttpOptions) -> Result { + let client = http_client(options)?; let resp = client .post(format!("{SERVER}/auth/device/token")) .json(&serde_json::json!({ @@ -437,7 +434,11 @@ async fn fetch_remote_offerings( } } -async fn fetch_metadata(access: &str, existing: Option<&serde_json::Value>) -> serde_json::Value { +async fn fetch_metadata( + access: &str, + existing: Option<&serde_json::Value>, + options: &SubscriptionHttpOptions, +) -> serde_json::Value { let mut metadata = existing .and_then(serde_json::Value::as_object) .cloned() @@ -446,7 +447,7 @@ async fn fetch_metadata(access: &str, existing: Option<&serde_json::Value>) -> s "server".to_string(), serde_json::Value::String(SERVER.to_string()), ); - let client = match http_client() { + let client = match http_client(options) { Ok(client) => client, Err(_) => return serde_json::Value::Object(metadata), }; @@ -529,8 +530,8 @@ async fn persist_tokens( Ok(()) } -async fn refresh(refresh_token: &str) -> Result { - let client = http_client()?; +async fn refresh(refresh_token: &str, options: &SubscriptionHttpOptions) -> Result { + let client = http_client(options)?; let resp = client .post(format!("{SERVER}/auth/device/token")) .json(&serde_json::json!({ @@ -571,8 +572,9 @@ fn absolute_verification_url(uri: &str) -> String { pub(crate) async fn begin_login( cancel: CancellationToken, expected_revision: u64, + options: SubscriptionHttpOptions, ) -> Result { - let device = request_device_code().await?; + let device = request_device_code(&options).await?; let interval = device.interval.unwrap_or(5).max(1); let device_code = device.device_code.clone(); let user_code = device.user_code.clone(); @@ -586,14 +588,15 @@ pub(crate) async fn begin_login( let mut wait = interval; loop { tokio::time::sleep(Duration::from_secs(wait)).await; - match poll_once(&device_code).await? { + match poll_once(&device_code, &options).await? { DevicePoll::Authorized(tokens) => { // Optional profile/org network calls belong to the // cancellable authorization phase. The provider // commit lock should cover only the credential // store transaction, never up to 60 seconds of // metadata fetching. - let metadata = fetch_metadata(&tokens.access_token, None).await; + let metadata = + fetch_metadata(&tokens.access_token, None, &options).await; return Ok((tokens, metadata)); } DevicePoll::Pending => { @@ -621,7 +624,7 @@ pub(crate) async fn begin_login( }) } -async fn ensure_fresh() -> Result { +async fn ensure_fresh(options: &SubscriptionHttpOptions) -> Result { let snapshot = store::load_entry_with_revision(STORE_KEY).await?; let entry = snapshot .credential @@ -638,7 +641,7 @@ async fn ensure_fresh() -> Result { if expires > now_ms() + REFRESH_LEEWAY_MS { return Ok(access); } - let refreshed = refresh(&refresh_token).await?; + let refreshed = refresh(&refresh_token, options).await?; let new_expires = now_ms() + refreshed.expires_in * 1000; let outcome = store::upsert_if_revision( STORE_KEY, @@ -652,16 +655,46 @@ async fn ensure_fresh() -> Result { }, ) .await?; - super::require_current_store_revision(super::SubscriptionProvider::Opencode, outcome)?; - log::info!("opencode subscription tokens refreshed"); - Ok(refreshed.access_token) + match outcome { + store::ConditionalCommitOutcome::Committed { .. } => { + log::info!("opencode subscription tokens refreshed"); + Ok(refreshed.access_token) + } + store::ConditionalCommitOutcome::Conflict { current_revision } => { + let current = super::load_current_store_after_conflict( + super::SubscriptionProvider::Opencode, + current_revision, + ) + .await?; + match current.credential { + Some(StoredCredential::Api { key, .. }) => { + log::info!( + "opencode refresh reused the current API credential after a concurrent update" + ); + Ok(key) + } + Some(StoredCredential::Oauth { + access, expires, .. + }) if expires > now_ms() => { + log::info!( + "opencode refresh reused tokens committed by a concurrent refresh" + ); + Ok(access) + } + _ => Err(super::store_revision_conflict( + super::SubscriptionProvider::Opencode, + current_revision, + )), + } + } + } } } } /// Refreshes account/org/catalog metadata using a fresh credential. -pub(crate) async fn refresh_profile() -> Result<()> { - let access = ensure_fresh().await?; +pub(crate) async fn refresh_profile(options: &SubscriptionHttpOptions) -> Result<()> { + let access = ensure_fresh(options).await?; let snapshot = store::load_entry_with_revision(STORE_KEY).await?; let entry = snapshot .credential @@ -671,7 +704,7 @@ pub(crate) async fn refresh_profile() -> Result<()> { metadata.as_ref() } }; - let metadata = fetch_metadata(&access, existing_metadata).await; + let metadata = fetch_metadata(&access, existing_metadata, options).await; if existing_metadata == Some(&metadata) { return Ok(()); } @@ -701,8 +734,11 @@ pub(crate) async fn refresh_profile() -> Result<()> { Ok(()) } -async fn resolve_route(route: OpenCodeRoute) -> Result { - let api_key = ensure_fresh().await?; +async fn resolve_route( + route: OpenCodeRoute, + options: &SubscriptionHttpOptions, +) -> Result { + let api_key = ensure_fresh(options).await?; Ok(ResolvedCredential { api_key, base_url: Some(route.base_url.to_string()), @@ -715,13 +751,17 @@ async fn resolve_route(route: OpenCodeRoute) -> Result { /// Resolves the legacy OpenCode target. Models saved before plan-aware auth /// are kept on their historical Zen Chat Completions route. -pub(crate) async fn resolve() -> Result { - resolve_route(route_for(OpenCodePlan::Zen, "openai")?).await +pub(crate) async fn resolve(options: &SubscriptionHttpOptions) -> Result { + resolve_route(route_for(OpenCodePlan::Zen, "openai")?, options).await } /// Resolves a concrete OpenCode plan and wire format to a trusted endpoint. -pub(crate) async fn resolve_for(plan: OpenCodePlan, format: &str) -> Result { - resolve_route(route_for(plan, format)?).await +pub(crate) async fn resolve_for( + plan: OpenCodePlan, + format: &str, + options: &SubscriptionHttpOptions, +) -> Result { + resolve_route(route_for(plan, format)?, options).await } /// Provider metadata used to seed a new model entry. diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index c9bff5aeeb..8f71839dc9 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -80,9 +80,28 @@ SessionManager -> Session -> DialogTurn -> ModelRound - Feature work must keep `product-full` as the compatibility product assembly boundary unless a separate product matrix review changes default capability selection. -- `agent-runtime` owns the existing Core Agent Runtime compatibility facade, - including its MCP, Remote Connect, workspace-search, and native Hook runtime - services. `external-sources` adds third-party discovery/import adapters, +- `agent-runtime` owns the Core Agent lifecycle baseline, native Hook runtime, + basic filesystem/process tools, and Agent-control tools, including scheduled + job execution. Concrete network and product capabilities stay explicitly + selectable: `model-catalog`, + `mcp-runtime`, `remote-connect`, `workspace-search`, `browser-control`, + `web-tools`, `deep-research`, and `script-tool-runtime`. + `model-catalog` composes runtime services for catalog update events; + `mcp-runtime` layers the Core MCP tool bridge on the Agent lifecycle; and + `remote-connect` layers its phone relay on the Agent lifecycle and model + catalog. None of these relationships may be hidden in the `agent-runtime` + baseline. `scheduled-jobs` is only the additive dependency/source modifier + used with that baseline; it is not a standalone service profile. + Product-owned managed worktree lifecycle is available only when the Agent + lifecycle and Git service owners are both selected; it is not a tool-pack owner. + Function Agent adapters use the independent `function-agents` owner; + MiniApp domain/runtime/market dependencies belong only to `tools-miniapp`. + Tool implementation groups use the matching `tools-*` owner feature. + Product Assembly supplies the exact `ProductToolPlan`; Core materialization + validates that requested owners were compiled and must not infer product + capability from Cargo's feature union. The Agent Runtime baseline plan is + exactly `Basic` plus `AgentControl`, not a hidden delivery profile. + `external-sources` adds third-party discovery/import adapters, `plugin-runtime` adds executable plugin-client wiring, and `debug-log` keeps the debug ingest server separate. None may enable `product-full`. - CLI/ACP closure checks keep Cargo resolver-v2 normal and host @@ -100,8 +119,9 @@ SessionManager -> Session -> DialogTurn -> ModelRound narrow features may enable `product-full` directly or transitively. - `product-full` must explicitly compose every capability it consumes, including product-only `services-core` features such as `permission`, `session-git`, and - `runtime-ownership`. Do not put those features on the dependency declaration, - because Cargo feature union would force them into every core consumer. + `runtime-ownership`, every concrete service owner, and every `tools-*` group. + Do not put those features on the dependency declaration, because Cargo + feature union would force them into every core consumer. - Keep `cargo check -p bitfun-core --no-default-features` viable. Gate product-only modules at their owner feature; if a light facade operation cannot safely complete without a product owner, fail closed and preserve any diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 9a29e5e58c..78d53c2044 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -104,10 +104,7 @@ bitfun-services-integrations = { path = "../../services/services-integrations", bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false, optional = true } # Tool runtime -tool-runtime = { path = "../../execution/tool-execution", default-features = false, optional = true, features = [ - "document-read", - "web-readable", -] } +tool-runtime = { path = "../../execution/tool-execution", default-features = false, optional = true } # terminal terminal-core = { path = "../../services/terminal", optional = true } @@ -160,6 +157,25 @@ ts = [ ] product-full = [ "agent-runtime", + "model-catalog", + "mcp-runtime", + "remote-connect", + "workspace-search", + "browser-control", + "web-tools", + "deep-research", + "scheduled-jobs", + "tools-basic", + "tools-git", + "tools-mcp", + "tools-browser-web", + "tools-computer-use", + "tools-image-analysis", + "tools-miniapp", + "tools-canvas", + "tools-agent-control", + "function-agents", + "canvas-runtime", "external-sources", "plugin-runtime", "debug-log", @@ -178,7 +194,6 @@ product-full = [ "terminal", "workspace-runtime", "product-capabilities", - "product-domains", "runtime-services", "tool-packs", ] @@ -191,8 +206,6 @@ agent-runtime = [ "dep:bitfun-agent-content", "dep:bitfun-agent-stream", "dep:bitfun-harness", - "dep:chrono-tz", - "dep:cron", "dep:dashmap", "dep:filetime", "dep:flate2", @@ -201,50 +214,125 @@ agent-runtime = [ "dep:indexmap", "dep:image", "dep:md5", - "dep:reqwest", - "dep:semver", "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", ] external-sources = [ "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", ] + +# Concrete service capabilities are selected independently from the portable +# Agent lifecycle. Product entrypoints compose only the owners they expose. +model-catalog = [ + "ai-adapter-runtime", + "bitfun-services-integrations/models-dev", + "runtime-services", +] +mcp-runtime = [ + "agent-runtime", + "dep:axum", + "dep:reqwest", + "bitfun-services-integrations/mcp", + "tokio/rt-multi-thread", +] +remote-connect = [ + "agent-runtime", + "git", + "model-catalog", + "bitfun-services-integrations/remote-connect", +] +workspace-search = [ + "workspace-runtime", + "bitfun-services-integrations/workspace-search", +] +browser-control = [ + "dep:tokio-tungstenite", + "bitfun-services-integrations/browser-control", +] +web-tools = [ + "bitfun-services-integrations/web-tools", + "tool-runtime/web-readable", +] +deep-research = ["bitfun-services-integrations/deep-research"] +script-tool-runtime = ["bitfun-services-integrations/script-tool-runtime"] +scheduled-jobs = [ + "dep:chrono-tz", + "dep:cron", +] + +# Tool groups mirror the provider-neutral groups in bitfun-tool-packs. They +# compose concrete service owners but never form a product-shaped umbrella. +tools-basic = [ + "bitfun-tool-packs/basic", + "tool-runtime/document-read", + "workspace-search", +] +tools-git = [ + "bitfun-tool-packs/git", + "git", + "review-platform", +] +tools-mcp = [ + "bitfun-tool-packs/mcp", + "mcp-runtime", +] +tools-browser-web = [ + "bitfun-tool-packs/browser-web", + "browser-control", + "web-tools", +] +tools-computer-use = [ + "bitfun-tool-packs/computer-use", +] +tools-image-analysis = [ + "bitfun-tool-packs/image-analysis", +] +tools-miniapp = [ + "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", +] +tools-canvas = [ + "bitfun-tool-packs/canvas", + "canvas-runtime", +] +tools-agent-control = [ + "bitfun-tool-packs/agent-control", + "scheduled-jobs", +] plugin-runtime = [ "external-sources", "dep:bitfun-plugin-runtime-client", @@ -261,20 +349,19 @@ ai-adapter-runtime = [ ] product-capabilities = ["dep:bitfun-product-capabilities"] plugin-source = [ + "dep:bitfun-product-domains", + "bitfun-product-domains/plugin-source", "bitfun-services-integrations/plugin-source", ] -product-domains = [ +function-agents = [ "ai-adapter-runtime", "dep:bitfun-product-domains", - "plugin-source", + "bitfun-product-domains/function-agents", "bitfun-services-integrations/function-agents", - "bitfun-services-integrations/miniapp-runtime", - "bitfun-services-integrations/miniapp-market", - "bitfun-product-domains/product-full", "runtime-services", ] canvas-runtime = [ - "product-domains", + "dep:bitfun-product-domains", "bitfun-services-integrations/canvas-runtime", ] runtime-services = ["dep:bitfun-runtime-services"] @@ -283,7 +370,7 @@ file-watch = ["bitfun-services-integrations/file-watch"] git = ["bitfun-services-integrations/git"] review-platform = ["bitfun-services-integrations/review-platform"] service-integrations = ["announcement", "file-watch", "git", "review-platform"] -dispatch-store = ["local-storage"] +dispatch-store = ["local-storage", "bitfun-services-core/dispatch-workspace"] filesystem = ["bitfun-services-core/filesystem"] local-storage = ["bitfun-services-core/local-storage"] process-runtime = ["bitfun-services-core/process-runtime"] @@ -305,7 +392,7 @@ remote-workspace = [ "dep:bitfun-services-integrations", "bitfun-services-integrations/remote-ssh", ] -tool-packs = ["dep:bitfun-tool-packs", "bitfun-tool-packs/product-full", "dep:image"] +tool-packs = ["dep:bitfun-tool-packs"] # Deprecated compatibility feature. Tauri integration is desktop-owned now; # keep the public feature name as a no-op for downstream manifests. tauri-support = [] diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/custom/mode.rs b/src/crates/assembly/core/src/agentic/agents/definitions/custom/mode.rs index 73ad73050a..e228ddc373 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/custom/mode.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/custom/mode.rs @@ -95,7 +95,9 @@ impl Agent for CustomMode { } fn default_tools(&self) -> Vec { - self.data.tools.clone() + let mut tools = self.data.tools.clone(); + bitfun_agent_runtime::thread_goal_tools::ensure_thread_goal_tools(&mut tools); + tools } fn user_context_policy(&self) -> UserContextPolicy { diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/mod.rs b/src/crates/assembly/core/src/agentic/agents/definitions/mod.rs index 470972aa08..bfa372f2df 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/mod.rs @@ -1,4 +1,5 @@ pub(super) mod custom; +#[cfg(feature = "external-sources")] pub(super) mod external; pub(super) mod hidden; pub(super) mod modes; diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs index eb4135dc44..1a7cb1bef3 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs @@ -32,6 +32,9 @@ impl ClawMode { "Glob".to_string(), "WebSearch".to_string(), "WebFetch".to_string(), + "get_goal".to_string(), + "create_goal".to_string(), + "update_goal".to_string(), "Skill".to_string(), "Git".to_string(), "SessionControl".to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs index d94da2f301..2fb8f94582 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs @@ -30,6 +30,9 @@ impl CoworkMode { // Clarification + planning helpers "AskUserQuestion".to_string(), "TodoWrite".to_string(), + "get_goal".to_string(), + "create_goal".to_string(), + "update_goal".to_string(), "Task".to_string(), "ListModels".to_string(), "AgentWait".to_string(), @@ -109,6 +112,14 @@ mod tests { use super::CoworkMode; use crate::agentic::agents::Agent; + #[test] + fn cowork_mode_includes_goal_lifecycle_tools_in_defaults() { + let tools = CoworkMode::new().default_tools(); + for tool in ["get_goal", "create_goal", "update_goal"] { + assert!(tools.contains(&tool.to_string())); + } + } + #[test] fn cowork_mode_includes_miniapp_lifecycle_tools_in_defaults() { let tools = CoworkMode::new().default_tools(); diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/debug.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/debug.rs index d8ce73046c..2141cabb87 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/debug.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/debug.rs @@ -15,8 +15,10 @@ use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::{DebugModeConfig, LanguageDebugTemplate}; use crate::util::errors::BitFunResult; use async_trait::async_trait; -use bitfun_services_core::lsp::project_detector::{ProjectDetector, ProjectInfo}; +#[cfg(feature = "lsp")] +use bitfun_services_core::lsp::project_detector::ProjectDetector; use log::debug; +#[cfg(feature = "lsp")] use std::path::Path; pub struct DebugMode { @@ -24,6 +26,12 @@ pub struct DebugMode { tool_exposure_overrides: AgentToolPolicyOverrides, } +#[derive(Debug, Default)] +struct DebugProjectInfo { + languages: Vec, + project_types: Vec, +} + const DEBUG_MODE_FIRST_ENTRY_REMINDER_TEMPLATE: &str = "debug_mode_first_entry_reminder"; const DEBUG_MODE_ONGOING_REMINDER_TEMPLATE: &str = "debug_mode_ongoing_reminder"; @@ -52,9 +60,23 @@ impl DebugMode { } } - async fn detect_project_info(&self, workspace_path: &str) -> ProjectInfo { - let path = Path::new(workspace_path); - ProjectDetector::detect(path).await.unwrap_or_default() + async fn detect_project_info(&self, workspace_path: &str) -> DebugProjectInfo { + #[cfg(feature = "lsp")] + { + let detected = ProjectDetector::detect(Path::new(workspace_path)) + .await + .unwrap_or_default(); + return DebugProjectInfo { + languages: detected.languages, + project_types: detected.project_types, + }; + } + + #[cfg(not(feature = "lsp"))] + { + let _ = workspace_path; + DebugProjectInfo::default() + } } fn load_reminder_template(&self, template_name: &str) -> BitFunResult { @@ -244,7 +266,7 @@ Use these exact values when inserting instrumentation code. The server automatic fn build_first_entry_reminder( &self, debug_config: &DebugModeConfig, - project_info: &ProjectInfo, + project_info: &DebugProjectInfo, workspace_path: &str, ) -> BitFunResult { let reminder_template = diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs index 4b1374e230..0011473285 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs @@ -25,6 +25,9 @@ impl DeepResearchMode { "AgentWait".to_string(), "WebSearch".to_string(), "WebFetch".to_string(), + "get_goal".to_string(), + "create_goal".to_string(), + "update_goal".to_string(), "Read".to_string(), "view_image".to_string(), "analyze_image".to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs index 3e3d4519cd..78216eef6b 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs @@ -37,6 +37,9 @@ impl TeamMode { "Glob".to_string(), "WebSearch".to_string(), "WebFetch".to_string(), + "get_goal".to_string(), + "create_goal".to_string(), + "update_goal".to_string(), "TodoWrite".to_string(), "AskUserQuestion".to_string(), "Git".to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 8001073cf2..3475947888 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -23,6 +23,7 @@ pub use bitfun_agent_runtime::custom_agent::{ }; use bitfun_runtime_ports::PermissionConstraintLayer; pub use definitions::custom::{CustomMode, CustomSubagent, CustomSubagentKind}; +#[cfg(feature = "external-sources")] pub(crate) use definitions::external::ExternalProvidedAgent; pub use definitions::hidden::{CodeReviewAgent, DeepReviewAgent, GenerateDocAgent}; pub use definitions::modes::{ @@ -41,6 +42,7 @@ pub use prompt_builder::{ UserContextPolicy, UserContextSection, }; pub use registry::catalog::{builtin_agent_specs, BuiltinAgentSpec}; +#[cfg(feature = "external-sources")] pub(crate) use registry::external_subagent_runtime_key; pub use registry::types::{ subagent_source_from_custom_kind, AgentCategory, AgentInfo, AgentSource, AgentToolPolicy, diff --git a/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs b/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs index 3b80b5ec6e..f0ef28f74f 100644 --- a/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs +++ b/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs @@ -2,7 +2,9 @@ use crate::agentic::memories::build_memory_read_path_reminder; use crate::agentic::memories::workspace::memory_root_dir; use crate::agentic::tools::implementations::ExecCommandTool; +#[cfg(feature = "remote-workspace")] use crate::agentic::util::remote_workspace_layout::build_remote_workspace_layout_preview; +#[cfg(feature = "remote-workspace")] use crate::agentic::workspace::WorkspaceBackend; use crate::agentic::WorkspaceBinding; use crate::infrastructure::try_get_path_manager_arc; @@ -12,6 +14,7 @@ use crate::service::config::{get_app_language_code, get_global_config_service}; use crate::service::filesystem::get_formatted_directory_listing; use crate::service::i18n::LocaleId; use crate::service::instruction_context::build_workspace_instruction_files_context; +#[cfg(feature = "remote-workspace")] use crate::service::remote_ssh::workspace_state::get_remote_workspace_manager; use crate::service::workspace::get_global_workspace_service; use crate::service::workspace::RelatedPath; @@ -193,60 +196,70 @@ pub async fn build_prompt_context_for_workspace( return Some(base); } - let Some(connection_id) = workspace.connection_id() else { - return Some(base); - }; - let connection_display_name = match &workspace.backend { - WorkspaceBackend::Remote { - connection_name, .. - } => connection_name.clone(), - _ => connection_id.to_string(), - }; - let Some(manager) = get_remote_workspace_manager() else { - warn!( + #[cfg(not(feature = "remote-workspace"))] + { + Some(base) + } + + #[cfg(feature = "remote-workspace")] + { + let Some(connection_id) = workspace.connection_id() else { + return Some(base); + }; + let connection_display_name = match &workspace.backend { + WorkspaceBackend::Remote { + connection_name, .. + } => connection_name.clone(), + _ => connection_id.to_string(), + }; + let Some(manager) = get_remote_workspace_manager() else { + warn!( "Remote workspace active but RemoteWorkspaceStateManager is missing; using minimal remote hints" ); - return Some(base.with_remote_prompt_overlay( - RemoteExecutionHints { - connection_display_name, - kernel_name: "unknown".to_string(), - hostname: "unknown".to_string(), - }, - None, - )); - }; + return Some(base.with_remote_prompt_overlay( + RemoteExecutionHints { + connection_display_name, + kernel_name: "unknown".to_string(), + hostname: "unknown".to_string(), + }, + None, + )); + }; - let ssh_manager = manager.get_ssh_manager().await; - let file_service = manager.get_file_service().await; - let (kernel_name, hostname) = if let Some(ref ssh) = ssh_manager { - if let Some(info) = ssh.get_server_info(connection_id).await { - (info.os_type, info.hostname) + let ssh_manager = manager.get_ssh_manager().await; + let file_service = manager.get_file_service().await; + let (kernel_name, hostname) = if let Some(ref ssh) = ssh_manager { + if let Some(info) = ssh.get_server_info(connection_id).await { + (info.os_type, info.hostname) + } else { + ("Linux".to_string(), "remote".to_string()) + } } else { ("Linux".to_string(), "remote".to_string()) - } - } else { - ("Linux".to_string(), "remote".to_string()) - }; - let remote_layout = if let Some(ref fs) = file_service { - match build_remote_workspace_layout_preview(fs, connection_id, &workspace_path, 200).await { - Ok((_, preview)) => Some(preview), - Err(e) => { - warn!("Remote workspace layout for prompt failed: {}", e); - None + }; + let remote_layout = if let Some(ref fs) = file_service { + match build_remote_workspace_layout_preview(fs, connection_id, &workspace_path, 200) + .await + { + Ok((_, preview)) => Some(preview), + Err(e) => { + warn!("Remote workspace layout for prompt failed: {}", e); + None + } } - } - } else { - None - }; + } else { + None + }; - Some(base.with_remote_prompt_overlay( - RemoteExecutionHints { - connection_display_name, - kernel_name, - hostname, - }, - remote_layout, - )) + Some(base.with_remote_prompt_overlay( + RemoteExecutionHints { + connection_display_name, + kernel_name, + hostname, + }, + remote_layout, + )) + } } pub struct PromptBuilder { diff --git a/src/crates/assembly/core/src/agentic/agents/registry/external.rs b/src/crates/assembly/core/src/agentic/agents/registry/external.rs index a9210cafc9..dbd4d9c9a8 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/external.rs @@ -16,8 +16,10 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock, Weak}; +#[cfg(feature = "external-sources")] pub(crate) const EXTERNAL_SUBAGENT_RUNTIME_KEY_PREFIX: &str = "external_subagent_runtime:"; +#[cfg(feature = "external-sources")] pub(crate) fn external_subagent_runtime_key(digest: &str) -> String { format!("{EXTERNAL_SUBAGENT_RUNTIME_KEY_PREFIX}{digest}") } @@ -654,7 +656,10 @@ fn external_agent_info( projection: ExternalAgentProjection, ) -> AgentInfo { let agent = entry.registration.agent.as_ref(); - let default_tools = agent.default_tools(); + let mut default_tools = agent.default_tools(); + if matches!(projection, ExternalAgentProjection::Primary) { + bitfun_agent_runtime::thread_goal_tools::ensure_thread_goal_tools(&mut default_tools); + } AgentInfo { key: format!( "external::{}::{}", diff --git a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs index d0e7e1e141..1952a3f9c4 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs @@ -22,6 +22,7 @@ use std::path::{Path, PathBuf}; use std::sync::RwLock; use std::sync::{Arc, OnceLock}; +#[cfg(feature = "external-sources")] pub(crate) use external::external_subagent_runtime_key; pub use external::{ ExternalPrimaryAgentTurnBinding, ExternalSubagentGenerationLease, diff --git a/src/crates/assembly/core/src/agentic/agents/registry/query.rs b/src/crates/assembly/core/src/agentic/agents/registry/query.rs index 6e2622b460..2d621735b9 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/query.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/query.rs @@ -21,6 +21,7 @@ impl AgentRegistry { /// product-level external-source conflict resolution. Main-agent modes and /// subagents share one logical id namespace in external ecosystems, so a /// same-name local definition in either role must prevent silent takeover. + #[cfg(feature = "external-sources")] pub(crate) async fn get_local_agents_for_external_resolution( &self, workspace_root: Option<&Path>, @@ -430,6 +431,7 @@ impl AgentRegistry { } } +#[cfg(feature = "external-sources")] fn local_conflict_info( entry: &AgentEntry, parent_agent_type: Option<&str>, diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index dfa63a70dc..462445ae79 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -9,7 +9,9 @@ use crate::agentic::agents::registry::types::{ use crate::agentic::agents::registry::visibility::{ BuiltinSubagentExposure, SubagentVisibilityPolicy, }; -use crate::agentic::agents::{resolve_mode_config_profile_id, Agent, UserContextPolicy}; +use crate::agentic::agents::{ + builtin_agent_specs, resolve_mode_config_profile_id, Agent, UserContextPolicy, +}; use crate::agentic::workspace::session_execution_workspace_root; use crate::service::config::types::AgentSubagentOverrideState; use async_trait::async_trait; @@ -19,6 +21,7 @@ use bitfun_agent_runtime::custom_agent::{ }; use bitfun_agent_runtime::sdk::{RuntimeAgentRegistry, RuntimeAgentRegistryQuery}; use bitfun_agent_runtime::session::SessionConfig; +use bitfun_agent_runtime::thread_goal_tools::THREAD_GOAL_TOOL_NAMES; use bitfun_product_domains::external_sources::EcosystemId; use bitfun_product_domains::external_subagents::ExternalSubagentMode; use std::collections::{BTreeMap, HashMap}; @@ -318,6 +321,25 @@ async fn computer_use_is_builtin_subagent_not_mode() { ); } +#[test] +fn every_builtin_primary_mode_defaults_to_the_thread_goal_lifecycle() { + for spec in builtin_agent_specs() + .iter() + .filter(|spec| spec.category == AgentCategory::Mode) + { + let mode = (spec.factory)(); + let default_tools = mode.default_tools(); + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert!( + default_tools.iter().any(|tool| tool == tool_name), + "builtin primary mode {} is missing {}", + mode.id(), + tool_name + ); + } + } +} + #[test] fn non_deep_review_builtin_subagents_default_to_primary() { for agent_type in [ @@ -803,10 +825,11 @@ async fn explicit_custom_mode_load_exposes_user_mode_metadata_in_modes_info() { assert_eq!(mode.source, AgentSource::User); assert_eq!(mode.path, Some(mode_path.to_string_lossy().to_string())); assert_eq!(mode.model, Some("primary".to_string())); - assert_eq!( - mode.default_tools, - vec!["Read".to_string(), "Grep".to_string()] - ); + assert!(mode.default_tools.contains(&"Read".to_string())); + assert!(mode.default_tools.contains(&"Grep".to_string())); + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert!(mode.default_tools.iter().any(|tool| tool == tool_name)); + } assert!(mode.is_readonly); } @@ -1443,11 +1466,15 @@ async fn external_agent_role_controls_main_and_task_projection() { )], route("external::primary"), ); - assert!(registry + let primary = registry .get_modes_info_for_workspace(Some(&workspace), true) .await - .iter() - .any(|agent| agent.id == logical_id)); + .into_iter() + .find(|agent| agent.id == logical_id) + .expect("external primary projection should be visible"); + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert!(primary.default_tools.iter().any(|tool| tool == tool_name)); + } assert!(!registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some("agentic"), @@ -1473,7 +1500,7 @@ async fn external_agent_role_controls_main_and_task_projection() { .await .iter() .any(|agent| agent.id == logical_id)); - assert!(registry + let subagent = registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some("agentic"), workspace_root: Some(&workspace), @@ -1482,8 +1509,12 @@ async fn external_agent_role_controls_main_and_task_projection() { external_sources_supported: true, }) .await - .iter() - .any(|agent| agent.id == logical_id)); + .into_iter() + .find(|agent| agent.id == logical_id) + .expect("external subagent projection should be visible"); + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert!(!subagent.default_tools.iter().any(|tool| tool == tool_name)); + } } #[test] diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 9ce1ecd411..74d6c4255f 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -1236,56 +1236,104 @@ impl ConversationCoordinator { let path_buf = PathBuf::from(workspace_path); let workspace_id = Self::resolve_workspace_id_for_config(config).await; - let identity = - crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( - workspace_path, - config.remote_connection_id.as_deref(), - config.remote_ssh_host.as_deref(), - ) - .await?; + #[cfg(not(feature = "remote-workspace"))] + { + let has_remote_metadata = config + .remote_connection_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || config + .remote_ssh_host + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); + if has_remote_metadata { + let identity = + crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( + workspace_path, + config.remote_connection_id.as_deref(), + config.remote_ssh_host.as_deref(), + ) + .await?; + let connection_id = identity.remote_connection_id.clone()?; + let connection_name = config + .remote_ssh_host + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&connection_id) + .to_string(); + return Some( + WorkspaceBinding::new_remote( + workspace_id, + path_buf, + connection_id, + connection_name, + identity, + ) + .with_execution_target(config.execution_target.clone()), + ); + } + + let mut binding = WorkspaceBinding::new(workspace_id, path_buf); + if let Some(project_workspace_path) = config.project_workspace_path.as_deref() { + binding = binding.with_project_root_path(PathBuf::from(project_workspace_path)); + } + binding = binding.with_execution_target(config.execution_target.clone()); + return Some(binding); + } + + #[cfg(feature = "remote-workspace")] + { + let identity = + crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( + workspace_path, + config.remote_connection_id.as_deref(), + config.remote_ssh_host.as_deref(), + ) + .await?; - if let Some(rid) = identity.remote_connection_id.as_deref() { - // Try to look up the connection by the session's stored ID first. - let lookup = + if let Some(rid) = identity.remote_connection_id.as_deref() { + // Try to look up the connection by the session's stored ID first. + let lookup = crate::service::remote_ssh::workspace_state::lookup_remote_connection_with_hint( workspace_path, Some(rid), ) .await; - // If the stored connection_id does not resolve to a registered - // workspace, attempt a path-only lookup. This covers the case - // where the user changed the SSH port: the old connection_id is - // no longer registered, but the same remote path is now bound to - // a new connection with the updated port. - let (effective_rid, entry) = if lookup.is_some() { - (rid.to_string(), lookup) - } else { - let path_entry = - crate::service::remote_ssh::workspace_state::lookup_remote_connection( - workspace_path, - ) - .await; - if let Some(ref pe) = path_entry { - log::info!( + // If the stored connection_id does not resolve to a registered + // workspace, attempt a path-only lookup. This covers the case + // where the user changed the SSH port: the old connection_id is + // no longer registered, but the same remote path is now bound to + // a new connection with the updated port. + let (effective_rid, entry) = if lookup.is_some() { + (rid.to_string(), lookup) + } else { + let path_entry = + crate::service::remote_ssh::workspace_state::lookup_remote_connection( + workspace_path, + ) + .await; + if let Some(ref pe) = path_entry { + log::info!( "Session connection_id {} not registered for workspace {}; remapping to {}", rid, workspace_path, pe.connection_id ); - (pe.connection_id.clone(), path_entry) - } else { - (rid.to_string(), lookup) - } - }; + (pe.connection_id.clone(), path_entry) + } else { + (rid.to_string(), lookup) + } + }; - let connection_name = entry - .map(|e| e.connection_name) - .unwrap_or_else(|| effective_rid.clone()); + let connection_name = entry + .map(|e| e.connection_name) + .unwrap_or_else(|| effective_rid.clone()); - // Re-resolve identity with the effective connection_id so the - // session storage path is correct. - let effective_identity = + // Re-resolve identity with the effective connection_id so the + // session storage path is correct. + let effective_identity = crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( workspace_path, Some(&effective_rid), @@ -1294,47 +1342,55 @@ impl ConversationCoordinator { .await .unwrap_or(identity); - let binding = WorkspaceBinding::new_remote( - workspace_id.clone(), - path_buf, - effective_rid, - connection_name, - effective_identity, - ); + let binding = WorkspaceBinding::new_remote( + workspace_id.clone(), + path_buf, + effective_rid, + connection_name, + effective_identity, + ); - return Some(binding); - } + return Some(binding); + } - let mut binding = WorkspaceBinding::new(workspace_id, path_buf); - if let Some(project_workspace_path) = config.project_workspace_path.as_deref() { - binding = binding.with_project_root_path(PathBuf::from(project_workspace_path)); - } - binding = binding.with_execution_target(config.execution_target.clone()); + let mut binding = WorkspaceBinding::new(workspace_id, path_buf); + if let Some(project_workspace_path) = config.project_workspace_path.as_deref() { + binding = binding.with_project_root_path(PathBuf::from(project_workspace_path)); + } + binding = binding.with_execution_target(config.execution_target.clone()); - Some(binding) + Some(binding) + } } async fn build_session_config_for_workspace( workspace_path: String, model_id: Option, ) -> SessionConfig { - let remote_entry = - crate::service::remote_ssh::workspace_state::lookup_remote_connection(&workspace_path) - .await; - - let mut config = SessionConfig { + let config = SessionConfig { workspace_path: Some(workspace_path), model_id, ..SessionConfig::default() }; - if let Some(entry) = remote_entry { - config.remote_connection_id = Some(entry.connection_id); - if !entry.ssh_host.trim().is_empty() { - config.remote_ssh_host = Some(entry.ssh_host); + #[cfg(feature = "remote-workspace")] + { + let mut config = config; + let remote_entry = + crate::service::remote_ssh::workspace_state::lookup_remote_connection( + config.workspace_path.as_deref().unwrap_or_default(), + ) + .await; + if let Some(entry) = remote_entry { + config.remote_connection_id = Some(entry.connection_id); + if !entry.ssh_host.trim().is_empty() { + config.remote_ssh_host = Some(entry.ssh_host); + } } + return config; } + #[cfg(not(feature = "remote-workspace"))] config } @@ -1347,51 +1403,60 @@ impl ConversationCoordinator { let binding = binding.as_ref()?; if binding.is_remote() { - let manager = - match crate::service::remote_ssh::workspace_state::get_remote_workspace_manager() { + #[cfg(not(feature = "remote-workspace"))] + return None; + + #[cfg(feature = "remote-workspace")] + { + let manager = + match crate::service::remote_ssh::workspace_state::get_remote_workspace_manager( + ) { + Some(m) => m, + None => { + log::warn!( + "build_workspace_services: RemoteWorkspaceStateManager not initialized" + ); + return None; + } + }; + let ssh_manager = match manager.get_ssh_manager().await { Some(m) => m, None => { log::warn!( - "build_workspace_services: RemoteWorkspaceStateManager not initialized" + "build_workspace_services: SSH manager not available in state manager" ); return None; } }; - let ssh_manager = match manager.get_ssh_manager().await { - Some(m) => m, - None => { - log::warn!( - "build_workspace_services: SSH manager not available in state manager" - ); - return None; - } - }; - let file_service = match manager.get_file_service().await { - Some(f) => f, - None => { - log::warn!( - "build_workspace_services: File service not available in state manager" - ); - return None; - } - }; - let connection_id = match binding.connection_id() { - Some(id) => id.to_string(), - None => { - log::warn!("build_workspace_services: No connection_id in workspace binding"); - return None; - } - }; - log::info!( - "build_workspace_services: Built remote services for connection_id={}", - connection_id - ); - Some(crate::agentic::workspace::remote_workspace_services( - connection_id, - file_service, - ssh_manager, - binding.root_path_string(), - )) + let file_service = match manager.get_file_service().await { + Some(f) => f, + None => { + log::warn!( + "build_workspace_services: File service not available in state manager" + ); + return None; + } + }; + let connection_id = match binding.connection_id() { + Some(id) => id.to_string(), + None => { + log::warn!( + "build_workspace_services: No connection_id in workspace binding" + ); + return None; + } + }; + log::info!( + "build_workspace_services: Built remote services for connection_id={}", + connection_id + ); + Some(crate::agentic::workspace::remote_workspace_services( + connection_id, + file_service, + ssh_manager, + binding.root_path_string(), + )) + } } else { Some(crate::agentic::workspace::local_workspace_services( binding.root_path_string(), @@ -12768,6 +12833,48 @@ mod tests { use bitfun_runtime_services::test_support::FakeRuntimePort; use bitfun_services_core::permission_store::ProjectPermissionSqliteStore; + #[cfg(not(feature = "remote-workspace"))] + #[tokio::test] + async fn remote_session_metadata_never_falls_back_to_a_local_workspace_binding() { + let binding = ConversationCoordinator::build_workspace_binding(&SessionConfig { + workspace_path: Some("/srv/remote-project".to_string()), + remote_connection_id: Some("ssh-user@example.test:22".to_string()), + remote_ssh_host: Some("example.test".to_string()), + ..SessionConfig::default() + }) + .await + .expect("remote metadata must remain a remote binding"); + + assert!(binding.is_remote()); + assert_eq!(binding.connection_id(), Some("ssh-user@example.test:22")); + assert!( + ConversationCoordinator::build_workspace_services(&Some(binding)) + .await + .is_none(), + "a binary without remote-workspace must not create local services for a remote binding" + ); + + for incomplete in [ + SessionConfig { + workspace_path: Some("/srv/remote-project".to_string()), + remote_connection_id: Some("ssh-user@example.test:22".to_string()), + ..SessionConfig::default() + }, + SessionConfig { + workspace_path: Some("/srv/remote-project".to_string()), + remote_ssh_host: Some("example.test".to_string()), + ..SessionConfig::default() + }, + ] { + assert!( + ConversationCoordinator::build_workspace_binding(&incomplete) + .await + .is_none(), + "incomplete remote metadata must fail closed instead of becoming local" + ); + } + } + #[test] fn external_command_delegation_uses_the_resolved_primary_binding() { let source = include_str!("coordinator.rs").replace("\r\n", "\n"); @@ -13045,6 +13152,7 @@ mod tests { model_runtime_binding_fingerprint, AIConfig, AIModelConfig, }; use crate::service::config::{AgentModelDefaultsConfig, SubagentModelSelection}; + #[cfg(feature = "remote-workspace")] use crate::service::remote_ssh::workspace_state::init_remote_workspace_manager; use crate::service::session::{ DialogTurnData, DialogTurnKind, SessionMetadata, SessionRelationship, SessionStatus, @@ -13094,6 +13202,7 @@ mod tests { assert!(!gate.try_cancel()); } + #[cfg(feature = "external-sources")] #[tokio::test] async fn manual_compaction_fails_closed_before_admission_when_external_agent_is_unavailable() { let (coordinator, session_manager) = test_persistent_coordinator(); @@ -13137,6 +13246,7 @@ mod tests { assert!(session.dialog_turn_ids.is_empty()); } + #[cfg(feature = "external-sources")] #[tokio::test] async fn explicit_agent_change_switches_owner_but_case_variant_does_not() { let (_coordinator, session_manager) = test_persistent_coordinator(); @@ -14774,6 +14884,7 @@ mod tests { assert!(!bot_router.contains("initialize_snapshot_manager_for_workspace")); } + #[cfg(feature = "remote-workspace")] #[tokio::test] async fn workspace_open_owner_resolves_known_remote_before_ownership_gate() { let root = tempfile::tempdir().expect("test root"); @@ -16126,6 +16237,7 @@ mod tests { assert!(error.message.starts_with("Validation error:")); } + #[cfg(feature = "remote-workspace")] #[tokio::test] async fn thread_goal_management_keeps_cold_remote_workspaces_isolated() { let (coordinator, session_manager) = test_coordinator(); @@ -16307,6 +16419,7 @@ mod tests { } } + #[cfg(feature = "remote-workspace")] #[tokio::test] async fn thread_goal_mutations_use_loaded_remote_workspace_facts() { let (coordinator, session_manager) = test_persistent_coordinator(); @@ -16626,6 +16739,7 @@ mod tests { assert!(error.message.starts_with("Validation error:")); } + #[cfg(feature = "remote-workspace")] #[tokio::test] async fn subagent_session_config_preserves_registered_remote_workspace_identity() { let manager = init_remote_workspace_manager(); diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 0ce48d0e5c..e5a51d2c08 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -58,6 +58,7 @@ use crate::util::types::ToolDefinition; use crate::util::{elapsed_ms_u64, truncate_at_char_boundary}; use bitfun_agent_runtime::output_surface::TOOL_CONTEXT_INLINE_MARKDOWN_IMAGE_DISPLAY_KEY; use bitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY; +use bitfun_agent_runtime::thread_goal_tools::ensure_thread_goal_tools; use bitfun_ai_adapters::ModelExchangeTraceConfig; use bitfun_core_types::SessionModelBindingPolicy; use log::{debug, error, info, trace, warn}; @@ -69,6 +70,12 @@ use std::sync::Arc; use tokio_util::sync::CancellationToken; use tool_runtime::context::PrimaryModelFacts; +fn ensure_primary_session_goal_tools(allowed_tools: &mut Vec, is_subagent: bool) { + if !is_subagent { + ensure_thread_goal_tools(allowed_tools); + } +} + /// Execution engine configuration #[derive(Debug, Clone)] pub struct ExecutionEngineConfig { @@ -2257,7 +2264,11 @@ impl ExecutionEngine { .map(|workspace| workspace.root_path()), ) .await; - let allowed_tools = tool_policy.allowed_tools.clone(); + let mut allowed_tools = tool_policy.allowed_tools.clone(); + ensure_primary_session_goal_tools( + &mut allowed_tools, + context.subagent_parent_info.is_some(), + ); let enable_tools = context .context .get("enable_tools") @@ -3182,7 +3193,11 @@ impl ExecutionEngine { .map(|workspace| workspace.root_path()), ) .await; - let allowed_tools = tool_policy.allowed_tools.clone(); + let mut allowed_tools = tool_policy.allowed_tools.clone(); + ensure_primary_session_goal_tools( + &mut allowed_tools, + context.subagent_parent_info.is_some(), + ); let enable_tools = context .context .get("enable_tools") @@ -4487,7 +4502,7 @@ impl ExecutionEngine { // successfully, renumber `cit_XXX` references in the final report // into consecutive `[N]` display IDs. Two gates apply (agent type + // dialog success) so other agents and failed turns are unaffected. - #[cfg(feature = "agent-runtime")] + #[cfg(feature = "deep-research")] { if bitfun_agent_runtime::deep_research::should_post_process_research_report( &agent_type, @@ -4621,8 +4636,9 @@ impl ExecutionEngine { #[cfg(test)] mod tests { use super::{ - activate_conditional_instructions_after_round, manual_compaction_terminal_error, - ContextHealthSnapshot, ExecutionEngine, RoundResult, TurnPromptScaffold, + activate_conditional_instructions_after_round, ensure_primary_session_goal_tools, + manual_compaction_terminal_error, ContextHealthSnapshot, ExecutionEngine, RoundResult, + TurnPromptScaffold, }; use crate::agentic::agents::{ PrependedPromptReminders, PromptBuilderContext, UserContextPolicy, @@ -4642,6 +4658,7 @@ mod tests { use crate::service::config::types::AIModelConfig; use crate::service::remote_ssh::workspace_state::workspace_session_identity; use crate::util::types::ToolDefinition; + use bitfun_agent_runtime::thread_goal_tools::THREAD_GOAL_TOOL_NAMES; use bitfun_runtime_ports::{WorkspaceDirEntry, WorkspaceFileSystem, WorkspacePathKind}; use serde_json::json; use sha2::{Digest, Sha256}; @@ -4651,6 +4668,19 @@ mod tests { use std::sync::Arc; use std::time::Duration; + #[test] + fn primary_session_tool_policy_restores_goal_tools_but_subagents_stay_scoped() { + let mut primary_tools = vec!["Read".to_string()]; + ensure_primary_session_goal_tools(&mut primary_tools, false); + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert!(primary_tools.iter().any(|tool| tool == tool_name)); + } + + let mut subagent_tools = vec!["Read".to_string()]; + ensure_primary_session_goal_tools(&mut subagent_tools, true); + assert_eq!(subagent_tools, vec!["Read".to_string()]); + } + #[test] fn manual_compaction_preserves_cancellation_as_a_terminal_cancellation() { let error = manual_compaction_terminal_error(crate::BitFunError::Cancelled( diff --git a/src/crates/assembly/core/src/agentic/mod.rs b/src/crates/assembly/core/src/agentic/mod.rs index 84edde742e..1d72f17715 100644 --- a/src/crates/assembly/core/src/agentic/mod.rs +++ b/src/crates/assembly/core/src/agentic/mod.rs @@ -75,8 +75,10 @@ pub use round_preempt::{ pub use session::*; pub use side_question::*; pub use skill_agent_snapshot::*; +#[cfg(feature = "product-full")] +pub use system::init_agentic_system; pub use system::{ - init_agentic_system, init_agentic_system_for_profile, - init_agentic_system_for_profile_with_runtime_ownership, AgenticSystem, + init_agentic_system_for_profile, init_agentic_system_for_profile_with_runtime_ownership, + AgenticSystem, }; pub use workspace::{WorkspaceBackend, WorkspaceBinding}; diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 4bc9acb6fd..039f0cb2e0 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -913,7 +913,7 @@ impl SessionManager { .context_for_local_workspace(Path::new(project_workspace_path)) .sessions_dir } else if identity.hostname == "_unresolved" { - bitfun_services_integrations::remote_ssh::unresolved_remote_session_storage_dir( + bitfun_services_core::workspace_identity::unresolved_remote_session_storage_dir( runtime_service.path_manager().remote_ssh_mirror_root_dir(), identity.remote_connection_id.as_deref().unwrap_or_default(), identity.logical_workspace_path(), @@ -10397,6 +10397,7 @@ mod tests { } } + #[cfg(feature = "model-catalog")] #[tokio::test] async fn reasoning_preset_normalization_uses_the_updated_model() { let ai_config = ServiceAIConfig { @@ -10428,6 +10429,7 @@ mod tests { ); } + #[cfg(feature = "model-catalog")] #[tokio::test] async fn reasoning_preset_reconciliation_persists_auto_state() { let workspace = TestWorkspace::new(); @@ -11243,6 +11245,7 @@ mod tests { ); } + #[cfg(feature = "remote-workspace")] #[tokio::test] async fn core_session_store_port_resolves_unresolved_remote_storage_path() { use bitfun_runtime_ports::{ @@ -11272,6 +11275,7 @@ mod tests { ); } + #[cfg(feature = "remote-workspace")] #[tokio::test] async fn core_session_store_port_resolved_remote_sessions_dir_passes_through_only_sessions_root( ) { diff --git a/src/crates/assembly/core/src/agentic/session/session_store_port.rs b/src/crates/assembly/core/src/agentic/session/session_store_port.rs index ef5a44e44e..0a49820e5e 100644 --- a/src/crates/assembly/core/src/agentic/session/session_store_port.rs +++ b/src/crates/assembly/core/src/agentic/session/session_store_port.rs @@ -8,11 +8,31 @@ use bitfun_runtime_ports::{ use crate::agentic::core::SessionConfig; use crate::infrastructure::{get_path_manager_arc, PathManager}; -use crate::service::remote_ssh::workspace_state::{ - resolve_workspace_session_identity, unresolved_remote_session_storage_dir, - LOCAL_WORKSPACE_SSH_HOST, -}; use crate::service::WorkspaceRuntimeService; +#[cfg(not(feature = "remote-workspace"))] +use bitfun_services_core::workspace_identity::workspace_session_identity; +use bitfun_services_core::workspace_identity::{ + unresolved_remote_session_storage_dir, WorkspaceSessionIdentity, LOCAL_WORKSPACE_SSH_HOST, +}; + +async fn resolve_workspace_session_identity( + workspace_path: &str, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, +) -> Option { + #[cfg(feature = "remote-workspace")] + { + return crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( + workspace_path, + remote_connection_id, + remote_ssh_host, + ) + .await; + } + + #[cfg(not(feature = "remote-workspace"))] + workspace_session_identity(workspace_path, remote_connection_id, remote_ssh_host) +} #[derive(Debug, Clone, Default)] pub struct CoreSessionStorePort { @@ -194,7 +214,7 @@ impl SessionStorePort for CoreSessionStorePort { })?; let requested_workspace_path = request.workspace_path; - let runtime_service = WorkspaceRuntimeService::new(path_manager); + let runtime_service = WorkspaceRuntimeService::new(path_manager.clone()); let (effective_storage_path, storage_kind, remote_ssh_host) = if identity.hostname == LOCAL_WORKSPACE_SSH_HOST { ( @@ -207,6 +227,7 @@ impl SessionStorePort for CoreSessionStorePort { } else if identity.hostname == "_unresolved" { ( unresolved_remote_session_storage_dir( + path_manager.remote_ssh_mirror_root_dir(), identity.remote_connection_id.as_deref().unwrap_or_default(), identity.logical_workspace_path(), ), diff --git a/src/crates/assembly/core/src/agentic/system.rs b/src/crates/assembly/core/src/agentic/system.rs index 8a34433d1c..b08863a503 100644 --- a/src/crates/assembly/core/src/agentic/system.rs +++ b/src/crates/assembly/core/src/agentic/system.rs @@ -16,7 +16,7 @@ use crate::infrastructure::ai::AIClientFactory; use crate::infrastructure::try_get_path_manager_arc; use crate::runtime_ownership::CoreRuntimeOwnership; use crate::service::token_usage::{TokenUsageService, TokenUsageSubscriber}; -use bitfun_product_capabilities::DeliveryProfile; +pub use bitfun_product_capabilities::DeliveryProfile; /// Agentic runtime state shared by host adapters. #[derive(Clone)] @@ -26,7 +26,12 @@ pub struct AgenticSystem { pub token_usage_service: Arc, } -/// Initialize the agentic runtime and register the global coordinator. +/// Initialize the full compatibility Agent Runtime and register the global +/// coordinator. +/// +/// Narrow product hosts must use `init_agentic_system_for_profile` so their +/// explicit assembly plan remains the only capability authority. +#[cfg(feature = "product-full")] pub async fn init_agentic_system() -> Result { init_agentic_system_for_profile(DeliveryProfile::ProductFull).await } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index 8d5c04effe..36ec9159c1 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -1,7 +1,9 @@ //! Tool implementation module pub mod agent_wait_tool; +#[cfg(feature = "tools-image-analysis")] pub mod analyze_image_tool; +#[cfg(feature = "tools-miniapp")] pub mod appearance_publish_tool; pub mod arkts_knowledge_search_tool; pub mod ask_user_question_tool; @@ -10,15 +12,21 @@ pub mod build_project_tool; pub mod check_arkts_files_tool; pub mod check_cpp_files_tool; pub mod switch_cwd_tool; -#[cfg(feature = "canvas-runtime")] +#[cfg(feature = "tools-canvas")] pub mod canvas_tools; pub mod code_review_tool; +#[cfg(feature = "tools-computer-use")] pub mod computer_use_actions; +#[cfg(feature = "tools-computer-use")] pub mod computer_use_locate; +#[cfg(feature = "tools-computer-use")] pub mod computer_use_tool; +#[cfg(feature = "tools-browser-web")] pub mod control_hub; +#[cfg(feature = "tools-browser-web")] pub mod control_hub_tool; pub mod create_plan_tool; +#[cfg(feature = "tools-agent-control")] pub mod cron_tool; pub mod devecocli_run; pub mod deveco_mcp_check; @@ -27,10 +35,13 @@ pub mod exec_command; pub mod file_edit_tool; pub mod file_read_tool; pub mod file_write_tool; +#[cfg(feature = "tools-miniapp")] pub mod generative_ui_tool; +#[cfg(feature = "tools-git")] pub mod get_file_diff_tool; pub mod get_time_tool; pub mod get_ui_verification_log_tool; +#[cfg(feature = "tools-git")] pub mod git_tool; pub mod glob_tool; pub mod grep_tool; @@ -39,13 +50,21 @@ pub mod hdc_fallback; pub mod hdc_log_tool; pub mod list_models_tool; pub mod ls_tool; +#[cfg(feature = "tools-mcp")] pub mod mcp_tools; +#[cfg(feature = "tools-miniapp")] pub mod miniapp_finalize_tool; +#[cfg(feature = "tools-miniapp")] pub mod miniapp_init_tool; +#[cfg(feature = "tools-miniapp")] pub mod miniapp_publish_tool; +#[cfg(feature = "tools-miniapp")] pub mod page_deploy_tool; +#[cfg(feature = "tools-miniapp")] pub mod page_publish_tool; +#[cfg(feature = "tools-miniapp")] pub mod playbook_tool; +#[cfg(feature = "tools-git")] pub mod review_platform_tool; pub mod save_ui_screenshot_tool; pub mod session_cwd; @@ -62,36 +81,47 @@ pub mod todo_write_tool; pub mod ui_verification_mcp; pub mod util; pub mod verify_ui_tool; +#[cfg(feature = "tools-image-analysis")] pub mod view_image_tool; +#[cfg(feature = "tools-browser-web")] pub mod web; +#[cfg(feature = "tools-git")] pub mod worktree_tool; #[deprecated(note = "GetToolSpecTool is owned by the product tool runtime boundary")] pub use crate::agentic::tools::product_runtime::GetToolSpecTool; pub use agent_wait_tool::AgentWaitTool; +#[cfg(feature = "tools-image-analysis")] pub use analyze_image_tool::AnalyzeImageTool; +#[cfg(feature = "tools-miniapp")] pub use appearance_publish_tool::PublishAppearanceTool; pub use ask_user_question_tool::AskUserQuestionTool; pub use bash_tool::BashTool; pub use check_arkts_files_tool::CheckArktsFilesTool; pub use check_cpp_files_tool::CheckCppFilesTool; pub use switch_cwd_tool::SwitchCwdTool; -#[cfg(feature = "canvas-runtime")] +#[cfg(feature = "tools-canvas")] pub use canvas_tools::{CreateCanvasTool, PatchCanvasTool, ReadCanvasTool, UpdateCanvasTool}; pub use code_review_tool::CodeReviewTool; +#[cfg(feature = "tools-computer-use")] pub use computer_use_tool::ComputerUseTool; +#[cfg(feature = "tools-browser-web")] pub use control_hub_tool::ControlHubTool; pub use create_plan_tool::CreatePlanTool; +#[cfg(feature = "tools-agent-control")] pub use cron_tool::CronTool; pub use delete_file_tool::DeleteFileTool; pub use exec_command::{ExecCommandTool, ExecControlTool, WriteStdinTool}; pub use file_edit_tool::FileEditTool; pub use file_read_tool::FileReadTool; pub use file_write_tool::FileWriteTool; +#[cfg(feature = "tools-miniapp")] pub use generative_ui_tool::GenerativeUITool; +#[cfg(feature = "tools-git")] pub use get_file_diff_tool::GetFileDiffTool; pub use get_time_tool::GetTimeTool; pub use get_ui_verification_log_tool::GetUiVerificationLogTool; +#[cfg(feature = "tools-git")] pub use git_tool::GitTool; pub use glob_tool::GlobTool; pub use grep_tool::GrepTool; @@ -101,15 +131,23 @@ pub use hdc_log_tool::HdcLogTool; pub use start_app_tool::StartAppTool; pub use list_models_tool::ListModelsTool; pub use ls_tool::LSTool; +#[cfg(feature = "tools-mcp")] pub use mcp_tools::{ GetMCPPromptTool, ListMCPPromptsTool, ListMCPResourcesTool, ReadMCPResourceTool, }; +#[cfg(feature = "tools-miniapp")] pub use miniapp_finalize_tool::FinalizeMiniAppTool; +#[cfg(feature = "tools-miniapp")] pub use miniapp_init_tool::InitMiniAppTool; +#[cfg(feature = "tools-miniapp")] pub use miniapp_publish_tool::PublishMiniAppTool; +#[cfg(feature = "tools-miniapp")] pub use page_deploy_tool::PageDeployTool; +#[cfg(feature = "tools-miniapp")] pub use page_publish_tool::PagePublishTool; +#[cfg(feature = "tools-miniapp")] pub use playbook_tool::PlaybookTool; +#[cfg(feature = "tools-git")] pub use review_platform_tool::ReviewPlatformTool; pub use save_ui_screenshot_tool::SaveUiScreenshotTool; pub use session_control_tool::SessionControlTool; @@ -121,6 +159,9 @@ pub use terminal_control_tool::TerminalControlTool; pub use thread_goal_tools::{CreateGoalTool, GetGoalTool, UpdateGoalTool}; pub use todo_write_tool::TodoWriteTool; pub use verify_ui_tool::VerifyUiTool; +#[cfg(feature = "tools-image-analysis")] pub use view_image_tool::ViewImageTool; +#[cfg(feature = "tools-browser-web")] pub use web::{WebFetchTool, WebSearchTool}; +#[cfg(feature = "tools-git")] pub use worktree_tool::WorktreeTool; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs index f4563e5adb..676345e847 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs @@ -81,6 +81,7 @@ struct RemoteSkillRootEntry { #[derive(Debug, Clone)] struct UserSkillSources { standard: Vec, + #[cfg(feature = "file-watch")] cacheable: bool, #[cfg(feature = "file-watch")] watch_roots: Vec, @@ -807,6 +808,7 @@ impl SkillRegistry { } async fn scan_user_skill_sources() -> UserSkillSources { + #[cfg(feature = "file-watch")] let mut cacheable = match ensure_builtin_skills_installed().await { Ok(()) => true, Err(error) => { @@ -814,16 +816,26 @@ impl SkillRegistry { false } }; + #[cfg(not(feature = "file-watch"))] + if let Err(error) = ensure_builtin_skills_installed().await { + debug!("Failed to install built-in skills: {}", error); + } let mut standard = Vec::new(); for entry in Self::get_user_skill_roots() { let mut scan = Self::scan_skills_in_dir_with_status(&entry).await; - cacheable &= scan.cacheable; + #[cfg(feature = "file-watch")] + { + cacheable &= scan.cacheable; + } + #[cfg(not(feature = "file-watch"))] + let _ = scan.cacheable; standard.append(&mut scan.candidates); } UserSkillSources { standard, + #[cfg(feature = "file-watch")] cacheable, #[cfg(feature = "file-watch")] watch_roots: Self::standard_user_skill_watch_roots(), diff --git a/src/crates/assembly/core/src/agentic/tools/manifest_resolver.rs b/src/crates/assembly/core/src/agentic/tools/manifest_resolver.rs index 12c8b43f78..f96f55160f 100644 --- a/src/crates/assembly/core/src/agentic/tools/manifest_resolver.rs +++ b/src/crates/assembly/core/src/agentic/tools/manifest_resolver.rs @@ -52,6 +52,7 @@ mod tests { } } + #[cfg(feature = "tools-browser-web")] #[tokio::test] async fn manifest_resolver_facade_preserves_product_owner_output() { let allowed_tools = vec!["Read".to_string(), "WebFetch".to_string()]; diff --git a/src/crates/assembly/core/src/agentic/tools/mod.rs b/src/crates/assembly/core/src/agentic/tools/mod.rs index 3ebd0d2c21..c1035e758c 100644 --- a/src/crates/assembly/core/src/agentic/tools/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/mod.rs @@ -1,6 +1,7 @@ //! Tool system - includes Tool interface, tool registry and tool executor pub mod account_login_capability; +#[cfg(feature = "browser-control")] pub mod browser_control; pub mod computer_use_capability; pub mod computer_use_host; @@ -12,7 +13,9 @@ pub mod framework; pub mod image_context; pub mod implementations; pub mod manifest_resolver; +#[cfg(feature = "tools-miniapp")] pub mod page_deploy_host; +#[cfg(feature = "tools-miniapp")] pub mod page_publish_host; pub mod pipeline; pub(crate) mod post_call_hooks; diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index 4ab99d8cef..66a8d5c2bd 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -472,12 +472,12 @@ enum PermissionPlanDraft { } pub fn permission_project_id_for_workspace_identity( - identity: &crate::service::remote_ssh::workspace_state::WorkspaceSessionIdentity, + identity: &bitfun_services_core::workspace_identity::WorkspaceSessionIdentity, is_remote: bool, ) -> BitFunResult { if !is_remote { return Ok( - bitfun_services_integrations::remote_ssh::paths::local_workspace_stable_storage_id( + bitfun_services_core::workspace_identity::local_workspace_stable_storage_id( identity.logical_workspace_path(), ), ); @@ -489,16 +489,15 @@ pub fn permission_project_id_for_workspace_identity( "Unresolved remote workspace permission identity has no connection id".to_string(), ) })?; - let key = - bitfun_services_integrations::remote_ssh::paths::unresolved_remote_session_storage_key( - connection_id, - identity.logical_workspace_path(), - ); + let key = bitfun_services_core::workspace_identity::unresolved_remote_session_storage_key( + connection_id, + identity.logical_workspace_path(), + ); return Ok(format!("remote_unresolved_{key}")); } Ok( - bitfun_services_integrations::remote_ssh::paths::remote_workspace_stable_id( + bitfun_services_core::workspace_identity::remote_workspace_stable_id( &identity.hostname, identity.logical_workspace_path(), ), diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs index b6e3a22057..5b053d636f 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs @@ -14,10 +14,13 @@ mod snapshot; use crate::agentic::tools::registry::{ProductToolDecoratorRef, ToolRegistry}; use bitfun_agent_tools::SnapshotToolDecorator; +#[cfg(not(feature = "product-full"))] +use bitfun_product_capabilities::agent_runtime_baseline_tool_plan; use bitfun_product_capabilities::{ - product_assembly_plan_for_profile, DeliveryProfile, ProductAssemblyPlan, + product_assembly_plan_for_profile, DeliveryProfile, ProductAssemblyPlan, ProductToolPlan, }; -use materialization::create_product_tool_registry_from_plan; +use bitfun_tool_packs::{ToolPackFeatureGroup, ToolProviderGroupPlan}; +use materialization::{create_product_tool_registry_from_plan, ProductToolMaterializationError}; use snapshot::ProductSnapshotToolWrapper; use std::sync::Arc; @@ -34,7 +37,8 @@ pub(crate) use loaded_spec_state::collect_product_loaded_deferred_tool_specs; #[derive(Clone)] pub(crate) struct ProductToolRuntime { tool_decorator: ProductToolDecoratorRef, - assembly_plan: ProductAssemblyPlan, + tool_provider_group_plan: Vec, + requested_feature_groups: Vec, } impl Default for ProductToolRuntime { @@ -45,7 +49,14 @@ impl Default for ProductToolRuntime { impl ProductToolRuntime { pub(crate) fn new() -> Self { - Self::for_profile(DeliveryProfile::ProductFull) + #[cfg(feature = "product-full")] + { + Self::for_profile(DeliveryProfile::ProductFull) + } + #[cfg(not(feature = "product-full"))] + { + Self::agent_runtime_baseline() + } } pub(crate) fn for_profile(profile: DeliveryProfile) -> Self { @@ -57,35 +68,64 @@ impl ProductToolRuntime { ) } - pub(crate) fn with_tool_decorator(tool_decorator: ProductToolDecoratorRef) -> Self { - Self::with_tool_decorator_and_assembly_plan( - tool_decorator, - product_assembly_plan_for_profile(DeliveryProfile::ProductFull), + #[cfg(not(feature = "product-full"))] + pub(crate) fn agent_runtime_baseline() -> Self { + Self::with_tool_decorator_and_plan( + Arc::new(SnapshotToolDecorator::new(Arc::new( + ProductSnapshotToolWrapper, + ))), + agent_runtime_baseline_tool_plan(), ) } + pub(crate) fn with_tool_decorator(tool_decorator: ProductToolDecoratorRef) -> Self { + #[cfg(feature = "product-full")] + { + Self::with_tool_decorator_and_assembly_plan( + tool_decorator, + product_assembly_plan_for_profile(DeliveryProfile::ProductFull), + ) + } + #[cfg(not(feature = "product-full"))] + { + Self::with_tool_decorator_and_plan(tool_decorator, agent_runtime_baseline_tool_plan()) + } + } + pub(crate) fn with_tool_decorator_and_assembly_plan( tool_decorator: ProductToolDecoratorRef, assembly_plan: ProductAssemblyPlan, + ) -> Self { + Self::with_tool_decorator_and_plan(tool_decorator, assembly_plan.tool_plan()) + } + + fn with_tool_decorator_and_plan( + tool_decorator: ProductToolDecoratorRef, + tool_plan: ProductToolPlan, ) -> Self { Self { tool_decorator, - assembly_plan, + tool_provider_group_plan: tool_plan.tool_provider_group_plan().to_vec(), + requested_feature_groups: tool_plan + .feature_groups() + .iter() + .copied() + .map(ToolPackFeatureGroup::from) + .collect(), } } - pub(crate) fn create_registry(&self) -> ToolRegistry { + pub(crate) fn create_registry(&self) -> Result { let inner = create_product_tool_registry_from_plan( - self.assembly_plan - .capability_assembly() - .tool_provider_group_plan(), + &self.tool_provider_group_plan, + &self.requested_feature_groups, self.tool_decorator.clone(), - ); - ToolRegistry::from_inner(inner) + )?; + Ok(ToolRegistry::from_inner(inner)) } } -#[cfg(test)] +#[cfg(all(test, feature = "product-full"))] mod tests { use super::ProductToolRuntime; use crate::agentic::tools::registry::create_tool_registry; @@ -94,7 +134,9 @@ mod tests { #[test] fn product_tool_runtime_owner_preserves_registry_contract() { let runtime = ProductToolRuntime::default(); - let owner_registry = runtime.create_registry(); + let owner_registry = runtime + .create_registry() + .expect("product-full runtime plan must materialize"); let compatibility_registry = create_tool_registry(); assert_eq!( @@ -127,7 +169,9 @@ mod tests { #[test] fn product_tool_runtime_can_consume_explicit_product_assembly_plan() { let runtime = ProductToolRuntime::for_profile(DeliveryProfile::Cli); - let owner_registry = runtime.create_registry(); + let owner_registry = runtime + .create_registry() + .expect("CLI runtime plan must materialize in the product-full test build"); let compatibility_registry = create_tool_registry(); assert_eq!( @@ -143,7 +187,9 @@ mod tests { #[test] fn product_tool_runtime_can_consume_acp_product_assembly_plan() { let runtime = ProductToolRuntime::for_profile(DeliveryProfile::Acp); - let owner_registry = runtime.create_registry(); + let owner_registry = runtime + .create_registry() + .expect("ACP runtime plan must materialize in the product-full test build"); let compatibility_registry = create_tool_registry(); assert_eq!( @@ -158,8 +204,12 @@ mod tests { #[test] fn sdk_and_cli_profiles_current_tool_plan_ceilings_match_without_sharing_identity() { - let sdk = ProductToolRuntime::for_profile(DeliveryProfile::Sdk).create_registry(); - let cli = ProductToolRuntime::for_profile(DeliveryProfile::Cli).create_registry(); + let sdk = ProductToolRuntime::for_profile(DeliveryProfile::Sdk) + .create_registry() + .expect("SDK runtime plan must materialize in the product-full test build"); + let cli = ProductToolRuntime::for_profile(DeliveryProfile::Cli) + .create_registry() + .expect("CLI runtime plan must materialize in the product-full test build"); assert_eq!(sdk.get_tool_names(), cli.get_tool_names()); assert_eq!(sdk.get_deferred_tool_names(), cli.get_deferred_tool_names()); @@ -174,7 +224,9 @@ mod tests { DeliveryProfile::MobileWeb, ] { let runtime = ProductToolRuntime::for_profile(profile); - let registry = runtime.create_registry(); + let registry = runtime + .create_registry() + .expect("empty no-direct-Core profile must materialize"); assert!( registry.get_tool_names().is_empty(), @@ -187,3 +239,49 @@ mod tests { } } } + +#[cfg(all(test, not(feature = "product-full")))] +mod baseline_tests { + use super::ProductToolRuntime; + use bitfun_product_capabilities::DeliveryProfile; + + #[test] + fn agent_runtime_baseline_materializes_only_its_owned_tool_groups() { + let registry = ProductToolRuntime::agent_runtime_baseline() + .create_registry() + .expect("agent-runtime guarantees its Basic and AgentControl owners"); + let names = registry.get_tool_names(); + + for required in ["LS", "Read", "Task", "SessionControl", "Cron"] { + assert!( + names.iter().any(|name| name == required), + "missing {required}" + ); + } + for excluded in [ + "view_image", + "GetFileDiff", + "CreateCanvas", + "WebSearch", + "ListMCPResources", + "Git", + "ComputerUse", + ] { + assert!( + names.iter().all(|name| name != excluded), + "baseline must not expose {excluded}" + ); + } + } + + #[test] + fn unavailable_product_profile_returns_a_typed_materialization_error() { + let error = + match ProductToolRuntime::for_profile(DeliveryProfile::ProductFull).create_registry() { + Ok(_) => panic!("a narrow binary must reject the ProductFull tool plan"), + Err(error) => error, + }; + + assert!(error.to_string().contains("absent from this binary")); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs index 6dabde876c..b7b935e17b 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs @@ -553,6 +553,7 @@ mod tests { ); } + #[cfg(feature = "tools-browser-web")] #[tokio::test] async fn product_catalog_facade_resolves_manifest_from_same_provider_owner() { let allowed_tools = vec!["Read".to_string(), "WebFetch".to_string()]; @@ -618,6 +619,7 @@ mod tests { assert!(!deferred_names.iter().any(|name| name == "WebFetch")); } + #[cfg(feature = "tools-browser-web")] #[tokio::test] async fn product_resolved_manifest_owner_matches_legacy_shape() { let allowed_tools = vec!["Read".to_string(), "WebFetch".to_string()]; @@ -726,6 +728,7 @@ mod tests { ); } + #[cfg(all(feature = "tools-browser-web", feature = "tools-mcp"))] #[tokio::test] async fn disabled_deferred_tool_loading_exposes_builtin_and_mcp_tools_directly() { let registry = create_tool_registry(); @@ -802,6 +805,7 @@ mod tests { assert_eq!(mcp_tool.parameters["required"], json!(["query"])); } + #[cfg(feature = "tools-browser-web")] #[tokio::test] async fn product_resolved_visible_tools_owner_matches_registry_visibility() { let visible = resolve_product_resolved_visible_tools( @@ -833,6 +837,7 @@ mod tests { ); } + #[cfg(feature = "tools-browser-web")] #[tokio::test] async fn product_catalog_facade_resolves_get_tool_spec_results_from_same_provider_owner() { let results = resolve_product_get_tool_spec_results( @@ -853,6 +858,7 @@ mod tests { assert!(data["catalog_generation"].as_u64().is_some()); } + #[cfg(feature = "tools-browser-web")] #[tokio::test] async fn product_get_tool_spec_returns_assistant_hint_for_direct_webfetch_in_agentic_mode() { let results = resolve_product_get_tool_spec_results( @@ -884,6 +890,7 @@ mod tests { ); } + #[cfg(feature = "product-full")] #[tokio::test] async fn product_agentic_manifest_exposes_default_product_tools() { let policy = crate::agentic::agents::get_agent_registry() @@ -976,6 +983,7 @@ mod tests { .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME)); } + #[cfg(feature = "tools-image-analysis")] #[tokio::test] async fn product_manifest_keeps_view_image_for_multimodal_anthropic_context() { let allowed_tools = vec!["Read".to_string(), "view_image".to_string()]; @@ -994,6 +1002,7 @@ mod tests { .any(|tool| tool.name == "view_image")); } + #[cfg(feature = "tools-browser-web")] #[tokio::test] async fn product_manifest_snapshot_preserves_deferred_tool_discovery_contract() { let allowed_tools = vec![ @@ -1038,6 +1047,7 @@ mod tests { ); } + #[cfg(all(feature = "tools-browser-web", feature = "tools-git"))] #[tokio::test] async fn product_manifest_guard_preserves_deferred_gateway_surface() { let allowed_tools = vec![ @@ -1096,6 +1106,7 @@ mod tests { } } + #[cfg(feature = "tools-browser-web")] #[tokio::test] async fn product_manifest_preserves_explicit_get_tool_spec_runtime_contract() { let allowed_tools = vec![GET_TOOL_SPEC_TOOL_NAME.to_string(), "WebFetch".to_string()]; @@ -1127,6 +1138,7 @@ mod tests { ); } + #[cfg(feature = "tools-browser-web")] #[tokio::test] async fn product_manifest_expands_tool_when_agent_override_requests_it() { let allowed_tools = vec!["Read".to_string(), "WebFetch".to_string()]; diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index 481ee3ea86..8c9adbd1b3 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -5,11 +5,28 @@ use crate::agentic::tools::implementations::*; use crate::agentic::tools::product_runtime::CallDeferredTool; use crate::agentic::tools::registry::ProductToolDecoratorRef; use bitfun_agent_tools::{ - StaticToolProviderFactory, ToolRegistry as AgentToolRegistry, ToolRuntimeAssembly, + StaticToolMaterializationError, StaticToolProviderFactory, ToolRegistry as AgentToolRegistry, + ToolRuntimeAssembly, }; -use bitfun_tool_packs::ToolProviderGroupPlan; +use bitfun_tool_packs::{ + tool_feature_group, unavailable_feature_groups, ToolPackFeatureGroup, ToolProviderGroupPlan, +}; +use std::collections::HashSet; use std::sync::Arc; +#[derive(Debug, thiserror::Error)] +pub(crate) enum ProductToolMaterializationError { + #[error("product capability plan requires tool groups absent from this binary: {groups}")] + UnavailableFeatureGroups { groups: String }, + #[error("product tool {tool_name} in provider {provider_id} has no feature owner")] + MissingFeatureOwner { + provider_id: &'static str, + tool_name: &'static str, + }, + #[error(transparent)] + StaticToolMaterialization(#[from] StaticToolMaterializationError), +} + #[derive(Debug, Clone, Copy, Default)] pub(in crate::agentic::tools) struct ProductConcreteToolFactory; @@ -18,7 +35,9 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { match tool_name { "LS" => Some(Arc::new(LSTool::new())), "Read" => Some(Arc::new(FileReadTool::new())), + #[cfg(feature = "tools-image-analysis")] "view_image" => Some(Arc::new(ViewImageTool::new())), + #[cfg(feature = "tools-image-analysis")] "analyze_image" => Some(Arc::new(AnalyzeImageTool::new())), "Glob" => Some(Arc::new(GlobTool::new())), "Grep" => Some(Arc::new(GrepTool::new())), @@ -42,41 +61,62 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { "get_goal" => Some(Arc::new(GetGoalTool::new())), "create_goal" => Some(Arc::new(CreateGoalTool::new())), "update_goal" => Some(Arc::new(UpdateGoalTool::new())), - #[cfg(feature = "canvas-runtime")] + #[cfg(feature = "tools-canvas")] "CreateCanvas" => Some(Arc::new(CreateCanvasTool::new())), - #[cfg(feature = "canvas-runtime")] + #[cfg(feature = "tools-canvas")] "ReadCanvas" => Some(Arc::new(ReadCanvasTool::new())), - #[cfg(feature = "canvas-runtime")] + #[cfg(feature = "tools-canvas")] "UpdateCanvas" => Some(Arc::new(UpdateCanvasTool::new())), - #[cfg(feature = "canvas-runtime")] + #[cfg(feature = "tools-canvas")] "PatchCanvas" => Some(Arc::new(PatchCanvasTool::new())), "CreatePlan" => Some(Arc::new(CreatePlanTool::new())), "submit_code_review" => Some(Arc::new(CodeReviewTool::new())), "GetToolSpec" => Some(Arc::new(GetToolSpecTool::new())), "CallDeferredTool" => Some(Arc::new(CallDeferredTool::new())), + #[cfg(feature = "tools-git")] "GetFileDiff" => Some(Arc::new(GetFileDiffTool::new())), "SessionControl" => Some(Arc::new(SessionControlTool::new())), "SessionMessage" => Some(Arc::new(SessionMessageTool::new())), "SessionHistory" => Some(Arc::new(SessionHistoryTool::new())), + #[cfg(feature = "tools-agent-control")] "Cron" => Some(Arc::new(CronTool::new())), + #[cfg(feature = "tools-browser-web")] "WebSearch" => Some(Arc::new(WebSearchTool::new())), + #[cfg(feature = "tools-browser-web")] "WebFetch" => Some(Arc::new(WebFetchTool::new())), + #[cfg(feature = "tools-mcp")] "ListMCPResources" => Some(Arc::new(ListMCPResourcesTool::new())), + #[cfg(feature = "tools-mcp")] "ReadMCPResource" => Some(Arc::new(ReadMCPResourceTool::new())), + #[cfg(feature = "tools-mcp")] "ListMCPPrompts" => Some(Arc::new(ListMCPPromptsTool::new())), + #[cfg(feature = "tools-mcp")] "GetMCPPrompt" => Some(Arc::new(GetMCPPromptTool::new())), + #[cfg(feature = "tools-miniapp")] "GenerativeUI" => Some(Arc::new(GenerativeUITool::new())), + #[cfg(feature = "tools-git")] "Git" => Some(Arc::new(GitTool::new())), + #[cfg(feature = "tools-git")] "Worktree" => Some(Arc::new(WorktreeTool::new())), + #[cfg(feature = "tools-git")] "ReviewPlatform" => Some(Arc::new(ReviewPlatformTool::new())), + #[cfg(feature = "tools-miniapp")] "InitMiniApp" => Some(Arc::new(InitMiniAppTool::new())), + #[cfg(feature = "tools-miniapp")] "FinalizeMiniApp" => Some(Arc::new(FinalizeMiniAppTool::new())), + #[cfg(feature = "tools-miniapp")] "PublishMiniApp" => Some(Arc::new(PublishMiniAppTool::new())), + #[cfg(feature = "tools-miniapp")] "PublishAppearance" => Some(Arc::new(PublishAppearanceTool::new())), + #[cfg(feature = "tools-miniapp")] "PageDeploy" => Some(Arc::new(PageDeployTool::new())), + #[cfg(feature = "tools-miniapp")] "PagePublish" => Some(Arc::new(PagePublishTool::new())), + #[cfg(feature = "tools-browser-web")] "ControlHub" => Some(Arc::new(ControlHubTool::new())), + #[cfg(feature = "tools-computer-use")] "ComputerUse" => Some(Arc::new(ComputerUseTool::new())), + #[cfg(feature = "tools-miniapp")] "Playbook" => Some(Arc::new(PlaybookTool::new())), "build_project" => Some(Arc::new(BuildProjectTool::new())), "start_app" => Some(Arc::new(StartAppTool::new())), @@ -92,13 +132,43 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { pub(in crate::agentic::tools) fn create_product_tool_registry_from_plan( plan: &[ToolProviderGroupPlan], + requested_feature_groups: &[ToolPackFeatureGroup], tool_decorator: ProductToolDecoratorRef, -) -> AgentToolRegistry { - let entries = plan +) -> Result, ProductToolMaterializationError> { + let unavailable = unavailable_feature_groups(requested_feature_groups); + if !unavailable.is_empty() { + return Err(ProductToolMaterializationError::UnavailableFeatureGroups { + groups: unavailable + .iter() + .map(|group| group.id()) + .collect::>() + .join(", "), + }); + } + + let requested = requested_feature_groups .iter() - .map(|group| (group.provider_id(), group.tool_names())); + .copied() + .collect::>(); + let mut entries = Vec::new(); + for provider in plan { + let mut tool_names = Vec::new(); + for tool_name in provider.tool_names() { + let feature_group = tool_feature_group(tool_name).ok_or( + ProductToolMaterializationError::MissingFeatureOwner { + provider_id: provider.provider_id(), + tool_name, + }, + )?; + if requested.contains(&feature_group) { + tool_names.push(*tool_name); + } + } + if !tool_names.is_empty() { + entries.push((provider.provider_id(), tool_names)); + } + } - ToolRuntimeAssembly::with_tool_decorator(tool_decorator) - .create_registry_from_static_provider_entries(entries, &ProductConcreteToolFactory) - .expect("product capability tool provider plan must reference concrete core tools") + Ok(ToolRuntimeAssembly::with_tool_decorator(tool_decorator) + .create_registry_from_static_provider_entries(entries, &ProductConcreteToolFactory)?) } diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index 9c918cc73e..3a4f233a72 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -72,11 +72,15 @@ impl Default for ToolRegistry { impl ToolRegistry { /// Create a new tool registry pub fn new() -> Self { - ProductToolRuntime::default().create_registry() + ProductToolRuntime::default() + .create_registry() + .expect("the default tool runtime feature closure must be complete") } - pub(in crate::agentic) fn for_profile(profile: DeliveryProfile) -> Self { - ProductToolRuntime::for_profile(profile).create_registry() + pub(in crate::agentic) fn for_profile(profile: DeliveryProfile) -> Result { + ProductToolRuntime::for_profile(profile) + .create_registry() + .map_err(|error| error.to_string()) } /// Create a registry with an injected decoration boundary. @@ -85,7 +89,9 @@ impl ToolRegistry { /// allowing future owner crates to replace this concrete service coupling /// through the `bitfun-runtime-ports` interface. pub fn with_tool_decorator(tool_decorator: ProductToolDecoratorRef) -> Self { - ProductToolRuntime::with_tool_decorator(tool_decorator).create_registry() + ProductToolRuntime::with_tool_decorator(tool_decorator) + .create_registry() + .expect("the decorated default tool runtime feature closure must be complete") } pub(in crate::agentic::tools) fn from_inner(inner: AgentToolRegistry) -> Self { @@ -283,7 +289,7 @@ use std::sync::OnceLock; use tokio::sync::RwLock as TokioRwLock; struct GlobalToolRegistry { - profile: DeliveryProfile, + profile: Option, registry: Arc>, } @@ -293,28 +299,36 @@ pub(in crate::agentic) fn initialize_global_tool_registry_for_profile( profile: DeliveryProfile, ) -> Result>, String> { if let Some(global) = GLOBAL_TOOL_REGISTRY.get() { - return if global.profile == profile { + return if global.profile == Some(profile) { Ok(global.registry.clone()) } else { Err(format!( - "Global tool registry already uses delivery profile {}; cannot replace it with {}", - global.profile, profile + "Global tool registry already uses {}; cannot replace it with {}", + global + .profile + .map(|selected| selected.to_string()) + .unwrap_or_else(|| "the Agent Runtime baseline".to_string()), + profile )) }; } let candidate = GlobalToolRegistry { - profile, - registry: Arc::new(TokioRwLock::new(ToolRegistry::for_profile(profile))), + profile: Some(profile), + registry: Arc::new(TokioRwLock::new(ToolRegistry::for_profile(profile)?)), }; let _ = GLOBAL_TOOL_REGISTRY.set(candidate); let global = GLOBAL_TOOL_REGISTRY .get() .expect("global tool registry must be initialized"); - if global.profile != profile { + if global.profile != Some(profile) { return Err(format!( - "Global tool registry concurrently selected delivery profile {}; requested {}", - global.profile, profile + "Global tool registry concurrently selected {}; requested {}", + global + .profile + .map(|selected| selected.to_string()) + .unwrap_or_else(|| "the Agent Runtime baseline".to_string()), + profile )); } Ok(global.registry.clone()) @@ -326,7 +340,10 @@ pub fn get_global_tool_registry() -> Arc> { .get_or_init(|| { info!("Initializing global tool registry"); GlobalToolRegistry { - profile: DeliveryProfile::ProductFull, + #[cfg(feature = "product-full")] + profile: Some(DeliveryProfile::ProductFull), + #[cfg(not(feature = "product-full"))] + profile: None, registry: Arc::new(TokioRwLock::new(ToolRegistry::new())), } }) @@ -520,6 +537,7 @@ mod tests { } } + #[cfg(feature = "tools-browser-web")] #[test] fn registry_includes_webfetch_tool() { let registry = create_tool_registry(); @@ -532,6 +550,7 @@ mod tests { assert!(registry.get_tool("Cron").is_some()); } + #[cfg(feature = "product-full")] #[test] fn registry_preserves_builtin_tool_manifest_for_owner_migration() { let registry = create_tool_registry(); @@ -616,6 +635,7 @@ mod tests { ); } + #[cfg(feature = "product-full")] #[test] fn product_capability_provider_plan_covers_registry_manifest_in_order() { let assembly = bitfun_product_capabilities::default_product_capability_assembly(); @@ -658,7 +678,9 @@ mod tests { #[test] fn product_tool_runtime_preserves_core_owned_registry_contract() { let runtime = ProductToolRuntime::default(); - let assembled_registry = runtime.create_registry(); + let assembled_registry = runtime + .create_registry() + .expect("the default runtime plan must materialize"); let compatibility_registry = create_tool_registry(); assert_eq!( @@ -691,7 +713,9 @@ mod tests { #[test] fn product_tool_runtime_owner_preserves_registry_contract() { let runtime = ProductToolRuntime::default(); - let owner_registry = runtime.create_registry(); + let owner_registry = runtime + .create_registry() + .expect("the default runtime plan must materialize"); let compatibility_registry = create_tool_registry(); assert_eq!( @@ -709,7 +733,8 @@ mod tests { #[test] fn product_tool_runtime_keeps_custom_decorator_provider_contract() { let registry = ProductToolRuntime::with_tool_decorator(Arc::new(MarkerToolDecorator)) - .create_registry(); + .create_registry() + .expect("the decorated default runtime plan must materialize"); let compatibility_registry = create_tool_registry(); assert_eq!( @@ -723,7 +748,7 @@ mod tests { "custom decorator assembly must keep deferred exposure stable" ); - for tool_name in ["Write", "GetToolSpec", "WebFetch"] { + for tool_name in ["Write", "GetToolSpec", "SessionControl"] { let tool = registry .get_tool(tool_name) .unwrap_or_else(|| panic!("{tool_name} tool should be registered")); @@ -735,6 +760,7 @@ mod tests { } } + #[cfg(feature = "product-full")] #[test] fn registry_marks_deferred_tools_for_get_tool_spec() { let registry = create_tool_registry(); @@ -752,6 +778,7 @@ mod tests { assert!(!registry.is_tool_deferred("PublishAppearance")); } + #[cfg(feature = "product-full")] #[test] fn registry_preserves_deferred_tool_manifest_for_owner_migration() { let registry = create_tool_registry(); @@ -784,6 +811,7 @@ mod tests { ); } + #[cfg(feature = "product-full")] #[tokio::test] async fn registry_preserves_readonly_tool_manifest_for_owner_migration() { let readonly_names = super::get_readonly_tools() @@ -1011,6 +1039,7 @@ mod tests { assert!(error.to_string().contains("loaded spec for deferred tool")); assert!(error.to_string().contains("is stale")); } + #[cfg(all(feature = "tools-browser-web", feature = "tools-computer-use"))] #[test] fn registry_exposes_controlhub_and_computer_use() { let registry = create_tool_registry(); diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index 732eeb2c91..bae3e62182 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -27,13 +27,15 @@ use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; use crate::infrastructure::get_path_manager_arc; +#[cfg(feature = "git")] use crate::service::git::{GitDiffParams, GitService}; use crate::service::remote_ssh::workspace_state::remote_workspace_runtime_root; use crate::service::{get_workspace_runtime_service_arc, WorkspaceRuntimeContext}; use crate::util::errors::{BitFunError, BitFunResult}; +#[cfg(feature = "git")] +use bitfun_agent_runtime::checkpoint::GitStatusCheckpointFacts; use bitfun_agent_runtime::checkpoint::{ - build_light_checkpoint as build_runtime_light_checkpoint, GitStatusCheckpointFacts, - LightCheckpointWorkspaceFacts, + build_light_checkpoint as build_runtime_light_checkpoint, LightCheckpointWorkspaceFacts, }; use bitfun_agent_runtime::permission::AUTO_APPROVE_ASK_CONTEXT_KEY; use bitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY; @@ -46,8 +48,10 @@ use bitfun_product_domains::canvas::CanvasStoragePort; use bitfun_runtime_ports::{DelegationPolicy, RemoteExecPort, TerminalPort, ToolRuntimeHandles}; #[cfg(feature = "canvas-runtime")] use bitfun_services_integrations::canvas::CanvasService; +#[cfg(feature = "git")] use log::warn; use serde_json::Value; +#[cfg(feature = "git")] use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::future::Future; @@ -402,6 +406,7 @@ impl ToolUseContext { .into(); }; + #[cfg(feature = "git")] let git_status = GitService::get_status(workspace_root) .await .map(|status| GitStatusCheckpointFacts { @@ -411,6 +416,8 @@ impl ToolUseContext { untracked_count: status.untracked.len(), }) .map_err(|error| error.to_string()); + #[cfg(not(feature = "git"))] + let git_status = Err("Git capability is not compiled into this runtime".to_string()); let diff_hash = self .checkpoint_diff_hash(workspace_root, &touched_files) .await; @@ -429,39 +436,48 @@ impl ToolUseContext { workspace_root: &Path, touched_files: &[String], ) -> Option { - let files = touched_files - .iter() - .filter_map(|file| git_relative_path(workspace_root, file)) - .collect::>(); - - if files.is_empty() { + #[cfg(not(feature = "git"))] + { + let _ = (workspace_root, touched_files); return None; } - let mut diff = String::new(); - for staged in [false, true] { - let params = GitDiffParams { - files: Some(files.clone()), - staged: Some(staged), - ..Default::default() - }; - match GitService::get_diff(workspace_root, ¶ms).await { - Ok(part) => diff.push_str(&part), - Err(error) => { - warn!( - "Failed to collect checkpoint diff hash: staged={}, error={}", - staged, error - ); - return None; + #[cfg(feature = "git")] + { + let files = touched_files + .iter() + .filter_map(|file| git_relative_path(workspace_root, file)) + .collect::>(); + + if files.is_empty() { + return None; + } + + let mut diff = String::new(); + for staged in [false, true] { + let params = GitDiffParams { + files: Some(files.clone()), + staged: Some(staged), + ..Default::default() + }; + match GitService::get_diff(workspace_root, ¶ms).await { + Ok(part) => diff.push_str(&part), + Err(error) => { + warn!( + "Failed to collect checkpoint diff hash: staged={}, error={}", + staged, error + ); + return None; + } } } - } - if diff.is_empty() { - return None; - } + if diff.is_empty() { + return None; + } - Some(hex::encode(Sha256::digest(diff.as_bytes()))) + Some(hex::encode(Sha256::digest(diff.as_bytes()))) + } } pub fn enforce_tool_runtime_restrictions(&self, tool_name: &str) -> BitFunResult<()> { @@ -719,6 +735,7 @@ impl ToolUseContext { } } +#[cfg(feature = "git")] fn git_relative_path(workspace_root: &Path, path: &str) -> Option { if is_bitfun_tool_uri(path) { return None; diff --git a/src/crates/assembly/core/src/agentic/util/mod.rs b/src/crates/assembly/core/src/agentic/util/mod.rs index d5c9e55f8d..8585374352 100644 --- a/src/crates/assembly/core/src/agentic/util/mod.rs +++ b/src/crates/assembly/core/src/agentic/util/mod.rs @@ -1 +1,2 @@ +#[cfg(feature = "remote-workspace")] pub(super) mod remote_workspace_layout; diff --git a/src/crates/assembly/core/src/agentic/workspace.rs b/src/crates/assembly/core/src/agentic/workspace.rs index da16fddb7f..42ba1f525c 100644 --- a/src/crates/assembly/core/src/agentic/workspace.rs +++ b/src/crates/assembly/core/src/agentic/workspace.rs @@ -1,5 +1,4 @@ use crate::agentic::core::SessionConfig; -use crate::service::remote_ssh::workspace_state::WorkspaceSessionIdentity; use crate::service::workspace_runtime::WorkspaceRuntimeService; use bitfun_core_types::SessionExecutionTarget; pub use bitfun_runtime_ports::{ @@ -9,6 +8,10 @@ pub use bitfun_runtime_ports::{ pub use bitfun_services_core::workspace::{ local_workspace_services, LocalWorkspaceFs, LocalWorkspaceShell, }; +use bitfun_services_core::workspace_identity::{ + workspace_session_identity, WorkspaceSessionIdentity, LOCAL_WORKSPACE_SSH_HOST, +}; +#[cfg(feature = "remote-workspace")] pub use bitfun_services_integrations::remote_ssh::{ remote_workspace_services, RemoteWorkspaceFs, RemoteWorkspaceShell, }; @@ -24,7 +27,8 @@ pub(crate) fn canonical_local_workspace_path(path: &Path) -> PathBuf { dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } -/// Stable local workspace identity shared by per-workspace runtime routers. +/// Stable local workspace identity shared by external-source and MCP routers. +#[cfg(any(feature = "external-sources", feature = "mcp-runtime"))] pub(crate) fn workspace_route_key(workspace_root: Option<&Path>) -> String { workspace_root .map(|path| { @@ -80,15 +84,9 @@ pub struct WorkspaceBinding { impl WorkspaceBinding { pub fn new(workspace_id: Option, root_path: PathBuf) -> Self { let logical_workspace_path = root_path.to_string_lossy().to_string(); - let session_identity = - crate::service::remote_ssh::workspace_state::workspace_session_identity( - &logical_workspace_path, - None, - None, - ) + let session_identity = workspace_session_identity(&logical_workspace_path, None, None) .unwrap_or(WorkspaceSessionIdentity { - hostname: crate::service::remote_ssh::workspace_state::LOCAL_WORKSPACE_SSH_HOST - .to_string(), + hostname: LOCAL_WORKSPACE_SSH_HOST.to_string(), logical_workspace_path, remote_connection_id: None, }); @@ -185,7 +183,8 @@ impl WorkspaceBinding { if self.is_remote() { if self.session_identity.hostname == "_unresolved" { if let Some(connection_id) = self.session_identity.remote_connection_id.as_deref() { - return crate::service::remote_ssh::workspace_state::unresolved_remote_session_storage_dir( + return bitfun_services_core::workspace_identity::unresolved_remote_session_storage_dir( + crate::infrastructure::get_path_manager_arc().remote_ssh_mirror_root_dir(), connection_id, self.session_identity.logical_workspace_path(), ); @@ -209,13 +208,13 @@ impl WorkspaceBinding { mod tests { use super::{session_execution_workspace_root, WorkspaceBackend, WorkspaceBinding}; use crate::agentic::core::SessionConfig; - use crate::service::remote_ssh::workspace_state::{ - remote_workspace_session_mirror_dir, workspace_session_identity, - }; use crate::service::workspace_runtime::WorkspaceRuntimeService; use bitfun_core_types::{ SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle, }; + use bitfun_services_core::workspace_identity::{ + remote_workspace_session_mirror_dir, workspace_session_identity, + }; use std::path::PathBuf; #[test] @@ -260,7 +259,11 @@ mod tests { assert!(matches!(binding.backend, WorkspaceBackend::Remote { .. })); assert_eq!( binding.session_storage_dir(), - remote_workspace_session_mirror_dir("127.0.0.1", "/home/wsp/projects/test") + remote_workspace_session_mirror_dir( + crate::infrastructure::get_path_manager_arc().remote_ssh_mirror_root_dir(), + "127.0.0.1", + "/home/wsp/projects/test" + ) ); } diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 72d9ff991f..0d1016cf4b 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -9,25 +9,6 @@ pub use bitfun_product_domains::external_integration_policy::{ ExternalIntegrationPolicyScope, ExternalIntegrationPolicySnapshot, ExternalIntegrationPolicyStatus, }; -use bitfun_product_domains::external_source_control::{ - derive_external_application_status_v2, ExternalApplicationConnectionStateV2, - ExternalApplicationControlActionV2, ExternalApplicationControlRequestV2, - ExternalApplicationControlResultV2, ExternalApplicationDefaultConnectionPolicyV2, - ExternalApplicationDesiredConnectionV2, ExternalApplicationDiscoveryStateV2, - ExternalApplicationHealthV2, ExternalApplicationHostCapabilitiesV2, - ExternalApplicationOperationOutcomeV2, ExternalApplicationOwnerGenerationV2, - ExternalApplicationPrimaryActionV2, ExternalApplicationRecoveryActionV2, - ExternalApplicationReviewCategoryCountV2, ExternalApplicationReviewItemKindV2, - ExternalApplicationReviewItemRefV2, ExternalApplicationReviewItemResultV2, - ExternalApplicationReviewItemV2, ExternalApplicationReviewPageRequestV2, - ExternalApplicationReviewPageV2, ExternalApplicationReviewRecommendationSummaryV2, - ExternalApplicationReviewSelectionBaselineV2, ExternalApplicationReviewSummaryV2, - ExternalApplicationRiskLevelV2, ExternalApplicationRiskSummaryV2, - ExternalApplicationSafetyCeilingV2, ExternalApplicationSnapshotV2, - ExternalApplicationSummaryV2, ExternalApplicationTargetScopeV2, - ExternalApplicationUserDecisionV2, EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS, - EXTERNAL_APPLICATION_SCHEMA_V2, -}; pub use bitfun_product_domains::external_source_control::{ ExternalCapabilityKindV1, ExternalSourceControlActionV1, ExternalSourceControlRequestV1, ExternalSourceControlSnapshotV1, ExternalSourceRuntimeState, ExternalSourceSurfaceSnapshotV1, @@ -146,7 +127,6 @@ pub const EXTERNAL_CAPABILITY_SUBAGENT: &str = "subagent"; pub const EXTERNAL_CAPABILITY_MCP: &str = "mcp"; pub const EXTERNAL_CAPABILITY_REFERENCE: &str = "reference"; const EXTERNAL_ADAPTER_CONTRACT_MAJOR: u32 = 1; -const EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION: u32 = 1; const MAX_PROMPT_COMMAND_FILE_REFERENCES: usize = 8; const MAX_PROMPT_COMMAND_FILE_BYTES: usize = 64 * 1024; const MAX_PROMPT_COMMAND_TOTAL_FILE_BYTES: usize = 128 * 1024; @@ -693,8 +673,6 @@ fn external_capability_descriptor( #[derive(Clone)] struct ExternalEcosystemRegistration { descriptor: ExternalIntegrationEcosystemDescriptor, - default_connection_policy: ExternalApplicationDefaultConnectionPolicyV2, - default_connection_reason: &'static str, contract_major: u32, upstream_format_revision: &'static str, command_provider: Option>, @@ -806,8 +784,6 @@ fn default_external_integration_registry() -> Vec ), ], }, - default_connection_policy: ExternalApplicationDefaultConnectionPolicyV2::Connect, - default_connection_reason: "mature_declarative_support", contract_major: EXTERNAL_ADAPTER_CONTRACT_MAJOR, upstream_format_revision: "opencode-config-v1", command_provider: Some(Arc::new(OpenCodeCommandProvider::default())), @@ -842,8 +818,6 @@ fn default_external_integration_registry() -> Vec ), ], }, - default_connection_policy: ExternalApplicationDefaultConnectionPolicyV2::DiscoverOnly, - default_connection_reason: "explicit_user_connection_required", contract_major: EXTERNAL_ADAPTER_CONTRACT_MAJOR, upstream_format_revision: "claude-code-config-v1", command_provider: Some(Arc::new(ClaudeCodeCommandProvider::default())), @@ -871,8 +845,6 @@ fn default_external_integration_registry() -> Vec ), ], }, - default_connection_policy: ExternalApplicationDefaultConnectionPolicyV2::DiscoverOnly, - default_connection_reason: "explicit_user_connection_required", contract_major: EXTERNAL_ADAPTER_CONTRACT_MAJOR, upstream_format_revision: "codex-config-v1", command_provider: None, @@ -908,49 +880,9 @@ fn default_external_integration_ecosystems() -> Vec, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - application_connections: BTreeMap, #[serde(default)] integration_policy: StoredExternalIntegrationPolicy, /// Bounded recovery history for a policy document written by an @@ -1014,12 +946,6 @@ impl std::fmt::Debug for ExternalSourcesConfig { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("ExternalSourcesConfig") - .field( - "connection_schema_migration_version", - &self.connection_schema_migration_version, - ) - .field("config_origin", &self.config_origin) - .field("application_connections", &self.application_connections) .field("integration_policy", &self.integration_policy) .field( "integration_policy_backups", @@ -1155,6 +1081,57 @@ struct ExternalSourcePreferenceStore { path: PathBuf, } +fn retired_automatic_application_policy() -> ExternalIntegrationPolicyDocument { + let mut policy = ExternalIntegrationPolicyDocument::default(); + policy.user_defaults.enabled = true; + for (ecosystem, mode) in [ + (OPENCODE_ECOSYSTEM_ID, ExternalIntegrationMode::Recommended), + ( + CLAUDE_CODE_ECOSYSTEM_ID, + ExternalIntegrationMode::DiscoverOnly, + ), + (CODEX_ECOSYSTEM_ID, ExternalIntegrationMode::DiscoverOnly), + ] { + policy + .user_defaults + .ecosystems + .entry(EcosystemId::new(ecosystem).expect("built-in ecosystem id is valid")) + .or_default() + .mode = mode; + } + policy +} + +fn normalize_retired_application_defaults(config: &mut ExternalSourcesConfig) { + // The retired application setup enabled integrations automatically. Undo + // only that exact untouched default; every user-authored deviation wins. + let from_retired_automatic_setup = config + .extensions + .get("configOrigin") + .and_then(serde_json::Value::as_str) + == Some("fresh_v2"); + if !from_retired_automatic_setup { + return; + } + + // Consume the retired origin on the first persisted update, including + // documents that already contain a user deviation or application choice. + // Otherwise a later user-authored policy matching the old default could be + // mistaken for untouched setup state and reset again. + config.extensions.remove("configOrigin"); + let has_application_choice = match config.extensions.get("applicationConnections") { + None => false, + Some(serde_json::Value::Object(decisions)) => !decisions.is_empty(), + Some(_) => true, + }; + if has_application_choice { + return; + } + if config.integration_policy.known() == Some(&retired_automatic_application_policy()) { + config.integration_policy = StoredExternalIntegrationPolicy::default(); + } +} + impl ExternalSourcePreferenceStore { fn new(path: PathBuf) -> Self { Self { path } @@ -1174,7 +1151,11 @@ impl ExternalSourcePreferenceStore { JsonFileStore .read_locked_optional(&self.path) .await - .map(|config| config.unwrap_or_default()) + .map(|config| { + let mut config = config.unwrap_or_default(); + normalize_retired_application_defaults(&mut config); + config + }) .map_err(|error| error.to_string()) } @@ -1183,352 +1164,13 @@ impl ExternalSourcePreferenceStore { update: impl FnOnce(&mut ExternalSourcesConfig) -> R, ) -> Result<(R, ExternalSourcesConfig), String> { JsonFileStore - .update_locked(&self.path, ExternalSourcesConfig::default(), update) + .update_locked(&self.path, ExternalSourcesConfig::default(), |config| { + normalize_retired_application_defaults(config); + update(config) + }) .await .map_err(|error| error.to_string()) } - - async fn ensure_application_connection_schema( - &self, - _execution_domain_id: &str, - ) -> Result { - let json_store = JsonFileStore; - let _lock = json_store - .acquire_cross_process_lock(&self.path) - .await - .map_err(|error| error.to_string())?; - let existing = json_store - .read_optional::(&self.path) - .await - .map_err(|error| error.to_string())?; - let was_missing = existing.is_none(); - let mut changed = was_missing; - - let mut config = match existing { - Some(config) => config, - None => { - let mut config = ExternalSourcesConfig::default(); - apply_fresh_v2_product_defaults(&mut config)?; - config.config_origin = Some(ExternalSourcesConfigOrigin::FreshV2); - config - } - }; - if config.integration_policy.known().is_none() { - return Err(format!( - "policy_unavailable: external integration policy schema major {} is not supported", - config.integration_policy.schema_major() - )); - } - if config.connection_schema_migration_version - > EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION - { - return Err(format!( - "policy_unavailable: external application connection schema version {} is not supported", - config.connection_schema_migration_version - )); - } - if config.connection_schema_migration_version - < EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION - { - if !was_missing && config.config_origin != Some(ExternalSourcesConfigOrigin::FreshV2) { - migrate_legacy_application_connections(&mut config, _execution_domain_id)?; - } - config.connection_schema_migration_version = - EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION; - config - .config_origin - .get_or_insert(ExternalSourcesConfigOrigin::LegacyMigration); - changed = true; - } - changed |= ensure_mcp_revision_secret(&mut config); - if changed { - json_store - .write_atomic_strict(&self.path, &config) - .await - .map_err(|error| error.to_string())?; - } - Ok(config) - } -} - -fn is_zero_u32(value: &u32) -> bool { - *value == 0 -} - -fn apply_fresh_v2_product_defaults(config: &mut ExternalSourcesConfig) -> Result<(), String> { - let policy = config.integration_policy.known_mut().ok_or_else(|| { - "policy_unavailable: external integration policy is incompatible".to_string() - })?; - policy.user_defaults.enabled = true; - for registration in default_external_integration_registry() { - let mode = match registration.default_connection_policy { - ExternalApplicationDefaultConnectionPolicyV2::Connect => { - ExternalIntegrationMode::Recommended - } - ExternalApplicationDefaultConnectionPolicyV2::DiscoverOnly - | ExternalApplicationDefaultConnectionPolicyV2::Unsupported => { - ExternalIntegrationMode::DiscoverOnly - } - }; - policy - .user_defaults - .ecosystems - .entry(registration.descriptor.ecosystem_id) - .or_default() - .mode = mode; - } - Ok(()) -} - -fn external_application_connection_key( - execution_domain_id: &str, - application_id: &str, - workspace_scope_id: Option<&str>, -) -> String { - format!( - "{execution_domain_id}\u{1f}{application_id}\u{1f}{}", - workspace_scope_id.unwrap_or("user_default") - ) -} - -fn migrate_legacy_application_connections( - config: &mut ExternalSourcesConfig, - execution_domain_id: &str, -) -> Result<(), String> { - let document = config.integration_policy.known().ok_or_else(|| { - "policy_unavailable: external integration policy is incompatible".to_string() - })?; - let workspace_scope_ids = document - .workspace_overrides - .keys() - .cloned() - .collect::>(); - let reset_origin = config.config_origin == Some(ExternalSourcesConfigOrigin::IncompatibleReset); - let mut decisions = BTreeMap::new(); - for workspace_scope_id in std::iter::once(None).chain( - workspace_scope_ids - .iter() - .map(|workspace_scope_id| Some(workspace_scope_id.as_str())), - ) { - let policy = external_integration_policy_snapshot( - document, - workspace_scope_id, - default_external_integration_ecosystems(), - ) - .map_err(|error| format!("policy_unavailable: {error}"))?; - for descriptor in &policy.registered_ecosystems { - let (desired_connection, decision_origin) = if reset_origin { - ( - StoredExternalApplicationDesiredConnection::Disconnected, - StoredExternalApplicationDecisionOrigin::IncompatibleReset, - ) - } else { - legacy_application_connection_decision(&policy.effective, &descriptor.ecosystem_id) - }; - decisions.insert( - external_application_connection_key( - execution_domain_id, - descriptor.ecosystem_id.as_str(), - workspace_scope_id, - ), - StoredExternalApplicationConnectionDecision { - desired_connection, - decision_origin, - }, - ); - } - } - config.application_connections = decisions; - Ok(()) -} - -fn legacy_application_connection_decision( - policy: &EffectiveExternalIntegrationPolicy, - ecosystem_id: &EcosystemId, -) -> ( - StoredExternalApplicationDesiredConnection, - StoredExternalApplicationDecisionOrigin, -) { - let Some(ecosystem) = policy.ecosystems.get(ecosystem_id) else { - return ( - StoredExternalApplicationDesiredConnection::NeedsReview, - StoredExternalApplicationDecisionOrigin::LegacyNeedsReview, - ); - }; - if !policy.enabled - || matches!( - ecosystem.mode, - ExternalIntegrationMode::Disabled - | ExternalIntegrationMode::DiscoverOnly - | ExternalIntegrationMode::Unknown(_) - ) - { - return ( - StoredExternalApplicationDesiredConnection::Disconnected, - StoredExternalApplicationDecisionOrigin::LegacySafety, - ); - } - if ecosystem.capabilities.values().any(|access| { - matches!( - access, - ExternalIntegrationAccess::AskBeforeUse | ExternalIntegrationAccess::Auto - ) - }) { - return ( - StoredExternalApplicationDesiredConnection::Connected, - StoredExternalApplicationDecisionOrigin::LegacyActive, - ); - } - ( - StoredExternalApplicationDesiredConnection::NeedsReview, - StoredExternalApplicationDecisionOrigin::LegacyNeedsReview, - ) -} - -fn apply_external_application_connection_decision( - config: &mut ExternalSourcesConfig, - execution_domain_id: &str, - target_scope: ExternalApplicationTargetScopeV2, - workspace_scope_id: Option<&str>, - application_id: &str, - desired_connection: StoredExternalApplicationDesiredConnection, - expected_preference_revision: u64, -) -> Result { - if config.preference_revision != expected_preference_revision { - return Err(stale_operation_error( - "External application preferences changed; refresh before retrying", - )); - } - if config.connection_schema_migration_version != EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION - { - return Err(incompatible_policy_error( - "External application connection preferences require migration", - )); - } - let registration = default_external_integration_registry() - .into_iter() - .find(|registration| registration.descriptor.ecosystem_id.as_str() == application_id) - .ok_or_else(|| { - invalid_operation_error(format!( - "External application '{application_id}' is not registered" - )) - })?; - match (target_scope, workspace_scope_id) { - (ExternalApplicationTargetScopeV2::UserDefault, None) - | (ExternalApplicationTargetScopeV2::WorkspaceOverride, Some(_)) => {} - (ExternalApplicationTargetScopeV2::UserDefault, Some(_)) => { - return Err(invalid_operation_error( - "User-default application decisions cannot include a workspace scope", - )); - } - (ExternalApplicationTargetScopeV2::WorkspaceOverride, None) => { - return Err(invalid_operation_error( - "Workspace application decisions require a workspace scope", - )); - } - } - let policy = config.integration_policy.known_mut().ok_or_else(|| { - incompatible_policy_error("External integration policy requires a backup and reset") - })?; - let mode = match desired_connection { - StoredExternalApplicationDesiredConnection::Connected => { - ExternalIntegrationMode::Recommended - } - StoredExternalApplicationDesiredConnection::Disconnected - | StoredExternalApplicationDesiredConnection::Deferred => ExternalIntegrationMode::Disabled, - StoredExternalApplicationDesiredConnection::NeedsReview => { - ExternalIntegrationMode::DiscoverOnly - } - }; - let policy_changed = match target_scope { - ExternalApplicationTargetScopeV2::UserDefault => { - let enabled_changed = desired_connection - == StoredExternalApplicationDesiredConnection::Connected - && !policy.user_defaults.enabled; - if enabled_changed { - policy.user_defaults.enabled = true; - } - let ecosystem = policy - .user_defaults - .ecosystems - .entry(registration.descriptor.ecosystem_id.clone()) - .or_default(); - let mode_changed = ecosystem.mode != mode; - ecosystem.mode = mode; - enabled_changed || mode_changed - } - ExternalApplicationTargetScopeV2::WorkspaceOverride => { - let workspace_scope_id = workspace_scope_id - .expect("workspace scope was validated before applying its policy"); - let workspace = policy - .workspace_overrides - .entry(workspace_scope_id.to_string()) - .or_default(); - let enabled_changed = desired_connection - == StoredExternalApplicationDesiredConnection::Connected - && workspace.enabled != Some(true); - if enabled_changed { - workspace.enabled = Some(true); - } - let ecosystem = workspace - .ecosystems - .entry(registration.descriptor.ecosystem_id.clone()) - .or_default(); - let mode_changed = ecosystem.mode.as_ref() != Some(&mode); - ecosystem.mode = Some(mode); - enabled_changed || mode_changed - } - }; - let key = external_application_connection_key( - execution_domain_id, - application_id, - workspace_scope_id, - ); - let decision = StoredExternalApplicationConnectionDecision { - desired_connection, - decision_origin: StoredExternalApplicationDecisionOrigin::User, - }; - let decision_changed = config.application_connections.get(&key) != Some(&decision); - if decision_changed { - config.application_connections.insert(key, decision); - } - let changed = policy_changed || decision_changed; - if changed { - config.preference_revision = config.preference_revision.saturating_add(1); - } - Ok(changed) -} - -fn external_application_action_scope_matches( - current_workspace_scope_id: Option<&str>, - target_scope: ExternalApplicationTargetScopeV2, - requested_workspace_scope_id: Option<&str>, -) -> bool { - match target_scope { - ExternalApplicationTargetScopeV2::UserDefault => requested_workspace_scope_id.is_none(), - ExternalApplicationTargetScopeV2::WorkspaceOverride => { - current_workspace_scope_id.is_some() - && current_workspace_scope_id == requested_workspace_scope_id - } - } -} - -fn ensure_mcp_revision_secret(config: &mut ExternalSourcesConfig) -> bool { - if config - .mcp_revision_secret - .as_deref() - .and_then(decode_mcp_revision_key) - .is_some() - { - return false; - } - let first = uuid::Uuid::new_v4(); - let second = uuid::Uuid::new_v4(); - let mut bytes = [0_u8; 32]; - bytes[..16].copy_from_slice(first.as_bytes()); - bytes[16..].copy_from_slice(second.as_bytes()); - config.mcp_revision_secret = Some(hex::encode(bytes)); - true } fn decode_mcp_revision_key(value: &str) -> Option { @@ -1537,38 +1179,37 @@ fn decode_mcp_revision_key(value: &str) -> Option { Some(ExternalMcpRevisionKey::new(bytes)) } -fn legacy_config_after_migration_failure( - config: ExternalSourcesConfig, - migration_error: String, +async fn external_sources_config_with_mcp_revision_key( ) -> Result<(ExternalSourcesConfig, ExternalMcpRevisionKey), String> { - let revision_key = config + let store = ExternalSourcePreferenceStore::global()?; + let config = store.read().await?; + if let Some(revision_key) = config .mcp_revision_secret .as_deref() .and_then(decode_mcp_revision_key) - .ok_or(migration_error)?; - Ok((config, revision_key)) -} - -async fn external_sources_config_with_mcp_revision_key( -) -> Result<(ExternalSourcesConfig, ExternalMcpRevisionKey), String> { - let store = ExternalSourcePreferenceStore::global()?; - let config = match store - .ensure_application_connection_schema(LEGACY_LOCAL_EXECUTION_DOMAIN_ID) - .await { - Ok(config) => config, - Err(migration_error) => { - let legacy = store.read().await.map_err(|read_error| { - format!("{migration_error}; legacy preferences could not be read: {read_error}") - })?; - let fallback = legacy_config_after_migration_failure(legacy, migration_error.clone())?; - log::warn!( - "External application preference migration failed; continuing with legacy V1 preferences reason={}", - safe_external_log_token(&migration_error), - ); - return Ok(fallback); - } + return Ok((config, revision_key)); + } + let generated = { + let first = uuid::Uuid::new_v4(); + let second = uuid::Uuid::new_v4(); + let mut bytes = [0_u8; 32]; + bytes[..16].copy_from_slice(first.as_bytes()); + bytes[16..].copy_from_slice(second.as_bytes()); + bytes }; + let (_, config) = store + .update(|config| { + if config + .mcp_revision_secret + .as_deref() + .and_then(decode_mcp_revision_key) + .is_none() + { + config.mcp_revision_secret = Some(hex::encode(generated)); + } + }) + .await?; let revision_key = config .mcp_revision_secret .as_deref() @@ -3304,469 +2945,18 @@ impl WorkspaceExternalSourceService { } } - fn application_snapshot_v2( - &self, - preferences: &ExternalSourcesConfig, - host_capabilities: ExternalApplicationHostCapabilitiesV2, - ) -> Result { - let catalog = self.snapshot(); - let workspace_scope_id = workspace_policy_key(self.workspace_root.as_deref()); - let target_scope = if workspace_scope_id.is_some() { - ExternalApplicationTargetScopeV2::WorkspaceOverride - } else { - ExternalApplicationTargetScopeV2::UserDefault - }; - let source_ecosystems = catalog - .sources - .iter() - .map(|source| { - ( - source.record.key.clone(), - source.record.ecosystem_id.clone(), - ) - }) - .collect::>(); - let subagents_by_candidate_id = catalog - .subagents - .iter() - .map(|subagent| (subagent.candidate_id.as_str(), subagent)) - .collect::>(); - let applications = default_external_integration_registry() - .into_iter() - .map(|registration| { - project_external_application_v2( - &catalog, - preferences, - self.execution_domain_id.as_str(), - workspace_scope_id.as_deref(), - registration, - host_capabilities, - &source_ecosystems, - &subagents_by_candidate_id, - ) - }) - .collect::>(); - let review_summary = external_application_review_summary( - &catalog, + fn safe_mode_enabled(&self) -> bool { + external_source_safe_mode_enabled_for( self.execution_domain_id.as_str(), - workspace_scope_id.as_deref(), - target_scope, - preferences.preference_revision, - &subagents_by_candidate_id, - ); - let snapshot = ExternalApplicationSnapshotV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: self.execution_domain_id.clone(), - workspace_scope_id, - effective_connection_scope: target_scope, - refresh_generation: catalog.generation, - preference_revision: preferences.preference_revision, - safe_mode: self.safe_mode_enabled(), - host_capabilities, - applications, - review_summary, - }; - snapshot - .validate() - .map_err(|error| format!("invalid external application projection: {error}"))?; - Ok(snapshot) + &workspace_route_key(self.workspace_root.as_deref()), + ) } - fn application_review_page_v2( - &self, - preferences: &ExternalSourcesConfig, - request: ExternalApplicationReviewPageRequestV2, - ) -> Result { - request - .validate() - .map_err(|error| invalid_operation_error(error))?; - let workspace_scope_id = workspace_policy_key(self.workspace_root.as_deref()); - let target_scope = if workspace_scope_id.is_some() { - ExternalApplicationTargetScopeV2::WorkspaceOverride - } else { - ExternalApplicationTargetScopeV2::UserDefault - }; - if request.execution_domain_id != self.execution_domain_id - || request.workspace_scope_id != workspace_scope_id - || request.target_scope != target_scope - { - return Err(stale_operation_error( - "External application review belongs to a different Host or workspace", - )); - } - let catalog = self.snapshot(); - let plan = external_application_review_plan( - &catalog, + fn write_safe_mode(&self, enabled: bool) { + set_external_source_safe_mode_for( self.execution_domain_id.as_str(), - workspace_scope_id.as_deref(), - target_scope, - preferences.preference_revision, - ); - let opening_request = request.cursor.is_none() && request.expected_generations.is_empty(); - if request.preference_revision != preferences.preference_revision - || (!opening_request - && (request.review_id != plan.review_id - || request.expected_generations != plan.expected_generations)) - { - return Err(stale_operation_error( - "External application review changed; refresh before continuing", - )); - } - let offset = match request.cursor.as_deref() { - None => 0, - Some(cursor) => plan.parse_cursor(cursor)?, - }; - let end = offset - .saturating_add(request.page_size) - .min(plan.items.len()); - let items = plan.items[offset..end].to_vec(); - let next_cursor = (end < plan.items.len()).then(|| plan.cursor(end)); - let page = ExternalApplicationReviewPageV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: self.execution_domain_id.clone(), - workspace_scope_id, - target_scope, - review_id: plan.review_id, - preference_revision: preferences.preference_revision, - expected_generations: plan.expected_generations, - cursor: request.cursor, - next_cursor, - total_count: plan.items.len(), - items, - }; - page.validate() - .map_err(|error| format!("invalid external application review projection: {error}"))?; - Ok(page) - } - - async fn apply_application_action_v2( - self: &Arc, - request: ExternalApplicationControlRequestV2, - ) -> Result { - request - .validate() - .map_err(|error| invalid_operation_error(error))?; - let workspace_scope_id = workspace_policy_key(self.workspace_root.as_deref()); - if request.execution_domain_id != self.execution_domain_id - || !external_application_action_scope_matches( - workspace_scope_id.as_deref(), - request.target_scope, - request.workspace_scope_id.as_deref(), - ) - { - return Err(stale_operation_error( - "External application action belongs to a different Host or workspace", - )); - } - let operation_id = request.operation_id; - let expected_preference_revision = request.expected_preference_revision; - let item_results = match request.action { - ExternalApplicationControlActionV2::Refresh => { - self.refresh_with_runtime_invalidation().await?; - Vec::new() - } - ExternalApplicationControlActionV2::SetSafeMode { enabled } => { - self.set_safe_mode(enabled, Some(expected_preference_revision)) - .await?; - Vec::new() - } - ExternalApplicationControlActionV2::SetSourceEnabled { - source_key, - enabled, - } => { - self.set_source_enabled(&source_key, enabled, expected_preference_revision) - .await?; - Vec::new() - } - ExternalApplicationControlActionV2::ConnectApplication { application_id } => { - self.persist_application_connection( - request.target_scope, - request.workspace_scope_id.as_deref(), - &application_id, - StoredExternalApplicationDesiredConnection::Connected, - expected_preference_revision, - ) - .await?; - Vec::new() - } - ExternalApplicationControlActionV2::DisconnectApplication { application_id } => { - self.persist_application_connection( - request.target_scope, - request.workspace_scope_id.as_deref(), - &application_id, - StoredExternalApplicationDesiredConnection::Disconnected, - expected_preference_revision, - ) - .await?; - Vec::new() - } - ExternalApplicationControlActionV2::SetApplicationDeferred { application_id } => { - self.persist_application_connection( - request.target_scope, - request.workspace_scope_id.as_deref(), - &application_id, - StoredExternalApplicationDesiredConnection::Deferred, - expected_preference_revision, - ) - .await?; - Vec::new() - } - ExternalApplicationControlActionV2::SubmitApplicationReview { - review_id, - expected_generations, - selection_baseline, - selection_overrides, - } => { - self.apply_application_review_v2( - request.target_scope, - request.workspace_scope_id.as_deref(), - expected_preference_revision, - &review_id, - expected_generations, - selection_baseline, - selection_overrides, - ) - .await? - } - }; - let preferences = read_external_sources_config().await?; - let outcome = if item_results - .iter() - .any(|item| item.outcome != ExternalApplicationOperationOutcomeV2::Applied) - && !item_results - .iter() - .any(|item| item.outcome == ExternalApplicationOperationOutcomeV2::Applied) - { - item_results - .iter() - .map(|item| item.outcome) - .next() - .unwrap_or(ExternalApplicationOperationOutcomeV2::Applied) - } else { - ExternalApplicationOperationOutcomeV2::Applied - }; - let result = ExternalApplicationControlResultV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - operation_id, - preference_revision: preferences.preference_revision, - outcome, - item_results, - }; - result - .validate() - .map_err(|error| format!("invalid external application action result: {error}"))?; - Ok(result) - } - - async fn persist_application_connection( - self: &Arc, - target_scope: ExternalApplicationTargetScopeV2, - workspace_scope_id: Option<&str>, - application_id: &str, - desired_connection: StoredExternalApplicationDesiredConnection, - expected_preference_revision: u64, - ) -> Result<(), String> { - let workspace_scope_id = workspace_scope_id.map(str::to_string); - let execution_domain_id = self.execution_domain_id.to_string(); - let application_id = application_id.to_string(); - let (result, preferences) = ExternalSourcePreferenceStore::global()? - .update(move |config| { - apply_external_application_connection_decision( - config, - &execution_domain_id, - target_scope, - workspace_scope_id.as_deref(), - &application_id, - desired_connection, - expected_preference_revision, - ) - }) - .await?; - result?; - propagate_integration_policy_preferences(&preferences, self); - self.refresh_preserving_worker_recovery().await?; - Ok(()) - } - - async fn apply_application_review_v2( - &self, - target_scope: ExternalApplicationTargetScopeV2, - workspace_scope_id: Option<&str>, - expected_preference_revision: u64, - review_id: &str, - expected_generations: Vec, - selection_baseline: ExternalApplicationReviewSelectionBaselineV2, - selection_overrides: Vec< - bitfun_product_domains::external_source_control::ExternalApplicationReviewSelectionOverrideV2, - >, - ) -> Result, String> { - if selection_overrides.len() > EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS { - return Err(invalid_operation_error( - "External application review has too many selection overrides", - )); - } - let catalog = self.snapshot(); - let plan = external_application_review_plan( - &catalog, - self.execution_domain_id.as_str(), - workspace_scope_id, - target_scope, - expected_preference_revision, - ); - if review_id != plan.review_id - || expected_generations != plan.expected_generations - || expected_preference_revision != catalog.preference_revision - { - return Err(stale_operation_error( - "External application review changed; refresh before applying it", - )); - } - let plan_refs = plan - .items - .iter() - .map(|item| item.item_ref.clone()) - .collect::>(); - let mut overrides = BTreeMap::new(); - for selection in selection_overrides { - if !plan_refs.contains(&selection.item_ref) - || overrides - .insert(selection.item_ref, selection.selected) - .is_some() - { - return Err(stale_operation_error( - "External application review selections no longer match the current plan", - )); - } - } - let mut current_revision = expected_preference_revision; - let mut results = Vec::with_capacity(plan.items.len()); - for item in plan.items { - if item.safety_ceiling == ExternalApplicationSafetyCeilingV2::Blocked { - results.push(ExternalApplicationReviewItemResultV2 { - item_ref: item.item_ref, - outcome: ExternalApplicationOperationOutcomeV2::Blocked, - reason_code: Some("resolve_conflict".to_string()), - recovery_actions: vec![ExternalApplicationRecoveryActionV2::ResolveConflict], - }); - continue; - } - let selected = - overrides - .get(&item.item_ref) - .copied() - .unwrap_or(match selection_baseline { - ExternalApplicationReviewSelectionBaselineV2::Recommended => { - item.recommended - } - ExternalApplicationReviewSelectionBaselineV2::None => false, - }); - let outcome = self - .apply_application_review_item(&catalog, &item.item_ref, selected, current_revision) - .await; - match outcome { - Ok(snapshot) => { - current_revision = snapshot.preference_revision; - results.push(ExternalApplicationReviewItemResultV2 { - item_ref: item.item_ref, - outcome: ExternalApplicationOperationOutcomeV2::Applied, - reason_code: None, - recovery_actions: Vec::new(), - }); - } - Err(error) => { - let (outcome, reason_code, recovery_actions) = - external_application_item_failure(&error); - results.push(ExternalApplicationReviewItemResultV2 { - item_ref: item.item_ref, - outcome, - reason_code: Some(reason_code), - recovery_actions, - }); - } - } - } - Ok(results) - } - - async fn apply_application_review_item( - &self, - catalog: &ExternalSourceCatalogSnapshot, - item_ref: &ExternalApplicationReviewItemRefV2, - selected: bool, - expected_preference_revision: u64, - ) -> Result { - match item_ref.kind { - ExternalApplicationReviewItemKindV2::Tool => { - let request = catalog - .tool_approval_requests - .iter() - .find(|request| request.approval_key == item_ref.stable_id) - .ok_or_else(|| { - missing_candidate_error("External tool review item is no longer available") - })?; - self.set_tool_target_decision( - &request.approval_key, - &request.decision_key, - selected, - expected_preference_revision, - ) - .await - } - ExternalApplicationReviewItemKindV2::Mcp => { - let request = catalog - .mcp_approval_requests - .iter() - .find(|request| request.decision_key == item_ref.stable_id) - .ok_or_else(|| { - missing_candidate_error("External MCP review item is no longer available") - })?; - self.set_mcp_server_decision( - &request.candidate_id, - &request.decision_key, - selected, - catalog.mcp_generation, - expected_preference_revision, - ) - .await - } - ExternalApplicationReviewItemKindV2::Subagent => { - let summary = catalog - .subagents - .iter() - .find(|summary| summary.decision_key == item_ref.stable_id) - .ok_or_else(|| { - missing_candidate_error( - "External subagent review item is no longer available", - ) - })?; - self.set_subagent_activation( - &summary.candidate_id, - selected, - catalog.subagent_generation, - expected_preference_revision, - &summary.decision_key, - ) - .await - } - ExternalApplicationReviewItemKindV2::Command - | ExternalApplicationReviewItemKindV2::Conflict => Err(conflict_operation_error( - "External conflict review requires an explicit owner choice", - )), - } - } - - fn safe_mode_enabled(&self) -> bool { - external_source_safe_mode_enabled_for( - self.execution_domain_id.as_str(), - &workspace_route_key(self.workspace_root.as_deref()), - ) - } - - fn write_safe_mode(&self, enabled: bool) { - set_external_source_safe_mode_for( - self.execution_domain_id.as_str(), - &workspace_route_key(self.workspace_root.as_deref()), - enabled, + &workspace_route_key(self.workspace_root.as_deref()), + enabled, ); } @@ -4798,847 +3988,90 @@ impl WorkspaceExternalSourceService { } } -#[derive(Default)] -struct ExternalApplicationAggregateCounts { - enabled: usize, - pending_review: usize, - blocked: usize, - conflicts: usize, +fn lock_coordinator( + control_plane: &ExternalSourceControlPlane, +) -> MutexGuard<'_, bitfun_external_sources::ExternalSourceCoordinator> { + control_plane.lock_commands() +} + +fn lock_tool_coordinator( + control_plane: &ExternalSourceControlPlane, +) -> MutexGuard<'_, bitfun_external_sources::ExternalToolCoordinator> { + control_plane.lock_tools() } -struct ExternalApplicationReviewPlan { - review_id: String, - expected_generations: Vec, - items: Vec, - summary_items: Vec, +fn lock_subagent_coordinator( + control_plane: &ExternalSourceControlPlane, +) -> MutexGuard<'_, bitfun_external_sources::ExternalSubagentCoordinator> { + control_plane.lock_subagents() } -struct ExternalApplicationReviewSummaryItem { - item_ref: ExternalApplicationReviewItemRefV2, - recommended: bool, - safety_ceiling: ExternalApplicationSafetyCeilingV2, +fn lock_mcp_coordinator( + control_plane: &ExternalSourceControlPlane, +) -> MutexGuard<'_, bitfun_external_sources::ExternalMcpCoordinator> { + control_plane.lock_mcp() } -impl ExternalApplicationReviewPlan { - fn summary(&self) -> Option { - if self.summary_items.is_empty() { - return None; - } - let mut counts = BTreeMap::new(); - for item in &self.summary_items { - *counts.entry(item.item_ref.kind).or_insert(0usize) += 1; - } - let recommended_count = self - .summary_items - .iter() - .filter(|item| item.recommended) - .count(); - let blocked_count = self - .summary_items - .iter() - .filter(|item| item.safety_ceiling == ExternalApplicationSafetyCeilingV2::Blocked) - .count(); - Some(ExternalApplicationReviewSummaryV2 { - review_id: self.review_id.clone(), - total_count: self.summary_items.len(), - category_counts: counts - .into_iter() - .map(|(kind, count)| ExternalApplicationReviewCategoryCountV2 { kind, count }) - .collect(), - max_selection_count: self.summary_items.len().saturating_sub(blocked_count), - risk_summary: ExternalApplicationRiskSummaryV2 { - highest_level: Some(ExternalApplicationRiskLevelV2::High), - reason_codes: vec!["executable_content_requires_review".to_string()], - }, - recommendation_summary: ExternalApplicationReviewRecommendationSummaryV2 { - recommended_count, - optional_count: self - .summary_items - .len() - .saturating_sub(recommended_count) - .saturating_sub(blocked_count), - blocked_count, - }, - safety_ceiling: if blocked_count == self.summary_items.len() { - ExternalApplicationSafetyCeilingV2::Blocked - } else { - ExternalApplicationSafetyCeilingV2::ReviewRequired - }, - }) - } +fn lock_workspace_reference_coordinator( + control_plane: &ExternalSourceControlPlane, +) -> MutexGuard<'_, bitfun_external_sources::ExternalWorkspaceReferenceCoordinator> { + control_plane.lock_workspace_references() +} - fn cursor(&self, offset: usize) -> String { - format!("{}:{offset}", self.review_id) +fn lock_snapshot( + snapshot: &StdMutex, +) -> MutexGuard<'_, ExternalSourceCatalogSnapshot> { + match snapshot.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), } +} - fn parse_cursor(&self, cursor: &str) -> Result { - let offset = cursor - .strip_prefix(&format!("{}:", self.review_id)) - .and_then(|offset| offset.parse::().ok()) - .filter(|offset| *offset < self.items.len()) - .ok_or_else(|| { - stale_operation_error( - "External application review cursor changed; restart the review", - ) - })?; - Ok(offset) - } +static WORKSPACE_SERVICES: OnceLock< + DashMap, Weak>, +> = OnceLock::new(); +static READ_ONLY_WORKSPACE_SERVICES: OnceLock< + DashMap, Weak>, +> = OnceLock::new(); +static SAFE_MODE_WORKSPACES: OnceLock> = OnceLock::new(); + +fn safe_mode_workspaces() -> &'static DashMap { + SAFE_MODE_WORKSPACES.get_or_init(DashMap::new) } +static TOOL_REGISTRY_CHANGE_EPOCH: AtomicU64 = AtomicU64::new(0); +static TOOL_REGISTRY_REBUILD_SCHEDULED: AtomicBool = AtomicBool::new(false); -fn external_application_review_plan( - catalog: &ExternalSourceCatalogSnapshot, - execution_domain_id: &str, - workspace_scope_id: Option<&str>, - target_scope: ExternalApplicationTargetScopeV2, - preference_revision: u64, -) -> ExternalApplicationReviewPlan { - let subagents_by_candidate_id = catalog - .subagents - .iter() - .map(|subagent| (subagent.candidate_id.as_str(), subagent)) - .collect::>(); - external_application_review_plan_internal( - catalog, - execution_domain_id, - workspace_scope_id, - target_scope, - preference_revision, - &subagents_by_candidate_id, - true, - ) +fn workspace_services() -> &'static DashMap, Weak> { + WORKSPACE_SERVICES.get_or_init(DashMap::new) } -fn external_application_review_summary( - catalog: &ExternalSourceCatalogSnapshot, - execution_domain_id: &str, - workspace_scope_id: Option<&str>, - target_scope: ExternalApplicationTargetScopeV2, - preference_revision: u64, - subagents_by_candidate_id: &BTreeMap<&str, &ExternalSubagentSummary>, -) -> Option { - external_application_review_plan_internal( - catalog, - execution_domain_id, - workspace_scope_id, - target_scope, - preference_revision, - subagents_by_candidate_id, - false, - ) - .summary() -} - -fn push_external_application_review_item( - items: &mut Vec, - summary_items: &mut Vec, - include_item_details: bool, - item_ref: ExternalApplicationReviewItemRefV2, - recommended: bool, - safety_ceiling: ExternalApplicationSafetyCeilingV2, - build_item: F, -) where - F: FnOnce(ExternalApplicationReviewItemRefV2) -> ExternalApplicationReviewItemV2, -{ - summary_items.push(ExternalApplicationReviewSummaryItem { - item_ref: item_ref.clone(), - recommended, - safety_ceiling, - }); - if include_item_details { - items.push(build_item(item_ref)); +fn read_only_workspace_services( +) -> &'static DashMap, Weak> { + READ_ONLY_WORKSPACE_SERVICES.get_or_init(DashMap::new) +} + +fn workspace_services_for_profile( + profile: ExternalSourceServiceProfile, +) -> &'static DashMap, Weak> { + match profile { + ExternalSourceServiceProfile::LocalExecution => workspace_services(), + ExternalSourceServiceProfile::ReadOnlyProjection => read_only_workspace_services(), } } -fn push_external_application_conflict_review_item( - items: &mut Vec, - summary_items: &mut Vec, - include_item_details: bool, - conflict_key: String, - build_display: F, -) where - F: FnOnce() -> (String, String), -{ - let item_ref = ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Conflict, - stable_id: conflict_key, - }; - push_external_application_review_item( - items, - summary_items, - include_item_details, - item_ref, - false, - ExternalApplicationSafetyCeilingV2::Blocked, - |item_ref| { - let (display_name, display_summary) = build_display(); - ExternalApplicationReviewItemV2 { - item_ref, - display_name, - display_summary, - risk_level: ExternalApplicationRiskLevelV2::High, - risk_reason_codes: vec!["ambiguous_runtime_route".to_string()], - recommended: false, - safety_ceiling: ExternalApplicationSafetyCeilingV2::Blocked, - } - }, - ); +fn workspace_service_gate() -> &'static tokio::sync::Mutex<()> { + static GATE: OnceLock> = OnceLock::new(); + GATE.get_or_init(|| tokio::sync::Mutex::new(())) } -fn external_application_review_plan_internal( - catalog: &ExternalSourceCatalogSnapshot, - execution_domain_id: &str, - workspace_scope_id: Option<&str>, - target_scope: ExternalApplicationTargetScopeV2, - preference_revision: u64, - subagents_by_candidate_id: &BTreeMap<&str, &ExternalSubagentSummary>, - include_item_details: bool, -) -> ExternalApplicationReviewPlan { - let mut items = Vec::new(); - let mut summary_items = Vec::new(); - for request in &catalog.tool_approval_requests { - let item_ref = ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: request.approval_key.clone(), - }; - push_external_application_review_item( - &mut items, - &mut summary_items, - include_item_details, - item_ref, - false, - ExternalApplicationSafetyCeilingV2::ReviewRequired, - |item_ref| ExternalApplicationReviewItemV2 { - item_ref, - display_name: request.source_display_name.clone(), - display_summary: format!( - "{} external tool{} require approval", - request.tool_names.len(), - if request.tool_names.len() == 1 { - "" - } else { - "s" - } - ), - risk_level: ExternalApplicationRiskLevelV2::High, - risk_reason_codes: vec!["process_or_resource_access".to_string()], - recommended: false, - safety_ceiling: ExternalApplicationSafetyCeilingV2::ReviewRequired, - }, - ); - } - for request in &catalog.mcp_approval_requests { - let item_ref = ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Mcp, - stable_id: request.decision_key.clone(), - }; - push_external_application_review_item( - &mut items, - &mut summary_items, - include_item_details, - item_ref, - false, - ExternalApplicationSafetyCeilingV2::ReviewRequired, - |item_ref| ExternalApplicationReviewItemV2 { - item_ref, - display_name: request.definition.name.clone(), - display_summary: "External MCP server requires approval".to_string(), - risk_level: ExternalApplicationRiskLevelV2::High, - risk_reason_codes: vec!["process_or_network_access".to_string()], - recommended: false, - safety_ceiling: ExternalApplicationSafetyCeilingV2::ReviewRequired, - }, - ); - } - for candidate_id in &catalog.pending_subagent_approvals { - let Some(summary) = subagents_by_candidate_id - .get(candidate_id.as_str()) - .copied() - else { - continue; - }; - let item_ref = ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Subagent, - stable_id: summary.decision_key.clone(), - }; - push_external_application_review_item( - &mut items, - &mut summary_items, - include_item_details, - item_ref, - false, - ExternalApplicationSafetyCeilingV2::ReviewRequired, - |item_ref| ExternalApplicationReviewItemV2 { - item_ref, - display_name: summary.display_name.clone(), - display_summary: "External subagent and its requested tools require approval" - .to_string(), - risk_level: ExternalApplicationRiskLevelV2::High, - risk_reason_codes: vec!["delegated_tool_access".to_string()], - recommended: false, - safety_ceiling: ExternalApplicationSafetyCeilingV2::ReviewRequired, - }, - ); - } - for conflict in catalog - .command_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - { - push_external_application_conflict_review_item( - &mut items, - &mut summary_items, - include_item_details, - conflict.conflict_key.clone(), - || { - ( - format!("Resolve command conflict: {}", conflict.command_name), - "Choose one compatible command source".to_string(), - ) - }, - ); - } - for conflict in catalog - .tool_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - { - push_external_application_conflict_review_item( - &mut items, - &mut summary_items, - include_item_details, - conflict.conflict_key.clone(), - || { - ( - format!("Resolve tool conflict: {}", conflict.tool_name), - "Choose one compatible tool source".to_string(), - ) - }, - ); - } - for conflict in catalog - .mcp_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - { - push_external_application_conflict_review_item( - &mut items, - &mut summary_items, - include_item_details, - conflict.conflict_key.clone(), - || { - ( - format!("Resolve MCP conflict: {}", conflict.server_name), - "Choose one compatible MCP server".to_string(), - ) - }, - ); - } - for conflict in catalog - .subagent_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - { - push_external_application_conflict_review_item( - &mut items, - &mut summary_items, - include_item_details, - conflict.conflict_key.clone(), - || { - ( - format!("Resolve subagent conflict: {}", conflict.logical_id), - "Choose one compatible subagent source".to_string(), - ) - }, - ); - } - items.sort_by(|left, right| left.item_ref.cmp(&right.item_ref)); - items.dedup_by(|left, right| left.item_ref == right.item_ref); - summary_items.sort_by(|left, right| left.item_ref.cmp(&right.item_ref)); - summary_items.dedup_by(|left, right| left.item_ref == right.item_ref); - let expected_generations = vec![ - ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Command, - generation: catalog.generation, - }, - ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Tool, - generation: catalog.generation, - }, - ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Subagent, - generation: catalog.subagent_generation, - }, - ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Mcp, - generation: catalog.mcp_generation, - }, - ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Conflict, - generation: catalog.generation, - }, - ]; - let mut hasher = Sha256::new(); - hasher.update(execution_domain_id.as_bytes()); - hasher.update([0]); - hasher.update(workspace_scope_id.unwrap_or("").as_bytes()); - hasher.update([target_scope as u8]); - hasher.update(preference_revision.to_le_bytes()); - for generation in &expected_generations { - hasher.update([generation.owner as u8]); - hasher.update(generation.generation.to_le_bytes()); - } - for item in &summary_items { - hasher.update([item.item_ref.kind as u8]); - hasher.update(item.item_ref.stable_id.as_bytes()); - hasher.update([0]); - } - let review_id = format!("review:{}", hex::encode(&hasher.finalize()[..16])); - ExternalApplicationReviewPlan { - review_id, - expected_generations, - items, - summary_items, - } -} - -fn external_application_item_failure( - error: &str, -) -> ( - ExternalApplicationOperationOutcomeV2, - String, - Vec, -) { - let code = ExternalSourceOperationError::decode(error) - .map(|error| error.code) - .unwrap_or(ExternalSourceOperationErrorCode::Internal); - match code { - ExternalSourceOperationErrorCode::StaleRevision => ( - ExternalApplicationOperationOutcomeV2::Stale, - code.as_str().to_string(), - vec![ExternalApplicationRecoveryActionV2::Refresh], - ), - ExternalSourceOperationErrorCode::Conflict - | ExternalSourceOperationErrorCode::PolicyLimited - | ExternalSourceOperationErrorCode::TrustRequired - | ExternalSourceOperationErrorCode::Unavailable - | ExternalSourceOperationErrorCode::RuntimeUnavailable => ( - ExternalApplicationOperationOutcomeV2::Blocked, - code.as_str().to_string(), - vec![ExternalApplicationRecoveryActionV2::ViewReason], - ), - ExternalSourceOperationErrorCode::InvalidRequest - | ExternalSourceOperationErrorCode::NotFound => ( - ExternalApplicationOperationOutcomeV2::Rejected, - code.as_str().to_string(), - vec![ExternalApplicationRecoveryActionV2::Refresh], - ), - _ => ( - ExternalApplicationOperationOutcomeV2::Failed, - code.as_str().to_string(), - vec![ExternalApplicationRecoveryActionV2::Retry], - ), - } -} - -fn project_external_application_v2( - catalog: &ExternalSourceCatalogSnapshot, - preferences: &ExternalSourcesConfig, - execution_domain_id: &str, - workspace_scope_id: Option<&str>, - registration: ExternalEcosystemRegistration, - host_capabilities: ExternalApplicationHostCapabilitiesV2, - source_ecosystems: &BTreeMap, - subagents_by_candidate_id: &BTreeMap<&str, &ExternalSubagentSummary>, -) -> ExternalApplicationSummaryV2 { - let ecosystem_id = ®istration.descriptor.ecosystem_id; - let sources = catalog - .sources - .iter() - .filter(|source| { - source.record.ecosystem_id == *ecosystem_id - && !matches!(source.lifecycle, ExternalSourceLifecycleState::Removed) - }) - .collect::>(); - let discovery = if sources.is_empty() { - ExternalApplicationDiscoveryStateV2::NotDiscovered - } else { - ExternalApplicationDiscoveryStateV2::Discovered - }; - let unavailable_sources = sources - .iter() - .filter(|source| matches!(source.lifecycle, ExternalSourceLifecycleState::Unavailable)) - .count(); - let degraded = sources.iter().any(|source| { - matches!( - source.lifecycle, - ExternalSourceLifecycleState::Degraded - | ExternalSourceLifecycleState::Restricted - | ExternalSourceLifecycleState::UsingLastValidVersion - ) || !source.record.diagnostics.is_empty() - }); - let health = if !sources.is_empty() && unavailable_sources == sources.len() { - ExternalApplicationHealthV2::Unavailable - } else if degraded || unavailable_sources > 0 { - ExternalApplicationHealthV2::Degraded - } else { - ExternalApplicationHealthV2::Healthy - }; - let explicit = workspace_scope_id - .and_then(|workspace_scope_id| { - preferences - .application_connections - .get(&external_application_connection_key( - execution_domain_id, - ecosystem_id.as_str(), - Some(workspace_scope_id), - )) - }) - .or_else(|| { - preferences - .application_connections - .get(&external_application_connection_key( - execution_domain_id, - ecosystem_id.as_str(), - None, - )) - }); - let (desired_connection, user_decision) = match explicit { - Some(decision) => ( - public_desired_connection(decision.desired_connection), - public_user_decision(decision.desired_connection), - ), - None => ( - match registration.default_connection_policy { - ExternalApplicationDefaultConnectionPolicyV2::Connect => { - ExternalApplicationDesiredConnectionV2::Connected - } - ExternalApplicationDefaultConnectionPolicyV2::DiscoverOnly - | ExternalApplicationDefaultConnectionPolicyV2::Unsupported => { - ExternalApplicationDesiredConnectionV2::Disconnected - } - }, - ExternalApplicationUserDecisionV2::None, - ), - }; - let connection = if desired_connection == ExternalApplicationDesiredConnectionV2::Connected - && discovery == ExternalApplicationDiscoveryStateV2::Discovered - { - ExternalApplicationConnectionStateV2::Connected - } else { - ExternalApplicationConnectionStateV2::Disconnected - }; - let counts = external_application_counts( - catalog, - ecosystem_id, - source_ecosystems, - subagents_by_candidate_id, - ); - let needs_attention = desired_connection == ExternalApplicationDesiredConnectionV2::NeedsReview - || (connection == ExternalApplicationConnectionStateV2::Connected - && (counts.pending_review > 0 || counts.conflicts > 0)); - let temporarily_unavailable = discovery == ExternalApplicationDiscoveryStateV2::Discovered - && health == ExternalApplicationHealthV2::Unavailable; - let (effective_status, primary_action) = derive_external_application_status_v2( - needs_attention, - temporarily_unavailable, - host_capabilities.can_refresh, - connection, - discovery, - ); - let recovery_actions = match primary_action { - ExternalApplicationPrimaryActionV2::Review => { - vec![ExternalApplicationRecoveryActionV2::Review] - } - ExternalApplicationPrimaryActionV2::Retry => { - vec![ExternalApplicationRecoveryActionV2::Retry] - } - ExternalApplicationPrimaryActionV2::ViewReason => { - vec![ExternalApplicationRecoveryActionV2::ViewReason] - } - _ => Vec::new(), - }; - let acknowledged = preferences - .acknowledged_ecosystems - .contains(&acknowledged_ecosystem_key( - execution_domain_id, - ecosystem_id.as_str(), - )); - ExternalApplicationSummaryV2 { - application_id: ecosystem_id.to_string(), - ecosystem_id: ecosystem_id.to_string(), - display_name: registration.descriptor.display_name, - discovery, - connection, - desired_connection, - health, - effective_status, - primary_action, - default_connection_policy: registration.default_connection_policy, - default_connection_reason: registration.default_connection_reason.to_string(), - enabled_count: counts.enabled, - pending_review_count: counts.pending_review, - blocked_count: counts.blocked, - conflict_count: counts.conflicts, - risk_summary: ExternalApplicationRiskSummaryV2 { - highest_level: (counts.pending_review > 0 || counts.conflicts > 0) - .then_some(ExternalApplicationRiskLevelV2::High), - reason_codes: (counts.pending_review > 0 || counts.conflicts > 0) - .then(|| vec!["executable_content_requires_review".to_string()]) - .unwrap_or_default(), - }, - notice_key: (!acknowledged && discovery == ExternalApplicationDiscoveryStateV2::Discovered) - .then(|| { - format!( - "application_discovered:{}:{}", - ecosystem_id, registration.descriptor.adapter_revision - ) - }), - user_decision, - recovery_actions, - } -} - -fn public_desired_connection( - desired: StoredExternalApplicationDesiredConnection, -) -> ExternalApplicationDesiredConnectionV2 { - match desired { - StoredExternalApplicationDesiredConnection::Connected => { - ExternalApplicationDesiredConnectionV2::Connected - } - StoredExternalApplicationDesiredConnection::Disconnected => { - ExternalApplicationDesiredConnectionV2::Disconnected - } - StoredExternalApplicationDesiredConnection::Deferred => { - ExternalApplicationDesiredConnectionV2::Deferred - } - StoredExternalApplicationDesiredConnection::NeedsReview => { - ExternalApplicationDesiredConnectionV2::NeedsReview - } - } -} - -fn public_user_decision( - desired: StoredExternalApplicationDesiredConnection, -) -> ExternalApplicationUserDecisionV2 { - match desired { - StoredExternalApplicationDesiredConnection::Connected => { - ExternalApplicationUserDecisionV2::Connected - } - StoredExternalApplicationDesiredConnection::Disconnected => { - ExternalApplicationUserDecisionV2::Disconnected - } - StoredExternalApplicationDesiredConnection::Deferred => { - ExternalApplicationUserDecisionV2::Deferred - } - StoredExternalApplicationDesiredConnection::NeedsReview => { - ExternalApplicationUserDecisionV2::NeedsReview - } - } -} - -fn external_application_counts( - catalog: &ExternalSourceCatalogSnapshot, - ecosystem_id: &EcosystemId, - source_ecosystems: &BTreeMap, - subagents_by_candidate_id: &BTreeMap<&str, &ExternalSubagentSummary>, -) -> ExternalApplicationAggregateCounts { - let source_belongs = |source_key: &SourceKey| { - source_ecosystems - .get(source_key) - .is_some_and(|source_ecosystem| source_ecosystem == ecosystem_id) - }; - let mut counts = ExternalApplicationAggregateCounts::default(); - for command in &catalog.commands { - if !source_belongs(&command.definition.id.source) { - continue; - } - match command.definition.availability { - PromptCommandAvailability::Available => counts.enabled += 1, - PromptCommandAvailability::Restricted { .. } - | PromptCommandAvailability::Invalid { .. } => counts.blocked += 1, - _ => counts.blocked += 1, - } - } - for tool in &catalog.tools { - if !source_belongs(&tool.definition.id.target.source) { - continue; - } - match tool.activation { - ExternalToolActivationState::Active => counts.enabled += 1, - ExternalToolActivationState::ApprovalRequired => counts.pending_review += 1, - ExternalToolActivationState::Conflict => {} - ExternalToolActivationState::Unsupported { .. } - | ExternalToolActivationState::RuntimeUnavailable { .. } - | ExternalToolActivationState::LoadFailed { .. } => counts.blocked += 1, - ExternalToolActivationState::Declined | ExternalToolActivationState::Disabled => {} - _ => counts.blocked += 1, - } - } - for subagent in &catalog.subagents { - if !subagent.source_keys.iter().any(source_belongs) { - continue; - } - match subagent.activation_state { - ExternalSubagentActivationState::Active => counts.enabled += 1, - ExternalSubagentActivationState::ApprovalRequired => counts.pending_review += 1, - ExternalSubagentActivationState::Conflict => {} - ExternalSubagentActivationState::Blocked - | ExternalSubagentActivationState::Unavailable => counts.blocked += 1, - ExternalSubagentActivationState::Declined - | ExternalSubagentActivationState::Disabled => {} - } - } - for server in &catalog.mcp_servers { - if !source_belongs(&server.definition.id.source) { - continue; - } - match server.activation_state { - ExternalMcpActivationState::Active => counts.enabled += 1, - ExternalMcpActivationState::ApprovalRequired - | ExternalMcpActivationState::ConfigurationChanged => counts.pending_review += 1, - ExternalMcpActivationState::Conflict => {} - ExternalMcpActivationState::Unsupported { .. } - | ExternalMcpActivationState::RuntimeUnavailable { .. } - | ExternalMcpActivationState::Removed => counts.blocked += 1, - ExternalMcpActivationState::Starting => counts.enabled += 1, - ExternalMcpActivationState::Declined - | ExternalMcpActivationState::Covered { .. } - | ExternalMcpActivationState::SourceDisabled => {} - _ => counts.blocked += 1, - } - } - counts.conflicts += catalog - .command_conflicts - .iter() - .filter(|conflict| { - conflict.selected_candidate_id.is_none() - && conflict - .candidates - .iter() - .any(|candidate| candidate.ecosystem_id == *ecosystem_id) - }) - .count(); - counts.conflicts += catalog - .tool_conflicts - .iter() - .filter(|conflict| { - conflict.selected_candidate_id.is_none() - && conflict - .candidates - .iter() - .any(|candidate| candidate.source.as_ref().is_some_and(&source_belongs)) - }) - .count(); - counts.conflicts += catalog - .mcp_conflicts - .iter() - .filter(|conflict| { - conflict.selected_candidate_id.is_none() - && conflict - .candidates - .iter() - .any(|candidate| candidate.source.as_ref().is_some_and(&source_belongs)) - }) - .count(); - counts.conflicts += catalog - .subagent_conflicts - .iter() - .filter(|conflict| { - conflict.selected_candidate_id.is_none() - && conflict.candidates.iter().any(|candidate| { - subagents_by_candidate_id - .get(candidate.candidate_id.as_str()) - .is_some_and(|subagent| subagent.source_keys.iter().any(&source_belongs)) - }) - }) - .count(); - counts -} - -fn lock_coordinator( - control_plane: &ExternalSourceControlPlane, -) -> MutexGuard<'_, bitfun_external_sources::ExternalSourceCoordinator> { - control_plane.lock_commands() -} - -fn lock_tool_coordinator( - control_plane: &ExternalSourceControlPlane, -) -> MutexGuard<'_, bitfun_external_sources::ExternalToolCoordinator> { - control_plane.lock_tools() -} - -fn lock_subagent_coordinator( - control_plane: &ExternalSourceControlPlane, -) -> MutexGuard<'_, bitfun_external_sources::ExternalSubagentCoordinator> { - control_plane.lock_subagents() -} - -fn lock_mcp_coordinator( - control_plane: &ExternalSourceControlPlane, -) -> MutexGuard<'_, bitfun_external_sources::ExternalMcpCoordinator> { - control_plane.lock_mcp() -} - -fn lock_workspace_reference_coordinator( - control_plane: &ExternalSourceControlPlane, -) -> MutexGuard<'_, bitfun_external_sources::ExternalWorkspaceReferenceCoordinator> { - control_plane.lock_workspace_references() -} - -fn lock_snapshot( - snapshot: &StdMutex, -) -> MutexGuard<'_, ExternalSourceCatalogSnapshot> { - match snapshot.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - } -} - -static WORKSPACE_SERVICES: OnceLock< - DashMap, Weak>, -> = OnceLock::new(); -static READ_ONLY_WORKSPACE_SERVICES: OnceLock< - DashMap, Weak>, -> = OnceLock::new(); -static SAFE_MODE_WORKSPACES: OnceLock> = OnceLock::new(); - -fn safe_mode_workspaces() -> &'static DashMap { - SAFE_MODE_WORKSPACES.get_or_init(DashMap::new) -} -static TOOL_REGISTRY_CHANGE_EPOCH: AtomicU64 = AtomicU64::new(0); -static TOOL_REGISTRY_REBUILD_SCHEDULED: AtomicBool = AtomicBool::new(false); - -fn workspace_services() -> &'static DashMap, Weak> { - WORKSPACE_SERVICES.get_or_init(DashMap::new) -} - -fn read_only_workspace_services( -) -> &'static DashMap, Weak> { - READ_ONLY_WORKSPACE_SERVICES.get_or_init(DashMap::new) -} - -fn workspace_services_for_profile( - profile: ExternalSourceServiceProfile, -) -> &'static DashMap, Weak> { - match profile { - ExternalSourceServiceProfile::LocalExecution => workspace_services(), - ExternalSourceServiceProfile::ReadOnlyProjection => read_only_workspace_services(), - } -} - -fn workspace_service_gate() -> &'static tokio::sync::Mutex<()> { - static GATE: OnceLock> = OnceLock::new(); - GATE.get_or_init(|| tokio::sync::Mutex::new(())) -} - -pub(crate) fn normalize_workspace_root( - workspace_root: Option<&Path>, -) -> Result, String> { - let Some(workspace_root) = workspace_root else { - return Ok(None); - }; - if !workspace_root.is_absolute() { - return Err("external source workspace root must be absolute".to_string()); +pub(crate) fn normalize_workspace_root( + workspace_root: Option<&Path>, +) -> Result, String> { + let Some(workspace_root) = workspace_root else { + return Ok(None); + }; + if !workspace_root.is_absolute() { + return Err("external source workspace root must be absolute".to_string()); } Ok(Some( crate::agentic::workspace::canonical_local_workspace_path(workspace_root), @@ -6209,24 +4642,6 @@ async fn service_for_profile( Ok(service) } -async fn existing_service_for_profile( - workspace_root: Option<&Path>, - profile: ExternalSourceServiceProfile, -) -> Result, String> { - let workspace_root = normalize_workspace_root(workspace_root)?; - let _service_gate = workspace_service_gate().lock().await; - let service = workspace_services_for_profile(profile) - .get(&workspace_root) - .and_then(|service| service.value().upgrade()) - .ok_or_else(|| { - stale_operation_error( - "External application snapshot expired; refresh before continuing", - ) - })?; - service.touch(); - Ok(service) -} - fn epoch_seconds() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -7167,27 +5582,6 @@ fn apply_integration_policy_mutation_to_config( .user_defaults .enabled = false; config.integration_policy = reset_policy; - config.config_origin = Some(ExternalSourcesConfigOrigin::IncompatibleReset); - config.connection_schema_migration_version = - EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION; - config.application_connections = default_external_integration_registry() - .into_iter() - .map(|registration| { - ( - external_application_connection_key( - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, - registration.descriptor.ecosystem_id.as_str(), - None, - ), - StoredExternalApplicationConnectionDecision { - desired_connection: - StoredExternalApplicationDesiredConnection::Disconnected, - decision_origin: - StoredExternalApplicationDecisionOrigin::IncompatibleReset, - }, - ) - }) - .collect(); config.preference_revision = config.preference_revision.saturating_add(1); return Ok(true); } @@ -8321,60 +6715,7 @@ pub async fn get_external_source_control_snapshot( Ok(service.surface_snapshot(host_capabilities)) } -pub async fn get_external_application_snapshot_v2( - workspace_root: Option<&Path>, - force_refresh: bool, - host_capabilities: ExternalApplicationHostCapabilitiesV2, -) -> Result { - if !host_capabilities.can_read_snapshot { - return Err(unavailable_operation_error( - "This Host cannot read external application state", - )); - } - let service = if host_capabilities.can_mutate { - service_for(workspace_root).await? - } else { - read_only_service_for(workspace_root).await? - }; - if force_refresh { - if host_capabilities.can_refresh && host_capabilities.can_mutate { - service.refresh_with_runtime_invalidation().await?; - } else { - service.refresh().await?; - } - } else { - service.ensure_background_refresh(); - } - let preferences = ExternalSourcePreferenceStore::global()? - .ensure_application_connection_schema(service.execution_domain_id.as_str()) - .await?; - service.application_snapshot_v2(&preferences, host_capabilities) -} - -pub async fn get_external_application_review_page_v2( - workspace_root: Option<&Path>, - request: ExternalApplicationReviewPageRequestV2, -) -> Result { - let service = - existing_service_for_profile(workspace_root, ExternalSourceServiceProfile::LocalExecution) - .await?; - let preferences = ExternalSourcePreferenceStore::global()? - .ensure_application_connection_schema(service.execution_domain_id.as_str()) - .await?; - service.application_review_page_v2(&preferences, request) -} - -pub async fn apply_external_application_action_v2( - workspace_root: Option<&Path>, - request: ExternalApplicationControlRequestV2, -) -> Result { - service_for(workspace_root) - .await? - .apply_application_action_v2(request) - .await -} - -pub async fn apply_external_source_control_action( +pub async fn apply_external_source_control_action( workspace_root: Option<&Path>, request: ExternalSourceControlRequestV1, ) -> ExternalSourceOperationResult { @@ -8735,7 +7076,6 @@ mod opencode_local_source_order_tests; mod tests { use super::*; use crate::service::mcp::{ConfigLocation, MCPServerConfig, MCPServerType}; - use bitfun_product_domains::external_source_control::ExternalApplicationEffectiveStatusV2; use bitfun_product_domains::external_sources::{ EcosystemId, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, PromptCommandAvailability, PromptCommandCatalogEntry, PromptCommandConflict, @@ -10555,561 +8895,6 @@ mod tests { ); } - #[tokio::test] - async fn fresh_v2_store_applies_only_the_product_default_connection() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("external-sources.json"); - let store = ExternalSourcePreferenceStore::new(path); - - let migrated = store - .ensure_application_connection_schema(LEGACY_LOCAL_EXECUTION_DOMAIN_ID) - .await - .expect("a missing preference file should initialize as fresh v2"); - - assert_eq!( - migrated.connection_schema_migration_version, - EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION - ); - assert_eq!( - migrated.config_origin, - Some(ExternalSourcesConfigOrigin::FreshV2) - ); - assert!(migrated.application_connections.is_empty()); - - let policy = integration_policy_snapshot(&migrated, None).unwrap(); - assert!(policy.global_effective.enabled); - assert_eq!( - policy.global_effective.ecosystems[&EcosystemId::new(OPENCODE_ECOSYSTEM_ID).unwrap()] - .mode, - ExternalIntegrationMode::Recommended - ); - for ecosystem_id in [CLAUDE_CODE_ECOSYSTEM_ID, CODEX_ECOSYSTEM_ID] { - assert_eq!( - policy.global_effective.ecosystems[&EcosystemId::new(ecosystem_id).unwrap()].mode, - ExternalIntegrationMode::DiscoverOnly - ); - } - } - - #[tokio::test] - async fn legacy_disabled_policy_migrates_to_explicit_safe_disconnects() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("external-sources.json"); - let store = ExternalSourcePreferenceStore::new(path.clone()); - let mut legacy = ExternalSourcesConfig::default(); - legacy.preference_revision = 7; - legacy - .approved_tool_targets - .insert("preserved-approval".to_string()); - JsonFileStore - .write_atomic_strict(&path, &legacy) - .await - .unwrap(); - - let migrated = store - .ensure_application_connection_schema(LEGACY_LOCAL_EXECUTION_DOMAIN_ID) - .await - .unwrap(); - - assert_eq!( - migrated.config_origin, - Some(ExternalSourcesConfigOrigin::LegacyMigration) - ); - assert_eq!(migrated.preference_revision, 7); - assert!(migrated - .approved_tool_targets - .contains("preserved-approval")); - for application_id in [ - OPENCODE_ECOSYSTEM_ID, - CLAUDE_CODE_ECOSYSTEM_ID, - CODEX_ECOSYSTEM_ID, - ] { - let key = format!( - "{}\u{1f}{}\u{1f}user_default", - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, application_id - ); - assert_eq!( - migrated.application_connections.get(&key), - Some(&StoredExternalApplicationConnectionDecision { - desired_connection: StoredExternalApplicationDesiredConnection::Disconnected, - decision_origin: StoredExternalApplicationDecisionOrigin::LegacySafety, - }) - ); - } - } - - #[tokio::test] - async fn legacy_enabled_policy_migrates_each_opaque_workspace_scope_independently() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("external-sources.json"); - let store = ExternalSourcePreferenceStore::new(path.clone()); - let workspace_scope_id = "workspace:fedcba9876543210"; - let mut legacy = ExternalSourcesConfig::default(); - let policy = legacy.integration_policy.known_mut().unwrap(); - policy.user_defaults.enabled = true; - policy - .user_defaults - .ecosystems - .entry(EcosystemId::new(CLAUDE_CODE_ECOSYSTEM_ID).unwrap()) - .or_default() - .mode = ExternalIntegrationMode::DiscoverOnly; - let workspace = policy - .workspace_overrides - .entry(workspace_scope_id.to_string()) - .or_default(); - workspace.enabled = Some(true); - workspace - .ecosystems - .entry(EcosystemId::new(OPENCODE_ECOSYSTEM_ID).unwrap()) - .or_default() - .mode = Some(ExternalIntegrationMode::DiscoverOnly); - workspace - .ecosystems - .entry(EcosystemId::new(CODEX_ECOSYSTEM_ID).unwrap()) - .or_default() - .mode = Some(ExternalIntegrationMode::Recommended); - JsonFileStore - .write_atomic_strict(&path, &legacy) - .await - .unwrap(); - - let migrated = store - .ensure_application_connection_schema(LEGACY_LOCAL_EXECUTION_DOMAIN_ID) - .await - .unwrap(); - let decision = |application_id: &str, scope: Option<&str>| { - migrated.application_connections[&external_application_connection_key( - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, - application_id, - scope, - )] - .clone() - }; - assert_eq!( - decision(OPENCODE_ECOSYSTEM_ID, None).desired_connection, - StoredExternalApplicationDesiredConnection::Connected - ); - assert_eq!( - decision(CLAUDE_CODE_ECOSYSTEM_ID, None).desired_connection, - StoredExternalApplicationDesiredConnection::Disconnected - ); - assert_eq!( - decision(OPENCODE_ECOSYSTEM_ID, Some(workspace_scope_id)).desired_connection, - StoredExternalApplicationDesiredConnection::Disconnected - ); - assert_eq!( - decision(CODEX_ECOSYSTEM_ID, Some(workspace_scope_id)).desired_connection, - StoredExternalApplicationDesiredConnection::Connected - ); - } - - #[tokio::test] - async fn future_policy_schema_is_preserved_byte_for_byte_by_the_migration_gate() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("external-sources.json"); - let raw = br#"{ "integrationPolicy": { "schemaMajor": 99, "opaque": [3, 2, 1] }, "future": true }"#; - std::fs::write(&path, raw).unwrap(); - let store = ExternalSourcePreferenceStore::new(path.clone()); - - let error = store - .ensure_application_connection_schema(LEGACY_LOCAL_EXECUTION_DOMAIN_ID) - .await - .expect_err("future policy schemas must not be migrated"); - - assert!(error.contains("schema major 99")); - assert_eq!(std::fs::read(path).unwrap(), raw); - } - - #[tokio::test] - async fn current_v2_schema_snapshot_gate_does_not_rewrite_preferences() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("external-sources.json"); - let store = ExternalSourcePreferenceStore::new(path.clone()); - let mut current = ExternalSourcesConfig::default(); - current.connection_schema_migration_version = - EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION; - current.config_origin = Some(ExternalSourcesConfigOrigin::FreshV2); - current.mcp_revision_secret = Some("00".repeat(32)); - let mut raw = serde_json::to_vec_pretty(¤t).unwrap(); - raw.extend_from_slice(b"\r\n"); - std::fs::write(&path, &raw).unwrap(); - - store - .ensure_application_connection_schema(LEGACY_LOCAL_EXECUTION_DOMAIN_ID) - .await - .unwrap(); - - assert_eq!(std::fs::read(path).unwrap(), raw); - } - - #[test] - fn legacy_config_with_a_revision_key_remains_available_after_migration_failure() { - let mut legacy = ExternalSourcesConfig::default(); - legacy.mcp_revision_secret = Some("11".repeat(32)); - - let (preserved, revision_key) = legacy_config_after_migration_failure( - legacy.clone(), - "migration write failed".to_string(), - ) - .unwrap(); - - assert_eq!(preserved, legacy); - assert!(!revision_key - .opaque_revision("test", [b"payload".as_slice()]) - .is_empty()); - assert!(legacy_config_after_migration_failure( - ExternalSourcesConfig::default(), - "migration write failed".to_string(), - ) - .unwrap_err() - .contains("migration write failed")); - } - - #[tokio::test] - async fn review_page_does_not_cold_start_an_external_source_service() { - let temp = tempfile::tempdir().unwrap(); - - let error = match existing_service_for_profile( - Some(temp.path()), - ExternalSourceServiceProfile::LocalExecution, - ) - .await - { - Ok(_) => panic!("review page must not cold-start an external source service"), - Err(error) => error, - }; - - assert!(error.contains("stale_revision")); - } - - #[test] - fn application_snapshot_uses_registry_defaults_and_shared_status_priority() { - let service = test_service(Vec::new()); - let source_key = SourceKey::new("opencode.commands", "project").unwrap(); - lock_snapshot(&service.snapshot).sources = vec![ExternalSourceCatalogEntry { - stable_key: source_key.stable_key(), - presentation_group_id: None, - record: ExternalSourceRecord { - key: source_key, - ecosystem_id: EcosystemId::new(OPENCODE_ECOSYSTEM_ID).unwrap(), - display_name: "OpenCode project commands".to_string(), - source_kind: "opencode_commands".to_string(), - scope: ExternalSourceScope::Project, - location: "/.opencode/commands".to_string(), - execution_domain_id: ExecutionDomainId::new(LEGACY_LOCAL_EXECUTION_DOMAIN_ID) - .unwrap(), - health: bitfun_product_domains::external_sources::ExternalSourceHealth::Available, - content_version: "v1".to_string(), - diagnostics: Vec::new(), - }, - lifecycle: ExternalSourceLifecycleState::Available, - }]; - let mut preferences = ExternalSourcesConfig::default(); - apply_fresh_v2_product_defaults(&mut preferences).unwrap(); - preferences.config_origin = Some(ExternalSourcesConfigOrigin::FreshV2); - preferences.connection_schema_migration_version = - EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION; - - let snapshot = service - .application_snapshot_v2( - &preferences, - ExternalApplicationHostCapabilitiesV2::read_write(), - ) - .unwrap(); - - assert_eq!(snapshot.schema_version, EXTERNAL_APPLICATION_SCHEMA_V2); - assert_eq!(snapshot.applications.len(), 3); - let open_code = snapshot - .applications - .iter() - .find(|application| application.application_id == OPENCODE_ECOSYSTEM_ID) - .unwrap(); - assert_eq!( - open_code.default_connection_policy, - ExternalApplicationDefaultConnectionPolicyV2::Connect - ); - assert_eq!( - open_code.user_decision, - ExternalApplicationUserDecisionV2::None - ); - assert_eq!( - open_code.desired_connection, - ExternalApplicationDesiredConnectionV2::Connected - ); - assert_eq!( - open_code.connection, - ExternalApplicationConnectionStateV2::Connected - ); - assert_eq!( - open_code.effective_status, - ExternalApplicationEffectiveStatusV2::Connected - ); - assert_eq!( - open_code.primary_action, - ExternalApplicationPrimaryActionV2::View - ); - for application_id in [CLAUDE_CODE_ECOSYSTEM_ID, CODEX_ECOSYSTEM_ID] { - let application = snapshot - .applications - .iter() - .find(|application| application.application_id == application_id) - .unwrap(); - assert_eq!( - application.default_connection_policy, - ExternalApplicationDefaultConnectionPolicyV2::DiscoverOnly - ); - assert_eq!( - application.effective_status, - ExternalApplicationEffectiveStatusV2::NoConfiguration - ); - } - } - - #[test] - fn workspace_connection_decision_updates_v2_and_v1_projection_together() { - let mut preferences = ExternalSourcesConfig::default(); - apply_fresh_v2_product_defaults(&mut preferences).unwrap(); - preferences.config_origin = Some(ExternalSourcesConfigOrigin::FreshV2); - preferences.connection_schema_migration_version = - EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION; - preferences.preference_revision = 3; - let workspace_scope_id = "workspace:0123456789abcdef"; - - assert!(apply_external_application_connection_decision( - &mut preferences, - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, - ExternalApplicationTargetScopeV2::WorkspaceOverride, - Some(workspace_scope_id), - CODEX_ECOSYSTEM_ID, - StoredExternalApplicationDesiredConnection::Connected, - 3, - ) - .unwrap()); - - assert_eq!(preferences.preference_revision, 4); - let key = external_application_connection_key( - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, - CODEX_ECOSYSTEM_ID, - Some(workspace_scope_id), - ); - assert_eq!( - preferences.application_connections.get(&key), - Some(&StoredExternalApplicationConnectionDecision { - desired_connection: StoredExternalApplicationDesiredConnection::Connected, - decision_origin: StoredExternalApplicationDecisionOrigin::User, - }) - ); - let document = preferences.integration_policy.known().unwrap(); - assert_eq!( - document.user_defaults.ecosystems[&EcosystemId::new(CODEX_ECOSYSTEM_ID).unwrap()].mode, - ExternalIntegrationMode::DiscoverOnly - ); - assert_eq!( - document.workspace_overrides[workspace_scope_id].ecosystems - [&EcosystemId::new(CODEX_ECOSYSTEM_ID).unwrap()] - .mode, - Some(ExternalIntegrationMode::Recommended) - ); - assert!(apply_external_application_connection_decision( - &mut preferences, - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, - ExternalApplicationTargetScopeV2::WorkspaceOverride, - Some(workspace_scope_id), - CODEX_ECOSYSTEM_ID, - StoredExternalApplicationDesiredConnection::Disconnected, - 3, - ) - .is_err()); - assert_eq!(preferences.preference_revision, 4); - } - - #[test] - fn application_action_scope_allows_explicit_user_default_from_a_workspace() { - let current_workspace_scope = Some("workspace:0123456789abcdef"); - - assert!(external_application_action_scope_matches( - current_workspace_scope, - ExternalApplicationTargetScopeV2::UserDefault, - None, - )); - assert!(external_application_action_scope_matches( - current_workspace_scope, - ExternalApplicationTargetScopeV2::WorkspaceOverride, - current_workspace_scope, - )); - assert!(!external_application_action_scope_matches( - current_workspace_scope, - ExternalApplicationTargetScopeV2::WorkspaceOverride, - Some("workspace:different"), - )); - assert!(!external_application_action_scope_matches( - None, - ExternalApplicationTargetScopeV2::WorkspaceOverride, - Some("workspace:0123456789abcdef"), - )); - } - - #[test] - fn application_review_page_is_bounded_and_bound_to_owner_generations() { - let service = test_service(Vec::new()); - let source_key = SourceKey::new("opencode.commands", "project").unwrap(); - { - let mut catalog = lock_snapshot(&service.snapshot); - catalog.generation = 7; - catalog.subagent_generation = 3; - catalog.mcp_generation = 5; - catalog.sources = vec![ExternalSourceCatalogEntry { - stable_key: source_key.stable_key(), - presentation_group_id: None, - record: ExternalSourceRecord { - key: source_key.clone(), - ecosystem_id: EcosystemId::new(OPENCODE_ECOSYSTEM_ID).unwrap(), - display_name: "OpenCode project commands".to_string(), - source_kind: "opencode_commands".to_string(), - scope: ExternalSourceScope::Project, - location: "/.opencode/commands".to_string(), - execution_domain_id: ExecutionDomainId::new(LEGACY_LOCAL_EXECUTION_DOMAIN_ID) - .unwrap(), - health: - bitfun_product_domains::external_sources::ExternalSourceHealth::Available, - content_version: "v1".to_string(), - diagnostics: Vec::new(), - }, - lifecycle: ExternalSourceLifecycleState::Available, - }]; - catalog.command_conflicts = vec![PromptCommandConflict { - conflict_key: "prompt-command-conflict".to_string(), - command_name: "review".to_string(), - candidates: vec![PromptCommandConflictCandidate { - candidate_id: "opencode.commands:project:review".to_string(), - source: source_key, - source_display_name: "OpenCode".to_string(), - ecosystem_id: EcosystemId::new(OPENCODE_ECOSYSTEM_ID).unwrap(), - content_version: "command-v1".to_string(), - command_description: "Review changes".to_string(), - source_scope: ExternalSourceScope::Project, - source_location: "/.opencode/commands/review.md".to_string(), - execution_target: PromptCommandExecutionTarget::Inline, - availability: PromptCommandAvailability::Available, - }], - selected_candidate_id: None, - }]; - } - let mut preferences = ExternalSourcesConfig::default(); - apply_fresh_v2_product_defaults(&mut preferences).unwrap(); - preferences.config_origin = Some(ExternalSourcesConfigOrigin::FreshV2); - preferences.connection_schema_migration_version = - EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION; - preferences.preference_revision = 11; - - let snapshot = service - .application_snapshot_v2( - &preferences, - ExternalApplicationHostCapabilitiesV2::read_write(), - ) - .unwrap(); - let review = snapshot - .review_summary - .expect("unresolved conflict requires review"); - assert_eq!(review.total_count, 1); - let initial_review_id = review.review_id.clone(); - let request = ExternalApplicationReviewPageRequestV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: service.execution_domain_id.clone(), - workspace_scope_id: None, - target_scope: ExternalApplicationTargetScopeV2::UserDefault, - review_id: review.review_id, - preference_revision: 11, - expected_generations: Vec::new(), - cursor: None, - page_size: EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS, - }; - lock_snapshot(&service.snapshot).generation += 1; - let mut unbound_follow_up = request.clone(); - unbound_follow_up.cursor = Some(format!("{}:0", unbound_follow_up.review_id)); - assert!(service - .application_review_page_v2(&preferences, unbound_follow_up) - .unwrap_err() - .contains("stale_revision")); - let page = service - .application_review_page_v2(&preferences, request) - .unwrap(); - assert_ne!(page.review_id, initial_review_id); - assert_eq!(page.items.len(), 1); - assert_eq!( - page.items[0].item_ref.kind, - ExternalApplicationReviewItemKindV2::Conflict - ); - assert!(!page.items[0].display_summary.contains(".opencode")); - assert_eq!(page.expected_generations.len(), 5); - } - - #[test] - fn application_review_plan_binds_pending_subagent_candidate_to_its_decision() { - let service = test_service(Vec::new()); - let candidate_id = "opencode.subagents:project:reviewer"; - let decision_key = "subagent-approval:reviewer"; - let catalog = { - let mut catalog = lock_snapshot(&service.snapshot); - catalog.subagent_generation = 4; - catalog.subagents = vec![ExternalSubagentSummary { - candidate_id: candidate_id.to_string(), - logical_id: "reviewer".to_string(), - display_name: "Code reviewer".to_string(), - description: "Reviews the current change".to_string(), - provider_label: "OpenCode".to_string(), - scope: ExternalSourceScope::Project, - source_keys: Vec::new(), - source_location_labels: Vec::new(), - source_count: 1, - mode: Default::default(), - requested_model: Default::default(), - requested_model_profile: None, - model_binding_method: Default::default(), - model_binding_key: None, - effective_model_label: None, - effective_tool_labels: vec!["read".to_string()], - unavailable_tool_labels: Vec::new(), - supports_follow_up: false, - compatibility_state: ExternalSubagentCompatibilityState::Ready, - diagnostics: Vec::new(), - activation_state: ExternalSubagentActivationState::ApprovalRequired, - decision_key: decision_key.to_string(), - }]; - catalog.pending_subagent_approvals = vec![candidate_id.to_string()]; - catalog.clone() - }; - - let plan = external_application_review_plan( - &catalog, - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, - None, - ExternalApplicationTargetScopeV2::UserDefault, - 0, - ); - - assert_eq!(plan.items.len(), 1); - assert_eq!(plan.items[0].display_name, "Code reviewer"); - assert_eq!(plan.items[0].item_ref.stable_id, decision_key); - - let subagents_by_candidate_id = catalog - .subagents - .iter() - .map(|subagent| (subagent.candidate_id.as_str(), subagent)) - .collect::>(); - let summary_only = external_application_review_plan_internal( - &catalog, - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, - None, - ExternalApplicationTargetScopeV2::UserDefault, - 0, - &subagents_by_candidate_id, - false, - ); - assert!(summary_only.items.is_empty()); - assert_eq!(summary_only.summary(), plan.summary()); - } - #[tokio::test] async fn acknowledging_an_ecosystem_survives_a_reload_and_stays_idempotent() { let temp = tempfile::tempdir().unwrap(); @@ -11474,6 +9259,189 @@ mod tests { assert_eq!(encoded["futurePreferenceField"][0], "keep"); } + fn retired_application_default_fixture() -> ExternalSourcesConfig { + let mut config = ExternalSourcesConfig::default(); + let policy = config + .integration_policy + .known_mut() + .expect("the built-in integration policy is known"); + policy.user_defaults.enabled = true; + for (ecosystem, mode) in [ + (OPENCODE_ECOSYSTEM_ID, ExternalIntegrationMode::Recommended), + ( + CLAUDE_CODE_ECOSYSTEM_ID, + ExternalIntegrationMode::DiscoverOnly, + ), + (CODEX_ECOSYSTEM_ID, ExternalIntegrationMode::DiscoverOnly), + ] { + policy + .user_defaults + .ecosystems + .entry(EcosystemId::new(ecosystem).unwrap()) + .or_default() + .mode = mode; + } + config + } + + fn retired_application_document( + config: ExternalSourcesConfig, + decisions: serde_json::Value, + ) -> serde_json::Value { + let mut raw = serde_json::to_value(config).unwrap(); + raw["configOrigin"] = serde_json::json!("fresh_v2"); + raw["connectionSchemaMigrationVersion"] = serde_json::json!(1); + raw["applicationConnections"] = decisions; + raw + } + + #[tokio::test] + async fn retired_automatic_application_default_is_not_user_consent() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("external-sources.json"); + let raw = retired_application_document( + retired_application_default_fixture(), + serde_json::json!({}), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + let store = ExternalSourcePreferenceStore::new(path); + + let read = store.read().await.unwrap(); + let read_policy = read.integration_policy.known().unwrap(); + assert!(!read_policy.user_defaults.enabled); + assert!(read_policy.user_defaults.ecosystems.is_empty()); + assert!(read.extensions.contains_key("applicationConnections")); + + let (was_enabled, updated) = store + .update(|config| { + config + .integration_policy + .known() + .unwrap() + .user_defaults + .enabled + }) + .await + .unwrap(); + assert!(!was_enabled); + assert!( + !updated + .integration_policy + .known() + .unwrap() + .user_defaults + .enabled + ); + } + + #[tokio::test] + async fn retired_migration_is_consumed_before_later_user_policy_changes() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("external-sources.json"); + let mut config = retired_application_default_fixture(); + config + .integration_policy + .known_mut() + .unwrap() + .user_defaults + .ecosystems + .get_mut(&EcosystemId::new(CLAUDE_CODE_ECOSYSTEM_ID).unwrap()) + .unwrap() + .mode = ExternalIntegrationMode::Disabled; + let raw = retired_application_document(config, serde_json::json!({})); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + let store = ExternalSourcePreferenceStore::new(path); + + let (_, migrated) = store.update(|_| {}).await.unwrap(); + assert!(!migrated.extensions.contains_key("configOrigin")); + assert_eq!( + migrated + .integration_policy + .known() + .unwrap() + .user_defaults + .ecosystems[&EcosystemId::new(CLAUDE_CODE_ECOSYSTEM_ID).unwrap()] + .mode, + ExternalIntegrationMode::Disabled + ); + + store + .update(|config| { + config.integration_policy = + StoredExternalIntegrationPolicy::Known(retired_automatic_application_policy()); + }) + .await + .unwrap(); + + let read = store.read().await.unwrap(); + assert_eq!( + read.integration_policy.known(), + Some(&retired_automatic_application_policy()) + ); + } + + #[tokio::test] + async fn retired_application_metadata_preserves_a_policy_user_deviation() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("external-sources.json"); + let mut config = retired_application_default_fixture(); + config + .integration_policy + .known_mut() + .unwrap() + .user_defaults + .ecosystems + .get_mut(&EcosystemId::new(CLAUDE_CODE_ECOSYSTEM_ID).unwrap()) + .unwrap() + .mode = ExternalIntegrationMode::Disabled; + let raw = retired_application_document(config, serde_json::json!({})); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let config = ExternalSourcePreferenceStore::new(path) + .read() + .await + .unwrap(); + let policy = config.integration_policy.known().unwrap(); + + assert!(policy.user_defaults.enabled); + assert_eq!( + policy.user_defaults.ecosystems[&EcosystemId::new(CLAUDE_CODE_ECOSYSTEM_ID).unwrap()] + .mode, + ExternalIntegrationMode::Disabled + ); + } + + #[tokio::test] + async fn retired_application_metadata_preserves_an_explicit_application_choice() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("external-sources.json"); + let raw = retired_application_document( + retired_application_default_fixture(), + serde_json::json!({ + "local-user\u{1f}opencode\u{1f}user_default": { + "desiredConnection": "connected", + "decisionOrigin": "user" + } + }), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap(); + + let config = ExternalSourcePreferenceStore::new(path) + .read() + .await + .unwrap(); + + assert!( + config + .integration_policy + .known() + .unwrap() + .user_defaults + .enabled + ); + assert!(config.extensions.contains_key("applicationConnections")); + } + #[test] fn incompatible_policy_requires_explicit_reset_and_keeps_a_bounded_backup() { let future_policy = serde_json::json!({ @@ -11560,32 +9528,6 @@ mod tests { vec![11, 12, 13] ); assert_eq!(config.integration_policy_backups[2], future_policy); - assert_eq!( - config.config_origin, - Some(ExternalSourcesConfigOrigin::IncompatibleReset) - ); - assert_eq!( - config.connection_schema_migration_version, - EXTERNAL_APPLICATION_CONNECTION_SCHEMA_VERSION - ); - for application_id in [ - OPENCODE_ECOSYSTEM_ID, - CLAUDE_CODE_ECOSYSTEM_ID, - CODEX_ECOSYSTEM_ID, - ] { - let key = external_application_connection_key( - LEGACY_LOCAL_EXECUTION_DOMAIN_ID, - application_id, - None, - ); - assert_eq!( - config.application_connections.get(&key), - Some(&StoredExternalApplicationConnectionDecision { - desired_connection: StoredExternalApplicationDesiredConnection::Disconnected, - decision_origin: StoredExternalApplicationDecisionOrigin::IncompatibleReset, - }) - ); - } } #[tokio::test] diff --git a/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs b/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs index 2d9b04b5d6..c2d4c6a5f6 100644 --- a/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs +++ b/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs @@ -13,7 +13,8 @@ use crate::infrastructure::ai::reasoning_catalog::{ }; use crate::infrastructure::ai::{build_stream_options_for_model, AIClient}; use crate::infrastructure::subscription_auth::{ - self, OpenCodePlan as AdapterOpenCodePlan, SubscriptionProvider as AdapterProvider, + self, OpenCodePlan as AdapterOpenCodePlan, SubscriptionHttpOptions, + SubscriptionProvider as AdapterProvider, }; use crate::service::config::types::{ model_runtime_binding_fingerprint, AuthConfig, OpenCodePlan, SubscriptionProvider, @@ -42,7 +43,7 @@ struct CachedAIClient { } /// Once a cached subscription credential is within this window of expiry, the -/// client is rebuilt so `apply_subscription_auth` refreshes the token. Kept +/// client is rebuilt so subscription authentication refreshes the token. Kept /// equal to the providers' refresh leeway so the rebuilt client always gets a /// fresh token. const SUBSCRIPTION_CREDENTIAL_STALE_LEEWAY_SECS: i64 = 5 * 60; @@ -310,14 +311,20 @@ impl AIClientFactory { let mut ai_config = AIConfig::try_from(model_config.clone()) .map_err(|e| anyhow!("AI configuration conversion failed: {}", e))?; - let credential_expires_at = - apply_subscription_auth(&model_config.auth, &mut ai_config).await?; - + let skip_ssl_verify = ai_config.skip_ssl_verify; let proxy_config = if global_config.ai.proxy.enabled { Some(global_config.ai.proxy.clone()) } else { None }; + let subscription_options = + SubscriptionHttpOptions::new(proxy_config.clone(), skip_ssl_verify); + let credential_expires_at = apply_subscription_auth_with_options( + &model_config.auth, + &mut ai_config, + &subscription_options, + ) + .await?; let stream_options = build_stream_options_for_model(&global_config.ai, Some(model_config)); let client = apply_default_reasoning_preset( @@ -449,19 +456,32 @@ fn to_adapter_opencode_plan(plan: OpenCodePlan) -> AdapterOpenCodePlan { pub async fn apply_subscription_auth( auth: &AuthConfig, ai_config: &mut AIConfig, +) -> Result> { + apply_subscription_auth_with_options(auth, ai_config, &SubscriptionHttpOptions::default()).await +} + +/// Resolves subscription authentication with an explicit transport policy. +pub async fn apply_subscription_auth_with_options( + auth: &AuthConfig, + ai_config: &mut AIConfig, + options: &SubscriptionHttpOptions, ) -> Result> { let resolved = match auth { AuthConfig::ApiKey => return Ok(None), AuthConfig::Subscription { provider, plan } => { let resolved = match (*provider, *plan) { (SubscriptionProvider::Opencode, Some(plan)) => { - subscription_auth::resolve_opencode( + subscription_auth::resolve_opencode_with_options( to_adapter_opencode_plan(plan), &ai_config.format, + options, ) .await } - (_, None) => subscription_auth::resolve(to_adapter_provider(*provider)).await, + (_, None) => { + subscription_auth::resolve_with_options(to_adapter_provider(*provider), options) + .await + } (_, Some(plan)) => Err(anyhow!( "OpenCode plan {plan:?} cannot be used with provider {provider:?}" )), diff --git a/src/crates/assembly/core/src/infrastructure/ai/reasoning_catalog.rs b/src/crates/assembly/core/src/infrastructure/ai/reasoning_catalog.rs index ea19299219..587165e759 100644 --- a/src/crates/assembly/core/src/infrastructure/ai/reasoning_catalog.rs +++ b/src/crates/assembly/core/src/infrastructure/ai/reasoning_catalog.rs @@ -1,23 +1,26 @@ use std::sync::Arc; -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] use std::sync::{OnceLock, RwLock}; -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] use std::time::{Duration, Instant}; use bitfun_ai_adapters::models_dev::{ project_reasoning_catalog_with_limit_and_auto_binding, ModelsDevCatalog, }; +#[cfg(feature = "model-catalog")] use bitfun_core_types::{ ModelsDevCatalogSource, ModelsDevCatalogStatus, ModelsDevRefreshResult, ModelsDevRefreshStatus, +}; +use bitfun_core_types::{ ReasoningCatalogBinding, ReasoningCatalogProjection, ReasoningPresetDescriptor, }; -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] use bitfun_events::{AIModelCatalogUpdatedEvent, AI_MODEL_CATALOG_UPDATED_EVENT}; -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] use bitfun_services_integrations::models_dev::{ ModelsDevCatalogService, ModelsDevRefreshOutcome, ModelsDevSnapshot, ModelsDevSnapshotSource, }; -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] use log::debug; use crate::infrastructure::ai::provider_catalog::trusted_models_dev_binding; @@ -27,30 +30,30 @@ use crate::service::config::types::AIModelConfig; #[derive(Clone)] pub(crate) struct ModelsDevReasoningCatalogSnapshot { pub(crate) catalog: Option>, - #[cfg(feature = "agent-runtime")] + #[cfg(feature = "model-catalog")] pub(crate) version: u64, - #[cfg(feature = "agent-runtime")] + #[cfg(feature = "model-catalog")] pub(crate) sha256: String, - #[cfg(feature = "agent-runtime")] + #[cfg(feature = "model-catalog")] pub(crate) source: ModelsDevSnapshotSource, } -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] const CATALOG_RELOAD_INTERVAL: Duration = Duration::from_secs(60); -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] struct CachedReasoningCatalogSnapshot { loaded_at: Instant, snapshot: ModelsDevReasoningCatalogSnapshot, } -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] fn parsed_catalog_cache() -> &'static RwLock> { static CACHE: OnceLock>> = OnceLock::new(); CACHE.get_or_init(|| RwLock::new(None)) } -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] fn models_dev_catalog_service() -> &'static ModelsDevCatalogService { static SERVICE: OnceLock = OnceLock::new(); SERVICE.get_or_init(|| { @@ -62,7 +65,7 @@ fn models_dev_catalog_service() -> &'static ModelsDevCatalogService { }) } -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] fn models_dev_catalog_source(source: ModelsDevSnapshotSource) -> ModelsDevCatalogSource { match source { ModelsDevSnapshotSource::Cache => ModelsDevCatalogSource::Cache, @@ -71,7 +74,7 @@ fn models_dev_catalog_source(source: ModelsDevSnapshotSource) -> ModelsDevCatalo } } -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] async fn models_dev_catalog_status() -> ModelsDevCatalogStatus { let service = models_dev_catalog_service(); let snapshot = load_models_dev_reasoning_catalog_without_refresh().await; @@ -100,12 +103,12 @@ async fn models_dev_catalog_status() -> ModelsDevCatalogStatus { } } -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] pub(crate) async fn get_models_dev_catalog_status() -> ModelsDevCatalogStatus { models_dev_catalog_status().await } -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] pub(crate) async fn refresh_models_dev_catalog_now() -> Result { let service = models_dev_catalog_service(); let outcome = service.refresh_now().await; @@ -143,7 +146,7 @@ pub(crate) async fn refresh_models_dev_catalog_now() -> Result ModelsDevReasoningCatalogSnapshot { if let Ok(cache) = parsed_catalog_cache().read() { @@ -167,7 +170,7 @@ pub(crate) async fn load_models_dev_reasoning_catalog_without_refresh( let loaded = ModelsDevReasoningCatalogSnapshot { catalog, - #[cfg(feature = "agent-runtime")] + #[cfg(feature = "model-catalog")] version: snapshot.version, sha256: snapshot.sha256, source: snapshot.source, @@ -182,7 +185,7 @@ pub(crate) async fn load_models_dev_reasoning_catalog_without_refresh( loaded } -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] pub(crate) async fn load_models_dev_reasoning_catalog() -> ModelsDevReasoningCatalogSnapshot { let loaded = load_models_dev_reasoning_catalog_without_refresh().await; @@ -203,7 +206,7 @@ pub(crate) async fn load_models_dev_reasoning_catalog() -> ModelsDevReasoningCat loaded } -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] fn parse_models_dev_snapshot( snapshot: &ModelsDevSnapshot, ) -> Option { @@ -225,7 +228,7 @@ fn parse_models_dev_snapshot( }) } -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] fn replace_parsed_catalog_cache(updated: ModelsDevReasoningCatalogSnapshot) -> bool { let Ok(mut cache) = parsed_catalog_cache().write() else { return false; @@ -233,7 +236,7 @@ fn replace_parsed_catalog_cache(updated: ModelsDevReasoningCatalogSnapshot) -> b replace_cached_catalog(&mut cache, updated) } -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] fn replace_cached_catalog( cache: &mut Option, updated: ModelsDevReasoningCatalogSnapshot, @@ -250,7 +253,7 @@ fn replace_cached_catalog( true } -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] async fn emit_models_dev_catalog_updated(snapshot: &ModelsDevSnapshot) { crate::service::config::GlobalConfigManager::broadcast_update( crate::service::config::ConfigUpdateEvent::ReasoningCatalogUpdated, @@ -277,12 +280,12 @@ async fn emit_models_dev_catalog_updated(snapshot: &ModelsDevSnapshot) { .await; } -#[cfg(not(feature = "agent-runtime"))] +#[cfg(not(feature = "model-catalog"))] pub(crate) async fn load_models_dev_reasoning_catalog() -> ModelsDevReasoningCatalogSnapshot { ModelsDevReasoningCatalogSnapshot { catalog: None } } -#[cfg(not(feature = "agent-runtime"))] +#[cfg(not(feature = "model-catalog"))] pub(crate) async fn load_models_dev_reasoning_catalog_without_refresh( ) -> ModelsDevReasoningCatalogSnapshot { ModelsDevReasoningCatalogSnapshot { catalog: None } @@ -735,7 +738,7 @@ mod tests { ); } - #[cfg(feature = "agent-runtime")] + #[cfg(feature = "model-catalog")] #[test] fn refreshed_catalog_replaces_projection_without_waiting_for_reload_interval() { let mut cache = Some(super::CachedReasoningCatalogSnapshot { @@ -758,7 +761,7 @@ mod tests { assert_eq!(cache.as_ref().map(|value| value.snapshot.version), Some(2)); } - #[cfg(feature = "agent-runtime")] + #[cfg(feature = "model-catalog")] #[test] fn cache_source_change_replaces_equal_bundled_snapshot() { let catalog = std::sync::Arc::new(catalog()); diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 50c98d2431..ac5bb707c7 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -25,12 +25,12 @@ pub mod external_sources; mod external_subagents; #[cfg(feature = "external-sources")] mod external_tools; -#[cfg(feature = "product-domains")] +#[cfg(feature = "function-agents")] pub mod function_agents; // Function-based agents pub mod infrastructure; // AI clients, storage, logging, events #[cfg(feature = "external-sources")] mod instruction_sources; -#[cfg(feature = "product-domains")] +#[cfg(feature = "tools-miniapp")] pub mod miniapp; // AI-generated instant apps (Zero-Dialect Runtime) #[cfg(feature = "agent-runtime")] pub mod native_hooks; @@ -38,13 +38,13 @@ pub mod native_hooks; mod native_hooks_tests; #[cfg(feature = "plugin-runtime")] pub mod plugin_runtime; -#[cfg(any(feature = "plugin-source", feature = "product-domains"))] +#[cfg(feature = "plugin-source")] pub mod plugin_source; #[cfg(feature = "agent-runtime")] pub mod product_assembly; -#[cfg(all(test, feature = "agent-runtime"))] +#[cfg(all(test, feature = "product-full"))] mod product_assembly_tests; -#[cfg(feature = "product-domains")] +#[cfg(any(feature = "function-agents", feature = "tools-miniapp"))] pub(crate) mod product_domain_runtime; #[cfg(feature = "agent-runtime")] pub mod product_runtime; @@ -61,7 +61,7 @@ pub mod util; // General types, errors, helper functions #[cfg(feature = "debug-log")] pub use infrastructure::debug_log as debug; -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "remote-connect")] pub use bitfun_services_integrations::remote_connect::RemoteModelCatalog as AIModelCatalog; #[cfg(feature = "agent-runtime")] @@ -73,17 +73,17 @@ pub fn get_builtin_ai_provider_catalog() -> bitfun_core_types::ProviderCatalog { ) } -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "remote-connect")] pub async fn get_ai_model_catalog() -> Result { service_agent_runtime::CoreServiceAgentRuntime::load_remote_model_catalog(None).await } -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] pub async fn get_models_dev_catalog_status() -> bitfun_core_types::ModelsDevCatalogStatus { infrastructure::ai::reasoning_catalog::get_models_dev_catalog_status().await } -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "model-catalog")] pub async fn refresh_models_dev_catalog_now( ) -> Result { infrastructure::ai::reasoning_catalog::refresh_models_dev_catalog_now().await diff --git a/src/crates/assembly/core/src/miniapp/manager.rs b/src/crates/assembly/core/src/miniapp/manager.rs index b4777d146e..9f88d0d56c 100644 --- a/src/crates/assembly/core/src/miniapp/manager.rs +++ b/src/crates/assembly/core/src/miniapp/manager.rs @@ -119,20 +119,6 @@ impl MiniAppManager { compile_with_request(source, permissions, &request) } - fn compile_market_source_with_app_data_dir( - &self, - app_id: &str, - app_data_dir: &Path, - source: &MiniAppSource, - permissions: &MiniAppPermissions, - theme: &str, - workspace_root: Option<&Path>, - ) -> BitFunResult { - let request = - MiniAppCompileRequest::from_paths(app_id, app_data_dir, workspace_root, theme); - compile_market_with_request(source, permissions, &request) - } - pub async fn uses_market_strict_runtime(&self, app_id: &str) -> bool { self.storage .load_meta(app_id) diff --git a/src/crates/assembly/core/src/product_domain_runtime.rs b/src/crates/assembly/core/src/product_domain_runtime.rs index 0c359c77ae..600cbdcd41 100644 --- a/src/crates/assembly/core/src/product_domain_runtime.rs +++ b/src/crates/assembly/core/src/product_domain_runtime.rs @@ -4,44 +4,58 @@ //! module keeps the concrete MiniApp and function-agent runtime bindings in //! core so filesystem, process, Git, and AI behavior stays on the legacy path. +#[cfg(feature = "function-agents")] use std::path::Path; +#[cfg(feature = "function-agents")] use std::sync::Arc; +#[cfg(feature = "function-agents")] use bitfun_product_domains::function_agents::ports::{ FunctionAgentAiPort, FunctionAgentGitPort, FunctionAgentRuntimeFacade, }; +#[cfg(feature = "tools-miniapp")] use bitfun_product_domains::miniapp::ports::{MiniAppRuntimeFacade, MiniAppStoragePort}; +#[cfg(feature = "function-agents")] use chrono::{Local, Timelike}; +#[cfg(feature = "function-agents")] use log::info; +#[cfg(feature = "function-agents")] use crate::function_agents::common::AgentResult; +#[cfg(feature = "function-agents")] use crate::function_agents::port_adapters::{ CoreFunctionAgentAiAdapter, CoreFunctionAgentGitAdapter, }; +#[cfg(feature = "function-agents")] use crate::function_agents::{ CommitMessage, CommitMessageOptions, WorkStateAnalysis, WorkStateOptions, }; +#[cfg(feature = "function-agents")] use crate::infrastructure::ai::AIClientFactory; pub(crate) struct CoreProductDomainRuntime; impl CoreProductDomainRuntime { + #[cfg(feature = "tools-miniapp")] pub(crate) fn miniapp_runtime_facade( storage: &dyn MiniAppStoragePort, ) -> MiniAppRuntimeFacade<'_> { MiniAppRuntimeFacade::new(storage) } + #[cfg(feature = "function-agents")] pub(crate) fn function_agent_git_adapter() -> CoreFunctionAgentGitAdapter { CoreFunctionAgentGitAdapter } + #[cfg(feature = "function-agents")] pub(crate) fn function_agent_ai_adapter( factory: Arc, ) -> CoreFunctionAgentAiAdapter { CoreFunctionAgentAiAdapter::new(factory) } + #[cfg(feature = "function-agents")] pub(crate) fn function_agent_runtime_facade<'a>( git: &'a dyn FunctionAgentGitPort, ai: &'a dyn FunctionAgentAiPort, @@ -49,6 +63,7 @@ impl CoreProductDomainRuntime { FunctionAgentRuntimeFacade::new(git, ai) } + #[cfg(feature = "function-agents")] pub(crate) async fn generate_function_agent_commit_message( factory: Arc, repo_path: &Path, @@ -67,6 +82,7 @@ impl CoreProductDomainRuntime { .await } + #[cfg(feature = "function-agents")] pub(crate) async fn analyze_function_agent_work_state( factory: Arc, repo_path: &Path, diff --git a/src/crates/assembly/core/src/product_runtime/runtime_services.rs b/src/crates/assembly/core/src/product_runtime/runtime_services.rs index 5eb7f33db3..a58e5b6317 100644 --- a/src/crates/assembly/core/src/product_runtime/runtime_services.rs +++ b/src/crates/assembly/core/src/product_runtime/runtime_services.rs @@ -6,20 +6,33 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; +#[cfg(feature = "ssh-remote")] +use bitfun_runtime_ports::PortResult; #[cfg(feature = "ssh-remote")] use bitfun_runtime_ports::{PortError, PortErrorKind, RemoteExecPort}; -use bitfun_runtime_ports::{ - PortResult, RemoteProjectionPort, RemoteWorkspacePort, SessionStorePort, TerminalPort, -}; +#[cfg(feature = "remote-connect")] +use bitfun_runtime_ports::{RemoteProjectionPort, RemoteWorkspacePort}; +use bitfun_runtime_ports::{SessionStorePort, TerminalPort}; +#[cfg(any( + feature = "model-catalog", + feature = "mcp-runtime", + feature = "remote-connect", + feature = "browser-control", + feature = "web-tools", + feature = "deep-research", + feature = "tools-miniapp", + feature = "git" +))] +use bitfun_runtime_services::RuntimeServiceMarkerPort; use bitfun_runtime_services::{ - RuntimeServiceMarkerPort, RuntimeServices, RuntimeServicesBuilder, RuntimeServicesProvider, - RuntimeServicesRegistry, + RuntimeServices, RuntimeServicesBuilder, RuntimeServicesProvider, RuntimeServicesRegistry, }; use bitfun_services_core::local_runtime_ports::LocalRuntimePorts; use terminal_core::TerminalRuntimePort; use crate::agentic::session::CoreSessionStorePort; +#[cfg(feature = "remote-connect")] use crate::service_agent_runtime::{ CoreRemoteWorkspaceFileRuntimeHost, CoreRemoteWorkspaceRuntimeHost, }; @@ -81,22 +94,42 @@ impl RuntimeServicesProvider for CoreRuntimeServicesProvider { let terminal = Self::terminal_port(); let builder = builder .with_session_store(session_store) - .with_optional_terminal(Some(terminal)) - .with_optional_network(Some(RuntimeServiceMarkerPort::network_port())) - .with_optional_git(Some(RuntimeServiceMarkerPort::git_port())) - .with_optional_mcp_catalog(Some(RuntimeServiceMarkerPort::mcp_catalog_port())); + .with_optional_terminal(Some(terminal)); + + #[cfg(any( + feature = "model-catalog", + feature = "mcp-runtime", + feature = "remote-connect", + feature = "browser-control", + feature = "web-tools", + feature = "deep-research", + feature = "tools-miniapp" + ))] + let builder = builder.with_optional_network(Some(RuntimeServiceMarkerPort::network_port())); + + #[cfg(feature = "git")] + let builder = builder.with_optional_git(Some(RuntimeServiceMarkerPort::git_port())); + + #[cfg(feature = "mcp-runtime")] + let builder = + builder.with_optional_mcp_catalog(Some(RuntimeServiceMarkerPort::mcp_catalog_port())); #[cfg(feature = "ssh-remote")] let builder = builder.with_optional_remote_exec(Some(Self::remote_exec_port())); + #[cfg(feature = "remote-connect")] let remote_workspace: Arc = Arc::new(CoreRemoteWorkspaceRuntimeHost::new()); + #[cfg(feature = "remote-connect")] let remote_projection: Arc = Arc::new(CoreRemoteWorkspaceFileRuntimeHost::new()); - builder + #[cfg(feature = "remote-connect")] + let builder = builder .with_optional_remote_workspace(Some(remote_workspace)) - .with_optional_remote_projection(Some(remote_projection)) + .with_optional_remote_projection(Some(remote_projection)); + + builder } } @@ -172,14 +205,13 @@ mod local_runtime_tests { RuntimeServiceCapability::Events, RuntimeServiceCapability::Clock, RuntimeServiceCapability::Terminal, - RuntimeServiceCapability::Network, - RuntimeServiceCapability::Git, ] { assert!(services.has_capability(capability), "missing {capability}"); } assert!(services.clock.now_unix_millis() > 0); } + #[cfg(feature = "git")] #[tokio::test] async fn local_runtime_services_bind_git_queries_to_the_canonical_workspace() { let workspace = tempfile::tempdir().expect("workspace"); diff --git a/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs b/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs index 0a85c55927..5498251c42 100644 --- a/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs +++ b/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs @@ -13,6 +13,7 @@ use crate::service::config::types::{ }; use crate::util::errors::*; use bitfun_agent_runtime::skills::normalize_user_mode_skill_overrides; +use bitfun_agent_runtime::thread_goal_tools::THREAD_GOAL_TOOL_NAMES; use bitfun_runtime_ports::PermissionRule; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -88,13 +89,13 @@ pub fn resolve_effective_tools( mode_config: Option<&AgentProfileConfig>, valid_tools: &HashSet, ) -> Vec { - let Some(config) = mode_config else { - return normalize_tools(default_tools.to_vec(), valid_tools); - }; - let default_tools = normalize_tools(default_tools.to_vec(), valid_tools); - let removed: HashSet = config.removed_tools.iter().cloned().collect(); - let added = normalize_tools(config.added_tools.clone(), valid_tools); + let removed: HashSet = mode_config + .map(|config| config.removed_tools.iter().cloned().collect()) + .unwrap_or_default(); + let added = mode_config + .map(|config| normalize_tools(config.added_tools.clone(), valid_tools)) + .unwrap_or_default(); let mut effective = Vec::new(); let mut seen = HashSet::new(); @@ -114,6 +115,16 @@ pub fn resolve_effective_tools( } } + // Thread goals are a main-session lifecycle capability, not an optional + // mode specialization. The UI and backend can activate a goal without a + // model tool call, so allowing a profile override to remove update_goal + // would strand the active goal in the automatic continuation loop. + for tool_name in THREAD_GOAL_TOOL_NAMES { + if valid_tools.contains(tool_name) && seen.insert(tool_name.to_string()) { + effective.push(tool_name.to_string()); + } + } + effective } @@ -195,6 +206,7 @@ fn stored_agent_profile_from_overrides( added_tools.retain(|tool| !default_set.contains(tool)); removed_tools.retain(|tool| default_set.contains(tool)); + removed_tools.retain(|tool| !THREAD_GOAL_TOOL_NAMES.contains(&tool.as_str())); let removed_set: HashSet = removed_tools.iter().cloned().collect(); added_tools.retain(|tool| !removed_set.contains(tool)); @@ -578,14 +590,55 @@ pub fn agent_profile_member_mode_ids_for(agent_id: &str) -> Vec { mod tests { use super::{ agent_profile_member_mode_ids_for, canonicalize_agent_profile, - normalize_skill_override_lists, stored_agent_profile_from_overrides, - StoredAgentProfileOverrides, + normalize_skill_override_lists, resolve_effective_tools, + stored_agent_profile_from_overrides, StoredAgentProfileOverrides, }; - use crate::service::config::types::AgentSubagentOverrideState; + use crate::service::config::types::{AgentProfileConfig, AgentSubagentOverrideState}; + use bitfun_agent_runtime::thread_goal_tools::THREAD_GOAL_TOOL_NAMES; use bitfun_runtime_ports::{PermissionEffect, PermissionRule}; use serde_json::Value; use std::collections::HashSet; + #[test] + fn mode_profiles_cannot_remove_required_thread_goal_tools() { + let default_tools = vec![ + "Read".to_string(), + "get_goal".to_string(), + "create_goal".to_string(), + "update_goal".to_string(), + ]; + let valid_tools = default_tools.iter().cloned().collect(); + let stored = stored_agent_profile_from_overrides(StoredAgentProfileOverrides { + agent_id: "Claw", + added_tools: Vec::new(), + removed_tools: default_tools.clone(), + disabled_user_skills: Vec::new(), + enabled_user_skills: Vec::new(), + subagent_overrides: Default::default(), + tool_permission_rules: Vec::new(), + default_tools: &default_tools, + valid_tools: &valid_tools, + }) + .expect("the ordinary Read removal should keep the profile"); + + assert_eq!(stored.removed_tools, vec!["Read".to_string()]); + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert!(!stored.removed_tools.iter().any(|tool| tool == tool_name)); + } + + let legacy_config = AgentProfileConfig { + profile_id: "Claw".to_string(), + removed_tools: default_tools.clone(), + ..AgentProfileConfig::default() + }; + let effective_tools = + resolve_effective_tools(&default_tools, Some(&legacy_config), &valid_tools); + assert!(!effective_tools.contains(&"Read".to_string())); + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert!(effective_tools.iter().any(|tool| tool == tool_name)); + } + } + #[test] fn normalize_skill_override_lists_removes_duplicates_and_conflicts() { let (disabled, enabled) = normalize_skill_override_lists( diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index 46053b0e77..8aa5306c54 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -125,7 +125,7 @@ impl ConfigService { /// Atomically replaces one JSON configuration value when its current value /// still matches the caller's snapshot. The read, comparison, and persisted /// write share the existing manager write lock. - #[cfg(any(test, feature = "agent-runtime"))] + #[cfg(any(test, feature = "mcp-runtime"))] pub(crate) async fn compare_and_set_json_config( &self, path: &str, diff --git a/src/crates/assembly/core/src/service/dispatch/controller.rs b/src/crates/assembly/core/src/service/dispatch/controller.rs index 34ec3bce61..a3c407dfa1 100644 --- a/src/crates/assembly/core/src/service/dispatch/controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/controller.rs @@ -293,11 +293,15 @@ pub async fn install_cli_cancel( /// Copy this controller's model configuration (catalog, credentials, and /// default-model selections) onto the SSH target so its CLI can resolve a -/// ready model. Explicit, credential-bearing operation: the UI must confirm -/// before calling it, mirroring CLI installation. -pub async fn sync_model_config( +/// ready model. +/// +/// Credential-bearing: this writes the controller's API keys into the target +/// user's BitFun configuration. Callers are the explicit UI command and the +/// automatic submit-time repair in [`ensure_target_model_config`]; both leave +/// a durable record of having done it. +pub(super) async fn push_model_config( manager: &SSHConnectionManager, - request: DispatchConnectionRequest, + connection_id: &str, ) -> anyhow::Result<()> { crate::service::config::initialize_global_config() .await @@ -325,12 +329,14 @@ pub async fn sync_model_config( payload.insert(key.to_string(), value.clone()); } } - dispatch_ssh::sync_model_config( - manager, - request.connection_id.trim(), - &Value::Object(payload), - ) - .await + dispatch_ssh::sync_model_config(manager, connection_id, &Value::Object(payload)).await +} + +pub async fn sync_model_config( + manager: &SSHConnectionManager, + request: DispatchConnectionRequest, +) -> anyhow::Result<()> { + push_model_config(manager, request.connection_id.trim()).await } pub async fn submit( @@ -436,6 +442,30 @@ pub async fn submit( })?; dispatch_ssh::validate_dispatch_protocol(cli_protocol, Some(&request.approval_policy))?; + // Prepare the model before the Git baseline: the composer offers this + // controller's own model list, so the target is brought up to it here + // rather than failing the submission back to the user with a manual step. + // Doing it now also means a target that cannot serve the model at all + // fails before a worktree is created and released again. + let cli_probe = ensure_target_model_config( + manager, + store, + &request.job_id, + connection_id, + cli_probe, + request.model.as_deref(), + ) + .await?; + let cli_protocol = cli_probe.protocol.as_ref().ok_or_else(|| { + anyhow::anyhow!("BitFun CLI dispatch protocol is unavailable on the SSH target") + })?; + if !target_serves_model(cli_protocol, request.model.as_deref()) { + anyhow::bail!( + "{}", + unservable_model_message(cli_protocol, request.model.as_deref()) + ); + } + let baseline = prepare_baseline( store, &request.job_id, @@ -554,7 +584,10 @@ pub async fn submit( store .mark_preparation_outbound_bound(&request.job_id) .await?; - let setup_audit = store.preparation_setup_audit(&request.job_id).await?; + let setup_audit = setup_audit_for_target( + store.preparation_setup_audit(&request.job_id).await?, + protocol, + ); let mut protocol_request = json!({ "protocolVersion": DISPATCH_PROTOCOL_VERSION, @@ -621,6 +654,200 @@ pub async fn submit( Ok(response) } +/// Whether the target can already run the model this submission needs. +/// +/// Mirrors the model half of [`validate_submission_preflight`], which stays +/// the authoritative check immediately before submit. This one exists so the +/// controller can tell "needs repair" from "genuinely unusable" early, while +/// repair is still cheap. +pub(super) fn target_serves_model(protocol: &Value, requested_model: Option<&str>) -> bool { + match requested_model + .map(str::trim) + .filter(|model| !model.is_empty()) + { + Some(model) => protocol + .get("availableModels") + .and_then(Value::as_array) + .is_some_and(|models| models.iter().any(|entry| entry.as_str() == Some(model))), + None => protocol.get("modelConfigured").and_then(Value::as_bool) == Some(true), + } +} + +fn unservable_model_message(protocol: &Value, requested_model: Option<&str>) -> String { + match requested_model + .map(str::trim) + .filter(|model| !model.is_empty()) + { + Some(model) => { + format!("Requested model '{model}' is not ready on the dispatch target") + } + None => protocol + .get("modelDiagnostic") + .and_then(Value::as_str) + .filter(|diagnostic| !diagnostic.trim().is_empty()) + .unwrap_or("No ready default model is configured on the dispatch target") + .to_string(), + } +} + +/// Bring the target's model configuration up to this controller's when the +/// target cannot serve the submission's model, then re-probe. +/// +/// Returns the probe the caller should keep using: the fresh one when a sync +/// happened, the original otherwise. A failed sync is not fatal here — the +/// caller reports the target's own model diagnostic, which describes the +/// user-visible problem better than a transport error from the repair attempt. +async fn ensure_target_model_config( + manager: &SSHConnectionManager, + store: &OutboundDispatchStore, + job_id: &str, + connection_id: &str, + probe: DispatchSshProbe, + requested_model: Option<&str>, +) -> anyhow::Result { + if probe + .protocol + .as_ref() + .is_some_and(|protocol| target_serves_model(protocol, requested_model)) + { + return Ok(probe); + } + + let attempt = uuid::Uuid::new_v4().as_simple().to_string(); + + log::info!( + "Dispatch SSH model sync: stage=model-sync-started connection_id={connection_id} requested_model={requested_model:?}" + ); + // Persisted before the remote mutation, exactly like the CLI installer: a + // controller that dies mid-write must still leave evidence that this + // device's credentials may have reached the target. + append_model_sync_audit( + store, + job_id, + &attempt, + 1, + "model-sync-started", + json!({ "requestedModel": requested_model }), + ) + .await + .map_err(|error| anyhow::anyhow!("persist the model sync started audit event: {error}"))?; + + if let Err(error) = push_model_config(manager, connection_id).await { + log::warn!("Dispatch SSH model sync failed: connection_id={connection_id} error={error}"); + append_model_sync_audit( + store, + job_id, + &attempt, + 2, + "model-sync-failed", + json!({ "error": bounded_audit_detail(&error) }), + ) + .await?; + return Ok(probe); + } + + // Re-probe with no workspace path: this only needs to re-read the model + // readiness the sync just changed. + let resynced = match dispatch_ssh::probe(manager, connection_id, None).await { + Ok(resynced) => resynced, + Err(error) => { + append_model_sync_audit( + store, + job_id, + &attempt, + 2, + "model-sync-failed", + json!({ "error": bounded_audit_detail(&error) }), + ) + .await?; + return Err(error); + } + }; + let model_count = resynced + .protocol + .as_ref() + .and_then(|protocol| protocol.get("availableModels")) + .and_then(Value::as_array) + .map(|models| models.len()) + .unwrap_or(0); + append_model_sync_audit( + store, + job_id, + &attempt, + 2, + "model-sync-succeeded", + json!({ "modelCount": model_count }), + ) + .await?; + log::info!( + "Dispatch SSH model sync: stage=model-sync-succeeded connection_id={connection_id} model_count={model_count}" + ); + Ok(resynced) +} + +/// An audit event has a hard size limit, and a transport failure can carry an +/// unbounded remote tail. Truncating keeps a real failure from turning into a +/// confusing "audit event too large" error that hides it. +fn bounded_audit_detail(error: &anyhow::Error) -> String { + const MAX_AUDIT_DETAIL_CHARS: usize = 512; + let message = error.to_string(); + let mut chars = message.chars(); + let truncated: String = chars.by_ref().take(MAX_AUDIT_DETAIL_CHARS).collect(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + +async fn append_model_sync_audit( + store: &OutboundDispatchStore, + job_id: &str, + attempt: &str, + sequence: u32, + stage: &str, + detail: Value, +) -> anyhow::Result<()> { + store + .append_preparation_setup_audit( + job_id, + &format!("{attempt}:model-sync:{sequence}"), + json!({ + "timestamp": chrono::Utc::now().to_rfc3339(), + "action": bitfun_services_core::dispatch_contract::DISPATCH_MODEL_SYNC_SETUP_AUDIT_ACTION, + "details": { + "stage": stage, + // Never the synced payload itself: it carries API keys. + "sync": detail, + }, + }), + ) + .await +} + +/// Narrow the controller's own setup journal to the audit rows this target +/// accepts. A target rejects an unknown action outright, so forwarding one +/// would turn a working submission into a hard failure on an older CLI. +fn setup_audit_for_target(events: Vec, protocol: &Value) -> Vec { + let capabilities: Vec<&str> = protocol + .get("capabilities") + .and_then(Value::as_array) + .map(|list| list.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + events + .into_iter() + .filter(|event| { + event + .get("action") + .and_then(Value::as_str) + .is_some_and(|action| { + bitfun_services_core::dispatch_contract:: + dispatch_target_accepts_setup_audit_action(action, &capabilities) + }) + }) + .collect() +} + async fn recover_interrupted_cli_install_audit( store: &OutboundDispatchStore, job_id: &str, @@ -1488,6 +1715,55 @@ mod tests { assert!(validate_submission_preflight(&missing_model, None, None).is_err()); } + #[test] + fn model_readiness_distinguishes_repairable_targets_from_unusable_ones() { + let empty = json!({ "modelConfigured": false, "availableModels": [] }); + assert!(!target_serves_model(&empty, None)); + assert!(!target_serves_model(&empty, Some("local-model"))); + + let ready = json!({ + "modelConfigured": true, + "availableModels": ["local-model", "other-model"], + }); + assert!(target_serves_model(&ready, None)); + assert!(target_serves_model(&ready, Some("local-model"))); + // A blank selection means "whatever the target defaults to", not a + // model named "". + assert!(target_serves_model(&ready, Some(" "))); + assert!(!target_serves_model( + &ready, + Some("model-only-on-controller") + )); + + // A target with models but no default cannot serve an unspecified + // choice, so it is still repairable rather than already ready. + let no_default = json!({ + "modelConfigured": false, + "availableModels": ["local-model"], + }); + assert!(!target_serves_model(&no_default, None)); + assert!(target_serves_model(&no_default, Some("local-model"))); + } + + #[test] + fn setup_audit_drops_rows_an_older_target_would_reject() { + let events = vec![ + json!({ "action": "cli-install", "details": { "stage": "cli-install-succeeded" } }), + json!({ "action": "model-sync", "details": { "stage": "model-sync-succeeded" } }), + json!({ "action": "invented-later", "details": {} }), + ]; + + let legacy = json!({ "capabilities": ["persistent_jobs"] }); + let forwarded = setup_audit_for_target(events.clone(), &legacy); + assert_eq!(forwarded.len(), 1); + assert_eq!(forwarded[0]["action"], "cli-install"); + + let current = json!({ "capabilities": ["persistent_jobs", "setup_audit_model_sync"] }); + let forwarded = setup_audit_for_target(events, ¤t); + assert_eq!(forwarded.len(), 2); + assert_eq!(forwarded[1]["action"], "model-sync"); + } + #[test] fn continue_payload_preserves_explicit_auto_reasoning_preset() { let payload = continue_payload(&DispatchContinueRequest { diff --git a/src/crates/assembly/core/src/service/dispatch/preparation.rs b/src/crates/assembly/core/src/service/dispatch/preparation.rs index 5cdc6d92ee..c9a9f769f4 100644 --- a/src/crates/assembly/core/src/service/dispatch/preparation.rs +++ b/src/crates/assembly/core/src/service/dispatch/preparation.rs @@ -606,7 +606,16 @@ fn validate_setup_audit_event(event: &Value) -> Result<()> { let object = event .as_object() .ok_or_else(|| anyhow!("dispatch setup audit event must be an object"))?; - if object.get("action").and_then(Value::as_str) != Some("cli-install") + // The journal records every setup action this controller can perform. What + // an individual target accepts is narrower and is decided at submit time, + // so recovery of a journal written by a newer build cannot fail here. + if object + .get("action") + .and_then(Value::as_str) + .is_none_or(|action| { + !bitfun_services_core::dispatch_contract::dispatch_supported_setup_audit_actions() + .any(|supported| supported == action) + }) || object .get("timestamp") .and_then(Value::as_str) diff --git a/src/crates/assembly/core/src/service/mod.rs b/src/crates/assembly/core/src/service/mod.rs index d2894eabf6..3189351035 100644 --- a/src/crates/assembly/core/src/service/mod.rs +++ b/src/crates/assembly/core/src/service/mod.rs @@ -11,7 +11,7 @@ pub(crate) mod bootstrap; // Workspace persona bootstrap helpers #[cfg(feature = "canvas-runtime")] pub mod canvas; // Canvas service compatibility facade pub mod config; // Config management -#[cfg(feature = "agent-runtime")] +#[cfg(all(feature = "agent-runtime", feature = "scheduled-jobs"))] pub mod cron; // Scheduled jobs #[cfg(feature = "dispatch-store")] pub mod dispatch; // Outbound dispatch observer index and target contracts @@ -24,17 +24,20 @@ pub mod i18n; // I18n service pub(crate) mod instruction_context; // Workspace instruction file prompt helpers #[cfg(feature = "lsp")] pub mod lsp; // LSP (Language Server Protocol) system -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "mcp-runtime")] pub mod mcp; // MCP (Model Context Protocol) system -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "remote-connect")] pub mod remote_connect; // Remote Connect (phone → desktop) #[cfg(feature = "remote-workspace")] pub mod remote_ssh; // Remote SSH (desktop → server) +#[cfg(all(not(feature = "remote-workspace"), feature = "agent-runtime"))] +#[path = "remote_ssh_compat.rs"] +pub mod remote_ssh; #[cfg(feature = "review-platform")] pub mod review_platform; // Pull request review platform adapters #[cfg(feature = "process-runtime")] pub mod runtime; // Managed runtime and capability management -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "workspace-search")] pub mod search; // Workspace search via managed flashgrep daemon #[cfg(feature = "local-storage")] pub mod session; // Session persistence @@ -48,7 +51,7 @@ pub mod token_usage; // Token usage tracking pub mod workspace; // Workspace management // Diff calculation and merge service #[cfg(feature = "workspace-runtime")] pub mod workspace_runtime; // Workspace runtime layout / migration / initialization -#[cfg(feature = "agent-runtime")] +#[cfg(all(feature = "agent-runtime", feature = "git"))] pub mod worktree; // Managed Git worktree lifecycle and session bindings // Terminal is implemented in the workspace-level `terminal-core` crate. @@ -69,7 +72,7 @@ pub use bootstrap::reset_workspace_persona_files_to_default; #[cfg(feature = "canvas-runtime")] pub use canvas::{CanvasMemoryStore, CanvasService}; pub use config::{ConfigManager, ConfigProvider, ConfigService}; -#[cfg(feature = "agent-runtime")] +#[cfg(all(feature = "agent-runtime", feature = "scheduled-jobs"))] pub use cron::{ get_global_cron_service, set_global_cron_service, CronEventSubscriber, CronService, }; @@ -89,7 +92,7 @@ pub use git::GitService; pub use i18n::{get_global_i18n_service, I18nConfig, I18nService, LocaleId, LocaleMetadata}; #[cfg(feature = "lsp")] pub use lsp::LspManager; -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "mcp-runtime")] pub use mcp::MCPService; #[cfg(feature = "review-platform")] pub use review_platform::{ @@ -104,7 +107,7 @@ pub use review_platform::{ }; #[cfg(feature = "process-runtime")] pub use runtime::{ResolvedCommand, RuntimeCommandCapability, RuntimeManager, RuntimeSource}; -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "workspace-search")] pub use search::{ get_global_workspace_search_service, set_global_workspace_search_service, ContentSearchRequest, ContentSearchResult, GlobSearchRequest, GlobSearchResult, IndexTaskHandle, @@ -135,5 +138,5 @@ pub use workspace_runtime::{ RuntimeMigrationRecord, WorkspaceRuntimeContext, WorkspaceRuntimeEnsureResult, WorkspaceRuntimeService, WorkspaceRuntimeTarget, }; -#[cfg(feature = "agent-runtime")] +#[cfg(all(feature = "agent-runtime", feature = "git"))] pub use worktree::WorktreeService; diff --git a/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs b/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs new file mode 100644 index 0000000000..4c582263cb --- /dev/null +++ b/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs @@ -0,0 +1,1278 @@ +//! Shared account runtime owner for product Hosts. +//! +//! The runtime owns account identity transitions, persisted credentials, +//! settings synchronization, and account-backed Session backup. Product Hosts +//! inject device-routing and background-owner lifecycle effects without +//! exposing App Server wire DTOs to this owner. + +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tokio::sync::{Mutex, MutexGuard, Notify, RwLock}; + +use bitfun_services_integrations::remote_connect::account::{ + ensure_relay_session_history_exportable, relay_session_export_metadata, AccountClient, + AccountSession, +}; +use bitfun_services_integrations::remote_connect::{session_store, sync_state, DeviceIdentity}; + +use super::{settings_sync, validate_relay_base_url}; + +const UPLOAD_CONCURRENCY_CHUNK: usize = 5; + +#[derive(Debug, Clone)] +struct AccountContextState { + session: AccountSession, + relay_url: String, +} + +#[derive(Debug, Clone)] +pub struct AccountRoutingStartRequest { + pub session: AccountSession, + pub relay_url: String, + pub device_name: String, + pub account_generation: u64, +} + +#[derive(Debug)] +pub struct BackgroundRoutingOwnerRetirementError { + pub error: anyhow::Error, + pub owner_may_exit: bool, +} + +#[async_trait] +pub trait AccountRuntimeHost: Send + Sync { + async fn retire_background_routing_owner( + &self, + ) -> std::result::Result; + + fn background_routing_owner_is_running(&self) -> bool; + + fn request_background_routing_owner_shutdown(&self) -> bool; + + async fn start_device_routing(&self, request: AccountRoutingStartRequest) -> Result<()>; + + async fn stop_device_routing(&self); + + fn notify_controllers_settings_changed(&self); +} + +#[derive(Debug, Clone)] +pub struct AccountSessionBackup { + pub session_id: String, + pub metadata: serde_json::Value, + pub turns: Vec, +} + +#[async_trait] +pub trait AccountSessionBackupPort: Send + Sync { + async fn list_session_backups( + &self, + workspace_path: &Path, + ) -> Result>; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AutomaticAccountSyncPolicy { + pub background_engine: bool, + pub management_push: bool, +} + +fn automatic_account_sync_policy_for_pending( + pending_sync_choice: bool, +) -> AutomaticAccountSyncPolicy { + let allowed = !pending_sync_choice; + AutomaticAccountSyncPolicy { + background_engine: allowed, + management_push: allowed, + } +} + +#[derive(Debug, Clone)] +pub struct AccountLoginResult { + pub user_id: String, + pub relay_url: String, + pub has_cloud_settings: bool, + pub routing_owner_replaced: bool, + pub routing_connected: bool, + pub routing_error: Option, +} + +#[derive(Debug, Clone)] +pub struct AccountInfo { + pub user_id: String, + pub relay_url: String, + pub device_id: String, + pub device_name: String, +} + +#[derive(Debug, Clone)] +pub struct AccountDevice { + pub device_id: String, + pub device_name: String, + pub online: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AccountSyncStatus { + #[default] + Idle, + Syncing, + Done, + Failed, + Cancelled, +} + +#[derive(Debug, Clone)] +pub struct AccountSyncProgress { + pub operation_id: Option, + pub status: AccountSyncStatus, + pub phase: String, + pub percent: u8, + pub current: Option, + pub total: Option, + pub detail: Option, + pub error: Option, + pub settings_synced: bool, + pub sessions_exported: usize, +} + +impl Default for AccountSyncProgress { + fn default() -> Self { + Self { + operation_id: None, + status: AccountSyncStatus::Idle, + phase: String::new(), + percent: 0, + current: None, + total: None, + detail: None, + error: None, + settings_synced: false, + sessions_exported: 0, + } + } +} + +#[derive(Debug, Clone)] +pub struct AccountSnapshot { + pub logged_in: bool, + pub pending_sync_choice: bool, + pub info: Option, + pub devices: Vec, + pub sync: AccountSyncProgress, +} + +#[derive(Debug, Clone)] +struct AutoSyncResult { + settings_synced: bool, + sessions_exported: usize, +} + +#[derive(Serialize, Deserialize)] +struct SessionBundle { + session_id: String, + metadata: serde_json::Value, + turns: Vec, + source_device_id: Option, + source_device_name: Option, +} + +pub struct AccountRuntime { + host: Arc, + session_backup: Arc, + account_context: RwLock>, + account_context_generation: AtomicU64, + account_context_transitions: AtomicUsize, + account_sync_lock: Mutex<()>, + account_login_lock: Mutex<()>, + account_context_transition_lock: Mutex<()>, + account_sync_cancel: Notify, + routing_recovery_generation: AtomicU64, + token_expired: AtomicBool, + pending_sync_choice: AtomicBool, + sync_progress: RwLock, + auto_sync_in_flight: AtomicBool, +} + +impl AccountRuntime { + pub fn new( + host: Arc, + session_backup: Arc, + ) -> Arc { + Arc::new(Self { + host, + session_backup, + account_context: RwLock::new(None), + account_context_generation: AtomicU64::new(1), + account_context_transitions: AtomicUsize::new(0), + account_sync_lock: Mutex::new(()), + account_login_lock: Mutex::new(()), + account_context_transition_lock: Mutex::new(()), + account_sync_cancel: Notify::new(), + routing_recovery_generation: AtomicU64::new(0), + token_expired: AtomicBool::new(false), + pending_sync_choice: AtomicBool::new(false), + sync_progress: RwLock::new(AccountSyncProgress::default()), + auto_sync_in_flight: AtomicBool::new(false), + }) + } + + pub fn account_context_generation(&self) -> u64 { + self.account_context_generation.load(Ordering::Acquire) + } + + pub fn account_context_is_current(&self, generation: u64) -> bool { + self.account_context_transitions.load(Ordering::Acquire) == 0 + && self.account_context_generation() == generation + } + + pub fn automatic_account_sync_policy(&self) -> AutomaticAccountSyncPolicy { + automatic_account_sync_policy_for_pending(self.pending_sync_choice.load(Ordering::Acquire)) + } + + pub fn pending_sync_choice(&self) -> bool { + self.pending_sync_choice.load(Ordering::Acquire) + } + + pub fn is_token_expired(&self) -> bool { + self.token_expired.load(Ordering::Relaxed) + } + + pub fn mark_token_expired(&self) { + self.token_expired.store(true, Ordering::Relaxed); + } + + async fn lock_account_sync(&self, generation: u64) -> Result> { + let guard = self.account_sync_lock.lock().await; + if !self.account_context_is_current(generation) { + return Err(anyhow!("account sync cancelled")); + } + Ok(guard) + } + + async fn await_account_sync_current(&self, generation: u64, future: F) -> Result + where + F: Future, + { + let mut cancelled = Box::pin(self.account_sync_cancel.notified()); + cancelled.as_mut().enable(); + if !self.account_context_is_current(generation) { + return Err(anyhow!("account sync cancelled")); + } + tokio::select! { + _ = &mut cancelled => Err(anyhow!("account sync cancelled")), + result = future => { + if !self.account_context_is_current(generation) { + Err(anyhow!("account sync cancelled")) + } else { + Ok(result) + } + } + } + } + + async fn begin_account_transition(&self) -> AccountContextTransitionGuard<'_> { + let transition_guard = self.account_context_transition_lock.lock().await; + self.account_context_transitions + .fetch_add(1, Ordering::AcqRel); + self.account_context_generation + .fetch_add(1, Ordering::AcqRel); + self.account_sync_cancel.notify_waiters(); + let sync_guard = self.account_sync_lock.lock().await; + settings_sync::wait_for_sync_operations_idle().await; + AccountContextTransitionGuard { + runtime: self, + sync_guard: Some(sync_guard), + transition_guard: Some(transition_guard), + active: true, + } + } + + async fn begin_account_transition_if_current( + &self, + expected_generation: u64, + ) -> Option> { + let transition_guard = self.account_context_transition_lock.lock().await; + if !self.account_context_is_current(expected_generation) { + return None; + } + self.account_context_transitions + .fetch_add(1, Ordering::AcqRel); + self.account_context_generation + .fetch_add(1, Ordering::AcqRel); + self.account_sync_cancel.notify_waiters(); + let sync_guard = self.account_sync_lock.lock().await; + settings_sync::wait_for_sync_operations_idle().await; + Some(AccountContextTransitionGuard { + runtime: self, + sync_guard: Some(sync_guard), + transition_guard: Some(transition_guard), + active: true, + }) + } + + async fn read_account_context_raw(&self) -> Result<(AccountSession, String)> { + self.account_context + .read() + .await + .clone() + .map(|context| (context.session, context.relay_url)) + .ok_or_else(|| anyhow!("not logged in")) + } + + pub async fn read_account_context(&self) -> Result<(AccountSession, String)> { + let generation = self.account_context_generation(); + self.read_account_context_for_generation(generation).await + } + + pub async fn read_account_context_for_generation( + &self, + generation: u64, + ) -> Result<(AccountSession, String)> { + if !self.account_context_is_current(generation) { + return Err(anyhow!("account context changed")); + } + let context = self.read_account_context_raw().await?; + if !self.account_context_is_current(generation) { + return Err(anyhow!("account context changed")); + } + Ok(context) + } + + pub async fn is_logged_in(&self) -> bool { + if self.pending_sync_choice.load(Ordering::Acquire) { + return false; + } + self.read_account_context().await.is_ok() + } + + pub async fn try_restore_session(&self) -> Option { + let transition = self.begin_account_transition().await; + self.host.stop_device_routing().await; + let restored = match session_store::load_session_detailed() { + Ok(Some(loaded)) => { + let relay_url = match normalize_relay_url(&loaded.relay_url) { + Ok(url) => url, + Err(error) => { + log::warn!("Ignoring invalid persisted relay URL: {error}"); + session_store::clear_session(); + transition.finish(); + return None; + } + }; + let user_id = loaded.user_id.clone(); + if let Some(device_id) = loaded.device_id.as_deref() { + if let Err(error) = DeviceIdentity::adopt_account_device_id(device_id) { + log::warn!("Failed to adopt restored session device_id: {error}"); + } + } + let session = AccountSession { + token: loaded.token, + user_id: user_id.clone(), + master_key: loaded.master_key, + }; + *self.account_context.write().await = + Some(AccountContextState { session, relay_url }); + log::info!("Restored account session for user {user_id}"); + Some(user_id) + } + Ok(None) => None, + Err(error) => { + log::warn!("Failed to load persisted session: {error}"); + None + } + }; + transition.finish(); + restored + } + + pub async fn login_with_credentials( + self: &Arc, + relay_url: &str, + username: &str, + password: &str, + ) -> Result { + let _login_guard = self.account_login_lock.lock().await; + let relay_url_input = relay_url.trim(); + let username = username.trim(); + if relay_url_input.is_empty() { + return Err(anyhow!("Auth Server is required")); + } + if username.is_empty() { + return Err(anyhow!("Username is required")); + } + if password.is_empty() { + return Err(anyhow!("Password is required")); + } + let relay_url = normalize_relay_url(relay_url_input)?; + let expected_generation = self.account_context_generation(); + if !self.account_context_is_current(expected_generation) { + return Err(anyhow!("account context changed")); + } + + let device = current_device_identity()?; + let client = AccountClient::new(); + let session = client + .login(&relay_url, username, password, &device) + .await + .map_err(|error| anyhow!("login failed: {error}"))?; + let has_cloud_settings = + match resolve_cloud_settings_probe(client.fetch_settings(&relay_url, &session).await) { + Ok(value) => value, + Err(error) => { + revoke_rejected_login_candidate(&client, &relay_url, &session).await; + return Err(error); + } + }; + + let previous_account_context = self.account_context.read().await.clone(); + let retired_background_owner = match self.host.retire_background_routing_owner().await { + Ok(retired) => retired, + Err(failure) => { + if failure.owner_may_exit { + self.schedule_routing_recovery_after_background_owner_exit( + expected_generation, + device.device_name.clone(), + ); + } + revoke_rejected_login_candidate(&client, &relay_url, &session).await; + return Err(failure.error); + } + }; + let Some(transition) = self + .begin_account_transition_if_current(expected_generation) + .await + else { + revoke_rejected_login_candidate(&client, &relay_url, &session).await; + return Err(anyhow!("account context changed")); + }; + self.host.stop_device_routing().await; + session_store::clear_session(); + + let user_id = session.user_id.clone(); + let token = session.token.clone(); + let master_key = session.master_key; + *self.account_context.write().await = Some(AccountContextState { + session: session.clone(), + relay_url: relay_url.clone(), + }); + session_store::save_credential_hint(username, &relay_url); + self.token_expired.store(false, Ordering::Relaxed); + + if has_cloud_settings { + self.pending_sync_choice.store(true, Ordering::Release); + transition.finish(); + revoke_replaced_account_context(&client, previous_account_context, &relay_url, &token) + .await; + return Ok(AccountLoginResult { + user_id, + relay_url, + has_cloud_settings, + routing_owner_replaced: retired_background_owner, + routing_connected: false, + routing_error: None, + }); + } + + self.pending_sync_choice.store(false, Ordering::Release); + if let Err(error) = session_store::save_session_with_device( + &token, + &user_id, + &master_key, + &relay_url, + Some(device.device_id.as_str()), + ) { + log::warn!("Failed to persist session: {error}"); + } + let generation = transition.finish(); + let routing = self + .host + .start_device_routing(AccountRoutingStartRequest { + session, + relay_url: relay_url.clone(), + device_name: device.device_name, + account_generation: generation, + }) + .await; + revoke_replaced_account_context(&client, previous_account_context, &relay_url, &token) + .await; + + Ok(AccountLoginResult { + user_id, + relay_url, + has_cloud_settings, + routing_owner_replaced: retired_background_owner, + routing_connected: routing.is_ok(), + routing_error: routing.err().map(|error| error.to_string()), + }) + } + + pub async fn finalize_login_after_sync_choice(self: &Arc) -> Result<()> { + let generation = self.account_context_generation(); + let sync_guard = self.lock_account_sync(generation).await?; + let device = current_device_identity()?; + let (session, relay_url) = self.read_account_context().await?; + let retired_background_owner = self + .host + .retire_background_routing_owner() + .await + .map_err(|failure| failure.error)?; + session_store::save_session_with_device( + &session.token, + &session.user_id, + &session.master_key, + &relay_url, + Some(device.device_id.as_str()), + ) + .map_err(|error| anyhow!("persist session: {error}"))?; + self.pending_sync_choice.store(false, Ordering::Release); + if retired_background_owner { + log::info!("Stopped the previous background account routing owner"); + } + drop(sync_guard); + self.host + .start_device_routing(AccountRoutingStartRequest { + session, + relay_url, + device_name: device.device_name, + account_generation: generation, + }) + .await + .map_err(|error| anyhow!("device routing failed: {error}")) + } + + pub async fn restore_device_routing(self: &Arc, device_name: &str) -> Result<()> { + let generation = self.account_context_generation(); + let (session, relay_url) = self.read_account_context_for_generation(generation).await?; + self.host + .start_device_routing(AccountRoutingStartRequest { + session, + relay_url, + device_name: device_name.to_string(), + account_generation: generation, + }) + .await + } + + pub async fn logout(&self) -> Result<()> { + let transition = self.begin_account_transition().await; + self.host.stop_device_routing().await; + if self.host.request_background_routing_owner_shutdown() { + log::info!("Signalled the background account routing owner to shut down"); + } + if let Ok((session, relay_url)) = self.read_account_context_raw().await { + let _ = AccountClient::new() + .revoke_token(&relay_url, &session) + .await; + } + *self.account_context.write().await = None; + self.pending_sync_choice.store(false, Ordering::Release); + session_store::clear_session(); + session_store::clear_credential_hint(); + self.token_expired.store(false, Ordering::Relaxed); + transition.finish(); + Ok(()) + } + + pub async fn expire_rejected_context( + &self, + account_generation: u64, + expected_token: &str, + ) -> bool { + let Some(transition) = self + .begin_account_transition_if_current(account_generation) + .await + else { + return false; + }; + self.host.stop_device_routing().await; + let mut context = self.account_context.write().await; + if context + .as_ref() + .is_none_or(|context| context.session.token != expected_token) + { + transition.finish(); + return false; + } + *context = None; + drop(context); + self.token_expired.store(true, Ordering::Relaxed); + self.pending_sync_choice.store(false, Ordering::Release); + session_store::clear_session(); + transition.finish(); + true + } + + pub async fn account_info(&self) -> Result { + let (session, relay_url) = self.read_account_context().await?; + let device = current_device_identity()?; + Ok(AccountInfo { + user_id: session.user_id, + relay_url, + device_id: device.device_id, + device_name: device.device_name, + }) + } + + pub async fn list_devices(&self) -> Result> { + let (session, relay_url) = self.read_account_context().await?; + let devices = AccountClient::new() + .list_devices(&relay_url, &session) + .await?; + Ok(devices + .into_iter() + .map(|device| AccountDevice { + device_id: device.device_id, + device_name: device.device_name, + online: device.online, + }) + .collect()) + } + + pub async fn snapshot(&self) -> AccountSnapshot { + let logged_in = self.is_logged_in().await; + let info = if logged_in { + self.account_info().await.ok() + } else { + None + }; + let devices = if logged_in { + self.list_devices().await.unwrap_or_default() + } else { + Vec::new() + }; + AccountSnapshot { + logged_in, + pending_sync_choice: self.pending_sync_choice(), + info, + devices, + sync: self.current_sync_progress().await, + } + } + + pub fn start_settings_sync_loop(self: &Arc) { + let weak_runtime = Arc::downgrade(self); + let context_runtime = weak_runtime.clone(); + let current_runtime = weak_runtime.clone(); + let settings_runtime = weak_runtime.clone(); + let pushed_runtime = weak_runtime.clone(); + let expired_runtime = weak_runtime; + settings_sync::start_settings_sync_engine(settings_sync::SettingsSyncHooks { + account_context: Some(Arc::new(move || { + let runtime = context_runtime.clone(); + Box::pin(async move { + let runtime = runtime + .upgrade() + .ok_or_else(|| anyhow!("account runtime stopped"))?; + if !runtime.automatic_account_sync_policy().background_engine { + return Err(anyhow!("account login is awaiting a sync choice")); + } + let generation = runtime.account_context_generation(); + let (account, relay_url) = runtime + .read_account_context_for_generation(generation) + .await?; + if !runtime.automatic_account_sync_policy().background_engine { + return Err(anyhow!("account login is awaiting a sync choice")); + } + Ok((account, relay_url, generation)) + }) + })), + is_account_context_current: Some(Arc::new(move |generation| { + current_runtime + .upgrade() + .is_some_and(|runtime| runtime.account_context_is_current(generation)) + })), + on_settings_applied: Some(Arc::new(move || { + if let Some(runtime) = settings_runtime.upgrade() { + runtime.host.notify_controllers_settings_changed(); + } + })), + on_settings_pushed: Some(Arc::new(move || { + if let Some(runtime) = pushed_runtime.upgrade() { + runtime.host.notify_controllers_settings_changed(); + } + })), + on_token_expired: Some(Arc::new(move || { + if let Some(runtime) = expired_runtime.upgrade() { + runtime.mark_token_expired(); + } + })), + ..Default::default() + }); + } + + pub fn notify_local_settings_changed(&self) { + settings_sync::notify_settings_changed(); + } + + pub async fn push_settings_after_local_change(&self) { + if !self.automatic_account_sync_policy().management_push { + return; + } + if self.read_account_context().await.is_err() { + self.try_restore_session().await; + } + let generation = self.account_context_generation(); + let Ok(_sync_guard) = self.lock_account_sync(generation).await else { + return; + }; + if !self.automatic_account_sync_policy().management_push { + return; + } + let Ok((account, relay_url)) = self.read_account_context().await else { + return; + }; + match settings_sync::push_settings_now(&account, &relay_url).await { + Ok(true) => log::info!("Settings pushed to account cloud"), + Ok(false) => {} + Err(error) => log::warn!("Settings push failed: {error}"), + } + } + + pub async fn current_sync_progress(&self) -> AccountSyncProgress { + self.sync_progress.read().await.clone() + } + + async fn set_progress(&self, mut update: impl FnMut(&mut AccountSyncProgress)) { + let mut progress = self.sync_progress.write().await; + update(&mut progress); + } + + async fn emit_progress( + &self, + phase: &str, + percent: u8, + current: Option, + total: Option, + detail: Option<&str>, + ) { + self.set_progress(|progress| { + progress.status = AccountSyncStatus::Syncing; + progress.phase = phase.to_string(); + progress.percent = percent; + progress.current = current; + progress.total = total; + progress.detail = detail.map(str::to_string); + progress.error = None; + }) + .await; + } + + pub async fn start_auto_sync_background( + self: &Arc, + operation_id: String, + is_first_login: bool, + workspace_path: PathBuf, + ) -> bool { + if self.auto_sync_in_flight.swap(true, Ordering::SeqCst) { + log::warn!("Account auto-sync already in flight; skipping duplicate start"); + return false; + } + self.set_progress(|progress| { + progress.operation_id = Some(operation_id.clone()); + }) + .await; + let runtime = Arc::clone(self); + tokio::spawn(async move { + let result = runtime.run_auto_sync(is_first_login, &workspace_path).await; + runtime.auto_sync_in_flight.store(false, Ordering::SeqCst); + match result { + Ok(result) => { + runtime + .set_progress(|progress| { + if progress.operation_id.as_deref() != Some(operation_id.as_str()) { + return; + } + if progress.status == AccountSyncStatus::Cancelled { + return; + } + progress.status = AccountSyncStatus::Done; + progress.phase = "done".to_string(); + progress.percent = 100; + progress.settings_synced = result.settings_synced; + progress.sessions_exported = result.sessions_exported; + progress.error = None; + }) + .await; + } + Err(error) => { + runtime + .set_progress(|progress| { + if progress.operation_id.as_deref() != Some(operation_id.as_str()) { + return; + } + if progress.status == AccountSyncStatus::Cancelled { + return; + } + progress.status = AccountSyncStatus::Failed; + progress.error = Some(error.to_string()); + }) + .await; + log::warn!("Account auto-sync failed: {error}"); + } + } + }); + true + } + + pub async fn mark_sync_cancelled(&self, operation_id: String) { + self.set_progress(|progress| { + progress.operation_id = Some(operation_id.clone()); + progress.status = AccountSyncStatus::Cancelled; + progress.phase = "cancelled".to_string(); + progress.error = None; + }) + .await; + } + + pub async fn cancel_sync(&self, operation_id: String) -> Result { + self.logout().await?; + self.mark_sync_cancelled(operation_id).await; + Ok(self.current_sync_progress().await) + } + + async fn run_auto_sync( + self: &Arc, + is_first_login: bool, + workspace_path: &Path, + ) -> Result { + let generation = self.account_context_generation(); + let _sync_guard = self.lock_account_sync(generation).await?; + self.set_progress(|progress| { + *progress = AccountSyncProgress { + operation_id: progress.operation_id.clone(), + status: AccountSyncStatus::Syncing, + phase: "starting".to_string(), + percent: 1, + ..AccountSyncProgress::default() + }; + }) + .await; + + let (account, relay_url) = self.read_account_context().await?; + let client = AccountClient::new(); + let settings_synced = if is_first_login { + self.emit_progress("uploading_settings", 5, None, None, None) + .await; + let config_service = crate::service::config::get_global_config_service() + .await + .map_err(|error| anyhow!("config service: {error}"))?; + let exported = config_service + .export_config() + .await + .map_err(|error| anyhow!("export config: {error}"))?; + let config_json = serde_json::to_string(&exported) + .map_err(|error| anyhow!("serialize config: {error}"))?; + self.await_account_sync_current( + generation, + settings_sync::upload_settings_payload(&account, &relay_url, &config_json), + ) + .await??; + self.emit_progress("settings_done", 15, None, None, None) + .await; + true + } else { + self.emit_progress("downloading_settings", 5, None, None, None) + .await; + let cloud = self + .await_account_sync_current( + generation, + client.fetch_settings_with_version(&relay_url, &account), + ) + .await??; + if let Some(blob) = cloud { + self.emit_progress("applying_settings", 10, None, None, None) + .await; + self.await_account_sync_current( + generation, + settings_sync::apply_settings_blob(&account, &blob, true), + ) + .await??; + self.emit_progress("settings_done", 15, None, None, None) + .await; + true + } else { + self.emit_progress("settings_done", 15, None, None, None) + .await; + false + } + }; + + self.emit_progress("listing_sessions", 18, None, None, None) + .await; + let local_sessions = self + .await_account_sync_current( + generation, + self.session_backup.list_session_backups(workspace_path), + ) + .await??; + self.emit_progress( + "exporting_sessions", + 20, + Some(0), + Some(local_sessions.len()), + None, + ) + .await; + + let mut local_sync_state = sync_state::load(&account.user_id); + let mut pending_uploads = Vec::new(); + for backup in local_sessions { + if !self.account_context_is_current(generation) { + return Err(anyhow!("account sync cancelled")); + } + let bundle = SessionBundle { + session_id: backup.session_id.clone(), + metadata: backup.metadata, + turns: backup.turns, + source_device_id: None, + source_device_name: None, + }; + let bundle_json = serde_json::to_string(&bundle) + .map_err(|error| anyhow!("serialize bundle: {error}"))?; + let hash = sync_state::content_hash(&bundle_json); + if local_sync_state.uploaded_hash(&backup.session_id) == Some(hash.as_str()) { + continue; + } + pending_uploads.push((backup.session_id, bundle_json, hash)); + } + + let upload_total = pending_uploads.len(); + self.emit_progress("exporting_sessions", 20, Some(0), Some(upload_total), None) + .await; + let mut uploaded = Vec::new(); + let mut upload_errors = Vec::new(); + for chunk in pending_uploads.chunks(UPLOAD_CONCURRENCY_CHUNK) { + let mut handles = Vec::new(); + for (session_id, bundle_json, hash) in chunk { + let runtime = Arc::clone(self); + let client = AccountClient::new(); + let relay_url = relay_url.clone(); + let account = account.clone(); + let session_id = session_id.clone(); + let bundle_json = bundle_json.clone(); + let hash = hash.clone(); + handles.push(tokio::spawn(async move { + let result = runtime + .await_account_sync_current( + generation, + client.upload_session(&relay_url, &account, &session_id, &bundle_json), + ) + .await; + (session_id, hash, result) + })); + } + for handle in handles { + match handle.await { + Ok((session_id, hash, Ok(Ok(version)))) => { + uploaded.push((session_id.clone(), hash, version)); + let done = uploaded.len(); + let percent = if upload_total == 0 { + 95 + } else { + 20 + ((75 * done) / upload_total) as u8 + }; + self.emit_progress( + "exporting_sessions", + percent.min(95), + Some(done), + Some(upload_total), + Some(&session_id), + ) + .await; + } + Ok((session_id, _, Ok(Err(error)))) => { + log::warn!("Auto-sync upload {session_id} failed: {error}"); + upload_errors.push(format!("{session_id}: {error}")); + } + Ok((_, _, Err(error))) => return Err(error), + Err(error) => { + log::warn!("Auto-sync upload task join failed: {error}"); + upload_errors.push(format!("upload task join failed: {error}")); + } + } + } + if !self.account_context_is_current(generation) { + return Err(anyhow!("account sync cancelled")); + } + } + + let exported = uploaded.len(); + let mut max_uploaded_version = local_sync_state.last_session_since; + for (session_id, hash, version) in uploaded { + local_sync_state.set_uploaded_hash(&session_id, hash); + max_uploaded_version = max_uploaded_version.max(version); + } + if max_uploaded_version > local_sync_state.last_session_since { + local_sync_state.last_session_since = max_uploaded_version; + } + let _ = sync_state::save(&account.user_id, &local_sync_state); + ensure_session_backup_complete(upload_total, exported, &upload_errors)?; + log::info!("Auto-sync: settings={settings_synced} exported={exported} imported=0"); + self.emit_progress("done", 100, Some(exported), Some(0), None) + .await; + Ok(AutoSyncResult { + settings_synced, + sessions_exported: exported, + }) + } + + fn schedule_routing_recovery_after_background_owner_exit( + self: &Arc, + expected_generation: u64, + device_name: String, + ) { + if !self.account_context_is_current(expected_generation) + || self + .routing_recovery_generation + .swap(expected_generation, Ordering::AcqRel) + == expected_generation + { + return; + } + let runtime = Arc::clone(self); + tokio::spawn(async move { + while runtime.routing_recovery_generation.load(Ordering::Acquire) == expected_generation + && runtime.account_context_is_current(expected_generation) + && runtime.host.background_routing_owner_is_running() + { + tokio::time::sleep(Duration::from_millis(100)).await; + } + if runtime.routing_recovery_generation.load(Ordering::Acquire) == expected_generation + && runtime.account_context_is_current(expected_generation) + && !runtime.host.background_routing_owner_is_running() + { + if let Err(error) = runtime.restore_device_routing(&device_name).await { + log::warn!( + "Failed to restore account routing after background owner exit: {error}" + ); + } + } + let _ = runtime.routing_recovery_generation.compare_exchange( + expected_generation, + 0, + Ordering::AcqRel, + Ordering::Acquire, + ); + }); + } +} + +struct AccountContextTransitionGuard<'a> { + runtime: &'a AccountRuntime, + sync_guard: Option>, + transition_guard: Option>, + active: bool, +} + +impl AccountContextTransitionGuard<'_> { + fn finish(mut self) -> u64 { + self.release() + } + + fn release(&mut self) -> u64 { + drop(self.sync_guard.take()); + if self.active { + self.runtime + .account_context_generation + .fetch_add(1, Ordering::AcqRel); + self.runtime + .account_context_transitions + .fetch_sub(1, Ordering::AcqRel); + self.active = false; + } + let generation = self.runtime.account_context_generation(); + drop(self.transition_guard.take()); + generation + } +} + +impl Drop for AccountContextTransitionGuard<'_> { + fn drop(&mut self) { + self.release(); + } +} + +fn normalize_relay_url(relay_url: &str) -> Result { + let parsed = validate_relay_base_url(relay_url.trim())?; + Ok(parsed.as_str().trim_end_matches('/').to_string()) +} + +fn current_device_identity() -> Result { + DeviceIdentity::from_current_machine().map_err(|error| anyhow!("detect device: {error}")) +} + +async fn revoke_rejected_login_candidate( + client: &AccountClient, + relay_url: &str, + session: &AccountSession, +) { + if let Err(error) = client.revoke_token(relay_url, session).await { + log::warn!("Failed to revoke rejected login candidate token: {error}"); + } +} + +fn replaced_account_revocation_target( + previous: Option, + replacement_relay_url: &str, + replacement_token: &str, +) -> Option { + previous.filter(|context| { + context.relay_url != replacement_relay_url || context.session.token != replacement_token + }) +} + +async fn revoke_replaced_account_context( + client: &AccountClient, + previous: Option, + replacement_relay_url: &str, + replacement_token: &str, +) { + let Some(previous) = + replaced_account_revocation_target(previous, replacement_relay_url, replacement_token) + else { + return; + }; + if let Err(error) = client + .revoke_token(&previous.relay_url, &previous.session) + .await + { + log::warn!("Failed to revoke replaced account token: {error}"); + } +} + +fn resolve_cloud_settings_probe(result: Result>) -> Result { + result.map(|settings| settings.is_some()).map_err(|error| { + anyhow!("could not check cloud settings: {error}; the current account remains active") + }) +} + +fn ensure_session_backup_complete( + total: usize, + uploaded: usize, + upload_errors: &[String], +) -> Result<()> { + if uploaded == total { + return Ok(()); + } + let detail = upload_errors + .first() + .map(String::as_str) + .unwrap_or("retry will resume remaining sessions"); + Err(anyhow!( + "session backup incomplete: uploaded {uploaded} of {total}; {detail}" + )) +} + +pub fn build_session_backup( + metadata: &bitfun_services_core::session::SessionMetadata, + turns: &[bitfun_services_core::session::DialogTurnData], +) -> Result { + ensure_relay_session_history_exportable(metadata).map_err(anyhow::Error::msg)?; + let metadata = relay_session_export_metadata(metadata, turns.len()); + Ok(AccountSessionBackup { + session_id: metadata.session_id.clone(), + metadata: serde_json::to_value(metadata) + .map_err(|error| anyhow!("serialize metadata: {error}"))?, + turns: turns + .iter() + .map(|turn| serde_json::to_value(turn).unwrap_or(serde_json::Value::Null)) + .collect(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestAccountRuntimeHost; + + #[async_trait] + impl AccountRuntimeHost for TestAccountRuntimeHost { + async fn retire_background_routing_owner( + &self, + ) -> std::result::Result { + Ok(false) + } + + fn background_routing_owner_is_running(&self) -> bool { + false + } + + fn request_background_routing_owner_shutdown(&self) -> bool { + false + } + + async fn start_device_routing(&self, _request: AccountRoutingStartRequest) -> Result<()> { + Ok(()) + } + + async fn stop_device_routing(&self) {} + + fn notify_controllers_settings_changed(&self) {} + } + + struct EmptySessionBackup; + + #[async_trait] + impl AccountSessionBackupPort for EmptySessionBackup { + async fn list_session_backups( + &self, + _workspace_path: &Path, + ) -> Result> { + Ok(Vec::new()) + } + } + + fn test_runtime() -> Arc { + AccountRuntime::new( + Arc::new(TestAccountRuntimeHost), + Arc::new(EmptySessionBackup), + ) + } + + #[test] + fn pending_sync_choice_blocks_automatic_sync() { + let pending = automatic_account_sync_policy_for_pending(true); + assert!(!pending.background_engine); + assert!(!pending.management_push); + + let finalized = automatic_account_sync_policy_for_pending(false); + assert!(finalized.background_engine); + assert!(finalized.management_push); + } + + #[test] + fn cloud_settings_probe_errors_are_not_treated_as_missing_settings() { + assert!(!resolve_cloud_settings_probe(Ok(None)).expect("missing settings")); + assert!(resolve_cloud_settings_probe(Ok(Some("settings".to_string()))).unwrap()); + assert!(resolve_cloud_settings_probe(Err(anyhow!("relay unavailable"))).is_err()); + } + + #[test] + fn partial_session_backup_is_not_reported_as_success() { + assert!(ensure_session_backup_complete(4, 4, &[]).is_ok()); + assert!(ensure_session_backup_complete(4, 1, &["quota full".to_string()]).is_err()); + } + + #[tokio::test] + async fn invalid_login_does_not_advance_the_account_generation() { + let runtime = test_runtime(); + let generation = runtime.account_context_generation(); + + let error = runtime + .login_with_credentials("", "user", "password") + .await + .expect_err("empty relay URL must be rejected"); + + assert!(error.to_string().contains("Auth Server is required")); + assert_eq!(runtime.account_context_generation(), generation); + } +} diff --git a/src/crates/assembly/core/src/service/remote_connect/mod.rs b/src/crates/assembly/core/src/service/remote_connect/mod.rs index 7262c0ca7b..4509929b9f 100644 --- a/src/crates/assembly/core/src/service/remote_connect/mod.rs +++ b/src/crates/assembly/core/src/service/remote_connect/mod.rs @@ -8,6 +8,7 @@ //! tears down the relay side; bots keep running. Use `stop_bot()` or //! `stop_all()` to shut everything down. +pub mod account_runtime; pub mod bot; pub mod embedded_relay_host; pub mod lan; diff --git a/src/crates/assembly/core/src/service/remote_ssh_compat.rs b/src/crates/assembly/core/src/service/remote_ssh_compat.rs new file mode 100644 index 0000000000..684bbeebf0 --- /dev/null +++ b/src/crates/assembly/core/src/service/remote_ssh_compat.rs @@ -0,0 +1,61 @@ +//! Dependency-light compatibility surface for local workspace identity. +//! +//! The concrete SSH facade is compiled only by `remote-workspace`. Local Agent +//! Runtime code still shares the stable workspace/session identity helpers +//! owned by `bitfun-services-core`. + +pub mod workspace_state { + use std::path::PathBuf; + + pub use bitfun_services_core::workspace_identity::{ + canonicalize_local_workspace_root, local_workspace_roots_equal, + local_workspace_stable_storage_id, normalize_local_workspace_root_for_stable_id, + normalize_remote_workspace_path, remote_root_to_mirror_subpath, + remote_workspace_session_mirror_dir as remote_workspace_session_mirror_dir_at, + remote_workspace_stable_id, sanitize_remote_mirror_path_component, + sanitize_ssh_connection_id_for_local_dir, sanitize_ssh_hostname_for_mirror, + unresolved_remote_session_storage_key, workspace_logical_key, workspace_session_identity, + WorkspaceSessionIdentity, LOCAL_WORKSPACE_SSH_HOST, + }; + + pub async fn resolve_workspace_session_identity( + workspace_path: &str, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + ) -> Option { + workspace_session_identity(workspace_path, remote_connection_id, remote_ssh_host) + } + + pub fn remote_workspace_runtime_root(ssh_host: &str, remote_root_norm: &str) -> PathBuf { + bitfun_services_core::workspace_identity::remote_workspace_runtime_root( + crate::infrastructure::get_path_manager_arc().remote_ssh_mirror_root_dir(), + ssh_host, + remote_root_norm, + ) + } + + pub fn remote_workspace_session_mirror_dir(ssh_host: &str, remote_root_norm: &str) -> PathBuf { + bitfun_services_core::workspace_identity::remote_workspace_session_mirror_dir( + crate::infrastructure::get_path_manager_arc().remote_ssh_mirror_root_dir(), + ssh_host, + remote_root_norm, + ) + } + + pub fn unresolved_remote_session_storage_dir( + connection_id: &str, + workspace_path_norm: &str, + ) -> PathBuf { + bitfun_services_core::workspace_identity::unresolved_remote_session_storage_dir( + crate::infrastructure::get_path_manager_arc().remote_ssh_mirror_root_dir(), + connection_id, + workspace_path_norm, + ) + } + + pub async fn is_remote_path(_path: &str) -> bool { + false + } +} + +pub use workspace_state::normalize_remote_workspace_path; diff --git a/src/crates/assembly/core/src/service/review_platform/mod.rs b/src/crates/assembly/core/src/service/review_platform/mod.rs index 7ad3327a52..77baf499f4 100644 --- a/src/crates/assembly/core/src/service/review_platform/mod.rs +++ b/src/crates/assembly/core/src/service/review_platform/mod.rs @@ -36,7 +36,15 @@ struct CoreReviewPlatformWorkspaceClassifier; #[async_trait::async_trait] impl ReviewPlatformWorkspaceClassifier for CoreReviewPlatformWorkspaceClassifier { async fn is_remote_workspace_path(&self, path: &str) -> bool { - crate::service::remote_ssh::workspace_state::is_remote_path(path).await + #[cfg(feature = "remote-workspace")] + { + return crate::service::remote_ssh::workspace_state::is_remote_path(path).await; + } + #[cfg(not(feature = "remote-workspace"))] + { + let _ = path; + false + } } async fn execute_remote_git_command( @@ -45,51 +53,62 @@ impl ReviewPlatformWorkspaceClassifier for CoreReviewPlatformWorkspaceClassifier current_dir: &str, args: &[&str], ) -> Result { - use crate::service::remote_ssh::workspace_state::{ - get_remote_workspace_manager, lookup_remote_connection, - }; - use bitfun_services_integrations::remote_ssh::{ - build_remote_git_command, normalize_remote_workspace_path, - }; - - let entry = lookup_remote_connection(workspace_path) - .await + #[cfg(feature = "remote-workspace")] + { + use crate::service::remote_ssh::workspace_state::{ + get_remote_workspace_manager, lookup_remote_connection, + }; + use bitfun_services_integrations::remote_ssh::{ + build_remote_git_command, normalize_remote_workspace_path, + }; + + let entry = lookup_remote_connection(workspace_path) + .await + .ok_or_else(|| { + ReviewPlatformError::InvalidRepository(format!( + "No SSH connection is registered for remote workspace {workspace_path}" + )) + })?; + let manager = match get_remote_workspace_manager() { + Some(state) => state.get_ssh_manager().await, + None => None, + } .ok_or_else(|| { - ReviewPlatformError::InvalidRepository(format!( - "No SSH connection is registered for remote workspace {workspace_path}" - )) + ReviewPlatformError::InvalidRepository( + "SSH connection manager is not initialized for remote workspaces".to_string(), + ) })?; - let manager = match get_remote_workspace_manager() { - Some(state) => state.get_ssh_manager().await, - None => None, - } - .ok_or_else(|| { - ReviewPlatformError::InvalidRepository( - "SSH connection manager is not initialized for remote workspaces".to_string(), - ) - })?; - let command = build_remote_git_command(&normalize_remote_workspace_path(current_dir), args); - let (stdout, stderr, exit_code) = manager - .execute_command(&entry.connection_id, &command) - .await - .map_err(|error| { - ReviewPlatformError::InvalidRepository(format!( - "Failed to execute git command on remote workspace: {error}" - )) - })?; - - if exit_code == 0 { - return Ok(stdout); + let command = + build_remote_git_command(&normalize_remote_workspace_path(current_dir), args); + let (stdout, stderr, exit_code) = manager + .execute_command(&entry.connection_id, &command) + .await + .map_err(|error| { + ReviewPlatformError::InvalidRepository(format!( + "Failed to execute git command on remote workspace: {error}" + )) + })?; + + if exit_code == 0 { + return Ok(stdout); + } + let message = if stderr.trim().is_empty() { + stdout + } else { + stderr + }; + return Err(ReviewPlatformError::InvalidRepository( + message.trim().to_string(), + )); + } + #[cfg(not(feature = "remote-workspace"))] + { + let _ = (workspace_path, current_dir, args); + Err(ReviewPlatformError::InvalidRepository( + "Remote workspace support is not available in this build".to_string(), + )) } - let message = if stderr.trim().is_empty() { - stdout - } else { - stderr - }; - Err(ReviewPlatformError::InvalidRepository( - message.trim().to_string(), - )) } } @@ -342,6 +361,7 @@ impl ReviewPlatformService { mod tests { use super::*; + #[cfg(feature = "remote-workspace")] #[tokio::test] async fn remote_git_execution_fails_loudly_without_registered_connection() { let classifier = CoreReviewPlatformWorkspaceClassifier; @@ -361,4 +381,20 @@ mod tests { "unexpected error message: {message}" ); } + + #[cfg(not(feature = "remote-workspace"))] + #[tokio::test] + async fn remote_git_execution_fails_loudly_without_remote_workspace_capability() { + let classifier = CoreReviewPlatformWorkspaceClassifier; + + assert!(!classifier.is_remote_workspace_path("/remote/project").await); + let error = classifier + .execute_remote_git_command("/remote/project", "/remote/project", &["status"]) + .await + .expect_err("a narrow review-platform build must reject remote execution"); + + assert!(error + .to_string() + .contains("Remote workspace support is not available in this build")); + } } diff --git a/src/crates/assembly/core/src/service/search/mod.rs b/src/crates/assembly/core/src/service/search/mod.rs index b3bf7b8be6..933534ca1b 100644 --- a/src/crates/assembly/core/src/service/search/mod.rs +++ b/src/crates/assembly/core/src/service/search/mod.rs @@ -1,11 +1,9 @@ #[cfg(feature = "ssh-remote")] mod remote; +#[cfg(not(feature = "ssh-remote"))] +mod remote_disabled; pub mod service; -#[cfg(not(feature = "ssh-remote"))] -pub use bitfun_services_integrations::remote_ssh::workspace_search::disabled::{ - remote_workspace_search_service_for_path, RemoteWorkspaceSearchService, -}; pub use bitfun_services_integrations::workspace_search::{ ContentSearchOutputMode, ContentSearchRequest, ContentSearchResult, GlobSearchRequest, GlobSearchResult, IndexTaskHandle, WorkspaceIndexStatus, WorkspaceSearchBackend, @@ -17,6 +15,8 @@ pub use bitfun_services_integrations::workspace_search::{ }; #[cfg(feature = "ssh-remote")] pub use remote::{remote_workspace_search_service_for_path, RemoteWorkspaceSearchService}; +#[cfg(not(feature = "ssh-remote"))] +pub use remote_disabled::{remote_workspace_search_service_for_path, RemoteWorkspaceSearchService}; pub use service::{ get_global_workspace_search_service, resolve_workspace_search_daemon_program_path, set_global_workspace_search_service, workspace_search_daemon_available, diff --git a/src/crates/assembly/core/src/service/search/remote_disabled.rs b/src/crates/assembly/core/src/service/search/remote_disabled.rs new file mode 100644 index 0000000000..5c6d0764d9 --- /dev/null +++ b/src/crates/assembly/core/src/service/search/remote_disabled.rs @@ -0,0 +1,32 @@ +//! Disabled remote-search facade for builds without concrete SSH support. + +use bitfun_services_integrations::workspace_search::{ + ContentSearchRequest, ContentSearchResult, GlobSearchRequest, GlobSearchResult, +}; + +fn unsupported() -> String { + "Remote SSH search is disabled; enable the `ssh-remote` feature".to_string() +} + +#[derive(Clone)] +pub struct RemoteWorkspaceSearchService; + +impl RemoteWorkspaceSearchService { + pub async fn search_content( + &self, + _request: ContentSearchRequest, + ) -> Result { + Err(unsupported()) + } + + pub async fn glob(&self, _request: GlobSearchRequest) -> Result { + Err(unsupported()) + } +} + +pub async fn remote_workspace_search_service_for_path( + _root_path: &str, + _preferred_connection_id: Option, +) -> Result { + Err(unsupported()) +} diff --git a/src/crates/assembly/core/src/service/workspace/service.rs b/src/crates/assembly/core/src/service/workspace/service.rs index a8c794d7ee..7617934ab3 100644 --- a/src/crates/assembly/core/src/service/workspace/service.rs +++ b/src/crates/assembly/core/src/service/workspace/service.rs @@ -2901,6 +2901,7 @@ mod tests { assert!(service.get_opened_workspaces().await.is_empty()); } + #[cfg(feature = "remote-workspace")] #[tokio::test] async fn open_workspace_resolving_known_reopens_remote_without_local_exists() { let env = TestEnvironment::new(); diff --git a/src/crates/assembly/core/src/service/worktree/session_binding.rs b/src/crates/assembly/core/src/service/worktree/session_binding.rs index fe9a129a4d..496065e596 100644 --- a/src/crates/assembly/core/src/service/worktree/session_binding.rs +++ b/src/crates/assembly/core/src/service/worktree/session_binding.rs @@ -12,7 +12,6 @@ use crate::agentic::coordination::get_global_coordinator; use crate::agentic::keyed_lock::KeyedAsyncLock; use crate::agentic::session::{SessionExecutionBindingError, SessionExecutionBindingUpdate}; -use crate::service::remote_ssh::lookup_remote_connection; use crate::service::workspace::get_global_workspace_service; use crate::service::worktree::{ WorktreeCreateRequest, WorktreeListRequest, WorktreeRemoveRequest, WorktreeService, @@ -176,10 +175,7 @@ async fn load_binding_context( (workspace_path, persisted_project_path, execution_target) }; - if lookup_remote_connection(&project_workspace_path) - .await - .is_some() - { + if crate::service::remote_ssh::workspace_state::is_remote_path(&project_workspace_path).await { return Err(error( WorktreeErrorCode::RemoteUnsupported, "Managed worktrees are not supported for remote SSH workspaces yet", diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 038805f346..16f5f68f51 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -8,23 +8,30 @@ use bitfun_agent_runtime::sdk::{ AgentEventSource, AgentInteractionResponsePort, AgentRuntime, AgentRuntimeBuilder, AgentSessionCompactionPort, AgentSessionForkPort, AgentSessionLineagePort, - AgentSessionModePort, AgentSessionModelPort, AgentSessionModelSelection, - AgentSessionModelSelectionUpdateRequest, AgentSessionModelUpdateRequest, - AgentSessionRestorePort, AgentSessionRevertPort, AgentSessionUsagePort, - AgentTurnSettlementPort, RuntimeError, + AgentSessionModePort, AgentSessionModelPort, AgentSessionRestorePort, AgentSessionRevertPort, + AgentSessionUsagePort, AgentTurnSettlementPort, RuntimeError, +}; +#[cfg(feature = "remote-connect")] +use bitfun_agent_runtime::sdk::{ + AgentSessionModelSelection, AgentSessionModelSelectionUpdateRequest, + AgentSessionModelUpdateRequest, }; use bitfun_events::AgenticEvent; use bitfun_runtime_ports::{ - AgentDialogTurnPort, AgentDialogTurnRequest, AgentInputAttachment, AgentLifecycleDeliveryPort, - AgentLocalCommandTurnPort, AgentSessionClosePort, AgentSessionCreateRequest, - AgentSessionManagementPort, AgentSessionRevertRequest, AgentSessionRevertResult, - AgentSubmissionPort, AgentSubmissionSource, AgentThreadGoalManagementPort, - AgentTurnCancellationPort, AgentTurnCancellationRequest, AgentUserShellCommandPort, - AgentWorkspaceReferencePort, PermissionPolicyPreset, RemoteControlStatePort, + AgentDialogTurnPort, AgentDialogTurnRequest, AgentLifecycleDeliveryPort, + AgentLocalCommandTurnPort, AgentSessionClosePort, AgentSessionManagementPort, + AgentSessionRevertRequest, AgentSessionRevertResult, AgentSubmissionPort, + AgentThreadGoalManagementPort, AgentTurnCancellationPort, AgentUserShellCommandPort, + AgentWorkspaceReferencePort, SessionStoragePathRequest, SessionStorePort, +}; +#[cfg(feature = "remote-connect")] +use bitfun_runtime_ports::{ + AgentInputAttachment, AgentSessionCreateRequest, AgentSubmissionSource, + AgentTurnCancellationRequest, PermissionPolicyPreset, RemoteControlStatePort, RemoteControlStateRequest, RemoteControlStateSnapshot, RemoteSessionWorkspaceIdentity, - RuntimeServiceCapability, RuntimeServicePort, SessionStoragePathRequest, SessionStorePort, - ToolPermissionConfig, + RuntimeServiceCapability, RuntimeServicePort, ToolPermissionConfig, }; +#[cfg(feature = "remote-connect")] use bitfun_services_integrations::remote_connect::{ agent_input_attachment_from_remote_image_context, build_remote_chat_messages, build_remote_model_catalog, @@ -45,32 +52,46 @@ use bitfun_services_integrations::remote_connect::{ RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind as RemoteConnectWorkspaceKind, RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, }; +#[cfg(feature = "remote-connect")] use log::{debug, info}; use std::sync::Arc; use std::time::Duration; use crate::agentic::coordination::{ - get_global_coordinator, get_global_scheduler, ConversationCoordinator, DialogQueuePriority, - DialogScheduler, DialogSubmissionPolicy, DialogSubmitOutcome, DialogTriggerSource, + get_global_coordinator, get_global_scheduler, ConversationCoordinator, DialogScheduler, + DialogSubmitOutcome, +}; +#[cfg(feature = "remote-connect")] +use crate::agentic::coordination::{ + DialogQueuePriority, DialogSubmissionPolicy, DialogTriggerSource, }; +#[cfg(feature = "remote-connect")] use crate::agentic::core::{Session, SessionKind}; +#[cfg(feature = "remote-connect")] use crate::agentic::image_analysis::ImageContextData; use crate::agentic::session::session_store_port::CoreSessionStorePort; use crate::agentic::workspace::WorkspaceBinding; +#[cfg(feature = "remote-connect")] use crate::infrastructure::ai::provider_catalog::resolve_builtin_provider_catalog; +#[cfg(feature = "remote-connect")] use crate::infrastructure::ai::reasoning_catalog::{ load_models_dev_reasoning_catalog, project_model_reasoning_catalog, resolve_reasoning_preset, }; +#[cfg(feature = "remote-connect")] use crate::service::remote_connect::remote_server::RemoteExecutionDispatcher; +#[cfg(feature = "remote-connect")] use crate::service::config::types::{AIConfig, GlobalConfig, ModelCapability}; +#[cfg(feature = "remote-connect")] use crate::service::session::{DialogTurnData, ToolItemIdentityExt, TurnStatus}; +#[cfg(feature = "remote-connect")] fn current_workspace_path() -> Option { crate::service::workspace::get_global_workspace_service() .and_then(|service| service.try_get_current_workspace_path()) } +#[cfg(feature = "remote-connect")] fn session_storage_request_from_binding(binding: &WorkspaceBinding) -> SessionStoragePathRequest { SessionStoragePathRequest { workspace_path: binding.logical_workspace_path().to_path_buf(), @@ -83,6 +104,7 @@ fn session_storage_request_from_binding(binding: &WorkspaceBinding) -> SessionSt } } +#[cfg(feature = "remote-connect")] fn remote_workspace_kind( kind: crate::service::workspace::WorkspaceKind, ) -> RemoteConnectWorkspaceKind { @@ -95,6 +117,7 @@ fn remote_workspace_kind( } } +#[cfg(feature = "remote-connect")] fn git_branch_for_workspace_path(path: &std::path::Path) -> Option { let path_str = path.to_string_lossy(); bitfun_services_integrations::git::execute_git_command_sync( @@ -106,6 +129,7 @@ fn git_branch_for_workspace_path(path: &std::path::Path) -> Option { .filter(|s| !s.is_empty() && s != "HEAD") } +#[cfg(feature = "remote-connect")] fn workspace_metadata_string( metadata: &std::collections::HashMap, key: &str, @@ -118,6 +142,7 @@ fn workspace_metadata_string( .map(ToOwned::to_owned) } +#[cfg(feature = "remote-connect")] async fn current_remote_workspace_facts() -> Option { let workspace_service = crate::service::workspace::get_global_workspace_service()?; workspace_service @@ -140,6 +165,7 @@ async fn current_remote_workspace_facts() -> Option { }) } +#[cfg(feature = "remote-connect")] async fn open_workspace_with_snapshot( path: &str, snapshot_log_context: &str, @@ -176,6 +202,7 @@ async fn open_workspace_with_snapshot( }) } +#[cfg(feature = "remote-connect")] async fn load_remote_session_metadata_for_workspace( workspace_path: &std::path::Path, workspace_identity: RemoteSessionWorkspaceIdentity, @@ -222,6 +249,7 @@ async fn load_remote_session_metadata_for_workspace( .collect()) } +#[cfg(feature = "remote-connect")] fn normalize_remote_model_selection( requested_model_id: &str, ai_config: Option<&AIConfig>, @@ -235,10 +263,12 @@ fn normalize_remote_model_selection( }) } +#[cfg(feature = "remote-connect")] fn session_uses_shared_mode_default(session: &Session) -> bool { session.kind == SessionKind::Standard } +#[cfg(feature = "remote-connect")] fn remote_model_capability_fact(capability: ModelCapability) -> RemoteModelCapabilityFact { match capability { ModelCapability::TextChat => RemoteModelCapabilityFact::TextChat, @@ -254,6 +284,7 @@ fn remote_model_capability_fact(capability: ModelCapability) -> RemoteModelCapab /// Convert persisted turns into mobile ChatMessages. /// This is the same data source the desktop frontend uses. +#[cfg(feature = "remote-connect")] fn remote_chat_messages_from_turns(turns: &[DialogTurnData]) -> Vec { let projected_turns = turns .iter() @@ -263,6 +294,7 @@ fn remote_chat_messages_from_turns(turns: &[DialogTurnData]) -> Vec build_remote_chat_messages(projected_turns) } +#[cfg(feature = "remote-connect")] fn remote_chat_history_turn_from_core_turn(turn: &DialogTurnData) -> RemoteChatHistoryTurn { let prompt_visible_content = crate::agentic::core::strip_prompt_markup(&turn.user_message.content); @@ -326,6 +358,7 @@ fn remote_chat_history_turn_from_core_turn(turn: &DialogTurnData) -> RemoteChatH } } +#[cfg(feature = "remote-connect")] async fn resolve_session_model_selection(session_id: &str) -> (Option, Option) { let Some(coordinator) = get_global_coordinator() else { return (None, None); @@ -357,6 +390,7 @@ async fn resolve_session_model_selection(session_id: &str) -> (Option, O .unwrap_or_default() } +#[cfg(feature = "remote-connect")] fn core_dialog_submission_policy(policy: RemoteDialogSubmissionPolicy) -> DialogSubmissionPolicy { let trigger_source = match policy.source { RemoteConnectSubmissionSource::Relay => DialogTriggerSource::RemoteRelay, @@ -371,6 +405,7 @@ fn core_dialog_submission_policy(policy: RemoteDialogSubmissionPolicy) -> Dialog DialogSubmissionPolicy::new(trigger_source, queue_priority) } +#[cfg(feature = "remote-connect")] fn remote_dialog_scheduler_outcome_fact( outcome: DialogSubmitOutcome, ) -> RemoteDialogSchedulerOutcomeFact { @@ -392,6 +427,7 @@ fn remote_dialog_scheduler_outcome_fact( } } +#[cfg(feature = "remote-connect")] fn remote_image_context_from_image_context(context: ImageContextData) -> RemoteImageContext { RemoteImageContext { id: context.id, @@ -402,6 +438,7 @@ fn remote_image_context_from_image_context(context: ImageContextData) -> RemoteI } } +#[cfg(feature = "remote-connect")] fn image_context_from_remote_image_context(context: RemoteImageContext) -> ImageContextData { ImageContextData { id: context.id, @@ -412,6 +449,7 @@ fn image_context_from_remote_image_context(context: RemoteImageContext) -> Image } } +#[cfg(feature = "remote-connect")] fn agent_input_attachment_from_image_context(context: ImageContextData) -> AgentInputAttachment { agent_input_attachment_from_remote_image_context(remote_image_context_from_image_context( context, @@ -845,6 +883,7 @@ impl CoreServiceAgentRuntime { }) } + #[cfg(feature = "remote-connect")] pub(crate) async fn resolve_session_storage_dir( session_id: &str, ) -> Option { @@ -853,6 +892,7 @@ impl CoreServiceAgentRuntime { .map(|(_, storage_dir)| storage_dir) } + #[cfg(feature = "remote-connect")] pub(crate) async fn resolve_session_logical_workspace_path( session_id: &str, ) -> Option { @@ -861,6 +901,7 @@ impl CoreServiceAgentRuntime { .map(|(workspace_path, _)| workspace_path) } + #[cfg(feature = "remote-connect")] pub(crate) async fn resolve_remote_file_workspace_root( session_id: Option<&str>, ) -> Option { @@ -875,46 +916,56 @@ impl CoreServiceAgentRuntime { current_workspace_path() } + #[cfg(feature = "remote-connect")] pub(crate) fn remote_dialog_host( dispatcher: &RemoteExecutionDispatcher, ) -> Result, String> { CoreRemoteDialogRuntimeHost::new(dispatcher) } + #[cfg(feature = "remote-connect")] pub(crate) fn remote_cancel_host() -> Result { CoreRemoteCancelRuntimeHost::new() } + #[cfg(feature = "remote-connect")] pub(crate) fn remote_workspace_file_host() -> CoreRemoteWorkspaceFileRuntimeHost { CoreRemoteWorkspaceFileRuntimeHost::new() } + #[cfg(feature = "remote-connect")] pub(crate) fn remote_workspace_host() -> CoreRemoteWorkspaceRuntimeHost { CoreRemoteWorkspaceRuntimeHost::new() } + #[cfg(feature = "remote-connect")] pub(crate) fn remote_initial_sync_host() -> CoreRemoteWorkspaceRuntimeHost { CoreRemoteWorkspaceRuntimeHost::new() } + #[cfg(feature = "remote-connect")] pub(crate) fn remote_session_host() -> Result { CoreRemoteSessionRuntimeHost::new() } + #[cfg(feature = "remote-connect")] pub(crate) fn remote_poll_host( dispatcher: &RemoteExecutionDispatcher, ) -> CoreRemotePollRuntimeHost<'_> { CoreRemotePollRuntimeHost::new(dispatcher) } + #[cfg(feature = "remote-connect")] pub(crate) fn remote_interaction_host() -> CoreRemoteInteractionRuntimeHost { CoreRemoteInteractionRuntimeHost::new() } + #[cfg(feature = "remote-connect")] pub(crate) fn remote_image_context(context: RemoteImageContext) -> ImageContextData { image_context_from_remote_image_context(context) } + #[cfg(feature = "remote-connect")] pub(crate) async fn load_remote_chat_messages( session_storage_dir: &std::path::Path, session_id: &str, @@ -929,6 +980,7 @@ impl CoreServiceAgentRuntime { Ok((remote_chat_messages_from_turns(&turns), false)) } + #[cfg(feature = "remote-connect")] pub(crate) async fn load_remote_model_catalog( session_id: Option<&str>, ) -> Result { @@ -1021,6 +1073,7 @@ impl CoreServiceAgentRuntime { })) } + #[cfg(feature = "remote-connect")] pub(crate) async fn update_remote_session_model( coordinator: &ConversationCoordinator, runtime: &AgentRuntime, @@ -1154,6 +1207,7 @@ impl CoreServiceAgentRuntime { } /// Persist the shared selector used by future mode sessions. + #[cfg(feature = "remote-connect")] async fn persist_mode_model(model_id: &str) { let Ok(config_service) = crate::service::config::get_global_config_service().await else { return; @@ -1163,6 +1217,7 @@ impl CoreServiceAgentRuntime { .await; } + #[cfg(feature = "remote-connect")] pub(crate) fn remote_control_state_port( coordinator: &ConversationCoordinator, ) -> &(dyn RemoteControlStatePort + '_) { @@ -1545,10 +1600,13 @@ impl CoreServiceAgentRuntime { } } +#[cfg(feature = "remote-connect")] pub(crate) struct CoreRemoteSessionTrackerHost; +#[cfg(feature = "remote-connect")] struct CoreRemoteSessionStateTrackerSubscriber(Arc); +#[cfg(feature = "remote-connect")] #[async_trait::async_trait] impl crate::agentic::events::EventSubscriber for CoreRemoteSessionStateTrackerSubscriber { async fn on_event( @@ -1560,6 +1618,7 @@ impl crate::agentic::events::EventSubscriber for CoreRemoteSessionStateTrackerSu } } +#[cfg(feature = "remote-connect")] impl RemoteSessionTrackerHost for CoreRemoteSessionTrackerHost { fn subscribe_tracker(&self, session_id: &str, tracker: Arc) { if let Some(coordinator) = get_global_coordinator() { @@ -1596,12 +1655,14 @@ impl RemoteSessionTrackerHost for CoreRemoteSessionTrackerHost { } } +#[cfg(feature = "remote-connect")] pub(crate) struct CoreRemoteDialogRuntimeHost<'a> { dispatcher: &'a RemoteExecutionDispatcher, coordinator: Arc, runtime: AgentRuntime, } +#[cfg(feature = "remote-connect")] impl<'a> CoreRemoteDialogRuntimeHost<'a> { pub(crate) fn new(dispatcher: &'a RemoteExecutionDispatcher) -> Result { let coordinator = get_global_coordinator() @@ -1621,11 +1682,13 @@ impl<'a> CoreRemoteDialogRuntimeHost<'a> { } } +#[cfg(feature = "remote-connect")] pub(crate) struct CoreRemoteCancelRuntimeHost { coordinator: Arc, runtime: AgentRuntime, } +#[cfg(feature = "remote-connect")] impl CoreRemoteCancelRuntimeHost { pub(crate) fn new() -> Result { let coordinator = get_global_coordinator() @@ -1638,39 +1701,47 @@ impl CoreRemoteCancelRuntimeHost { } } +#[cfg(feature = "remote-connect")] pub(crate) struct CoreRemoteWorkspaceFileRuntimeHost; +#[cfg(feature = "remote-connect")] impl CoreRemoteWorkspaceFileRuntimeHost { pub(crate) fn new() -> Self { Self } } +#[cfg(feature = "remote-connect")] pub(crate) struct CoreRemoteWorkspaceRuntimeHost; +#[cfg(feature = "remote-connect")] impl CoreRemoteWorkspaceRuntimeHost { pub(crate) fn new() -> Self { Self } } +#[cfg(feature = "remote-connect")] impl RuntimeServicePort for CoreRemoteWorkspaceFileRuntimeHost { fn capability(&self) -> RuntimeServiceCapability { RuntimeServiceCapability::RemoteProjection } } +#[cfg(feature = "remote-connect")] impl RuntimeServicePort for CoreRemoteWorkspaceRuntimeHost { fn capability(&self) -> RuntimeServiceCapability { RuntimeServiceCapability::RemoteWorkspace } } +#[cfg(feature = "remote-connect")] pub(crate) struct CoreRemoteSessionRuntimeHost { coordinator: Arc, runtime: AgentRuntime, } +#[cfg(feature = "remote-connect")] impl CoreRemoteSessionRuntimeHost { pub(crate) fn new() -> Result { let coordinator = get_global_coordinator() @@ -1683,20 +1754,24 @@ impl CoreRemoteSessionRuntimeHost { } } +#[cfg(feature = "remote-connect")] pub(crate) struct CoreRemotePollRuntimeHost<'a> { dispatcher: &'a RemoteExecutionDispatcher, } +#[cfg(feature = "remote-connect")] impl<'a> CoreRemotePollRuntimeHost<'a> { pub(crate) fn new(dispatcher: &'a RemoteExecutionDispatcher) -> Self { Self { dispatcher } } } +#[cfg(feature = "remote-connect")] pub(crate) struct CoreRemoteInteractionRuntimeHost { coordinator: Option>, } +#[cfg(feature = "remote-connect")] impl CoreRemoteInteractionRuntimeHost { pub(crate) fn new() -> Self { Self { @@ -1711,10 +1786,12 @@ impl CoreRemoteInteractionRuntimeHost { } } +#[cfg(feature = "remote-connect")] fn generate_remote_turn_id() -> String { format!("turn_{}", uuid::Uuid::new_v4()) } +#[cfg(feature = "remote-connect")] #[async_trait::async_trait] impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { type ImageContext = ImageContextData; @@ -1858,6 +1935,7 @@ impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { } } +#[cfg(feature = "remote-connect")] #[async_trait::async_trait] impl RemoteWorkspaceFileRuntimeHost for CoreRemoteWorkspaceFileRuntimeHost { async fn resolve_remote_file_workspace_root( @@ -1868,6 +1946,7 @@ impl RemoteWorkspaceFileRuntimeHost for CoreRemoteWorkspaceFileRuntimeHost { } } +#[cfg(feature = "remote-connect")] #[async_trait::async_trait] impl RemoteWorkspaceRuntimeHost for CoreRemoteWorkspaceRuntimeHost { async fn current_workspace(&self) -> Option { @@ -1934,6 +2013,7 @@ impl RemoteWorkspaceRuntimeHost for CoreRemoteWorkspaceRuntimeHost { } } +#[cfg(feature = "remote-connect")] #[async_trait::async_trait] impl RemoteInitialSyncRuntimeHost for CoreRemoteWorkspaceRuntimeHost { async fn current_workspace(&self) -> Option { @@ -1949,6 +2029,7 @@ impl RemoteInitialSyncRuntimeHost for CoreRemoteWorkspaceRuntimeHost { } } +#[cfg(feature = "remote-connect")] #[async_trait::async_trait] impl RemoteSessionRuntimeHost for CoreRemoteSessionRuntimeHost { async fn list_session_metadata( @@ -2085,6 +2166,7 @@ impl RemoteSessionRuntimeHost for CoreRemoteSessionRuntimeHost { } } +#[cfg(feature = "remote-connect")] #[async_trait::async_trait] impl RemotePollRuntimeHost for CoreRemotePollRuntimeHost<'_> { fn ensure_tracker(&self, session_id: &str) -> Arc { @@ -2135,6 +2217,7 @@ impl RemotePollRuntimeHost for CoreRemotePollRuntimeHost<'_> { } } +#[cfg(feature = "remote-connect")] #[async_trait::async_trait] impl RemoteInteractionRuntimeHost for CoreRemoteInteractionRuntimeHost { async fn confirm_tool(&self, tool_id: &str) -> Result<(), String> { @@ -2220,6 +2303,7 @@ impl RemoteInteractionRuntimeHost for CoreRemoteInteractionRuntimeHost { } } +#[cfg(feature = "remote-connect")] #[async_trait::async_trait] impl RemoteCancelRuntimeHost for CoreRemoteCancelRuntimeHost { async fn resolve_session_storage_dir(&self, session_id: &str) -> Option { @@ -2279,7 +2363,7 @@ impl RemoteCancelRuntimeHost for CoreRemoteCancelRuntimeHost { } } -#[cfg(test)] +#[cfg(all(test, feature = "remote-connect"))] mod tests { use std::collections::HashSet; diff --git a/src/crates/assembly/core/src/util/errors.rs b/src/crates/assembly/core/src/util/errors.rs index 35de43aedd..5158a47ec1 100644 --- a/src/crates/assembly/core/src/util/errors.rs +++ b/src/crates/assembly/core/src/util/errors.rs @@ -230,7 +230,7 @@ impl From for BitFun } } -#[cfg(feature = "agent-runtime")] +#[cfg(feature = "mcp-runtime")] impl From for BitFunError { fn from(error: bitfun_services_integrations::mcp::MCPRuntimeError) -> Self { use bitfun_services_integrations::mcp::MCPRuntimeErrorKind; diff --git a/src/crates/assembly/product-capabilities/AGENTS.md b/src/crates/assembly/product-capabilities/AGENTS.md index de71a3fe1f..428af44d65 100644 --- a/src/crates/assembly/product-capabilities/AGENTS.md +++ b/src/crates/assembly/product-capabilities/AGENTS.md @@ -17,6 +17,11 @@ concrete runtime execution. feature group facts, service capability facts, runtime service availability checks, tool provider group id selection, and harness provider descriptor selection. +- `ProductToolPlan` is the assembly-owned authority for the exact tool feature + owners requested by one runtime. Provider groups preserve registration order; + they are not feature unions. The Agent Runtime baseline plan selects only + `Basic` and `AgentControl`, while delivery profiles select their reviewed + product plan explicitly. - `ProductAssembler` may validate explicit profile input and return immutable runtime parts; it must not create concrete services or product state. - `ProductCoreDependencyMode::ExplicitCoreCapabilityClosure` records that an diff --git a/src/crates/assembly/product-capabilities/src/lib.rs b/src/crates/assembly/product-capabilities/src/lib.rs index b9786c0b1a..14b74a9062 100644 --- a/src/crates/assembly/product-capabilities/src/lib.rs +++ b/src/crates/assembly/product-capabilities/src/lib.rs @@ -101,6 +101,22 @@ impl From for ProductFeatureGroup { } } +impl From for ToolPackFeatureGroup { + fn from(value: ProductFeatureGroup) -> Self { + match value { + ProductFeatureGroup::Basic => Self::Basic, + ProductFeatureGroup::Git => Self::Git, + ProductFeatureGroup::Mcp => Self::Mcp, + ProductFeatureGroup::BrowserWeb => Self::BrowserWeb, + ProductFeatureGroup::ComputerUse => Self::ComputerUse, + ProductFeatureGroup::ImageAnalysis => Self::ImageAnalysis, + ProductFeatureGroup::MiniApp => Self::MiniApp, + ProductFeatureGroup::Canvas => Self::Canvas, + ProductFeatureGroup::AgentControl => Self::AgentControl, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ProductCapabilityPack { id: ProductCapabilityId, @@ -361,6 +377,32 @@ pub struct ProductCapabilityAssembly { harness_provider_descriptors: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProductToolPlan { + feature_groups: Vec, + tool_provider_group_plan: Vec, +} + +impl ProductToolPlan { + fn new( + feature_groups: Vec, + tool_provider_group_plan: Vec, + ) -> Self { + Self { + feature_groups, + tool_provider_group_plan, + } + } + + pub fn feature_groups(&self) -> &[ProductFeatureGroup] { + &self.feature_groups + } + + pub fn tool_provider_group_plan(&self) -> &[ToolProviderGroupPlan] { + &self.tool_provider_group_plan + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProductAssemblyPlan { profile: DeliveryProfile, @@ -441,6 +483,10 @@ impl ProductAssemblyPlan { pub fn build_harness_registry(&self) -> Result { self.capability_assembly.build_harness_registry() } + + pub fn tool_plan(&self) -> ProductToolPlan { + self.capability_assembly.tool_plan() + } } #[derive(Debug, Clone)] @@ -748,6 +794,13 @@ impl ProductCapabilityAssembly { &self.tool_provider_group_plan } + pub fn tool_plan(&self) -> ProductToolPlan { + ProductToolPlan::new( + self.feature_groups.clone(), + self.tool_provider_group_plan.clone(), + ) + } + pub fn harness_provider_descriptors(&self) -> &[HarnessProviderDescriptor] { &self.harness_provider_descriptors } @@ -1081,6 +1134,16 @@ pub fn default_product_capability_assembly() -> ProductCapabilityAssembly { default_product_capability_registry().build_assembly() } +pub fn agent_runtime_baseline_tool_plan() -> ProductToolPlan { + ProductToolPlan::new( + vec![ + ProductFeatureGroup::Basic, + ProductFeatureGroup::AgentControl, + ], + bitfun_tool_packs::product_tool_provider_group_plan().to_vec(), + ) +} + pub fn product_assembly_plan_for_profile(profile: DeliveryProfile) -> ProductAssemblyPlan { product_capability_registry_for_profile(profile).build_assembly_plan(profile) } diff --git a/src/crates/assembly/product-capabilities/tests/product_capabilities.rs b/src/crates/assembly/product-capabilities/tests/product_capabilities.rs index 039168a898..a31d3c36a7 100644 --- a/src/crates/assembly/product-capabilities/tests/product_capabilities.rs +++ b/src/crates/assembly/product-capabilities/tests/product_capabilities.rs @@ -1,12 +1,13 @@ use bitfun_harness::{HarnessCapability, HarnessInput, HarnessStepKind, HarnessWorkflow}; use bitfun_product_capabilities::{ - default_product_assembly_plan, default_product_capability_assembly, - default_product_capability_registry, default_product_harness_registry, - product_assembly_plan_for_profile, product_delivery_profile_entries, - product_harness_registry_for_profile, DeliveryProfile, ProductAssembler, ProductAssemblyError, - ProductAssemblyInput, ProductCapabilityBuildError, ProductCapabilityId, ProductCapabilityPack, - ProductCapabilityRegistry, ProductCoreDependencyMode, ProductFeatureGroup, - ProductRuntimeAssembly, ProductServiceCapabilityRequirement, ProductServiceCapabilityStatus, + agent_runtime_baseline_tool_plan, default_product_assembly_plan, + default_product_capability_assembly, default_product_capability_registry, + default_product_harness_registry, product_assembly_plan_for_profile, + product_delivery_profile_entries, product_harness_registry_for_profile, DeliveryProfile, + ProductAssembler, ProductAssemblyError, ProductAssemblyInput, ProductCapabilityBuildError, + ProductCapabilityId, ProductCapabilityPack, ProductCapabilityRegistry, + ProductCoreDependencyMode, ProductFeatureGroup, ProductRuntimeAssembly, + ProductServiceCapabilityRequirement, ProductServiceCapabilityStatus, }; use bitfun_runtime_ports::{ PluginDispatchEnvelope, PluginResponseEnvelope, PluginRuntimeAvailability, @@ -21,6 +22,32 @@ use std::sync::Arc; struct AvailablePluginRuntimeClient; +#[test] +fn agent_runtime_baseline_tool_plan_requests_only_baseline_feature_owners() { + let plan = agent_runtime_baseline_tool_plan(); + + assert_eq!( + plan.feature_groups() + .iter() + .map(|feature_group| feature_group.id()) + .collect::>(), + ["basic", "agent-control"] + ); + assert_eq!( + plan.tool_provider_group_plan() + .iter() + .map(|provider| provider.provider_id()) + .collect::>(), + [ + "core.basic", + "core.agent", + "core.canvas", + "core.session", + "core.integration", + ] + ); +} + #[async_trait::async_trait] impl PluginRuntimeClient for AvailablePluginRuntimeClient { fn availability(&self) -> PluginRuntimeAvailability { @@ -488,26 +515,26 @@ fn product_assembly_plan_exposes_build_feature_groups_explicitly() { plan.feature_groups(), &[ ProductFeatureGroup::Basic, + ProductFeatureGroup::ImageAnalysis, ProductFeatureGroup::AgentControl, + ProductFeatureGroup::Git, ProductFeatureGroup::BrowserWeb, ProductFeatureGroup::Mcp, - ProductFeatureGroup::Git, ProductFeatureGroup::MiniApp, ProductFeatureGroup::ComputerUse, - ProductFeatureGroup::ImageAnalysis, ] ); assert_eq!( plan.feature_group_ids(), vec![ "basic", + "image-analysis", "agent-control", + "git", "browser-web", "mcp", - "git", "miniapp", "computer-use", - "image-analysis", ] ); } diff --git a/src/crates/contracts/product-domains/src/external_source_control.rs b/src/crates/contracts/product-domains/src/external_source_control.rs index 721a0866a5..3f19051079 100644 --- a/src/crates/contracts/product-domains/src/external_source_control.rs +++ b/src/crates/contracts/product-domains/src/external_source_control.rs @@ -13,8 +13,6 @@ use crate::external_subagents::ExternalSubagentActivationState; use serde::{Deserialize, Serialize}; pub const EXTERNAL_SOURCE_CONTROL_SCHEMA_V1: u32 = 1; -pub const EXTERNAL_APPLICATION_SCHEMA_V2: u32 = 2; -pub const EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS: usize = 128; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -211,637 +209,6 @@ impl ExternalSourceControlRequestV1 { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationTargetScopeV2 { - UserDefault, - WorkspaceOverride, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationDesiredConnectionV2 { - Unspecified, - Connected, - Disconnected, - Deferred, - NeedsReview, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationUserDecisionV2 { - None, - Connected, - Disconnected, - Deferred, - NeedsReview, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationDiscoveryStateV2 { - NotDiscovered, - Discovered, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationConnectionStateV2 { - Disconnected, - Connected, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationHealthV2 { - Healthy, - Degraded, - Unavailable, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationEffectiveStatusV2 { - Connected, - ConfigurationAvailable, - NoConfiguration, - NeedsAttention, - TemporarilyUnavailable, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationPrimaryActionV2 { - None, - View, - Connect, - Review, - Retry, - ViewReason, -} - -/// Derives the shared application summary and its single emphasized action. -/// Safe Mode is projected separately and intentionally is not an input. -pub const fn derive_external_application_status_v2( - needs_attention: bool, - temporarily_unavailable: bool, - can_retry: bool, - connection: ExternalApplicationConnectionStateV2, - discovery: ExternalApplicationDiscoveryStateV2, -) -> ( - ExternalApplicationEffectiveStatusV2, - ExternalApplicationPrimaryActionV2, -) { - if needs_attention { - ( - ExternalApplicationEffectiveStatusV2::NeedsAttention, - ExternalApplicationPrimaryActionV2::Review, - ) - } else if temporarily_unavailable { - ( - ExternalApplicationEffectiveStatusV2::TemporarilyUnavailable, - if can_retry { - ExternalApplicationPrimaryActionV2::Retry - } else { - ExternalApplicationPrimaryActionV2::ViewReason - }, - ) - } else if matches!(connection, ExternalApplicationConnectionStateV2::Connected) { - ( - ExternalApplicationEffectiveStatusV2::Connected, - ExternalApplicationPrimaryActionV2::View, - ) - } else if matches!(discovery, ExternalApplicationDiscoveryStateV2::Discovered) { - ( - ExternalApplicationEffectiveStatusV2::ConfigurationAvailable, - ExternalApplicationPrimaryActionV2::Connect, - ) - } else { - ( - ExternalApplicationEffectiveStatusV2::NoConfiguration, - ExternalApplicationPrimaryActionV2::None, - ) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationDefaultConnectionPolicyV2 { - Connect, - DiscoverOnly, - Unsupported, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationRiskLevelV2 { - Low, - Moderate, - High, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationSafetyCeilingV2 { - Blocked, - ReviewRequired, - Automatic, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] -pub enum ExternalApplicationRecoveryActionV2 { - Refresh, - Retry, - ReconnectHost, - Review, - UpgradeHost, - ViewReason, - ExitSafeMode, - ResolveConflict, - InstallRuntime, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationHostCapabilitiesV2 { - pub can_read_snapshot: bool, - pub can_read_review: bool, - pub can_mutate: bool, - pub can_manage_user_default: bool, - pub can_manage_workspace_override: bool, - pub can_refresh: bool, - pub can_set_safe_mode: bool, -} - -impl ExternalApplicationHostCapabilitiesV2 { - pub const fn read_write() -> Self { - Self { - can_read_snapshot: true, - can_read_review: true, - can_mutate: true, - can_manage_user_default: true, - can_manage_workspace_override: true, - can_refresh: true, - can_set_safe_mode: true, - } - } - - pub const fn read_only() -> Self { - Self { - can_read_snapshot: true, - can_read_review: true, - can_mutate: false, - can_manage_user_default: false, - can_manage_workspace_override: false, - can_refresh: true, - can_set_safe_mode: false, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationRiskSummaryV2 { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub highest_level: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub reason_codes: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationReviewItemKindV2 { - Command, - Tool, - Subagent, - Mcp, - Conflict, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewItemRefV2 { - pub kind: ExternalApplicationReviewItemKindV2, - pub stable_id: String, -} - -impl ExternalApplicationReviewItemRefV2 { - pub fn validate(&self) -> Result<(), &'static str> { - validate_external_application_reference(&self.stable_id) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationOwnerGenerationV2 { - pub owner: ExternalApplicationReviewItemKindV2, - pub generation: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewCategoryCountV2 { - pub kind: ExternalApplicationReviewItemKindV2, - pub count: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewRecommendationSummaryV2 { - pub recommended_count: usize, - pub optional_count: usize, - pub blocked_count: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewSummaryV2 { - pub review_id: String, - pub total_count: usize, - pub category_counts: Vec, - pub max_selection_count: usize, - pub risk_summary: ExternalApplicationRiskSummaryV2, - pub recommendation_summary: ExternalApplicationReviewRecommendationSummaryV2, - pub safety_ceiling: ExternalApplicationSafetyCeilingV2, -} - -impl ExternalApplicationReviewSummaryV2 { - pub fn validate(&self) -> Result<(), &'static str> { - validate_external_application_id(&self.review_id)?; - if self.max_selection_count > self.total_count { - return Err("external application max selection count exceeds total count"); - } - Ok(()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationSummaryV2 { - pub application_id: String, - pub ecosystem_id: String, - pub display_name: String, - pub discovery: ExternalApplicationDiscoveryStateV2, - pub connection: ExternalApplicationConnectionStateV2, - pub desired_connection: ExternalApplicationDesiredConnectionV2, - pub health: ExternalApplicationHealthV2, - pub effective_status: ExternalApplicationEffectiveStatusV2, - pub primary_action: ExternalApplicationPrimaryActionV2, - pub default_connection_policy: ExternalApplicationDefaultConnectionPolicyV2, - pub default_connection_reason: String, - pub enabled_count: usize, - pub pending_review_count: usize, - pub blocked_count: usize, - pub conflict_count: usize, - pub risk_summary: ExternalApplicationRiskSummaryV2, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub notice_key: Option, - pub user_decision: ExternalApplicationUserDecisionV2, - pub recovery_actions: Vec, -} - -impl ExternalApplicationSummaryV2 { - pub fn validate(&self) -> Result<(), &'static str> { - validate_external_application_id(&self.application_id)?; - validate_external_application_id(&self.ecosystem_id)?; - validate_external_application_text(&self.display_name)?; - validate_external_application_id(&self.default_connection_reason)?; - if let Some(notice_key) = &self.notice_key { - validate_external_application_reference(notice_key)?; - } - for reason_code in &self.risk_summary.reason_codes { - validate_external_application_id(reason_code)?; - } - Ok(()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationSnapshotV2 { - pub schema_version: u32, - pub execution_domain_id: ExecutionDomainId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_scope_id: Option, - pub effective_connection_scope: ExternalApplicationTargetScopeV2, - pub refresh_generation: u64, - pub preference_revision: u64, - pub safe_mode: bool, - pub host_capabilities: ExternalApplicationHostCapabilitiesV2, - pub applications: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub review_summary: Option, -} - -impl ExternalApplicationSnapshotV2 { - pub fn validate(&self) -> Result<(), &'static str> { - validate_external_application_schema(self.schema_version)?; - if let Some(workspace_scope_id) = &self.workspace_scope_id { - validate_external_application_id(workspace_scope_id)?; - } - for application in &self.applications { - application.validate()?; - } - if let Some(review_summary) = &self.review_summary { - review_summary.validate()?; - } - Ok(()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewItemV2 { - pub item_ref: ExternalApplicationReviewItemRefV2, - pub display_name: String, - pub display_summary: String, - pub risk_level: ExternalApplicationRiskLevelV2, - pub risk_reason_codes: Vec, - pub recommended: bool, - pub safety_ceiling: ExternalApplicationSafetyCeilingV2, -} - -impl ExternalApplicationReviewItemV2 { - pub fn validate(&self) -> Result<(), &'static str> { - self.item_ref.validate()?; - validate_external_application_text(&self.display_name)?; - validate_external_application_text(&self.display_summary)?; - for reason_code in &self.risk_reason_codes { - validate_external_application_id(reason_code)?; - } - Ok(()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewPageRequestV2 { - pub schema_version: u32, - pub execution_domain_id: ExecutionDomainId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_scope_id: Option, - pub target_scope: ExternalApplicationTargetScopeV2, - pub review_id: String, - pub preference_revision: u64, - pub expected_generations: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cursor: Option, - pub page_size: usize, -} - -impl ExternalApplicationReviewPageRequestV2 { - pub fn validate(&self) -> Result<(), &'static str> { - validate_external_application_schema(self.schema_version)?; - validate_external_application_target_scope(self.target_scope, &self.workspace_scope_id)?; - validate_external_application_id(&self.review_id)?; - if let Some(cursor) = &self.cursor { - validate_external_application_reference(cursor)?; - } - if self.page_size == 0 || self.page_size > EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS { - return Err("external application review page size must be between 1 and 128"); - } - Ok(()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewPageV2 { - pub schema_version: u32, - pub execution_domain_id: ExecutionDomainId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_scope_id: Option, - pub target_scope: ExternalApplicationTargetScopeV2, - pub review_id: String, - pub preference_revision: u64, - pub expected_generations: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cursor: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub next_cursor: Option, - pub total_count: usize, - pub items: Vec, -} - -impl ExternalApplicationReviewPageV2 { - pub fn validate(&self) -> Result<(), &'static str> { - validate_external_application_schema(self.schema_version)?; - validate_external_application_target_scope(self.target_scope, &self.workspace_scope_id)?; - validate_external_application_id(&self.review_id)?; - if self.items.len() > EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS { - return Err("external application review page exceeds 128 items"); - } - if self.items.len() > self.total_count { - return Err("external application review page exceeds total count"); - } - if let Some(cursor) = &self.cursor { - validate_external_application_reference(cursor)?; - } - if let Some(cursor) = &self.next_cursor { - validate_external_application_reference(cursor)?; - } - for item in &self.items { - item.validate()?; - } - Ok(()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationReviewSelectionBaselineV2 { - Recommended, - None, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewSelectionOverrideV2 { - pub item_ref: ExternalApplicationReviewItemRefV2, - pub selected: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde( - tag = "type", - rename_all = "snake_case", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -pub enum ExternalApplicationControlActionV2 { - ConnectApplication { - application_id: String, - }, - DisconnectApplication { - application_id: String, - }, - SetApplicationDeferred { - application_id: String, - }, - SubmitApplicationReview { - review_id: String, - expected_generations: Vec, - selection_baseline: ExternalApplicationReviewSelectionBaselineV2, - selection_overrides: Vec, - }, - Refresh, - SetSourceEnabled { - source_key: String, - enabled: bool, - }, - SetSafeMode { - enabled: bool, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationControlRequestV2 { - pub schema_version: u32, - pub execution_domain_id: ExecutionDomainId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_scope_id: Option, - pub target_scope: ExternalApplicationTargetScopeV2, - pub operation_id: String, - pub expected_preference_revision: u64, - pub action: ExternalApplicationControlActionV2, -} - -impl ExternalApplicationControlRequestV2 { - pub fn validate(&self) -> Result<(), &'static str> { - validate_external_application_schema(self.schema_version)?; - validate_external_application_target_scope(self.target_scope, &self.workspace_scope_id)?; - validate_external_application_id(&self.operation_id)?; - match &self.action { - ExternalApplicationControlActionV2::ConnectApplication { application_id } - | ExternalApplicationControlActionV2::DisconnectApplication { application_id } - | ExternalApplicationControlActionV2::SetApplicationDeferred { application_id } => { - validate_external_application_id(application_id) - } - ExternalApplicationControlActionV2::SubmitApplicationReview { - review_id, - selection_overrides, - .. - } => { - validate_external_application_id(review_id)?; - for selection in selection_overrides { - selection.item_ref.validate()?; - } - Ok(()) - } - ExternalApplicationControlActionV2::SetSourceEnabled { source_key, .. } => { - validate_external_application_reference(source_key) - } - ExternalApplicationControlActionV2::Refresh - | ExternalApplicationControlActionV2::SetSafeMode { .. } => Ok(()), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExternalApplicationOperationOutcomeV2 { - Applied, - Rejected, - Blocked, - Stale, - Failed, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewItemResultV2 { - pub item_ref: ExternalApplicationReviewItemRefV2, - pub outcome: ExternalApplicationOperationOutcomeV2, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason_code: Option, - pub recovery_actions: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationControlResultV2 { - pub schema_version: u32, - pub operation_id: String, - pub preference_revision: u64, - pub outcome: ExternalApplicationOperationOutcomeV2, - pub item_results: Vec, -} - -impl ExternalApplicationControlResultV2 { - pub fn validate(&self) -> Result<(), &'static str> { - validate_external_application_schema(self.schema_version)?; - validate_external_application_id(&self.operation_id)?; - for item in &self.item_results { - item.item_ref.validate()?; - if let Some(reason_code) = &item.reason_code { - validate_external_application_id(reason_code)?; - } - } - Ok(()) - } -} - -fn validate_external_application_schema(schema_version: u32) -> Result<(), &'static str> { - if schema_version == EXTERNAL_APPLICATION_SCHEMA_V2 { - Ok(()) - } else { - Err("unsupported external application schema") - } -} - -fn validate_external_application_target_scope( - target_scope: ExternalApplicationTargetScopeV2, - workspace_scope_id: &Option, -) -> Result<(), &'static str> { - match (target_scope, workspace_scope_id) { - (ExternalApplicationTargetScopeV2::UserDefault, None) => Ok(()), - (ExternalApplicationTargetScopeV2::UserDefault, Some(_)) => { - Err("user-default scope must not include a workspace scope id") - } - (ExternalApplicationTargetScopeV2::WorkspaceOverride, Some(workspace_scope_id)) => { - validate_external_application_id(workspace_scope_id) - } - (ExternalApplicationTargetScopeV2::WorkspaceOverride, None) => { - Err("workspace-override scope requires a workspace scope id") - } - } -} - -fn validate_external_application_id(value: &str) -> Result<(), &'static str> { - if value.is_empty() - || value.len() > 160 - || value.trim() != value - || value.chars().any(char::is_control) - { - Err("invalid external application identifier") - } else { - Ok(()) - } -} - -fn validate_external_application_reference(value: &str) -> Result<(), &'static str> { - if value.is_empty() - || value.len() > 4096 - || value.trim() != value - || value.chars().any(char::is_control) - { - Err("invalid external application reference") - } else { - Ok(()) - } -} - -fn validate_external_application_text(value: &str) -> Result<(), &'static str> { - if value.is_empty() || value.len() > 4096 || value.chars().any(char::is_control) { - Err("invalid external application text") - } else { - Ok(()) - } -} - impl ExternalSourceControlSnapshotV1 { pub fn from_catalog( catalog: &ExternalSourceCatalogSnapshot, diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts.rs index 80173dcd15..e9f9cc074b 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -6,27 +6,9 @@ use bitfun_product_domains::external_integration_policy::{ ExternalIntegrationPolicyStatus, }; use bitfun_product_domains::external_source_control::{ - derive_external_application_status_v2, ExternalApplicationConnectionStateV2, - ExternalApplicationControlActionV2, ExternalApplicationControlRequestV2, - ExternalApplicationControlResultV2, ExternalApplicationDefaultConnectionPolicyV2, - ExternalApplicationDesiredConnectionV2, ExternalApplicationDiscoveryStateV2, - ExternalApplicationEffectiveStatusV2, ExternalApplicationHealthV2, - ExternalApplicationHostCapabilitiesV2, ExternalApplicationOperationOutcomeV2, - ExternalApplicationOwnerGenerationV2, ExternalApplicationPrimaryActionV2, - ExternalApplicationRecoveryActionV2, ExternalApplicationReviewCategoryCountV2, - ExternalApplicationReviewItemKindV2, ExternalApplicationReviewItemRefV2, - ExternalApplicationReviewItemResultV2, ExternalApplicationReviewItemV2, - ExternalApplicationReviewPageRequestV2, ExternalApplicationReviewPageV2, - ExternalApplicationReviewRecommendationSummaryV2, ExternalApplicationReviewSelectionBaselineV2, - ExternalApplicationReviewSelectionOverrideV2, ExternalApplicationReviewSummaryV2, - ExternalApplicationRiskLevelV2, ExternalApplicationRiskSummaryV2, - ExternalApplicationSafetyCeilingV2, ExternalApplicationSnapshotV2, - ExternalApplicationSummaryV2, ExternalApplicationTargetScopeV2, - ExternalApplicationUserDecisionV2, ExternalSourceControlActionV1, - ExternalSourceControlRequestV1, ExternalSourceControlSnapshotV1, ExternalSourceDesiredState, - ExternalSourceDiscoveryState, ExternalSourceOperationStage, ExternalSourceRecoveryActionV1, - ExternalSourceReviewState, EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS, - EXTERNAL_APPLICATION_SCHEMA_V2, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, + ExternalSourceControlActionV1, ExternalSourceControlRequestV1, ExternalSourceControlSnapshotV1, + ExternalSourceDesiredState, ExternalSourceDiscoveryState, ExternalSourceOperationStage, + ExternalSourceRecoveryActionV1, ExternalSourceReviewState, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, }; use bitfun_product_domains::external_sources::{ external_mcp_approval_key, external_mcp_conflict_key, external_tool_approval_key, @@ -2090,381 +2072,3 @@ fn decoded_operation_errors_bound_untrusted_extension_fields() { ] ); } - -fn application_risk_summary() -> ExternalApplicationRiskSummaryV2 { - ExternalApplicationRiskSummaryV2 { - highest_level: Some(ExternalApplicationRiskLevelV2::High), - reason_codes: vec!["process_execution".to_string()], - } -} - -fn application_review_summary() -> ExternalApplicationReviewSummaryV2 { - ExternalApplicationReviewSummaryV2 { - review_id: "review-opencode-7".to_string(), - total_count: 3, - category_counts: vec![ExternalApplicationReviewCategoryCountV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - count: 3, - }], - max_selection_count: 3, - risk_summary: application_risk_summary(), - recommendation_summary: ExternalApplicationReviewRecommendationSummaryV2 { - recommended_count: 2, - optional_count: 1, - blocked_count: 0, - }, - safety_ceiling: ExternalApplicationSafetyCeilingV2::ReviewRequired, - } -} - -fn application_snapshot_v2() -> ExternalApplicationSnapshotV2 { - ExternalApplicationSnapshotV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: ExecutionDomainId::new("host-a").unwrap(), - workspace_scope_id: Some("workspace:0123456789abcdef".to_string()), - effective_connection_scope: ExternalApplicationTargetScopeV2::WorkspaceOverride, - refresh_generation: 7, - preference_revision: 11, - safe_mode: false, - host_capabilities: ExternalApplicationHostCapabilitiesV2::read_write(), - applications: vec![ExternalApplicationSummaryV2 { - application_id: "opencode".to_string(), - ecosystem_id: "opencode".to_string(), - display_name: "OpenCode".to_string(), - discovery: ExternalApplicationDiscoveryStateV2::Discovered, - connection: ExternalApplicationConnectionStateV2::Connected, - desired_connection: ExternalApplicationDesiredConnectionV2::Connected, - health: ExternalApplicationHealthV2::Healthy, - effective_status: ExternalApplicationEffectiveStatusV2::NeedsAttention, - primary_action: ExternalApplicationPrimaryActionV2::Review, - default_connection_policy: ExternalApplicationDefaultConnectionPolicyV2::Connect, - default_connection_reason: "supported_by_product".to_string(), - enabled_count: 2, - pending_review_count: 3, - blocked_count: 0, - conflict_count: 0, - risk_summary: application_risk_summary(), - notice_key: Some("opencode:review:7".to_string()), - user_decision: ExternalApplicationUserDecisionV2::Connected, - recovery_actions: vec![ExternalApplicationRecoveryActionV2::Review], - }], - review_summary: Some(application_review_summary()), - } -} - -#[test] -fn external_application_snapshot_v2_keeps_review_items_out_of_the_home_snapshot() { - let snapshot = application_snapshot_v2(); - snapshot.validate().unwrap(); - - let encoded = serde_json::to_value(&snapshot).unwrap(); - assert_eq!(encoded["schemaVersion"], EXTERNAL_APPLICATION_SCHEMA_V2); - assert_eq!(encoded["workspaceScopeId"], "workspace:0123456789abcdef"); - assert_eq!(encoded["effectiveConnectionScope"], "workspace_override"); - assert_eq!( - encoded["applications"][0]["effectiveStatus"], - "needs_attention" - ); - assert_eq!(encoded["applications"][0]["primaryAction"], "review"); - assert_eq!(encoded["reviewSummary"]["totalCount"], 3); - assert!(encoded["reviewSummary"].get("items").is_none()); - assert!(encoded["applications"][0].get("reviewSummary").is_none()); - assert!(serde_json::from_value::(serde_json::json!({ - "schemaVersion": 2, - "executionDomainId": "host-a", - "workspaceScopeId": "workspace:0123456789abcdef", - "effectiveConnectionScope": "workspace_override", - "refreshGeneration": 7, - "preferenceRevision": 11, - "safeMode": false, - "hostCapabilities": serde_json::to_value(ExternalApplicationHostCapabilitiesV2::read_write()).unwrap(), - "applications": [], - "reviewSummary": null, - "unexpected": true - })) - .is_err()); -} - -#[test] -fn external_application_v2_unknown_enums_fail_closed() { - let mut encoded = serde_json::to_value(application_snapshot_v2()).unwrap(); - encoded["applications"][0]["effectiveStatus"] = serde_json::json!("future_status"); - - assert!(serde_json::from_value::(encoded).is_err()); -} - -#[test] -fn external_application_status_v2_uses_one_shared_priority_and_primary_action() { - use ExternalApplicationConnectionStateV2::{Connected, Disconnected}; - use ExternalApplicationDiscoveryStateV2::{Discovered, NotDiscovered}; - use ExternalApplicationEffectiveStatusV2::{ - ConfigurationAvailable, Connected as ConnectedStatus, NeedsAttention, NoConfiguration, - TemporarilyUnavailable, - }; - use ExternalApplicationPrimaryActionV2::{Connect, None, Retry, Review, View, ViewReason}; - - let cases = [ - ( - true, - true, - true, - Connected, - Discovered, - (NeedsAttention, Review), - ), - ( - false, - true, - true, - Connected, - Discovered, - (TemporarilyUnavailable, Retry), - ), - ( - false, - true, - false, - Connected, - Discovered, - (TemporarilyUnavailable, ViewReason), - ), - ( - false, - false, - false, - Connected, - Discovered, - (ConnectedStatus, View), - ), - ( - false, - false, - false, - Disconnected, - Discovered, - (ConfigurationAvailable, Connect), - ), - ( - false, - false, - false, - Disconnected, - NotDiscovered, - (NoConfiguration, None), - ), - ]; - - for (needs_attention, temporarily_unavailable, can_retry, connection, discovery, expected) in - cases - { - assert_eq!( - derive_external_application_status_v2( - needs_attention, - temporarily_unavailable, - can_retry, - connection, - discovery, - ), - expected - ); - } -} - -#[test] -fn external_application_v2_host_capabilities_stay_at_current_host_boundaries() { - assert_eq!( - serde_json::to_value(ExternalApplicationHostCapabilitiesV2::read_write()).unwrap(), - serde_json::json!({ - "canReadSnapshot": true, - "canReadReview": true, - "canMutate": true, - "canManageUserDefault": true, - "canManageWorkspaceOverride": true, - "canRefresh": true, - "canSetSafeMode": true - }) - ); - assert_eq!( - serde_json::to_value(ExternalApplicationHostCapabilitiesV2::read_only()).unwrap(), - serde_json::json!({ - "canReadSnapshot": true, - "canReadReview": true, - "canMutate": false, - "canManageUserDefault": false, - "canManageWorkspaceOverride": false, - "canRefresh": true, - "canSetSafeMode": false - }) - ); -} - -#[test] -fn external_application_review_pages_are_bounded_and_carry_only_stable_refs() { - let item = ExternalApplicationReviewItemV2 { - item_ref: ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: "opencode.tool:project:review".to_string(), - }, - display_name: "Review tool".to_string(), - display_summary: "Runs the external review tool".to_string(), - risk_level: ExternalApplicationRiskLevelV2::High, - risk_reason_codes: vec!["process_execution".to_string()], - recommended: false, - safety_ceiling: ExternalApplicationSafetyCeilingV2::ReviewRequired, - }; - let page = ExternalApplicationReviewPageV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: ExecutionDomainId::new("host-a").unwrap(), - workspace_scope_id: Some("workspace:0123456789abcdef".to_string()), - target_scope: ExternalApplicationTargetScopeV2::WorkspaceOverride, - review_id: "review-opencode-7".to_string(), - preference_revision: 11, - expected_generations: vec![ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Tool, - generation: 7, - }], - cursor: None, - next_cursor: Some("page:2".to_string()), - total_count: 129, - items: vec![item.clone(); EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS], - }; - page.validate().unwrap(); - - let mut oversized = page.clone(); - oversized.items.push(item); - assert_eq!( - oversized.validate(), - Err("external application review page exceeds 128 items") - ); - - let encoded = serde_json::to_value(page).unwrap(); - assert!(encoded["items"][0].get("command").is_none()); - assert!(encoded["items"][0].get("prompt").is_none()); - assert!(encoded["items"][0].get("payload").is_none()); - assert_eq!( - encoded["items"][0]["itemRef"]["stableId"], - "opencode.tool:project:review" - ); -} - -#[test] -fn external_application_review_page_requests_enforce_scope_and_page_size() { - let request = ExternalApplicationReviewPageRequestV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: ExecutionDomainId::new("host-a").unwrap(), - workspace_scope_id: Some("workspace:0123456789abcdef".to_string()), - target_scope: ExternalApplicationTargetScopeV2::WorkspaceOverride, - review_id: "review-opencode-7".to_string(), - preference_revision: 11, - expected_generations: vec![ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Tool, - generation: 7, - }], - cursor: None, - page_size: EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS, - }; - request.validate().unwrap(); - - let mut oversized = request.clone(); - oversized.page_size += 1; - assert_eq!( - oversized.validate(), - Err("external application review page size must be between 1 and 128") - ); - - let mut leaked_workspace = request; - leaked_workspace.target_scope = ExternalApplicationTargetScopeV2::UserDefault; - assert_eq!( - leaked_workspace.validate(), - Err("user-default scope must not include a workspace scope id") - ); -} - -#[test] -fn external_application_control_v2_uses_a_typed_scope_and_closed_review_action() { - let request = ExternalApplicationControlRequestV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - execution_domain_id: ExecutionDomainId::new("host-a").unwrap(), - workspace_scope_id: Some("workspace:0123456789abcdef".to_string()), - target_scope: ExternalApplicationTargetScopeV2::WorkspaceOverride, - operation_id: "review-operation-1".to_string(), - expected_preference_revision: 11, - action: ExternalApplicationControlActionV2::SubmitApplicationReview { - review_id: "review-opencode-7".to_string(), - expected_generations: vec![ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2::Tool, - generation: 7, - }], - selection_baseline: ExternalApplicationReviewSelectionBaselineV2::Recommended, - selection_overrides: vec![ExternalApplicationReviewSelectionOverrideV2 { - item_ref: ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: "opencode.tool:project:review".to_string(), - }, - selected: true, - }], - }, - }; - request.validate().unwrap(); - - let encoded = serde_json::to_value(&request).unwrap(); - assert_eq!(encoded["action"]["type"], "submit_application_review"); - assert_eq!(encoded["action"]["selectionBaseline"], "recommended"); - assert!(encoded["action"].get("payload").is_none()); - assert_eq!( - serde_json::from_value::(encoded).unwrap(), - request - ); -} - -#[test] -fn external_application_control_results_keep_item_failures_typed() { - let result = ExternalApplicationControlResultV2 { - schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, - operation_id: "review-operation-1".to_string(), - preference_revision: 12, - outcome: ExternalApplicationOperationOutcomeV2::Applied, - item_results: vec![ExternalApplicationReviewItemResultV2 { - item_ref: ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: "opencode.tool:project:review".to_string(), - }, - outcome: ExternalApplicationOperationOutcomeV2::Blocked, - reason_code: Some("safe_mode".to_string()), - recovery_actions: vec![ExternalApplicationRecoveryActionV2::ExitSafeMode], - }], - }; - result.validate().unwrap(); - - let encoded = serde_json::to_value(&result).unwrap(); - assert_eq!(encoded["outcome"], "applied"); - assert_eq!(encoded["itemResults"][0]["outcome"], "blocked"); - assert_eq!( - encoded["itemResults"][0]["recoveryActions"][0]["type"], - "exit_safe_mode" - ); - - let mut unknown = encoded; - unknown["itemResults"][0]["outcome"] = serde_json::json!("future_success"); - assert!(serde_json::from_value::(unknown).is_err()); -} - -#[test] -fn v1_control_wire_golden_remains_unchanged_beside_v2() { - let request = ExternalSourceControlRequestV1 { - schema_version: EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, - operation_id: "legacy-operation".to_string(), - expected_preference_revision: Some(9), - action: ExternalSourceControlActionV1::SetSafeMode { enabled: true }, - }; - - assert_eq!( - serde_json::to_value(request).unwrap(), - serde_json::json!({ - "schemaVersion": 1, - "operationId": "legacy-operation", - "expectedPreferenceRevision": 9, - "action": { "type": "set_safe_mode", "enabled": true } - }) - ); -} diff --git a/src/crates/execution/agent-runtime/src/custom_agent.rs b/src/crates/execution/agent-runtime/src/custom_agent.rs index c0f6cf412c..6b6c0af6bd 100644 --- a/src/crates/execution/agent-runtime/src/custom_agent.rs +++ b/src/crates/execution/agent-runtime/src/custom_agent.rs @@ -22,6 +22,9 @@ pub const DEFAULT_CUSTOM_MODE_TOOLS: &[&str] = &[ "Skill", "WebSearch", "WebFetch", + "get_goal", + "create_goal", + "update_goal", ]; pub const DEFAULT_CUSTOM_SUBAGENT_TOOLS: &[&str] = &["LS", "Read", "Glob", "Grep"]; pub const DEFAULT_CUSTOM_MODE_READONLY: bool = false; @@ -783,8 +786,18 @@ fn custom_agent_markdown_metadata(definition: &CustomAgentDefinition) -> Value { #[cfg(test)] mod tests { use super::*; + use crate::thread_goal_tools::THREAD_GOAL_TOOL_NAMES; use std::time::{SystemTime, UNIX_EPOCH}; + #[test] + fn custom_mode_defaults_include_the_thread_goal_lifecycle() { + let tools = default_custom_agent_tools(CustomAgentKind::Mode); + + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert!(tools.iter().any(|tool| tool == tool_name)); + } + } + #[test] fn custom_agent_user_context_policy_round_trips_memory_summary() { let definition = CustomAgentDefinition { diff --git a/src/crates/execution/agent-runtime/src/thread_goal_tools.rs b/src/crates/execution/agent-runtime/src/thread_goal_tools.rs index c30463d469..f755499f86 100644 --- a/src/crates/execution/agent-runtime/src/thread_goal_tools.rs +++ b/src/crates/execution/agent-runtime/src/thread_goal_tools.rs @@ -9,6 +9,24 @@ use std::fmt; pub const GET_GOAL_TOOL_NAME: &str = "get_goal"; pub const CREATE_GOAL_TOOL_NAME: &str = "create_goal"; pub const UPDATE_GOAL_TOOL_NAME: &str = "update_goal"; +pub const THREAD_GOAL_TOOL_NAMES: [&str; 3] = [ + GET_GOAL_TOOL_NAME, + CREATE_GOAL_TOOL_NAME, + UPDATE_GOAL_TOOL_NAME, +]; + +/// Ensure a primary-session tool list exposes the complete thread-goal lifecycle. +/// +/// Goal state can be activated outside the model tool surface (for example by +/// the composer UI), so exposing only part of this bundle can leave an active +/// goal with no way for the model to inspect or finish it. +pub fn ensure_thread_goal_tools(tools: &mut Vec) { + for tool_name in THREAD_GOAL_TOOL_NAMES { + if !tools.iter().any(|tool| tool == tool_name) { + tools.push(tool_name.to_string()); + } + } +} #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -87,3 +105,26 @@ pub fn build_goal_tool_result( result_for_assistant, }) } + +#[cfg(test)] +mod tests { + use super::{ensure_thread_goal_tools, THREAD_GOAL_TOOL_NAMES}; + + #[test] + fn ensure_thread_goal_tools_adds_the_complete_bundle_without_duplicates() { + let mut tools = vec!["Read".to_string(), "get_goal".to_string()]; + + ensure_thread_goal_tools(&mut tools); + ensure_thread_goal_tools(&mut tools); + + for tool_name in THREAD_GOAL_TOOL_NAMES { + assert_eq!( + tools + .iter() + .filter(|tool| tool.as_str() == tool_name) + .count(), + 1 + ); + } + } +} diff --git a/src/crates/execution/tool-contracts/src/framework.rs b/src/crates/execution/tool-contracts/src/framework.rs index 943fe620ec..34f2acc1d0 100644 --- a/src/crates/execution/tool-contracts/src/framework.rs +++ b/src/crates/execution/tool-contracts/src/framework.rs @@ -1390,19 +1390,22 @@ impl ToolRuntimeAssembly { Ok(self.create_registry_from_static_providers(&providers)) } - pub fn create_registry_from_static_provider_entries( + pub fn create_registry_from_static_provider_entries( &self, entries: Entries, factory: &Factory, ) -> Result, StaticToolMaterializationError> where - Entries: IntoIterator, + Entries: IntoIterator, + ToolNames: IntoIterator, + ToolNames::Item: std::borrow::Borrow<&'static str>, Factory: StaticToolProviderFactory + ?Sized, { let mut providers = Vec::new(); for (provider_id, tool_names) in entries { let mut tools = Vec::new(); for tool_name in tool_names { + let tool_name = *std::borrow::Borrow::borrow(&tool_name); let tool = factory.materialize_tool(tool_name).ok_or( StaticToolMaterializationError::UnknownTool { provider_id, diff --git a/src/crates/execution/tool-provider-groups/AGENTS.md b/src/crates/execution/tool-provider-groups/AGENTS.md index f03026d774..86d8a0cbea 100644 --- a/src/crates/execution/tool-provider-groups/AGENTS.md +++ b/src/crates/execution/tool-provider-groups/AGENTS.md @@ -2,21 +2,28 @@ Scope: this guide applies to `src/crates/execution/tool-provider-groups`. -`bitfun-tool-packs` owns tool feature-group scaffold metadata, the product tool -provider group plan, and provider-group plan selection by id. It does not own -concrete tool implementations yet. +`bitfun-tool-packs` owns tool feature-group metadata, the stable tool-to-feature +mapping, the product tool provider group plan, and provider-group plan selection +by id. Concrete implementations and runtime materialization remain in Core. ## Guardrails - Keep `default = []`; `product-full` may aggregate feature groups but must not - silently enable new runtime behavior. Boundary checks enforce the current - feature-group list. + silently enable new runtime behavior. Adding a built-in tool requires one + exact feature-group owner and boundary coverage for the full group list. - Do not depend on `bitfun-core`, concrete service crates, app crates, Tauri, Git, MCP, network clients, or CLI UI dependencies unless a reviewed tool runtime owner move explicitly changes this boundary. - Do not own manifest/exposure contracts, concrete runtime manifest assembly, `GetToolSpec` execution, collapsed unlock state, snapshot decoration, or `ToolUseContext`. Provider group plans may list group ids and tool names only. + A provider may contain tools from multiple feature owners; the exact + tool-to-`ToolPackFeatureGroup` mapping is the owner authority. + Compile-time availability is a validation fact; materialization must fail + closed when a product plan requests a group that the binary did not compile. + It must not infer the requested plan from compiled features or inspect runtime + state, permissions, or service health. Filtering tools that belong to owners + omitted by an explicit `ProductToolPlan` is product selection, not degradation. - Product capability packs may select provider group ids; this crate owns the provider group plan and unknown provider-group validation. - Future concrete tool migration must preserve product registry order, diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index 9d3dc5c382..f97ca81139 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -1,7 +1,8 @@ //! Concrete tool-pack owner crate. //! -//! The feature scaffold is intentionally behavior-neutral until the core -//! `ToolUseContext` and registry boundaries are split into portable ports. +//! This crate owns compile-time availability facts and the stable product +//! provider plan. Concrete implementations, registry ordering, permissions, +//! and runtime materialization remain in Core. use std::collections::HashSet; use std::fmt; @@ -80,6 +81,63 @@ pub fn enabled_feature_groups() -> Vec { .collect() } +pub fn tool_feature_group(tool_name: &str) -> Option { + match tool_name { + "LS" + | "Read" + | "Glob" + | "Grep" + | "Write" + | "Edit" + | "Delete" + | "ExecCommand" + | "WriteStdin" + | "ExecControl" + | "GetTime" + | "ListModels" + | "build_project" + | "start_app" + | "hdc_log" + | "arkts_knowledge_search" + | "check_arkts_files" + | "check_cpp_files" + | "switch_cwd" + | "verify_ui" + | "get_ui_verification_log" + | "save_ui_screenshot" => Some(ToolPackFeatureGroup::Basic), + "Git" | "Worktree" | "ReviewPlatform" | "GetFileDiff" => Some(ToolPackFeatureGroup::Git), + "ListMCPResources" | "ReadMCPResource" | "ListMCPPrompts" | "GetMCPPrompt" => { + Some(ToolPackFeatureGroup::Mcp) + } + "WebSearch" | "WebFetch" | "ControlHub" => Some(ToolPackFeatureGroup::BrowserWeb), + "ComputerUse" => Some(ToolPackFeatureGroup::ComputerUse), + "view_image" | "analyze_image" => Some(ToolPackFeatureGroup::ImageAnalysis), + "GenerativeUI" | "InitMiniApp" | "FinalizeMiniApp" | "PublishMiniApp" + | "PublishAppearance" | "PageDeploy" | "PagePublish" | "Playbook" => { + Some(ToolPackFeatureGroup::MiniApp) + } + "CreateCanvas" | "ReadCanvas" | "UpdateCanvas" | "PatchCanvas" => { + Some(ToolPackFeatureGroup::Canvas) + } + "Task" | "AgentWait" | "LaunchReviewAgent" | "Skill" | "AskUserQuestion" | "TodoWrite" + | "get_goal" | "create_goal" | "update_goal" | "CreatePlan" | "submit_code_review" + | "GetToolSpec" | "CallDeferredTool" | "SessionControl" | "SessionMessage" + | "SessionHistory" | "Cron" => Some(ToolPackFeatureGroup::AgentControl), + _ => None, + } +} + +pub fn unavailable_feature_groups(requested: &[ToolPackFeatureGroup]) -> Vec { + let enabled = enabled_feature_groups().into_iter().collect::>(); + let mut seen = HashSet::new(); + requested + .iter() + .copied() + .filter(|group| !enabled.contains(group)) + .filter(|group| seen.insert(*group)) + .collect() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ToolProviderGroupPlan { provider_id: &'static str, @@ -101,8 +159,14 @@ impl ToolProviderGroupPlan { } } -const CORE_BASIC_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::Basic]; -const CORE_AGENT_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::AgentControl]; +const CORE_BASIC_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ + ToolPackFeatureGroup::Basic, + ToolPackFeatureGroup::ImageAnalysis, +]; +const CORE_AGENT_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ + ToolPackFeatureGroup::AgentControl, + ToolPackFeatureGroup::Git, +]; const CORE_CANVAS_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::Canvas]; const CORE_SESSION_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::AgentControl]; const CORE_INTEGRATION_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ @@ -111,8 +175,6 @@ const CORE_INTEGRATION_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ ToolPackFeatureGroup::Git, ToolPackFeatureGroup::MiniApp, ToolPackFeatureGroup::ComputerUse, - ToolPackFeatureGroup::ImageAnalysis, - ToolPackFeatureGroup::AgentControl, ]; const CORE_OPENHARMONY_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::Basic]; @@ -259,10 +321,12 @@ pub fn try_product_tool_provider_group_plan_for_ids( #[cfg(test)] mod tests { + use std::collections::HashSet; + use super::{ all_feature_groups, enabled_feature_groups, product_tool_provider_group_plan, - try_product_tool_provider_group_plan_for_ids, ToolPackFeatureGroup, - ToolProviderGroupPlanSelectionError, + tool_feature_group, try_product_tool_provider_group_plan_for_ids, + unavailable_feature_groups, ToolPackFeatureGroup, ToolProviderGroupPlanSelectionError, }; #[test] @@ -330,6 +394,65 @@ mod tests { ); } + #[test] + fn provider_plan_reports_every_requested_group_missing_from_the_binary() { + let unavailable = unavailable_feature_groups(all_feature_groups()); + for group in all_feature_groups() { + assert_eq!( + unavailable.contains(group), + !enabled_feature_groups().contains(group), + "{} availability must reflect the compiled tool-pack feature", + group.id(), + ); + } + } + + #[test] + fn every_builtin_tool_has_one_compile_time_owner_group() { + for tool_name in product_tool_provider_group_plan() + .iter() + .flat_map(|group| group.tool_names()) + { + assert!( + tool_feature_group(tool_name).is_some(), + "{tool_name} must have a compile-time feature owner" + ); + } + } + + #[test] + fn every_provider_declares_exactly_its_tool_owner_groups() { + for provider in product_tool_provider_group_plan() { + let declared = provider + .feature_groups() + .iter() + .copied() + .collect::>(); + let actual = provider + .tool_names() + .iter() + .map(|tool_name| { + tool_feature_group(tool_name).unwrap_or_else(|| { + panic!("{tool_name} must have a compile-time feature owner") + }) + }) + .collect::>(); + + assert_eq!( + declared, + actual, + "{} feature groups must match its tool owners", + provider.provider_id() + ); + assert_eq!( + provider.feature_groups().len(), + declared.len(), + "{} must not declare duplicate feature groups", + provider.provider_id() + ); + } + } + #[test] fn feature_group_ids_match_cargo_feature_names() { assert_eq!(ToolPackFeatureGroup::Basic.id(), "basic"); @@ -461,21 +584,13 @@ mod tests { assert_eq!( feature_groups, vec![ - ("core.basic", vec!["basic"]), - ("core.agent", vec!["agent-control"]), + ("core.basic", vec!["basic", "image-analysis"]), + ("core.agent", vec!["agent-control", "git"]), ("core.canvas", vec!["canvas"]), ("core.session", vec!["agent-control"]), ( "core.integration", - vec![ - "browser-web", - "mcp", - "git", - "miniapp", - "computer-use", - "image-analysis", - "agent-control", - ] + vec!["browser-web", "mcp", "git", "miniapp", "computer-use",] ), ("core.openharmony", vec!["basic"]), ] diff --git a/src/crates/interfaces/acp/Cargo.toml b/src/crates/interfaces/acp/Cargo.toml index 6563705fbf..cdc525cfe7 100644 --- a/src/crates/interfaces/acp/Cargo.toml +++ b/src/crates/interfaces/acp/Cargo.toml @@ -11,9 +11,19 @@ name = "bitfun_acp" [dependencies] bitfun-core = { path = "../../assembly/core", default-features = false, features = [ "agent-runtime", - "canvas-runtime", + "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-agent-runtime = { path = "../../execution/agent-runtime" } bitfun-agent-tools = { path = "../../execution/tool-contracts" } diff --git a/src/crates/interfaces/app-server-client/src/lib.rs b/src/crates/interfaces/app-server-client/src/lib.rs index 9c13b7a79a..155e327fe7 100644 --- a/src/crates/interfaces/app-server-client/src/lib.rs +++ b/src/crates/interfaces/app-server-client/src/lib.rs @@ -215,28 +215,6 @@ impl AppServerClient { self.rpc(|cx| Ok(cx.send_request(request))).await } - pub async fn external_application_snapshot_v2( - &self, - request: ExternalApplicationSnapshotRequestV2, - ) -> agent_client_protocol::Result { - self.rpc(|cx| Ok(cx.send_request(request))).await - } - - pub async fn external_application_review_page_v2( - &self, - request: ExternalApplicationReviewPageRequest, - ) -> agent_client_protocol::Result { - self.rpc(|cx| Ok(cx.send_request(request))).await - } - - pub async fn apply_external_application_action_v2( - &self, - request: ExternalApplicationActionRequest, - ) -> Result { - self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) - .await - } - pub async fn external_source_control( &self, request: ExternalSourceControlRequest, @@ -811,15 +789,3 @@ pub async fn connect( shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))), }) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn client_exposes_external_application_v2_methods() { - let _ = AppServerClient::external_application_snapshot_v2; - let _ = AppServerClient::external_application_review_page_v2; - let _ = AppServerClient::apply_external_application_action_v2; - } -} diff --git a/src/crates/interfaces/app-server-protocol/src/lib.rs b/src/crates/interfaces/app-server-protocol/src/lib.rs index 7ede605613..7f96360609 100644 --- a/src/crates/interfaces/app-server-protocol/src/lib.rs +++ b/src/crates/interfaces/app-server-protocol/src/lib.rs @@ -25,3 +25,13 @@ pub const PROTOCOL_VERSION: u32 = 3; /// Oldest protocol version this implementation accepts. pub const MIN_PROTOCOL_VERSION: u32 = 2; + +#[cfg(test)] +mod protocol_version_tests { + use super::PROTOCOL_VERSION; + + #[test] + fn application_protocol_stays_at_version_3() { + assert_eq!(PROTOCOL_VERSION, 3); + } +} diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs b/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs index 079c79abbf..2fa52d9be2 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs @@ -7,10 +7,8 @@ use std::collections::{BTreeMap, BTreeSet}; use agent_client_protocol::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse}; use bitfun_product_domains::external_source_control::{ - ExternalApplicationControlRequestV2, ExternalApplicationControlResultV2, - ExternalApplicationReviewPageRequestV2 as DomainExternalApplicationReviewPageRequestV2, - ExternalApplicationReviewPageV2, ExternalApplicationSnapshotV2, ExternalSourceControlRequestV1, - ExternalSourceControlSnapshotV1, ExternalSourceSurfaceSnapshotV1, + ExternalSourceControlRequestV1, ExternalSourceControlSnapshotV1, + ExternalSourceSurfaceSnapshotV1, }; use bitfun_product_domains::external_sources::{ ExternalSourceOperationError, ExternalSourcePublicSnapshot, @@ -44,51 +42,6 @@ pub struct ExternalSourceSnapshotResponse { pub preferences: ExternalSourceConflictPreferences, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request( - method = "externalSource/applicationSnapshotV2", - response = ExternalApplicationSnapshotResponseV2 -)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationSnapshotRequestV2 { - pub workspace_path: Option, - pub force_refresh: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[serde(transparent)] -pub struct ExternalApplicationSnapshotResponseV2(pub ExternalApplicationSnapshotV2); - -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request( - method = "externalSource/applicationReviewPageV2", - response = ExternalApplicationReviewPageResponseV2 -)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationReviewPageRequest { - pub workspace_path: Option, - pub request: DomainExternalApplicationReviewPageRequestV2, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[serde(transparent)] -pub struct ExternalApplicationReviewPageResponseV2(pub ExternalApplicationReviewPageV2); - -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request( - method = "externalSource/applicationActionV2", - response = ExternalApplicationActionResponseV2 -)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalApplicationActionRequest { - pub workspace_path: Option, - pub request: ExternalApplicationControlRequestV2, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -#[serde(transparent)] -pub struct ExternalApplicationActionResponseV2(pub ExternalApplicationControlResultV2); - #[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] #[notification(method = "externalSource/event")] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -285,113 +238,4 @@ mod tests { assert!(!debug.contains("C:/secret/project")); assert!(!debug.contains("--token secret")); } - - #[test] - fn application_v2_wire_requests_keep_workspace_binding_outside_domain_payloads() { - let snapshot: ExternalApplicationSnapshotRequestV2 = - serde_json::from_value(serde_json::json!({ - "workspacePath": null, - "forceRefresh": true - })) - .unwrap(); - assert_eq!(snapshot.workspace_path, None); - assert!(snapshot.force_refresh); - - let page: ExternalApplicationReviewPageRequest = - serde_json::from_value(serde_json::json!({ - "workspacePath": "C:/work/project", - "request": { - "schemaVersion": 2, - "executionDomainId": "host-a", - "workspaceScopeId": "workspace-a", - "targetScope": "workspace_override", - "reviewId": "review-a", - "preferenceRevision": 4, - "expectedGenerations": [], - "pageSize": 64 - } - })) - .unwrap(); - assert_eq!(page.workspace_path.as_deref(), Some("C:/work/project")); - assert_eq!(page.request.page_size, 64); - - let action: ExternalApplicationActionRequest = serde_json::from_value(serde_json::json!({ - "workspacePath": "C:/work/project", - "request": { - "schemaVersion": 2, - "executionDomainId": "host-a", - "workspaceScopeId": "workspace-a", - "targetScope": "workspace_override", - "operationId": "operation-a", - "expectedPreferenceRevision": 4, - "action": { - "type": "connect_application", - "applicationId": "opencode" - } - } - })) - .unwrap(); - assert_eq!(action.workspace_path.as_deref(), Some("C:/work/project")); - assert_eq!(action.request.operation_id, "operation-a"); - } - - #[test] - fn application_v2_snapshot_response_serializes_as_the_domain_object() { - let domain_json = serde_json::json!({ - "schemaVersion": 2, - "executionDomainId": "host-a", - "effectiveConnectionScope": "user_default", - "refreshGeneration": 7, - "preferenceRevision": 4, - "safeMode": false, - "hostCapabilities": { - "canReadSnapshot": true, - "canReadReview": true, - "canMutate": true, - "canManageUserDefault": true, - "canManageWorkspaceOverride": true, - "canRefresh": true, - "canSetSafeMode": true - }, - "applications": [] - }); - let domain: ExternalApplicationSnapshotV2 = - serde_json::from_value(domain_json.clone()).unwrap(); - - assert_eq!( - serde_json::to_value(ExternalApplicationSnapshotResponseV2(domain)).unwrap(), - domain_json - ); - - let page_json = serde_json::json!({ - "schemaVersion": 2, - "executionDomainId": "host-a", - "targetScope": "user_default", - "reviewId": "review-a", - "preferenceRevision": 4, - "expectedGenerations": [], - "totalCount": 0, - "items": [] - }); - let page: ExternalApplicationReviewPageV2 = - serde_json::from_value(page_json.clone()).unwrap(); - assert_eq!( - serde_json::to_value(ExternalApplicationReviewPageResponseV2(page)).unwrap(), - page_json - ); - - let action_json = serde_json::json!({ - "schemaVersion": 2, - "operationId": "operation-a", - "preferenceRevision": 5, - "outcome": "applied", - "itemResults": [] - }); - let action: ExternalApplicationControlResultV2 = - serde_json::from_value(action_json.clone()).unwrap(); - assert_eq!( - serde_json::to_value(ExternalApplicationActionResponseV2(action)).unwrap(), - action_json - ); - } } diff --git a/src/crates/interfaces/app-server/Cargo.toml b/src/crates/interfaces/app-server/Cargo.toml index 8e361c6a0e..8db5fcdcc5 100644 --- a/src/crates/interfaces/app-server/Cargo.toml +++ b/src/crates/interfaces/app-server/Cargo.toml @@ -14,7 +14,7 @@ bitfun-app-server-protocol = { path = "../app-server-protocol" } # Host-service handlers use the reviewed Agent Runtime owner closure. Add a # narrower Core owner feature when a newly registered domain needs it; the # protocol surface must not inherit the broad bitfun-core/product-full union. -bitfun-core = { path = "../../assembly/core", default-features = false, features = ["external-sources"] } +bitfun-core = { path = "../../assembly/core", default-features = false, features = ["external-sources", "git", "remote-connect"] } bitfun-agent-runtime = { path = "../../execution/agent-runtime" } bitfun-events = { path = "../../contracts/events" } bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false, features = ["external-sources"] } @@ -25,7 +25,6 @@ futures = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } -async-trait = { workspace = true } log = { workspace = true } tracing = { workspace = true } url = { workspace = true } diff --git a/src/crates/interfaces/app-server/src/management.rs b/src/crates/interfaces/app-server/src/management.rs index 2c6e2d04ac..79534bc75f 100644 --- a/src/crates/interfaces/app-server/src/management.rs +++ b/src/crates/interfaces/app-server/src/management.rs @@ -1,11 +1,9 @@ //! Host-injected management service and capability boundary. -use async_trait::async_trait; -use bitfun_app_server_protocol::account::*; use bitfun_app_server_protocol::app::{CapabilityAvailability, CapabilityDescriptor}; -use bitfun_app_server_protocol::worktree::*; mod service; +mod worktree; pub use service::AppManagementService; @@ -21,58 +19,6 @@ pub const ACCOUNT_CAPABILITY: &str = "tui.account"; pub const SETTINGS_SYNC_CAPABILITY: &str = "tui.settingsSync"; pub const WORKTREES_CAPABILITY: &str = "tui.worktrees"; -#[async_trait] -pub trait WorktreeManagementHost: Send + Sync { - async fn repository_status( - &self, - request: WorktreeRepositoryStatusRequest, - ) -> AppManagementResult; - async fn bind_session( - &self, - request: WorktreeBindSessionRequest, - ) -> AppManagementResult; - async fn release_session( - &self, - request: WorktreeReleaseSessionRequest, - ) -> AppManagementResult; -} - -#[async_trait] -pub trait AccountManagementHost: Send + Sync { - async fn account_snapshot( - &self, - request: AccountSnapshotRequest, - ) -> AppManagementResult; - async fn account_login( - &self, - request: AccountLoginRequest, - ) -> AppManagementResult; - async fn account_finalize_login( - &self, - request: AccountFinalizeLoginRequest, - ) -> AppManagementResult; - async fn account_logout( - &self, - request: AccountLogoutRequest, - ) -> AppManagementResult; - async fn settings_sync_start( - &self, - request: SettingsSyncStartRequest, - ) -> AppManagementResult; - async fn settings_sync_snapshot( - &self, - request: SettingsSyncSnapshotRequest, - ) -> AppManagementResult; - async fn settings_sync_cancel( - &self, - request: SettingsSyncCancelRequest, - ) -> AppManagementResult; - async fn settings_sync_local_changed( - &self, - request: SettingsSyncLocalChangedRequest, - ) -> AppManagementResult; -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct AppManagementCapabilities { pub modes: CapabilityAvailability, @@ -398,34 +344,4 @@ mod tests { ] ); } - - #[test] - fn external_source_capability_does_not_advertise_unwired_shared_v2_methods() { - let external_sources = AppManagementCapabilities::available() - .descriptors() - .into_iter() - .find(|descriptor| descriptor.id == EXTERNAL_SOURCES_CAPABILITY) - .expect("external source capability"); - - for method in [ - "externalSource/snapshot", - "externalSource/control", - "externalSource/review", - ] { - assert!( - external_sources.methods.iter().any(|item| item == method), - "missing {method}" - ); - } - for method in [ - "externalSource/applicationSnapshotV2", - "externalSource/applicationReviewPageV2", - "externalSource/applicationActionV2", - ] { - assert!( - !external_sources.methods.iter().any(|item| item == method), - "shared capability must not advertise unwired method {method}" - ); - } - } } diff --git a/src/crates/interfaces/app-server/src/management/service.rs b/src/crates/interfaces/app-server/src/management/service.rs index 12452dc49b..3a87bd2f52 100644 --- a/src/crates/interfaces/app-server/src/management/service.rs +++ b/src/crates/interfaces/app-server/src/management/service.rs @@ -16,17 +16,20 @@ use bitfun_app_server_protocol::model::*; use bitfun_app_server_protocol::skill::*; use bitfun_app_server_protocol::subagent::*; use bitfun_app_server_protocol::worktree::*; +use bitfun_core::service::remote_connect::account_runtime::{ + AccountRuntime, AccountSyncProgress, AccountSyncStatus, +}; use super::{ - AccountManagementHost, AppManagementCapabilities, AppManagementError, AppManagementResult, - WorktreeManagementHost, ACCOUNT_CAPABILITY, SETTINGS_SYNC_CAPABILITY, WORKTREES_CAPABILITY, + AppManagementCapabilities, AppManagementError, AppManagementResult, ACCOUNT_CAPABILITY, + SETTINGS_SYNC_CAPABILITY, WORKTREES_CAPABILITY, }; /// App Server adapter shared by Embedded and local Shared compatibility Hosts. /// /// The service delegates to the existing config, registry, MCP, and external -/// source owners. Hosts must inject it explicitly; constructing an App Server -/// does not make local management capabilities available by default. +/// source owners. Local-only capabilities must be enabled through the local +/// Host constructor; constructing an App Server does not enable them by default. pub struct AppManagementService { config: Arc, mcp: Option>, @@ -35,24 +38,22 @@ pub struct AppManagementService { bitfun_product_domains::external_sources::ExternalSourcePublicSnapshot, )>, external_source_subscriptions: Arc>>, - account: Option>, - worktree: Option>, + account: Option>, + local_worktrees_enabled: bool, } impl AppManagementService { pub async fn load() -> Result { - Self::load_with_hosts(None, None).await + Self::load_inner(None, false).await } - pub async fn load_with_account_host( - account: Option>, - ) -> Result { - Self::load_with_hosts(account, None).await + pub async fn load_for_local_host(account: Option>) -> Result { + Self::load_inner(account, true).await } - pub async fn load_with_hosts( - account: Option>, - worktree: Option>, + async fn load_inner( + account: Option>, + local_worktrees_enabled: bool, ) -> Result { let config = bitfun_core::service::config::get_global_config_service() .await @@ -64,20 +65,24 @@ impl AppManagementService { external_source_updates, external_source_subscriptions: Arc::new(Mutex::new(HashSet::new())), account, - worktree, + local_worktrees_enabled, }) } - fn account_host(&self, capability: &str) -> AppManagementResult<&dyn AccountManagementHost> { + fn account_runtime(&self, capability: &str) -> AppManagementResult<&Arc> { self.account - .as_deref() + .as_ref() .ok_or_else(|| AppManagementError::unsupported(format!("{capability} is unavailable"))) } - fn worktree_host(&self) -> AppManagementResult<&dyn WorktreeManagementHost> { - self.worktree.as_deref().ok_or_else(|| { - AppManagementError::unsupported(format!("{WORKTREES_CAPABILITY} is unavailable")) - }) + fn require_local_worktrees(&self) -> AppManagementResult<()> { + if self.local_worktrees_enabled { + Ok(()) + } else { + Err(AppManagementError::unsupported(format!( + "{WORKTREES_CAPABILITY} is unavailable" + ))) + } } async fn model_config( @@ -192,7 +197,8 @@ fn external_source_string_error_with_id(error: String, operation_id: &str) -> Ap } fn validate_external_operation(operation_id: &str) -> AppManagementResult<()> { - validate_operation_id(operation_id).map_err(AppManagementError::invalid_request) + bitfun_app_server_protocol::external_source::validate_operation_id(operation_id) + .map_err(AppManagementError::invalid_request) } const MAX_NATIVE_HOOK_COMMAND_CHARS: usize = 200; @@ -849,10 +855,10 @@ impl AppManagementService { capabilities.settings_sync = bitfun_app_server_protocol::app::CapabilityAvailability::Unavailable { reason }; } - if self.worktree.is_none() { + if !self.local_worktrees_enabled { capabilities.worktrees = bitfun_app_server_protocol::app::CapabilityAvailability::Unavailable { - reason: "The Host did not provide a Worktree owner".to_string(), + reason: "The Host did not enable local Worktree management".to_string(), }; } capabilities @@ -862,93 +868,158 @@ impl AppManagementService { &self, request: WorktreeRepositoryStatusRequest, ) -> AppManagementResult { - self.worktree_host()?.repository_status(request).await + self.require_local_worktrees()?; + super::worktree::repository_status(request).await } pub async fn worktree_bind_session( &self, request: WorktreeBindSessionRequest, ) -> AppManagementResult { - self.worktree_host()?.bind_session(request).await + self.require_local_worktrees()?; + super::worktree::bind_session(request).await } pub async fn worktree_release_session( &self, request: WorktreeReleaseSessionRequest, ) -> AppManagementResult { - self.worktree_host()?.release_session(request).await + self.require_local_worktrees()?; + super::worktree::release_session(request).await } pub async fn account_snapshot( &self, request: AccountSnapshotRequest, ) -> AppManagementResult { - self.account_host(ACCOUNT_CAPABILITY)? - .account_snapshot(request) - .await + let _ = request.workspace_path; + Ok(project_account_snapshot( + self.account_runtime(ACCOUNT_CAPABILITY)?.snapshot().await, + )) } pub async fn account_login( &self, request: AccountLoginRequest, ) -> AppManagementResult { - self.account_host(ACCOUNT_CAPABILITY)? - .account_login(request) + validate_account_operation_id(&request.operation_id)?; + let result = self + .account_runtime(ACCOUNT_CAPABILITY)? + .login_with_credentials(&request.relay_url, &request.username, &request.password) .await + .map_err(|error| account_error(error, &request))?; + let status_message = account_login_status_message(&result); + Ok(AccountLoginResponse { + user_id: result.user_id, + relay_url: result.relay_url, + has_cloud_settings: result.has_cloud_settings, + status_message, + }) } pub async fn account_finalize_login( &self, request: AccountFinalizeLoginRequest, ) -> AppManagementResult { - self.account_host(ACCOUNT_CAPABILITY)? - .account_finalize_login(request) + validate_account_operation_id(&request.operation_id)?; + let account = self.account_runtime(ACCOUNT_CAPABILITY)?; + account + .finalize_login_after_sync_choice() + .await + .map_err(internal_account_error)?; + if !account + .start_auto_sync_background( + request.operation_id, + request.choice == AccountSyncChoice::Local, + PathBuf::from(request.workspace_path), + ) .await + { + return Err(AppManagementError::invalid_request( + "Account settings sync is already in progress", + )); + } + Ok(project_account_snapshot(account.snapshot().await)) } pub async fn account_logout( &self, request: AccountLogoutRequest, ) -> AppManagementResult { - self.account_host(ACCOUNT_CAPABILITY)? - .account_logout(request) - .await + validate_account_operation_id(&request.operation_id)?; + let account = self.account_runtime(ACCOUNT_CAPABILITY)?; + account.logout().await.map_err(internal_account_error)?; + account.mark_sync_cancelled(request.operation_id).await; + Ok(project_account_snapshot(account.snapshot().await)) } pub async fn settings_sync_start( &self, request: SettingsSyncStartRequest, ) -> AppManagementResult { - self.account_host(SETTINGS_SYNC_CAPABILITY)? - .settings_sync_start(request) + validate_account_operation_id(&request.operation_id)?; + let account = self.account_runtime(SETTINGS_SYNC_CAPABILITY)?; + if !account.is_logged_in().await { + return Err(AppManagementError::invalid_request( + "Account login must be finalized before settings sync starts", + )); + } + if !account + .start_auto_sync_background( + request.operation_id, + request.is_first_login, + PathBuf::from(request.workspace_path), + ) .await + { + return Err(AppManagementError::invalid_request( + "Account settings sync is already in progress", + )); + } + Ok(SettingsSyncResponse { + progress: project_sync_progress(account.current_sync_progress().await), + }) } pub async fn settings_sync_snapshot( &self, request: SettingsSyncSnapshotRequest, ) -> AppManagementResult { - self.account_host(SETTINGS_SYNC_CAPABILITY)? - .settings_sync_snapshot(request) - .await + let _ = request; + let progress = self + .account_runtime(SETTINGS_SYNC_CAPABILITY)? + .current_sync_progress() + .await; + Ok(SettingsSyncResponse { + progress: project_sync_progress(progress), + }) } pub async fn settings_sync_cancel( &self, request: SettingsSyncCancelRequest, ) -> AppManagementResult { - self.account_host(SETTINGS_SYNC_CAPABILITY)? - .settings_sync_cancel(request) + validate_account_operation_id(&request.operation_id)?; + let progress = self + .account_runtime(SETTINGS_SYNC_CAPABILITY)? + .cancel_sync(request.operation_id) .await + .map_err(internal_account_error)?; + Ok(SettingsSyncResponse { + progress: project_sync_progress(progress), + }) } pub async fn settings_sync_local_changed( &self, request: SettingsSyncLocalChangedRequest, ) -> AppManagementResult { - self.account_host(SETTINGS_SYNC_CAPABILITY)? - .settings_sync_local_changed(request) - .await + validate_account_operation_id(&request.operation_id)?; + let account = self.account_runtime(SETTINGS_SYNC_CAPABILITY)?; + account.notify_local_settings_changed(); + Ok(SettingsSyncResponse { + progress: project_sync_progress(account.current_sync_progress().await), + }) } pub async fn native_hook_overview( @@ -1025,55 +1096,6 @@ impl AppManagementService { external_source_snapshot_response(workspace, request.force_refresh).await } - pub async fn external_application_snapshot_v2( - &self, - request: ExternalApplicationSnapshotRequestV2, - ) -> AppManagementResult { - bitfun_core::external_sources::get_external_application_snapshot_v2( - request.workspace_path.as_deref().map(Path::new), - request.force_refresh, - bitfun_product_domains::external_source_control::ExternalApplicationHostCapabilitiesV2::read_write(), - ) - .await - .map(ExternalApplicationSnapshotResponseV2) - .map_err(external_source_string_error) - } - - pub async fn external_application_review_page_v2( - &self, - request: ExternalApplicationReviewPageRequest, - ) -> AppManagementResult { - request - .request - .validate() - .map_err(AppManagementError::invalid_request)?; - bitfun_core::external_sources::get_external_application_review_page_v2( - request.workspace_path.as_deref().map(Path::new), - request.request, - ) - .await - .map(ExternalApplicationReviewPageResponseV2) - .map_err(external_source_string_error) - } - - pub async fn apply_external_application_action_v2( - &self, - request: ExternalApplicationActionRequest, - ) -> AppManagementResult { - request - .request - .validate() - .map_err(AppManagementError::invalid_request)?; - let operation_id = request.request.operation_id.clone(); - bitfun_core::external_sources::apply_external_application_action_v2( - request.workspace_path.as_deref().map(Path::new), - request.request, - ) - .await - .map(ExternalApplicationActionResponseV2) - .map_err(|error| external_source_string_error_with_id(error, &operation_id)) - } - pub async fn external_source_control( &self, request: ExternalSourceControlRequest, @@ -1741,6 +1763,114 @@ impl AppManagementService { } } +fn project_account_snapshot( + snapshot: bitfun_core::service::remote_connect::account_runtime::AccountSnapshot, +) -> AccountSnapshotResponse { + AccountSnapshotResponse { + logged_in: snapshot.logged_in, + pending_sync_choice: snapshot.pending_sync_choice, + info: snapshot.info.map(|info| AccountInfo { + user_id: info.user_id, + relay_url: info.relay_url, + device_id: info.device_id, + device_name: info.device_name, + }), + devices: snapshot + .devices + .into_iter() + .map(|device| AccountDevice { + device_id: device.device_id, + device_name: device.device_name, + online: device.online, + }) + .collect(), + sync: project_sync_progress(snapshot.sync), + } +} + +fn project_sync_progress(progress: AccountSyncProgress) -> SettingsSyncProgress { + SettingsSyncProgress { + operation_id: progress.operation_id, + status: match progress.status { + AccountSyncStatus::Idle => SettingsSyncStatus::Idle, + AccountSyncStatus::Syncing => SettingsSyncStatus::Syncing, + AccountSyncStatus::Done => SettingsSyncStatus::Done, + AccountSyncStatus::Failed => SettingsSyncStatus::Failed, + AccountSyncStatus::Cancelled => SettingsSyncStatus::Cancelled, + }, + phase: progress.phase, + percent: progress.percent, + current: progress.current, + total: progress.total, + detail: progress.detail, + error: progress.error, + settings_synced: progress.settings_synced, + sessions_exported: progress.sessions_exported, + } +} + +fn account_login_status_message( + result: &bitfun_core::service::remote_connect::account_runtime::AccountLoginResult, +) -> String { + if result.has_cloud_settings { + return format!( + "Authenticated as user {} on {}. Choose cloud or local settings to finish login.", + result.user_id, result.relay_url + ); + } + if result.routing_connected { + format!( + "Logged in as user {} on {}. Device routing connected.", + result.user_id, result.relay_url + ) + } else if let Some(error) = &result.routing_error { + format!( + "Logged in as user {} on {}. Device routing failed: {}", + result.user_id, + result.relay_url, + bounded_error(error.clone()) + ) + } else { + format!( + "Logged in as user {} on {}.", + result.user_id, result.relay_url + ) + } +} + +fn validate_account_operation_id(operation_id: &str) -> AppManagementResult<()> { + let valid = !operation_id.trim().is_empty() + && operation_id.len() <= 128 + && operation_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')); + valid + .then_some(()) + .ok_or_else(|| AppManagementError::invalid_request("Account operation ID is invalid")) +} + +fn account_error(error: anyhow::Error, request: &AccountLoginRequest) -> AppManagementError { + let mut message = error.to_string(); + for secret in [&request.relay_url, &request.username, &request.password] { + if !secret.is_empty() { + message = message.replace(secret, ""); + } + } + AppManagementError::internal(bounded_error(message)) +} + +fn internal_account_error(error: anyhow::Error) -> AppManagementError { + AppManagementError::internal(bounded_error(error.to_string())) +} + +fn bounded_error(message: String) -> String { + message + .chars() + .filter(|character| !character.is_control()) + .take(500) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/crates/interfaces/app-server/src/management/worktree.rs b/src/crates/interfaces/app-server/src/management/worktree.rs new file mode 100644 index 0000000000..3c86d77af3 --- /dev/null +++ b/src/crates/interfaces/app-server/src/management/worktree.rs @@ -0,0 +1,127 @@ +use bitfun_app_server_protocol::worktree::*; +use bitfun_core::service::git::GitService; +use bitfun_core::service::worktree::{WorktreeService, WorktreeSessionBindingRequest}; +use bitfun_runtime_ports::{AgentSessionWorkspaceBinding, WorktreeError, WorktreeErrorCode}; + +use super::{AppManagementError, AppManagementResult}; + +pub(crate) async fn repository_status( + request: WorktreeRepositoryStatusRequest, +) -> AppManagementResult { + if request.is_remote() { + return Err(worktree_error(WorktreeOperationError { + code: WorktreeErrorCode::RemoteUnsupported, + message: "Repository status is not supported for remote workspaces".to_string(), + recovery_path: None, + operation_id: None, + })); + } + + let repository = match GitService::resolve_worktree_repository(&request.workspace_path).await { + Ok(repository) => GitService::get_repository_basic(repository.query_path).await, + Err(error) => Err(error), + }; + match repository { + Ok(repository) => Ok(WorktreeRepositoryStatusResponse { + is_repository: true, + current_branch: Some(repository.current_branch), + }), + Err(_) => Ok(WorktreeRepositoryStatusResponse { + is_repository: false, + current_branch: None, + }), + } +} + +pub(crate) async fn bind_session( + request: WorktreeBindSessionRequest, +) -> AppManagementResult { + transition( + request.is_remote(), + request.operation_id, + request.session_id, + request.project_workspace_path, + true, + ) + .await +} + +pub(crate) async fn release_session( + request: WorktreeReleaseSessionRequest, +) -> AppManagementResult { + transition( + request.is_remote(), + request.operation_id, + request.session_id, + request.project_workspace_path, + false, + ) + .await +} + +async fn transition( + remote: bool, + operation_id: String, + session_id: String, + project_workspace_path: Option, + enabled: bool, +) -> AppManagementResult { + validate_operation_id(&operation_id)?; + if remote { + return Err(worktree_error(WorktreeOperationError { + code: WorktreeErrorCode::RemoteUnsupported, + message: "Managed worktrees are not supported for remote workspaces".to_string(), + recovery_path: None, + operation_id: Some(operation_id), + })); + } + + let result = WorktreeService::bind_session(WorktreeSessionBindingRequest { + request_id: operation_id.clone(), + session_id, + project_workspace_path, + enabled, + }) + .await + .map_err(|error| worktree_error(project_error(error, Some(operation_id.clone()))))?; + let execution_target = result.execution_target.clone(); + Ok(WorktreeBindingResponse { + workspace_binding: AgentSessionWorkspaceBinding { + workspace_id: result.workspace_id, + workspace_path: result.workspace_path, + project_workspace_path: Some(result.project_workspace_path), + execution_target: Some(execution_target), + remote_connection_id: None, + remote_ssh_host: None, + }, + retained_worktree_path: result.retained_worktree_path, + }) +} + +fn validate_operation_id(operation_id: &str) -> AppManagementResult<()> { + if !operation_id.trim().is_empty() + && operation_id.len() <= 160 + && operation_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + Ok(()) + } else { + Err(AppManagementError::invalid_request( + "Worktree operation ID is invalid", + )) + } +} + +fn project_error(error: WorktreeError, operation_id: Option) -> WorktreeOperationError { + WorktreeOperationError { + code: error.code, + message: error.message, + recovery_path: error.recovery_path, + operation_id, + } +} + +fn worktree_error(error: WorktreeOperationError) -> AppManagementError { + AppManagementError::internal(error.encode()) +} diff --git a/src/crates/interfaces/app-server/src/server/handlers/external_source.rs b/src/crates/interfaces/app-server/src/server/handlers/external_source.rs index e749cea243..70437c6b82 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/external_source.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/external_source.rs @@ -13,33 +13,6 @@ pub(in crate::server) fn builder( AppServer .builder() .name("external source handlers") - .on_receive_request( - management_handler!( - management, - EXTERNAL_SOURCES_CAPABILITY, - ExternalApplicationSnapshotRequestV2, - external_application_snapshot_v2 - ), - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - management_handler!( - management, - EXTERNAL_SOURCES_CAPABILITY, - ExternalApplicationReviewPageRequest, - external_application_review_page_v2 - ), - agent_client_protocol::on_receive_request!(), - ) - .on_receive_request( - management_handler!( - management, - EXTERNAL_SOURCES_CAPABILITY, - ExternalApplicationActionRequest, - apply_external_application_action_v2 - ), - agent_client_protocol::on_receive_request!(), - ) .on_receive_request( management_handler!( management, diff --git a/src/crates/services/page-function-runtime/Cargo.toml b/src/crates/services/page-function-runtime/Cargo.toml index 0c834327f5..581a26dbc8 100644 --- a/src/crates/services/page-function-runtime/Cargo.toml +++ b/src/crates/services/page-function-runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitfun-page-function-runtime" -version = "0.2.16" +version = "0.2.17" authors = ["BitFun Team"] edition = "2021" description = "Embedded JS Page Function runtime for BitFun Pages (rquickjs)" diff --git a/src/crates/services/relay-service/Cargo.toml b/src/crates/services/relay-service/Cargo.toml index 3d90fb3812..b1777efb5f 100644 --- a/src/crates/services/relay-service/Cargo.toml +++ b/src/crates/services/relay-service/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitfun-relay-service" -version = "0.2.16" +version = "0.2.17" authors = ["BitFun Team"] edition = "2021" description = "Reusable relay runtime for BitFun Remote Connect" diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index 87749c8a27..ae34edff61 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -61,18 +61,19 @@ crate. ## Verification +Start from the capability that owns the change. Integration targets group test +source files with the same owner and feature closure; keep a focused run small +with `--test ::` instead of adding another Cargo +target. Representative stable entry points are: + ```bash cargo check -p bitfun-services-core --no-default-features cargo check -p bitfun-services-core --no-default-features --features filesystem -cargo test -p bitfun-services-core --no-default-features --features json-io --lib json_store -cargo test -p bitfun-services-core --no-default-features --features local-storage --test session_metadata_contracts +cargo test -p bitfun-services-core --no-default-features --features local-storage --test session_contracts session_metadata_contracts:: +cargo test -p bitfun-services-core --no-default-features --features local-storage --test session_write_lock_contracts cargo test -p bitfun-services-core --no-default-features --features process-runtime --test process_runtime_contracts -cargo test -p bitfun-services-core --no-default-features --features workspace-instructions --test declarative_workspace_instruction_contracts -cargo test -p bitfun-services-core --no-default-features --features lsp --test lsp_plugin_registry_contracts -cargo test -p bitfun-services-core --no-default-features --features session-git memory_workspace -cargo check -p bitfun-services-core --no-default-features --features workspace-identity -cargo test -p bitfun-services-core --no-default-features --features workspace-runtime workspace -cargo test -p bitfun-services-core --no-default-features --features runtime-ownership --test runtime_ownership_contracts -node scripts/check-core-boundaries.mjs -cargo check -p bitfun-core --features product-full +pnpm run check:core-boundaries ``` + +Other capability-specific target names remain in `Cargo.toml`; document a new +command here only when it becomes a recurring owner workflow. diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index 45e9a63f6f..253487755a 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true authors.workspace = true edition.workspace = true description = "BitFun core service owner crate" +autotests = false [lib] name = "bitfun_services_core" @@ -117,70 +118,66 @@ tokio = { workspace = true, features = ["macros"] } [[test]] name = "markdown_owner_contracts" +path = "tests/markdown_owner_contracts.rs" required-features = ["markdown"] [[test]] name = "declarative_workspace_instruction_contracts" +path = "tests/declarative_workspace_instruction_contracts.rs" required-features = ["workspace-instructions"] -[[test]] -name = "json_store_contracts" -required-features = ["local-storage"] - [[test]] name = "lsp_plugin_registry_contracts" +path = "tests/lsp_plugin_registry_contracts.rs" required-features = ["lsp"] [[test]] name = "runtime_ownership_contracts" +path = "tests/runtime_ownership_contracts.rs" required-features = ["runtime-ownership"] [[test]] name = "local_runtime_ports" +path = "tests/local_runtime_ports.rs" required-features = ["workspace-runtime"] [[test]] name = "permission_store_contracts" +path = "tests/permission_store_contracts.rs" required-features = ["permission"] [[test]] name = "workspace_instruction_contracts" +path = "tests/workspace_instruction_contracts.rs" required-features = ["workspace-instructions", "workspace-runtime"] [[test]] name = "session_write_lock_contracts" +path = "tests/session_write_lock_contracts.rs" required-features = ["local-storage"] [[test]] name = "process_runtime_contracts" +path = "tests/process_runtime_contracts.rs" required-features = ["process-runtime"] [[test]] -name = "session_contracts" -required-features = ["local-storage"] +name = "service_contracts" +path = "tests/service_contracts.rs" [[test]] -name = "session_layout_contracts" -required-features = ["local-storage"] - -[[test]] -name = "session_metadata_contracts" +name = "storage_owner_contracts" +path = "tests/storage_owner_contracts.rs" required-features = ["local-storage"] [[test]] -name = "session_page_contracts" +name = "session_contracts" +path = "tests/session_contracts.rs" required-features = ["local-storage"] [[test]] name = "session_usage_contracts" -required-features = ["local-storage"] - -[[test]] -name = "storage_owner_contracts" -required-features = ["local-storage"] - -[[test]] -name = "token_usage_contracts" +path = "tests/session_usage_contracts.rs" required-features = ["local-storage"] [lints] diff --git a/src/crates/services/services-core/src/dispatch_contract.rs b/src/crates/services/services-core/src/dispatch_contract.rs index e02a8d586d..462fcb302d 100644 --- a/src/crates/services/services-core/src/dispatch_contract.rs +++ b/src/crates/services/services-core/src/dispatch_contract.rs @@ -55,6 +55,39 @@ pub const DISPATCH_ACCOUNT_DAEMON_PROVISIONING_CAPABILITY: &str = "account_daemo pub const DISPATCH_ACCOUNT_DAEMON_PROVISIONING_SCHEMA_VERSION: u32 = 1; +/// Setup-audit action every target has accepted since the audit channel +/// existed. A target rejects a submission carrying any action it does not +/// know, so this list — not the controller's journal — bounds what may be +/// forwarded to an arbitrary target. +pub const DISPATCH_BASE_SETUP_AUDIT_ACTIONS: &[&str] = &["cli-install"]; + +/// Audit action for an automatic model-configuration push performed while +/// preparing a submission. +pub const DISPATCH_MODEL_SYNC_SETUP_AUDIT_ACTION: &str = "model-sync"; + +/// Optional capability: the target accepts [`DISPATCH_MODEL_SYNC_SETUP_AUDIT_ACTION`] +/// rows. Deliberately outside [`dispatch_required_target_capabilities`]: an +/// older CLI still runs the job perfectly well, it just cannot render the +/// controller's model-sync record, so the controller drops those rows instead +/// of failing an otherwise valid submission. +pub const DISPATCH_SETUP_AUDIT_MODEL_SYNC_CAPABILITY: &str = "setup_audit_model_sync"; + +/// Whether a target advertising `capabilities` accepts this audit action. +pub fn dispatch_target_accepts_setup_audit_action(action: &str, capabilities: &[&str]) -> bool { + DISPATCH_BASE_SETUP_AUDIT_ACTIONS.contains(&action) + || (action == DISPATCH_MODEL_SYNC_SETUP_AUDIT_ACTION + && capabilities.contains(&DISPATCH_SETUP_AUDIT_MODEL_SYNC_CAPABILITY)) +} + +/// Every audit action a target of *this* build accepts, used by the target's +/// own request validation. +pub fn dispatch_supported_setup_audit_actions() -> impl Iterator { + DISPATCH_BASE_SETUP_AUDIT_ACTIONS + .iter() + .copied() + .chain(std::iter::once(DISPATCH_MODEL_SYNC_SETUP_AUDIT_ACTION)) +} + /// Non-secret identity returned by the target before the relay issues a token. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -158,6 +191,32 @@ mod tests { assert!(required.contains(capability)); } assert!(!required.contains(&DISPATCH_ACCOUNT_DAEMON_PROVISIONING_CAPABILITY)); + assert!(!required.contains(&DISPATCH_SETUP_AUDIT_MODEL_SYNC_CAPABILITY)); + } + + #[test] + fn model_sync_audit_rows_need_the_optional_capability() { + assert!(dispatch_target_accepts_setup_audit_action( + "cli-install", + &[] + )); + assert!(!dispatch_target_accepts_setup_audit_action( + DISPATCH_MODEL_SYNC_SETUP_AUDIT_ACTION, + &[] + )); + assert!(dispatch_target_accepts_setup_audit_action( + DISPATCH_MODEL_SYNC_SETUP_AUDIT_ACTION, + &[DISPATCH_SETUP_AUDIT_MODEL_SYNC_CAPABILITY] + )); + assert!(!dispatch_target_accepts_setup_audit_action( + "something-else", + &[DISPATCH_SETUP_AUDIT_MODEL_SYNC_CAPABILITY] + )); + let supported: Vec<&str> = dispatch_supported_setup_audit_actions().collect(); + assert!(supported.contains(&DISPATCH_MODEL_SYNC_SETUP_AUDIT_ACTION)); + for action in DISPATCH_BASE_SETUP_AUDIT_ACTIONS { + assert!(supported.contains(action)); + } } #[test] diff --git a/src/crates/services/services-core/tests/service_contracts.rs b/src/crates/services/services-core/tests/service_contracts.rs index 26d293f8b6..a105a86652 100644 --- a/src/crates/services/services-core/tests/service_contracts.rs +++ b/src/crates/services/services-core/tests/service_contracts.rs @@ -1,17 +1,6 @@ -use bitfun_services_core::diff::{DiffConfig, DiffLineType, DiffService}; - -#[test] -fn diff_service_preserves_line_count_contract() { - let service = DiffService::new(DiffConfig::new()); - let result = service.compute_diff("one\ntwo\n", "one\nthree\n"); - - assert_eq!(result.additions, 1); - assert_eq!(result.deletions, 1); - assert_eq!(result.changes, 2); - assert_eq!(result.hunks.len(), 1); - assert!(result - .hunks - .iter() - .flat_map(|hunk| hunk.lines.iter()) - .any(|line| line.line_type == DiffLineType::Add && line.content == "three")); -} +#[path = "service_contracts/diagnostic_log_redaction.rs"] +mod diagnostic_log_redaction; +#[path = "service_contracts/jsonc_contracts.rs"] +mod jsonc_contracts; +#[path = "service_contracts/service_contracts.rs"] +mod service_contracts; diff --git a/src/crates/services/services-core/tests/diagnostic_log_redaction.rs b/src/crates/services/services-core/tests/service_contracts/diagnostic_log_redaction.rs similarity index 100% rename from src/crates/services/services-core/tests/diagnostic_log_redaction.rs rename to src/crates/services/services-core/tests/service_contracts/diagnostic_log_redaction.rs diff --git a/src/crates/services/services-core/tests/jsonc_contracts.rs b/src/crates/services/services-core/tests/service_contracts/jsonc_contracts.rs similarity index 100% rename from src/crates/services/services-core/tests/jsonc_contracts.rs rename to src/crates/services/services-core/tests/service_contracts/jsonc_contracts.rs diff --git a/src/crates/services/services-core/tests/service_contracts/service_contracts.rs b/src/crates/services/services-core/tests/service_contracts/service_contracts.rs new file mode 100644 index 0000000000..26d293f8b6 --- /dev/null +++ b/src/crates/services/services-core/tests/service_contracts/service_contracts.rs @@ -0,0 +1,17 @@ +use bitfun_services_core::diff::{DiffConfig, DiffLineType, DiffService}; + +#[test] +fn diff_service_preserves_line_count_contract() { + let service = DiffService::new(DiffConfig::new()); + let result = service.compute_diff("one\ntwo\n", "one\nthree\n"); + + assert_eq!(result.additions, 1); + assert_eq!(result.deletions, 1); + assert_eq!(result.changes, 2); + assert_eq!(result.hunks.len(), 1); + assert!(result + .hunks + .iter() + .flat_map(|hunk| hunk.lines.iter()) + .any(|line| line.line_type == DiffLineType::Add && line.content == "three")); +} diff --git a/src/crates/services/services-core/tests/session_contracts.rs b/src/crates/services/services-core/tests/session_contracts.rs index 1ad2858a23..7d8eb66a96 100644 --- a/src/crates/services/services-core/tests/session_contracts.rs +++ b/src/crates/services/services-core/tests/session_contracts.rs @@ -1,40 +1,10 @@ #![cfg(feature = "local-storage")] -use bitfun_services_core::session::{DialogTurnKind, SessionKind, SessionMetadata}; - -#[test] -fn session_metadata_preserves_subagent_visibility_contract() { - let mut metadata = SessionMetadata::new( - "session-1".to_string(), - "Subagent: inspect".to_string(), - "Explore".to_string(), - "model".to_string(), - ); - metadata.session_kind = SessionKind::Subagent; - - assert!(metadata.is_subagent()); - assert!(metadata.should_hide_from_user_lists()); -} - -#[test] -fn session_metadata_hides_ephemeral_child_sessions_from_user_lists() { - let mut metadata = SessionMetadata::new( - "session-ephemeral".to_string(), - "Side thread".to_string(), - "agentic".to_string(), - "model".to_string(), - ); - metadata.session_kind = SessionKind::EphemeralChild; - - assert!(!metadata.is_subagent()); - assert!(metadata.is_internal_hidden()); - assert!(metadata.should_hide_from_user_lists()); -} - -#[test] -fn dialog_turn_kind_preserves_default_visibility_contract() { - assert_eq!(DialogTurnKind::default(), DialogTurnKind::UserDialog); - assert!(DialogTurnKind::UserDialog.is_model_visible()); - assert!(!DialogTurnKind::ManualCompaction.is_model_visible()); - assert!(!DialogTurnKind::LocalCommand.is_model_visible()); -} +#[path = "session_contracts/session_contracts.rs"] +mod session_contracts; +#[path = "session_contracts/session_layout_contracts.rs"] +mod session_layout_contracts; +#[path = "session_contracts/session_metadata_contracts.rs"] +mod session_metadata_contracts; +#[path = "session_contracts/session_page_contracts.rs"] +mod session_page_contracts; diff --git a/src/crates/services/services-core/tests/session_contracts/session_contracts.rs b/src/crates/services/services-core/tests/session_contracts/session_contracts.rs new file mode 100644 index 0000000000..08c98937a3 --- /dev/null +++ b/src/crates/services/services-core/tests/session_contracts/session_contracts.rs @@ -0,0 +1,38 @@ +use bitfun_services_core::session::{DialogTurnKind, SessionKind, SessionMetadata}; + +#[test] +fn session_metadata_preserves_subagent_visibility_contract() { + let mut metadata = SessionMetadata::new( + "session-1".to_string(), + "Subagent: inspect".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + metadata.session_kind = SessionKind::Subagent; + + assert!(metadata.is_subagent()); + assert!(metadata.should_hide_from_user_lists()); +} + +#[test] +fn session_metadata_hides_ephemeral_child_sessions_from_user_lists() { + let mut metadata = SessionMetadata::new( + "session-ephemeral".to_string(), + "Side thread".to_string(), + "agentic".to_string(), + "model".to_string(), + ); + metadata.session_kind = SessionKind::EphemeralChild; + + assert!(!metadata.is_subagent()); + assert!(metadata.is_internal_hidden()); + assert!(metadata.should_hide_from_user_lists()); +} + +#[test] +fn dialog_turn_kind_preserves_default_visibility_contract() { + assert_eq!(DialogTurnKind::default(), DialogTurnKind::UserDialog); + assert!(DialogTurnKind::UserDialog.is_model_visible()); + assert!(!DialogTurnKind::ManualCompaction.is_model_visible()); + assert!(!DialogTurnKind::LocalCommand.is_model_visible()); +} diff --git a/src/crates/services/services-core/tests/session_layout_contracts.rs b/src/crates/services/services-core/tests/session_contracts/session_layout_contracts.rs similarity index 99% rename from src/crates/services/services-core/tests/session_layout_contracts.rs rename to src/crates/services/services-core/tests/session_contracts/session_layout_contracts.rs index ed07625580..a5df9fb14c 100644 --- a/src/crates/services/services-core/tests/session_layout_contracts.rs +++ b/src/crates/services/services-core/tests/session_contracts/session_layout_contracts.rs @@ -1,5 +1,3 @@ -#![cfg(feature = "local-storage")] - use bitfun_services_core::session::SessionStorageLayout; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; diff --git a/src/crates/services/services-core/tests/session_metadata_contracts.rs b/src/crates/services/services-core/tests/session_contracts/session_metadata_contracts.rs similarity index 99% rename from src/crates/services/services-core/tests/session_metadata_contracts.rs rename to src/crates/services/services-core/tests/session_contracts/session_metadata_contracts.rs index 629a831678..8f088b51f5 100644 --- a/src/crates/services/services-core/tests/session_metadata_contracts.rs +++ b/src/crates/services/services-core/tests/session_contracts/session_metadata_contracts.rs @@ -1,5 +1,3 @@ -#![cfg(feature = "local-storage")] - use bitfun_services_core::session::{ build_session_index_snapshot, refresh_session_metadata_from_turns, remove_session_index_entry, try_refresh_session_metadata_for_saved_turn, upsert_session_index_entry, DialogTurnData, diff --git a/src/crates/services/services-core/tests/session_page_contracts.rs b/src/crates/services/services-core/tests/session_contracts/session_page_contracts.rs similarity index 98% rename from src/crates/services/services-core/tests/session_page_contracts.rs rename to src/crates/services/services-core/tests/session_contracts/session_page_contracts.rs index 4139f1b48c..e93c55394a 100644 --- a/src/crates/services/services-core/tests/session_page_contracts.rs +++ b/src/crates/services/services-core/tests/session_contracts/session_page_contracts.rs @@ -1,5 +1,3 @@ -#![cfg(feature = "local-storage")] - use bitfun_core_types::SessionKind; use bitfun_services_core::session::{ build_session_metadata_page, SessionMetadata, SessionRelationship, SessionRelationshipKind, diff --git a/src/crates/services/services-core/tests/session_usage_contracts.rs b/src/crates/services/services-core/tests/session_usage_contracts.rs index bacdc58193..d3b2a1f316 100644 --- a/src/crates/services/services-core/tests/session_usage_contracts.rs +++ b/src/crates/services/services-core/tests/session_usage_contracts.rs @@ -1,36 +1,6 @@ #![cfg(feature = "local-storage")] -use bitfun_services_core::session_usage::{ - classify_tool_usage, display_workspace_relative_path, render_usage_report_terminal, - SessionUsageReport, UsageToolCategory, -}; - -#[test] -fn usage_classifier_preserves_git_command_detection() { - let input = serde_json::json!({ "command": "git status --short" }); - - assert_eq!( - classify_tool_usage("execute_command", Some(&input)), - UsageToolCategory::Git - ); -} - -#[test] -fn usage_path_redaction_preserves_workspace_relative_display() { - let label = display_workspace_relative_path( - Some("D:/workspace/bitfun"), - "D:/workspace/bitfun/src/main.rs", - ); - - assert_eq!(label.value, "src/main.rs"); - assert!(!label.redacted); -} - -#[test] -fn usage_terminal_renderer_preserves_schema_label() { - let report = SessionUsageReport::partial_unavailable("session-1".to_string(), 42); - - let rendered = render_usage_report_terminal(&report); - - assert!(rendered.contains("session-1")); -} +#[path = "session_usage_contracts/session_usage_contracts.rs"] +mod session_usage_contracts; +#[path = "session_usage_contracts/token_usage_contracts.rs"] +mod token_usage_contracts; diff --git a/src/crates/services/services-core/tests/session_usage_contracts/session_usage_contracts.rs b/src/crates/services/services-core/tests/session_usage_contracts/session_usage_contracts.rs new file mode 100644 index 0000000000..a02ce8d262 --- /dev/null +++ b/src/crates/services/services-core/tests/session_usage_contracts/session_usage_contracts.rs @@ -0,0 +1,34 @@ +use bitfun_services_core::session_usage::{ + classify_tool_usage, display_workspace_relative_path, render_usage_report_terminal, + SessionUsageReport, UsageToolCategory, +}; + +#[test] +fn usage_classifier_preserves_git_command_detection() { + let input = serde_json::json!({ "command": "git status --short" }); + + assert_eq!( + classify_tool_usage("execute_command", Some(&input)), + UsageToolCategory::Git + ); +} + +#[test] +fn usage_path_redaction_preserves_workspace_relative_display() { + let label = display_workspace_relative_path( + Some("D:/workspace/bitfun"), + "D:/workspace/bitfun/src/main.rs", + ); + + assert_eq!(label.value, "src/main.rs"); + assert!(!label.redacted); +} + +#[test] +fn usage_terminal_renderer_preserves_schema_label() { + let report = SessionUsageReport::partial_unavailable("session-1".to_string(), 42); + + let rendered = render_usage_report_terminal(&report); + + assert!(rendered.contains("session-1")); +} diff --git a/src/crates/services/services-core/tests/token_usage_contracts.rs b/src/crates/services/services-core/tests/session_usage_contracts/token_usage_contracts.rs similarity index 98% rename from src/crates/services/services-core/tests/token_usage_contracts.rs rename to src/crates/services/services-core/tests/session_usage_contracts/token_usage_contracts.rs index 7a365c25e1..bc7cfae851 100644 --- a/src/crates/services/services-core/tests/token_usage_contracts.rs +++ b/src/crates/services/services-core/tests/session_usage_contracts/token_usage_contracts.rs @@ -1,5 +1,3 @@ -#![cfg(feature = "local-storage")] - use bitfun_services_core::token_usage::{ ModelTokenStats, SessionTokenStats, TimeRange, TokenUsageQuery, TokenUsageRecord, }; diff --git a/src/crates/services/services-core/tests/storage_owner_contracts.rs b/src/crates/services/services-core/tests/storage_owner_contracts.rs index bf03c4ceb8..4db583a104 100644 --- a/src/crates/services/services-core/tests/storage_owner_contracts.rs +++ b/src/crates/services/services-core/tests/storage_owner_contracts.rs @@ -1,342 +1,6 @@ #![cfg(feature = "local-storage")] -use bitfun_services_core::persistence::{PersistenceService, StorageOptions}; -use bitfun_services_core::storage_cleanup::{CleanupPolicy, CleanupRoots, CleanupService}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::fs; -use std::time::{Duration, SystemTime}; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct DemoRecord { - name: String, - count: u32, -} - -#[tokio::test] -async fn persistence_service_keeps_atomic_json_shape_and_backups() { - let temp = tempfile::tempdir().expect("tempdir"); - let service = PersistenceService::new(temp.path().join("store")) - .await - .expect("service"); - - service - .save_json( - "demo", - &DemoRecord { - name: "first".to_string(), - count: 1, - }, - StorageOptions::default(), - ) - .await - .expect("first save"); - service - .save_json( - "demo", - &DemoRecord { - name: "second".to_string(), - count: 2, - }, - StorageOptions::default(), - ) - .await - .expect("second save"); - - let loaded: DemoRecord = service - .load_json("demo") - .await - .expect("load") - .expect("record"); - assert_eq!( - loaded, - DemoRecord { - name: "second".to_string(), - count: 2 - } - ); - - let backups = fs::read_dir(temp.path().join("store").join("backups")) - .expect("backup dir") - .count(); - assert_eq!(backups, 1); - assert!(service.delete("demo").await.expect("delete")); - assert!(service - .load_json::("demo") - .await - .expect("load missing") - .is_none()); -} - -#[tokio::test] -async fn cleanup_service_deletes_old_temp_and_log_files_without_product_paths() { - let temp = tempfile::tempdir().expect("tempdir"); - let temp_dir = temp.path().join("temp"); - let logs_dir = temp.path().join("logs"); - let cache_dir = temp.path().join("cache"); - fs::create_dir_all(&temp_dir).expect("temp dir"); - fs::create_dir_all(&logs_dir).expect("logs dir"); - fs::create_dir_all(&cache_dir).expect("cache dir"); - - let old_temp_file = temp_dir.join("old.tmp"); - let old_log_file = logs_dir.join("old.log"); - fs::write(&old_temp_file, "old temp").expect("old temp"); - fs::write(&old_log_file, "old log").expect("old log"); - - let old_time = filetime::FileTime::from_system_time( - SystemTime::now() - Duration::from_secs(60 * 60 * 24 * 10), - ); - filetime::set_file_mtime(&old_temp_file, old_time).expect("mtime temp"); - filetime::set_file_mtime(&old_log_file, old_time).expect("mtime log"); - - let service = CleanupService::new( - CleanupRoots { - temp_dir, - logs_dir, - cache_dir, - }, - CleanupPolicy { - temp_retention_days: 7, - log_retention_days: 7, - ..CleanupPolicy::default() - }, - ); - - let result = service.cleanup_all().await.expect("cleanup"); - assert_eq!(result.files_deleted, 2); - assert!(!old_temp_file.exists()); - assert!(!old_log_file.exists()); -} - -#[tokio::test] -async fn cleanup_service_trims_oldest_cache_files_when_size_exceeds_policy() { - let temp = tempfile::tempdir().expect("tempdir"); - let temp_dir = temp.path().join("temp"); - let logs_dir = temp.path().join("logs"); - let cache_dir = temp.path().join("cache"); - fs::create_dir_all(&temp_dir).expect("temp dir"); - fs::create_dir_all(&logs_dir).expect("logs dir"); - fs::create_dir_all(&cache_dir).expect("cache dir"); - - let newest_file = cache_dir.join("newest.bin"); - let middle_file = cache_dir.join("middle.bin"); - let oldest_file = cache_dir.join("oldest.bin"); - let two_mb = vec![b'x'; 2 * 1_048_576]; - fs::write(&newest_file, &two_mb).expect("newest"); - fs::write(&middle_file, &two_mb).expect("middle"); - fs::write(&oldest_file, &two_mb).expect("oldest"); - - let now = SystemTime::now(); - filetime::set_file_mtime( - &newest_file, - filetime::FileTime::from_system_time(now - Duration::from_secs(60)), - ) - .expect("newest mtime"); - filetime::set_file_mtime( - &middle_file, - filetime::FileTime::from_system_time(now - Duration::from_secs(120)), - ) - .expect("middle mtime"); - filetime::set_file_mtime( - &oldest_file, - filetime::FileTime::from_system_time(now - Duration::from_secs(180)), - ) - .expect("oldest mtime"); - - let service = CleanupService::new( - CleanupRoots { - temp_dir, - logs_dir, - cache_dir, - }, - CleanupPolicy { - max_cache_size_mb: 4, - ..CleanupPolicy::default() - }, - ); - - let result = service.cleanup_all().await.expect("cleanup"); - - assert_eq!(result.files_deleted, 1); - assert_eq!(result.bytes_freed, two_mb.len() as u64); - assert!(newest_file.exists()); - assert!(middle_file.exists()); - assert!(!oldest_file.exists()); - assert_eq!(result.categories.len(), 1); - assert_eq!(result.categories[0].name, "Oversized Cache"); -} - -#[tokio::test] -async fn token_usage_service_persists_records_and_filters_subagents_by_default() { - let temp = tempfile::tempdir().expect("tempdir"); - let service = - bitfun_services_core::token_usage::TokenUsageService::new(temp.path().to_path_buf()) - .await - .expect("service"); - - service - .record_usage( - "model-config-a".to_string(), - "model-a".to_string(), - "session-a".to_string(), - "turn-a".to_string(), - 100, - 40, - Some(30), - Some(json!({ "cacheCreationTokenCount": 12 })), - false, - ) - .await - .expect("record main"); - service - .record_usage( - "model-config-a".to_string(), - "model-a".to_string(), - "session-a".to_string(), - "turn-sub".to_string(), - 50, - 10, - None, - None, - true, - ) - .await - .expect("record subagent"); - - let summary = service - .get_summary(bitfun_services_core::token_usage::TokenUsageQuery { - model_id: Some("model-a".to_string()), - session_id: None, - time_range: bitfun_services_core::token_usage::TimeRange::All, - limit: None, - offset: None, - include_subagent: false, - }) - .await - .expect("summary"); - - assert_eq!(summary.record_count, 1); - assert_eq!(summary.total_input, 100); - assert_eq!(summary.total_cached, 30); - assert_eq!(summary.total_cache_write, 12); - - let reloaded = - bitfun_services_core::token_usage::TokenUsageService::new(temp.path().to_path_buf()) - .await - .expect("reloaded"); - let stats = reloaded - .get_model_stats("model-a") - .await - .expect("model stats"); - assert_eq!(stats.request_count, 2); - assert_eq!(stats.total_input, 150); -} - -#[tokio::test] -async fn token_usage_clear_does_not_replay_cached_record_batches() { - let temp = tempfile::tempdir().expect("tempdir"); - let service = - bitfun_services_core::token_usage::TokenUsageService::new(temp.path().to_path_buf()) - .await - .expect("service"); - - service - .record_usage( - "model-config-old".to_string(), - "model-old".to_string(), - "session-old".to_string(), - "turn-old".to_string(), - 10, - 5, - None, - None, - false, - ) - .await - .expect("record old usage"); - service.clear_all_stats().await.expect("clear usage"); - service - .record_usage( - "model-config-new".to_string(), - "model-new".to_string(), - "session-new".to_string(), - "turn-new".to_string(), - 20, - 7, - None, - None, - false, - ) - .await - .expect("record new usage"); - - let summary = service - .get_summary(bitfun_services_core::token_usage::TokenUsageQuery { - model_id: None, - session_id: None, - time_range: bitfun_services_core::token_usage::TimeRange::All, - limit: None, - offset: None, - include_subagent: true, - }) - .await - .expect("summary after clear"); - - assert_eq!(summary.record_count, 1); - assert_eq!(summary.total_input, 20); - assert!(service.get_model_stats("model-old").await.is_none()); - assert_eq!( - service - .get_model_stats("model-new") - .await - .expect("new model stats") - .request_count, - 1 - ); -} - -#[tokio::test] -async fn token_usage_all_range_ignores_non_date_record_files() { - let temp = tempfile::tempdir().expect("tempdir"); - let service = - bitfun_services_core::token_usage::TokenUsageService::new(temp.path().to_path_buf()) - .await - .expect("service"); - - service - .record_usage( - "model-config-a".to_string(), - "model-a".to_string(), - "session-a".to_string(), - "turn-a".to_string(), - 10, - 1, - None, - None, - false, - ) - .await - .expect("record usage"); - - let records_dir = temp.path().join("records"); - fs::write( - records_dir.join("manual-backup.json"), - r#"{"records":[{"model_id":"model-b","session_id":"session-b","turn_id":"turn-b","timestamp":"2026-07-07T00:00:00Z","input_tokens":999,"output_tokens":1,"cached_tokens":0,"cached_tokens_available":false,"cache_write_tokens":0,"total_tokens":1000,"token_details":null,"is_subagent":false}]}"#, - ) - .expect("write stray record file"); - - let summary = service - .get_summary(bitfun_services_core::token_usage::TokenUsageQuery { - model_id: None, - session_id: None, - time_range: bitfun_services_core::token_usage::TimeRange::All, - limit: None, - offset: None, - include_subagent: true, - }) - .await - .expect("summary"); - - assert_eq!(summary.record_count, 1); - assert_eq!(summary.total_input, 10); -} +#[path = "storage_owner_contracts/json_store_contracts.rs"] +mod json_store_contracts; +#[path = "storage_owner_contracts/storage_owner_contracts.rs"] +mod storage_owner_contracts; diff --git a/src/crates/services/services-core/tests/json_store_contracts.rs b/src/crates/services/services-core/tests/storage_owner_contracts/json_store_contracts.rs similarity index 99% rename from src/crates/services/services-core/tests/json_store_contracts.rs rename to src/crates/services/services-core/tests/storage_owner_contracts/json_store_contracts.rs index 0f45850b99..e1b637bd49 100644 --- a/src/crates/services/services-core/tests/json_store_contracts.rs +++ b/src/crates/services/services-core/tests/storage_owner_contracts/json_store_contracts.rs @@ -1,5 +1,3 @@ -#![cfg(feature = "local-storage")] - use bitfun_services_core::json_store::{JsonFileStore, JsonFileStoreError}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; diff --git a/src/crates/services/services-core/tests/storage_owner_contracts/storage_owner_contracts.rs b/src/crates/services/services-core/tests/storage_owner_contracts/storage_owner_contracts.rs new file mode 100644 index 0000000000..f5aa3d3c57 --- /dev/null +++ b/src/crates/services/services-core/tests/storage_owner_contracts/storage_owner_contracts.rs @@ -0,0 +1,340 @@ +use bitfun_services_core::persistence::{PersistenceService, StorageOptions}; +use bitfun_services_core::storage_cleanup::{CleanupPolicy, CleanupRoots, CleanupService}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::fs; +use std::time::{Duration, SystemTime}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct DemoRecord { + name: String, + count: u32, +} + +#[tokio::test] +async fn persistence_service_keeps_atomic_json_shape_and_backups() { + let temp = tempfile::tempdir().expect("tempdir"); + let service = PersistenceService::new(temp.path().join("store")) + .await + .expect("service"); + + service + .save_json( + "demo", + &DemoRecord { + name: "first".to_string(), + count: 1, + }, + StorageOptions::default(), + ) + .await + .expect("first save"); + service + .save_json( + "demo", + &DemoRecord { + name: "second".to_string(), + count: 2, + }, + StorageOptions::default(), + ) + .await + .expect("second save"); + + let loaded: DemoRecord = service + .load_json("demo") + .await + .expect("load") + .expect("record"); + assert_eq!( + loaded, + DemoRecord { + name: "second".to_string(), + count: 2 + } + ); + + let backups = fs::read_dir(temp.path().join("store").join("backups")) + .expect("backup dir") + .count(); + assert_eq!(backups, 1); + assert!(service.delete("demo").await.expect("delete")); + assert!(service + .load_json::("demo") + .await + .expect("load missing") + .is_none()); +} + +#[tokio::test] +async fn cleanup_service_deletes_old_temp_and_log_files_without_product_paths() { + let temp = tempfile::tempdir().expect("tempdir"); + let temp_dir = temp.path().join("temp"); + let logs_dir = temp.path().join("logs"); + let cache_dir = temp.path().join("cache"); + fs::create_dir_all(&temp_dir).expect("temp dir"); + fs::create_dir_all(&logs_dir).expect("logs dir"); + fs::create_dir_all(&cache_dir).expect("cache dir"); + + let old_temp_file = temp_dir.join("old.tmp"); + let old_log_file = logs_dir.join("old.log"); + fs::write(&old_temp_file, "old temp").expect("old temp"); + fs::write(&old_log_file, "old log").expect("old log"); + + let old_time = filetime::FileTime::from_system_time( + SystemTime::now() - Duration::from_secs(60 * 60 * 24 * 10), + ); + filetime::set_file_mtime(&old_temp_file, old_time).expect("mtime temp"); + filetime::set_file_mtime(&old_log_file, old_time).expect("mtime log"); + + let service = CleanupService::new( + CleanupRoots { + temp_dir, + logs_dir, + cache_dir, + }, + CleanupPolicy { + temp_retention_days: 7, + log_retention_days: 7, + ..CleanupPolicy::default() + }, + ); + + let result = service.cleanup_all().await.expect("cleanup"); + assert_eq!(result.files_deleted, 2); + assert!(!old_temp_file.exists()); + assert!(!old_log_file.exists()); +} + +#[tokio::test] +async fn cleanup_service_trims_oldest_cache_files_when_size_exceeds_policy() { + let temp = tempfile::tempdir().expect("tempdir"); + let temp_dir = temp.path().join("temp"); + let logs_dir = temp.path().join("logs"); + let cache_dir = temp.path().join("cache"); + fs::create_dir_all(&temp_dir).expect("temp dir"); + fs::create_dir_all(&logs_dir).expect("logs dir"); + fs::create_dir_all(&cache_dir).expect("cache dir"); + + let newest_file = cache_dir.join("newest.bin"); + let middle_file = cache_dir.join("middle.bin"); + let oldest_file = cache_dir.join("oldest.bin"); + let two_mb = vec![b'x'; 2 * 1_048_576]; + fs::write(&newest_file, &two_mb).expect("newest"); + fs::write(&middle_file, &two_mb).expect("middle"); + fs::write(&oldest_file, &two_mb).expect("oldest"); + + let now = SystemTime::now(); + filetime::set_file_mtime( + &newest_file, + filetime::FileTime::from_system_time(now - Duration::from_secs(60)), + ) + .expect("newest mtime"); + filetime::set_file_mtime( + &middle_file, + filetime::FileTime::from_system_time(now - Duration::from_secs(120)), + ) + .expect("middle mtime"); + filetime::set_file_mtime( + &oldest_file, + filetime::FileTime::from_system_time(now - Duration::from_secs(180)), + ) + .expect("oldest mtime"); + + let service = CleanupService::new( + CleanupRoots { + temp_dir, + logs_dir, + cache_dir, + }, + CleanupPolicy { + max_cache_size_mb: 4, + ..CleanupPolicy::default() + }, + ); + + let result = service.cleanup_all().await.expect("cleanup"); + + assert_eq!(result.files_deleted, 1); + assert_eq!(result.bytes_freed, two_mb.len() as u64); + assert!(newest_file.exists()); + assert!(middle_file.exists()); + assert!(!oldest_file.exists()); + assert_eq!(result.categories.len(), 1); + assert_eq!(result.categories[0].name, "Oversized Cache"); +} + +#[tokio::test] +async fn token_usage_service_persists_records_and_filters_subagents_by_default() { + let temp = tempfile::tempdir().expect("tempdir"); + let service = + bitfun_services_core::token_usage::TokenUsageService::new(temp.path().to_path_buf()) + .await + .expect("service"); + + service + .record_usage( + "model-config-a".to_string(), + "model-a".to_string(), + "session-a".to_string(), + "turn-a".to_string(), + 100, + 40, + Some(30), + Some(json!({ "cacheCreationTokenCount": 12 })), + false, + ) + .await + .expect("record main"); + service + .record_usage( + "model-config-a".to_string(), + "model-a".to_string(), + "session-a".to_string(), + "turn-sub".to_string(), + 50, + 10, + None, + None, + true, + ) + .await + .expect("record subagent"); + + let summary = service + .get_summary(bitfun_services_core::token_usage::TokenUsageQuery { + model_id: Some("model-a".to_string()), + session_id: None, + time_range: bitfun_services_core::token_usage::TimeRange::All, + limit: None, + offset: None, + include_subagent: false, + }) + .await + .expect("summary"); + + assert_eq!(summary.record_count, 1); + assert_eq!(summary.total_input, 100); + assert_eq!(summary.total_cached, 30); + assert_eq!(summary.total_cache_write, 12); + + let reloaded = + bitfun_services_core::token_usage::TokenUsageService::new(temp.path().to_path_buf()) + .await + .expect("reloaded"); + let stats = reloaded + .get_model_stats("model-a") + .await + .expect("model stats"); + assert_eq!(stats.request_count, 2); + assert_eq!(stats.total_input, 150); +} + +#[tokio::test] +async fn token_usage_clear_does_not_replay_cached_record_batches() { + let temp = tempfile::tempdir().expect("tempdir"); + let service = + bitfun_services_core::token_usage::TokenUsageService::new(temp.path().to_path_buf()) + .await + .expect("service"); + + service + .record_usage( + "model-config-old".to_string(), + "model-old".to_string(), + "session-old".to_string(), + "turn-old".to_string(), + 10, + 5, + None, + None, + false, + ) + .await + .expect("record old usage"); + service.clear_all_stats().await.expect("clear usage"); + service + .record_usage( + "model-config-new".to_string(), + "model-new".to_string(), + "session-new".to_string(), + "turn-new".to_string(), + 20, + 7, + None, + None, + false, + ) + .await + .expect("record new usage"); + + let summary = service + .get_summary(bitfun_services_core::token_usage::TokenUsageQuery { + model_id: None, + session_id: None, + time_range: bitfun_services_core::token_usage::TimeRange::All, + limit: None, + offset: None, + include_subagent: true, + }) + .await + .expect("summary after clear"); + + assert_eq!(summary.record_count, 1); + assert_eq!(summary.total_input, 20); + assert!(service.get_model_stats("model-old").await.is_none()); + assert_eq!( + service + .get_model_stats("model-new") + .await + .expect("new model stats") + .request_count, + 1 + ); +} + +#[tokio::test] +async fn token_usage_all_range_ignores_non_date_record_files() { + let temp = tempfile::tempdir().expect("tempdir"); + let service = + bitfun_services_core::token_usage::TokenUsageService::new(temp.path().to_path_buf()) + .await + .expect("service"); + + service + .record_usage( + "model-config-a".to_string(), + "model-a".to_string(), + "session-a".to_string(), + "turn-a".to_string(), + 10, + 1, + None, + None, + false, + ) + .await + .expect("record usage"); + + let records_dir = temp.path().join("records"); + fs::write( + records_dir.join("manual-backup.json"), + r#"{"records":[{"model_id":"model-b","session_id":"session-b","turn_id":"turn-b","timestamp":"2026-07-07T00:00:00Z","input_tokens":999,"output_tokens":1,"cached_tokens":0,"cached_tokens_available":false,"cache_write_tokens":0,"total_tokens":1000,"token_details":null,"is_subagent":false}]}"#, + ) + .expect("write stray record file"); + + let summary = service + .get_summary(bitfun_services_core::token_usage::TokenUsageQuery { + model_id: None, + session_id: None, + time_range: bitfun_services_core::token_usage::TimeRange::All, + limit: None, + offset: None, + include_subagent: true, + }) + .await + .expect("summary"); + + assert_eq!(summary.record_count, 1); + assert_eq!(summary.total_input, 10); +} diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 62065f3490..3e68955033 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -96,13 +96,19 @@ slices that are outside pure product logic but still platform-neutral. ## Verification +Select one integration family and its minimum feature set. Remote SSH uses a +grouped target for tests within the same boundary; use +`--test ::` for a single source module instead of +creating another Cargo target. Real transport/system boundaries such as MCP +streamable HTTP stay independent. Representative stable entry points are: + ```bash -cargo test -p bitfun-services-integrations -cargo test -p bitfun-services-integrations --no-default-features --features plugin-source plugin_source --lib -cargo test -p bitfun-services-integrations --features debug-log --test debug_log_owner_contracts -cargo test -p bitfun-services-integrations --features remote-ssh --test remote_ssh_disabled_contracts -cargo test -p bitfun-services-integrations --features remote-ssh,workspace-search --test remote_workspace_search_disabled_contracts -cargo test -p bitfun-services-integrations --features remote-ssh,remote-ssh-concrete,workspace-search remote_ssh -node scripts/check-core-boundaries.mjs -cargo check -p bitfun-core --features product-full +cargo check -p bitfun-services-integrations --no-default-features +cargo test -p bitfun-services-integrations --no-default-features --features mcp --test mcp_contracts +cargo test -p bitfun-services-integrations --no-default-features --features remote-ssh --test remote_ssh_contracts remote_ssh_disabled_contracts:: +cargo test -p bitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts +pnpm run check:core-boundaries ``` + +Other family-specific targets remain in `Cargo.toml`; add a guide command only +for a recurring workflow, not to mirror every test target. diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 5e26453875..0efb3779bf 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true authors.workspace = true edition.workspace = true description = "BitFun integration service owner crate" +autotests = false [lib] name = "bitfun_services_integrations" @@ -428,54 +429,62 @@ tokio = { workspace = true, features = ["io-util", "macros", "net", "rt", "test- [[test]] name = "debug_log_owner_contracts" +path = "tests/debug_log_owner_contracts.rs" required-features = ["debug-log"] [[test]] name = "script_tool_runtime" +path = "tests/script_tool_runtime.rs" required-features = ["script-tool-runtime"] [[test]] name = "announcement_contracts" +path = "tests/announcement_contracts.rs" required-features = ["announcement"] [[test]] name = "file_watch_contracts" +path = "tests/file_watch_contracts.rs" required-features = ["file-watch"] [[test]] name = "function_agent_contracts" +path = "tests/function_agent_contracts.rs" required-features = ["function-agents"] [[test]] name = "git_contracts" +path = "tests/git_contracts.rs" required-features = ["git"] [[test]] name = "mcp_contracts" +path = "tests/mcp_contracts.rs" required-features = ["mcp"] [[test]] name = "mcp_streamable_http_contracts" +path = "tests/mcp_streamable_http_contracts.rs" required-features = ["mcp"] [[test]] name = "remote_connect_contracts" +path = "tests/remote_connect_contracts.rs" required-features = ["remote-connect"] [[test]] name = "remote_ssh_contracts" -required-features = ["remote-ssh"] - -[[test]] -name = "remote_ssh_disabled_contracts" +path = "tests/remote_ssh_contracts.rs" required-features = ["remote-ssh"] [[test]] name = "remote_workspace_search_disabled_contracts" +path = "tests/remote_workspace_search_disabled_contracts.rs" required-features = ["remote-ssh", "workspace-search"] [[test]] name = "workspace_search_contracts" +path = "tests/workspace_search_contracts.rs" required-features = ["workspace-search"] [lints] diff --git a/src/crates/services/services-integrations/src/remote_ssh/manager.rs b/src/crates/services/services-integrations/src/remote_ssh/manager.rs index 91bb4758b5..cec6b7a9d3 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/manager.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/manager.rs @@ -1234,6 +1234,10 @@ fn supervised_container_command( /// hit the supervisor itself; both `terminate_child` here and /// [`container_signal_command`] therefore fall back to `pkill -P` plus a direct /// `kill`, which reaches one generation instead of all of them. +/// +/// The supervisor must also duplicate stdin before starting the asynchronous +/// child. POSIX non-interactive shells attach `/dev/null` to an asynchronous +/// list's fd 0, so `<&0` inside that list cannot preserve streamed input. fn supervised_container_command_with_pid_file( container: &ContainerWorkspaceConfig, command: &str, @@ -1260,12 +1264,14 @@ fn supervised_container_command_with_pid_file( }}; \ trap remove_pid_file EXIT; \ trap 'terminate_child; exit 143' HUP TERM; \ + exec 9<&0 || exit 1; \ if command -v setsid >/dev/null 2>&1; then \ - setsid {quoted_shell} -lc {quoted_command} <&0 & \ + setsid {quoted_shell} -lc {quoted_command} <&9 & \ else \ - {quoted_shell} -lc {quoted_command} <&0 & \ + {quoted_shell} -lc {quoted_command} <&9 & \ fi; \ child=$!; \ + exec 9<&-; \ if [ \"$tracking\" -eq 1 ]; then \ printf '%s' \"$child\" > \"$pid_file\" || tracking=0; \ fi; \ @@ -5955,6 +5961,9 @@ mod tests { assert!(pid_file.starts_with("/tmp/.bitfun-exec-")); assert!(wrapped.contains("setsid '/bin/bash' -lc")); + assert!(wrapped.contains("exec 9<&0 || exit 1")); + assert!(wrapped.contains("<&9 &")); + assert!(wrapped.contains("exec 9<&-")); assert!(wrapped.contains("|| tracking=0")); assert!(wrapped.contains("printf '%s' \"$child\" > \"$pid_file\"")); assert!(signal.contains("[ -s \"$pid_file\" ] || exit 75")); @@ -5962,6 +5971,50 @@ mod tests { assert!(signal.contains("kill -KILL \"$pid\"")); } + #[test] + #[cfg(unix)] + fn supervised_container_command_preserves_streamed_stdin() { + use std::io::Write; + + let container = ContainerWorkspaceConfig { + name: "dev".to_string(), + access: ContainerAccess::DockerExec, + local: true, + docker_path: "docker".to_string(), + shell: "/bin/sh".to_string(), + user: None, + interactive: true, + }; + let wrapped = supervised_container_command_with_pid_file( + &container, + "read value; printf 'stdin:%s' \"$value\"", + "/tmp/.bitfun-exec-stdin-contract.pid", + ); + let mut child = std::process::Command::new("sh") + .args(["-lc", &wrapped]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn supervised command"); + child + .stdin + .take() + .expect("supervisor stdin") + .write_all(b"transport-contract\n") + .expect("write supervisor stdin"); + let output = child + .wait_with_output() + .expect("wait for supervised command"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"stdin:transport-contract"); + assert!( + String::from_utf8_lossy(&output.stderr).trim().is_empty(), + "stdin forwarding must not add command stderr" + ); + } + #[test] #[cfg(unix)] fn supervised_container_command_keeps_working_without_a_writable_pid_location() { diff --git a/src/crates/services/services-integrations/tests/remote_ssh_contracts.rs b/src/crates/services/services-integrations/tests/remote_ssh_contracts.rs index 4dae3e3092..4171c8ab12 100644 --- a/src/crates/services/services-integrations/tests/remote_ssh_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_ssh_contracts.rs @@ -1,348 +1,6 @@ #![cfg(feature = "remote-ssh")] -use bitfun_services_integrations::remote_ssh::{ - canonicalize_local_workspace_root, local_workspace_roots_equal, - local_workspace_stable_storage_id, normalize_local_workspace_root_for_stable_id, - normalize_remote_workspace_path, remote_root_to_mirror_subpath, remote_workspace_runtime_root, - remote_workspace_session_mirror_dir, remote_workspace_stable_id, - sanitize_remote_mirror_path_component, sanitize_ssh_connection_id_for_local_dir, - sanitize_ssh_hostname_for_mirror, unresolved_remote_session_storage_dir, - unresolved_remote_session_storage_key, workspace_logical_key, workspace_session_identity, - ContainerAccess, ContainerWorkspaceConfig, RemoteWorkspace, RemoteWorkspaceRegistry, - SSHAuthMethod, SSHConnectionConfig, SavedAuthType, SavedConnection, LOCAL_WORKSPACE_SSH_HOST, -}; - -#[test] -fn remote_ssh_legacy_agent_auth_keeps_default_private_key_fallback() { - let config: SSHConnectionConfig = serde_json::from_value(serde_json::json!({ - "id": "conn-1", - "name": "dev", - "host": "example.com", - "port": 22, - "username": "alice", - "auth": { "type": "Agent" }, - "defaultWorkspace": "/repo" - })) - .unwrap(); - - match config.auth { - SSHAuthMethod::Agent { - key_fingerprint, - fallback_key_path, - } => { - assert_eq!(key_fingerprint, None); - assert_eq!(fallback_key_path.as_deref(), Some("~/.ssh/id_rsa")); - } - _ => panic!("legacy agent auth must remain agent-compatible"), - } - assert_eq!(config.proxy_jump, None); - assert_eq!(config.container, None); - assert_eq!(config.options.connect_timeout_secs, 30); - assert_eq!(config.options.auth_timeout_secs, 60); - assert_eq!(config.options.auth_attempts, 3); - assert_eq!(config.options.connect_attempts, 1); - - let saved: SavedConnection = serde_json::from_value(serde_json::json!({ - "id": "conn-1", - "name": "dev", - "host": "example.com", - "port": 22, - "username": "alice", - "authType": { "type": "Agent" }, - "defaultWorkspace": "/repo", - "lastConnected": 1 - })) - .unwrap(); - - assert!(matches!( - saved.auth_type, - SavedAuthType::Agent { - key_fingerprint: None, - ref fallback_key_path, - } if fallback_key_path.as_deref() == Some("~/.ssh/id_rsa") - )); - assert_eq!(saved.proxy_jump, None); - assert_eq!(saved.container, None); - assert_eq!(saved.options.connect_timeout_secs, 30); - assert_eq!(saved.options.auth_timeout_secs, 60); - assert_eq!(saved.options.auth_attempts, 3); - assert_eq!(saved.options.connect_attempts, 1); -} - -#[test] -fn remote_target_contract_uses_proxy_jump_and_kebab_case_container_access() { - let config = SSHConnectionConfig { - id: "conn-1".to_string(), - name: "train".to_string(), - host: "train.internal".to_string(), - port: 22, - username: "trainer".to_string(), - auth: SSHAuthMethod::PrivateKey { - key_path: "~/.ssh/train".to_string(), - passphrase: None, - certificate_path: None, - }, - default_workspace: Some("/workspace".to_string()), - proxy_jump: Some("jump1,jump2".to_string()), - container: Some(ContainerWorkspaceConfig { - name: "trainer-dev".to_string(), - access: ContainerAccess::DockerExec, - local: false, - docker_path: "docker".to_string(), - shell: "/bin/bash".to_string(), - user: Some("trainer".to_string()), - interactive: true, - }), - options: Default::default(), - }; - - let json = serde_json::to_value(&config).unwrap(); - assert_eq!(json["proxyJump"], "jump1,jump2"); - assert_eq!(json["container"]["access"], "docker-exec"); - assert_eq!(json["container"]["dockerPath"], "docker"); - let round_trip: SSHConnectionConfig = serde_json::from_value(json).unwrap(); - assert_eq!( - round_trip.container.unwrap().access, - ContainerAccess::DockerExec - ); -} - -#[test] -fn remote_workspace_defaults_keep_older_files_loadable() { - let workspace: RemoteWorkspace = serde_json::from_value(serde_json::json!({ - "connectionId": "conn-1" - })) - .unwrap(); - - assert_eq!(workspace.connection_id, "conn-1"); - assert_eq!(workspace.remote_path, ""); - assert_eq!(workspace.connection_name, ""); - assert_eq!(workspace.ssh_host, ""); -} - -#[test] -fn remote_workspace_path_helpers_preserve_current_identity_contract() { - assert_eq!( - normalize_remote_workspace_path(r"\\home\\user\\repo//src"), - "/home/user/repo/src" - ); - assert_eq!(normalize_remote_workspace_path("///"), "/"); - assert_eq!( - normalize_remote_workspace_path("/home/user/repo/"), - "/home/user/repo" - ); - - #[cfg(windows)] - assert_eq!( - sanitize_ssh_connection_id_for_local_dir("ssh-root@1.95.50.146:22"), - "ssh-root@1.95.50.146-22" - ); - #[cfg(not(windows))] - assert_eq!( - sanitize_ssh_connection_id_for_local_dir("ssh-root@1.95.50.146:22"), - "ssh-root@1.95.50.146:22" - ); - assert_eq!( - sanitize_ssh_connection_id_for_local_dir("../unsafe/id"), - "..-unsafe-id" - ); - assert_eq!(sanitize_ssh_connection_id_for_local_dir(".."), "_dotdot_"); - - assert_eq!(sanitize_remote_mirror_path_component(""), "_"); - assert_eq!(sanitize_remote_mirror_path_component("."), "_dot_"); - assert_eq!(sanitize_remote_mirror_path_component(".."), "_dotdot_"); - assert!(remote_root_to_mirror_subpath("/../../escape") - .components() - .all(|component| !matches!(component, std::path::Component::ParentDir))); - assert_eq!( - remote_root_to_mirror_subpath("/home/user/../project"), - std::path::PathBuf::from("home").join("project"), - "safe legacy dot segments must keep their previous effective mirror path" - ); - #[cfg(windows)] - { - assert_eq!(sanitize_remote_mirror_path_component("CON"), "_CON"); - assert_eq!(sanitize_remote_mirror_path_component("report. "), "report"); - } - assert_eq!( - sanitize_ssh_hostname_for_mirror(" Example.COM "), - "example.com" - ); - assert_eq!( - remote_root_to_mirror_subpath("/home/user/repo"), - std::path::PathBuf::from("home").join("user").join("repo") - ); - assert_eq!( - remote_root_to_mirror_subpath("/"), - std::path::PathBuf::from("_root") - ); - - assert_eq!( - workspace_logical_key(LOCAL_WORKSPACE_SSH_HOST, "/Users/p/w"), - "localhost:/Users/p/w" - ); - - let local_id = local_workspace_stable_storage_id("/Users/foo/BitFun"); - assert_eq!(local_id, "local_1d9bbee7a88cb84fc9500423130a3e99"); - - let remote_id = remote_workspace_stable_id("myhost", "/root/proj"); - assert_eq!(remote_id, "remote_0b6e9c54b3e51fd56bf721ed35c1ce88"); - - let unresolved_key = unresolved_remote_session_storage_key(" conn-1 ", "/home/u/p"); - assert_eq!(unresolved_key, "d1c72f60fc1b7cb99599cf21"); -} - -#[test] -fn remote_workspace_session_paths_use_supplied_mirror_root() { - let mirror_root = std::path::PathBuf::from("/bitfun/remote_ssh"); - - assert_eq!( - remote_workspace_runtime_root(&mirror_root, " Example.COM ", "/home/user/repo"), - mirror_root - .join("example.com") - .join("home") - .join("user") - .join("repo") - ); - assert_eq!( - remote_workspace_session_mirror_dir(&mirror_root, " Example.COM ", "/"), - mirror_root - .join("example.com") - .join("_root") - .join("sessions") - ); - assert_eq!( - unresolved_remote_session_storage_dir(&mirror_root, " conn-1 ", "/home/u/p"), - mirror_root - .join("_unresolved") - .join("d1c72f60fc1b7cb99599cf21") - .join("sessions") - ); -} - -#[test] -fn local_workspace_identity_helpers_preserve_canonical_root_contract() { - let workspace_root = std::env::temp_dir().join(format!( - "bitfun-services-remote-ssh-contract-{}", - std::process::id() - )); - let nested = workspace_root.join("nested"); - std::fs::create_dir_all(&nested).expect("workspace root should exist"); - - let (canonical_path, stable_root) = - canonicalize_local_workspace_root(&workspace_root).expect("canonical local root"); - assert_eq!( - stable_root, - normalize_local_workspace_root_for_stable_id(&workspace_root) - .expect("normalized local root") - ); - assert_eq!( - stable_root, - canonical_path.to_string_lossy().replace('\\', "/") - ); - assert!(local_workspace_roots_equal( - &workspace_root, - &workspace_root - )); - assert!(!local_workspace_roots_equal(&workspace_root, &nested)); - - let _ = std::fs::remove_dir_all(workspace_root); -} - -#[test] -fn workspace_session_identity_preserves_local_and_remote_contracts() { - let workspace_root = std::env::temp_dir().join(format!( - "bitfun-services-workspace-identity-{}", - std::process::id() - )); - std::fs::create_dir_all(&workspace_root).expect("workspace root should exist"); - - let local = - workspace_session_identity(&workspace_root.to_string_lossy(), None, None).expect("local"); - assert_eq!(local.hostname, LOCAL_WORKSPACE_SSH_HOST); - assert!(!local.is_remote()); - assert_eq!(local.remote_connection_id, None); - - let remote = workspace_session_identity( - r"\\home\\wsp\\project//", - Some(" conn-1 "), - Some(" ssh.dev "), - ) - .expect("remote"); - assert_eq!(remote.hostname, "ssh.dev"); - assert_eq!(remote.logical_workspace_path(), "/home/wsp/project"); - assert_eq!(remote.remote_connection_id.as_deref(), Some("conn-1")); - assert!(remote.is_remote()); - - assert!( - workspace_session_identity("/home/wsp/project", Some("conn-1"), None).is_none(), - "remote identity requires a resolvable SSH host" - ); - - let _ = std::fs::remove_dir_all(workspace_root); -} - -#[tokio::test] -async fn remote_workspace_registry_preserves_ambiguous_root_resolution_contract() { - let registry = RemoteWorkspaceRegistry::new(); - registry - .register_remote_workspace( - "/".to_string(), - "conn-a".to_string(), - "Server A".to_string(), - "host-a".to_string(), - ) - .await; - registry - .register_remote_workspace( - "/".to_string(), - "conn-b".to_string(), - "Server B".to_string(), - "host-b".to_string(), - ) - .await; - - assert!(registry.lookup_connection("/tmp", None).await.is_none()); - - registry - .set_active_connection_hint(Some("conn-a".to_string())) - .await; - let hinted = registry.lookup_connection("/tmp", None).await.unwrap(); - assert_eq!(hinted.connection_id, "conn-a"); - assert_eq!(hinted.ssh_host, "host-a"); - - let preferred = registry - .lookup_connection("/tmp", Some("conn-b")) - .await - .unwrap(); - assert_eq!(preferred.connection_id, "conn-b"); - assert_eq!(preferred.ssh_host, "host-b"); -} - -#[tokio::test] -async fn remote_workspace_registry_preserves_legacy_state_and_clear_contract() { - let registry = RemoteWorkspaceRegistry::new(); - assert!(!registry.has_any().await); - assert!(!registry.get_state().await.is_active); - - registry - .register_remote_workspace( - "/repo".to_string(), - "conn-1".to_string(), - "Dev Server".to_string(), - "dev.example.com".to_string(), - ) - .await; - - let state = registry.get_state().await; - assert!(state.is_active); - assert_eq!(state.connection_id.as_deref(), Some("conn-1")); - assert_eq!(state.remote_path.as_deref(), Some("/repo")); - assert_eq!(state.connection_name.as_deref(), Some("Dev Server")); - - registry - .unregister_remote_workspace("conn-1", "/repo") - .await; - assert!(!registry.has_any().await); - assert!(!registry.get_state().await.is_active); -} +#[path = "remote_ssh_contracts/remote_ssh_contracts.rs"] +mod remote_ssh_contracts; +#[path = "remote_ssh_contracts/remote_ssh_disabled_contracts.rs"] +mod remote_ssh_disabled_contracts; diff --git a/src/crates/services/services-integrations/tests/remote_ssh_contracts/remote_ssh_contracts.rs b/src/crates/services/services-integrations/tests/remote_ssh_contracts/remote_ssh_contracts.rs new file mode 100644 index 0000000000..b251b8376c --- /dev/null +++ b/src/crates/services/services-integrations/tests/remote_ssh_contracts/remote_ssh_contracts.rs @@ -0,0 +1,346 @@ +use bitfun_services_integrations::remote_ssh::{ + canonicalize_local_workspace_root, local_workspace_roots_equal, + local_workspace_stable_storage_id, normalize_local_workspace_root_for_stable_id, + normalize_remote_workspace_path, remote_root_to_mirror_subpath, remote_workspace_runtime_root, + remote_workspace_session_mirror_dir, remote_workspace_stable_id, + sanitize_remote_mirror_path_component, sanitize_ssh_connection_id_for_local_dir, + sanitize_ssh_hostname_for_mirror, unresolved_remote_session_storage_dir, + unresolved_remote_session_storage_key, workspace_logical_key, workspace_session_identity, + ContainerAccess, ContainerWorkspaceConfig, RemoteWorkspace, RemoteWorkspaceRegistry, + SSHAuthMethod, SSHConnectionConfig, SavedAuthType, SavedConnection, LOCAL_WORKSPACE_SSH_HOST, +}; + +#[test] +fn remote_ssh_legacy_agent_auth_keeps_default_private_key_fallback() { + let config: SSHConnectionConfig = serde_json::from_value(serde_json::json!({ + "id": "conn-1", + "name": "dev", + "host": "example.com", + "port": 22, + "username": "alice", + "auth": { "type": "Agent" }, + "defaultWorkspace": "/repo" + })) + .unwrap(); + + match config.auth { + SSHAuthMethod::Agent { + key_fingerprint, + fallback_key_path, + } => { + assert_eq!(key_fingerprint, None); + assert_eq!(fallback_key_path.as_deref(), Some("~/.ssh/id_rsa")); + } + _ => panic!("legacy agent auth must remain agent-compatible"), + } + assert_eq!(config.proxy_jump, None); + assert_eq!(config.container, None); + assert_eq!(config.options.connect_timeout_secs, 30); + assert_eq!(config.options.auth_timeout_secs, 60); + assert_eq!(config.options.auth_attempts, 3); + assert_eq!(config.options.connect_attempts, 1); + + let saved: SavedConnection = serde_json::from_value(serde_json::json!({ + "id": "conn-1", + "name": "dev", + "host": "example.com", + "port": 22, + "username": "alice", + "authType": { "type": "Agent" }, + "defaultWorkspace": "/repo", + "lastConnected": 1 + })) + .unwrap(); + + assert!(matches!( + saved.auth_type, + SavedAuthType::Agent { + key_fingerprint: None, + ref fallback_key_path, + } if fallback_key_path.as_deref() == Some("~/.ssh/id_rsa") + )); + assert_eq!(saved.proxy_jump, None); + assert_eq!(saved.container, None); + assert_eq!(saved.options.connect_timeout_secs, 30); + assert_eq!(saved.options.auth_timeout_secs, 60); + assert_eq!(saved.options.auth_attempts, 3); + assert_eq!(saved.options.connect_attempts, 1); +} + +#[test] +fn remote_target_contract_uses_proxy_jump_and_kebab_case_container_access() { + let config = SSHConnectionConfig { + id: "conn-1".to_string(), + name: "train".to_string(), + host: "train.internal".to_string(), + port: 22, + username: "trainer".to_string(), + auth: SSHAuthMethod::PrivateKey { + key_path: "~/.ssh/train".to_string(), + passphrase: None, + certificate_path: None, + }, + default_workspace: Some("/workspace".to_string()), + proxy_jump: Some("jump1,jump2".to_string()), + container: Some(ContainerWorkspaceConfig { + name: "trainer-dev".to_string(), + access: ContainerAccess::DockerExec, + local: false, + docker_path: "docker".to_string(), + shell: "/bin/bash".to_string(), + user: Some("trainer".to_string()), + interactive: true, + }), + options: Default::default(), + }; + + let json = serde_json::to_value(&config).unwrap(); + assert_eq!(json["proxyJump"], "jump1,jump2"); + assert_eq!(json["container"]["access"], "docker-exec"); + assert_eq!(json["container"]["dockerPath"], "docker"); + let round_trip: SSHConnectionConfig = serde_json::from_value(json).unwrap(); + assert_eq!( + round_trip.container.unwrap().access, + ContainerAccess::DockerExec + ); +} + +#[test] +fn remote_workspace_defaults_keep_older_files_loadable() { + let workspace: RemoteWorkspace = serde_json::from_value(serde_json::json!({ + "connectionId": "conn-1" + })) + .unwrap(); + + assert_eq!(workspace.connection_id, "conn-1"); + assert_eq!(workspace.remote_path, ""); + assert_eq!(workspace.connection_name, ""); + assert_eq!(workspace.ssh_host, ""); +} + +#[test] +fn remote_workspace_path_helpers_preserve_current_identity_contract() { + assert_eq!( + normalize_remote_workspace_path(r"\\home\\user\\repo//src"), + "/home/user/repo/src" + ); + assert_eq!(normalize_remote_workspace_path("///"), "/"); + assert_eq!( + normalize_remote_workspace_path("/home/user/repo/"), + "/home/user/repo" + ); + + #[cfg(windows)] + assert_eq!( + sanitize_ssh_connection_id_for_local_dir("ssh-root@1.95.50.146:22"), + "ssh-root@1.95.50.146-22" + ); + #[cfg(not(windows))] + assert_eq!( + sanitize_ssh_connection_id_for_local_dir("ssh-root@1.95.50.146:22"), + "ssh-root@1.95.50.146:22" + ); + assert_eq!( + sanitize_ssh_connection_id_for_local_dir("../unsafe/id"), + "..-unsafe-id" + ); + assert_eq!(sanitize_ssh_connection_id_for_local_dir(".."), "_dotdot_"); + + assert_eq!(sanitize_remote_mirror_path_component(""), "_"); + assert_eq!(sanitize_remote_mirror_path_component("."), "_dot_"); + assert_eq!(sanitize_remote_mirror_path_component(".."), "_dotdot_"); + assert!(remote_root_to_mirror_subpath("/../../escape") + .components() + .all(|component| !matches!(component, std::path::Component::ParentDir))); + assert_eq!( + remote_root_to_mirror_subpath("/home/user/../project"), + std::path::PathBuf::from("home").join("project"), + "safe legacy dot segments must keep their previous effective mirror path" + ); + #[cfg(windows)] + { + assert_eq!(sanitize_remote_mirror_path_component("CON"), "_CON"); + assert_eq!(sanitize_remote_mirror_path_component("report. "), "report"); + } + assert_eq!( + sanitize_ssh_hostname_for_mirror(" Example.COM "), + "example.com" + ); + assert_eq!( + remote_root_to_mirror_subpath("/home/user/repo"), + std::path::PathBuf::from("home").join("user").join("repo") + ); + assert_eq!( + remote_root_to_mirror_subpath("/"), + std::path::PathBuf::from("_root") + ); + + assert_eq!( + workspace_logical_key(LOCAL_WORKSPACE_SSH_HOST, "/Users/p/w"), + "localhost:/Users/p/w" + ); + + let local_id = local_workspace_stable_storage_id("/Users/foo/BitFun"); + assert_eq!(local_id, "local_1d9bbee7a88cb84fc9500423130a3e99"); + + let remote_id = remote_workspace_stable_id("myhost", "/root/proj"); + assert_eq!(remote_id, "remote_0b6e9c54b3e51fd56bf721ed35c1ce88"); + + let unresolved_key = unresolved_remote_session_storage_key(" conn-1 ", "/home/u/p"); + assert_eq!(unresolved_key, "d1c72f60fc1b7cb99599cf21"); +} + +#[test] +fn remote_workspace_session_paths_use_supplied_mirror_root() { + let mirror_root = std::path::PathBuf::from("/bitfun/remote_ssh"); + + assert_eq!( + remote_workspace_runtime_root(&mirror_root, " Example.COM ", "/home/user/repo"), + mirror_root + .join("example.com") + .join("home") + .join("user") + .join("repo") + ); + assert_eq!( + remote_workspace_session_mirror_dir(&mirror_root, " Example.COM ", "/"), + mirror_root + .join("example.com") + .join("_root") + .join("sessions") + ); + assert_eq!( + unresolved_remote_session_storage_dir(&mirror_root, " conn-1 ", "/home/u/p"), + mirror_root + .join("_unresolved") + .join("d1c72f60fc1b7cb99599cf21") + .join("sessions") + ); +} + +#[test] +fn local_workspace_identity_helpers_preserve_canonical_root_contract() { + let workspace_root = std::env::temp_dir().join(format!( + "bitfun-services-remote-ssh-contract-{}", + std::process::id() + )); + let nested = workspace_root.join("nested"); + std::fs::create_dir_all(&nested).expect("workspace root should exist"); + + let (canonical_path, stable_root) = + canonicalize_local_workspace_root(&workspace_root).expect("canonical local root"); + assert_eq!( + stable_root, + normalize_local_workspace_root_for_stable_id(&workspace_root) + .expect("normalized local root") + ); + assert_eq!( + stable_root, + canonical_path.to_string_lossy().replace('\\', "/") + ); + assert!(local_workspace_roots_equal( + &workspace_root, + &workspace_root + )); + assert!(!local_workspace_roots_equal(&workspace_root, &nested)); + + let _ = std::fs::remove_dir_all(workspace_root); +} + +#[test] +fn workspace_session_identity_preserves_local_and_remote_contracts() { + let workspace_root = std::env::temp_dir().join(format!( + "bitfun-services-workspace-identity-{}", + std::process::id() + )); + std::fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + + let local = + workspace_session_identity(&workspace_root.to_string_lossy(), None, None).expect("local"); + assert_eq!(local.hostname, LOCAL_WORKSPACE_SSH_HOST); + assert!(!local.is_remote()); + assert_eq!(local.remote_connection_id, None); + + let remote = workspace_session_identity( + r"\\home\\wsp\\project//", + Some(" conn-1 "), + Some(" ssh.dev "), + ) + .expect("remote"); + assert_eq!(remote.hostname, "ssh.dev"); + assert_eq!(remote.logical_workspace_path(), "/home/wsp/project"); + assert_eq!(remote.remote_connection_id.as_deref(), Some("conn-1")); + assert!(remote.is_remote()); + + assert!( + workspace_session_identity("/home/wsp/project", Some("conn-1"), None).is_none(), + "remote identity requires a resolvable SSH host" + ); + + let _ = std::fs::remove_dir_all(workspace_root); +} + +#[tokio::test] +async fn remote_workspace_registry_preserves_ambiguous_root_resolution_contract() { + let registry = RemoteWorkspaceRegistry::new(); + registry + .register_remote_workspace( + "/".to_string(), + "conn-a".to_string(), + "Server A".to_string(), + "host-a".to_string(), + ) + .await; + registry + .register_remote_workspace( + "/".to_string(), + "conn-b".to_string(), + "Server B".to_string(), + "host-b".to_string(), + ) + .await; + + assert!(registry.lookup_connection("/tmp", None).await.is_none()); + + registry + .set_active_connection_hint(Some("conn-a".to_string())) + .await; + let hinted = registry.lookup_connection("/tmp", None).await.unwrap(); + assert_eq!(hinted.connection_id, "conn-a"); + assert_eq!(hinted.ssh_host, "host-a"); + + let preferred = registry + .lookup_connection("/tmp", Some("conn-b")) + .await + .unwrap(); + assert_eq!(preferred.connection_id, "conn-b"); + assert_eq!(preferred.ssh_host, "host-b"); +} + +#[tokio::test] +async fn remote_workspace_registry_preserves_legacy_state_and_clear_contract() { + let registry = RemoteWorkspaceRegistry::new(); + assert!(!registry.has_any().await); + assert!(!registry.get_state().await.is_active); + + registry + .register_remote_workspace( + "/repo".to_string(), + "conn-1".to_string(), + "Dev Server".to_string(), + "dev.example.com".to_string(), + ) + .await; + + let state = registry.get_state().await; + assert!(state.is_active); + assert_eq!(state.connection_id.as_deref(), Some("conn-1")); + assert_eq!(state.remote_path.as_deref(), Some("/repo")); + assert_eq!(state.connection_name.as_deref(), Some("Dev Server")); + + registry + .unregister_remote_workspace("conn-1", "/repo") + .await; + assert!(!registry.has_any().await); + assert!(!registry.get_state().await.is_active); +} diff --git a/src/crates/services/services-integrations/tests/remote_ssh_disabled_contracts.rs b/src/crates/services/services-integrations/tests/remote_ssh_contracts/remote_ssh_disabled_contracts.rs similarity index 97% rename from src/crates/services/services-integrations/tests/remote_ssh_disabled_contracts.rs rename to src/crates/services/services-integrations/tests/remote_ssh_contracts/remote_ssh_disabled_contracts.rs index af105b6e2f..a8f108e275 100644 --- a/src/crates/services/services-integrations/tests/remote_ssh_disabled_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_ssh_contracts/remote_ssh_disabled_contracts.rs @@ -1,4 +1,4 @@ -#![cfg(all(feature = "remote-ssh", not(feature = "remote-ssh-concrete")))] +#![cfg(not(feature = "remote-ssh-concrete"))] use std::path::PathBuf; use std::sync::Arc; diff --git a/src/miniapp-market-web/package.json b/src/miniapp-market-web/package.json index 5e9e8471ff..d22c2609ef 100644 --- a/src/miniapp-market-web/package.json +++ b/src/miniapp-market-web/package.json @@ -1,6 +1,6 @@ { "name": "bitfun-miniapp-market-web", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "scripts": { diff --git a/src/mobile-web/package-lock.json b/src/mobile-web/package-lock.json index cd98677356..356c15e940 100644 --- a/src/mobile-web/package-lock.json +++ b/src/mobile-web/package-lock.json @@ -1,12 +1,12 @@ { "name": "bitfun-mobile-web", - "version": "0.2.16", + "version": "0.2.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bitfun-mobile-web", - "version": "0.2.16", + "version": "0.2.17", "dependencies": { "@noble/ciphers": "^2.1.1", "@noble/curves": "^2.0.1", diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index 15b67f615c..5e65ede73c 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -1,6 +1,6 @@ { "name": "bitfun-mobile-web", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "scripts": { diff --git a/src/shared/ai-provider-catalog/providers.json b/src/shared/ai-provider-catalog/providers.json index 389199229d..db0eafad66 100644 --- a/src/shared/ai-provider-catalog/providers.json +++ b/src/shared/ai-provider-catalog/providers.json @@ -74,7 +74,7 @@ "region": "cn", "name": "Qwen (Alibaba)", "description": "Alibaba Qwen series", - "help_url": "https://dashscope.console.aliyun.com/apiKey", + "help_url": "https://bailian.console.aliyun.com", "requires_api_key": true, "catalog_provider_ids": ["alibaba"], "endpoints": [ diff --git a/src/skin-market-web/package.json b/src/skin-market-web/package.json index 44dd33b31b..7ccbbeb8f0 100644 --- a/src/skin-market-web/package.json +++ b/src/skin-market-web/package.json @@ -1,6 +1,6 @@ { "name": "bitfun-skin-market-web", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "scripts": { diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 58f5cd354d..cf4d323ced 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@bitfun/web-ui", - "version": "0.2.16", + "version": "0.2.17", "private": true, "description": "BitFun Web UI - 支持 Desktop 和 Server 两种部署方式", "type": "module", diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx index 96aa901b12..7564d379e2 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx @@ -105,7 +105,11 @@ beforeAll(async () => { 'flow-chat': { agentCompanion: { activity: { working: 'Working', completed: 'Completed' }, - menu: { closePet: 'Close pet', closeBubble: 'Close this bubble' }, + menu: { + switchPet: 'Switch pet', + closePet: 'Close pet', + closeBubble: 'Close this bubble', + }, composer: { openTitle: 'Send a message to this session', ariaLabel: 'Send a message to this session', @@ -185,7 +189,9 @@ describe('AgentCompanionDesktopPet', () => { dispatch(hitbox!, 'contextmenu', { clientX: 300, clientY: 200 }); - const menuItem = query('.bitfun-agent-companion-window__menu-item'); + const menuItem = Array.from( + container.querySelectorAll('.bitfun-agent-companion-window__menu-item'), + ).find(item => item.textContent === 'Close pet'); expect(menuItem?.textContent).toBe('Close pet'); act(() => { @@ -196,6 +202,25 @@ describe('AgentCompanionDesktopPet', () => { expect(query('.bitfun-agent-companion-window__menu-item')).toBeNull(); }); + it('opens the pet settings from the pet context menu', () => { + dispatch(query('.bitfun-agent-companion-window__pet-hitbox')!, 'contextmenu', { + clientX: 300, + clientY: 200, + }); + + const menuItems = Array.from( + container.querySelectorAll('.bitfun-agent-companion-window__menu-item'), + ); + expect(menuItems.map(item => item.textContent)).toEqual(['Switch pet', 'Close pet']); + + act(() => { + menuItems[0]!.click(); + }); + + expect(emitMock).toHaveBeenCalledWith(PET_COMMAND_EVENT, { type: 'open-pet-settings' }); + expect(query('.bitfun-agent-companion-window__menu-item')).toBeNull(); + }); + it('anchors the context menu to the cursor position', () => { dispatch(query('.bitfun-agent-companion-window__pet-hitbox')!, 'contextmenu', { clientX: 300, diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index ef82ef0370..3f82a5dfa3 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -613,6 +613,14 @@ export const AgentCompanionDesktopPet: React.FC = () => { }); }, [sendPetCommand]); + const openPetSettings = useCallback(() => { + setOverlay(null); + void sendPetCommand({ type: 'open-pet-settings' }) + .catch(error => { + log.warn('Failed to request Agent companion pet settings', error); + }); + }, [sendPetCommand]); + const closeBubble = useCallback((task: AgentCompanionTaskStatus) => { setOverlay(null); setDismissedBubbles(previous => ({ @@ -844,11 +852,18 @@ export const AgentCompanionDesktopPet: React.FC = () => { const overlayTask = overlay && overlay.kind !== 'pet-menu' ? visibleTasks.find(task => task.sessionId === overlay.sessionId) ?? null : null; - const menuItem = overlay?.kind === 'pet-menu' - ? { label: t('agentCompanion.menu.closePet'), onClick: closeDesktopPet } + const menuItems = overlay?.kind === 'pet-menu' + ? [ + { key: 'switch-pet', label: t('agentCompanion.menu.switchPet'), onClick: openPetSettings }, + { key: 'close-pet', label: t('agentCompanion.menu.closePet'), onClick: closeDesktopPet }, + ] : overlay?.kind === 'bubble-menu' && overlayTask - ? { label: t('agentCompanion.menu.closeBubble'), onClick: () => closeBubble(overlayTask) } - : null; + ? [{ + key: 'close-bubble', + label: t('agentCompanion.menu.closeBubble'), + onClick: () => closeBubble(overlayTask), + }] + : []; return (
{ onPointerDown={closeOverlay} /> )} - {menuItem && ( + {menuItems.length > 0 && (
{ }} >
- + {menuItems.map(menuItem => ( + + ))}
)} diff --git a/src/web-ui/src/app/services/agentCompanionPetCommands.test.ts b/src/web-ui/src/app/services/agentCompanionPetCommands.test.ts index 8b2b8efd74..0230192476 100644 --- a/src/web-ui/src/app/services/agentCompanionPetCommands.test.ts +++ b/src/web-ui/src/app/services/agentCompanionPetCommands.test.ts @@ -6,6 +6,12 @@ const sessionsMock = vi.hoisted(() => new Map()); const clearSessionUnreadCompletionMock = vi.hoisted(() => vi.fn()); const getSettingsAsyncMock = vi.hoisted(() => vi.fn()); const saveSettingsMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); +const openSettingsMock = vi.hoisted(() => vi.fn()); +const invokeMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: invokeMock, +})); vi.mock('@/flow_chat/services/FlowChatManager', () => ({ FlowChatManager: { @@ -31,11 +37,19 @@ vi.mock('@/infrastructure/config/services/AIExperienceConfigService', () => ({ }, })); +vi.mock('@/shared/services/ide-control', () => ({ + quickActions: { + openSettings: openSettingsMock, + }, +})); + describe('handleAgentCompanionPetCommand', () => { beforeEach(() => { sendMessageMock.mockClear(); clearSessionUnreadCompletionMock.mockClear(); saveSettingsMock.mockClear(); + openSettingsMock.mockClear(); + invokeMock.mockClear(); sessionsMock.clear(); getSettingsAsyncMock.mockReset(); getSettingsAsyncMock.mockResolvedValue({ @@ -44,6 +58,13 @@ describe('handleAgentCompanionPetCommand', () => { }); }); + it('opens the companion personalization settings', async () => { + await handleAgentCompanionPetCommand({ type: 'open-pet-settings' }); + + expect(openSettingsMock).toHaveBeenCalledWith('session-personalization'); + expect(invokeMock).toHaveBeenCalledWith('show_main_window'); + }); + it('turns the companion off and keeps the display mode when the pet is closed', async () => { await handleAgentCompanionPetCommand({ type: 'close-desktop-pet' }); diff --git a/src/web-ui/src/app/services/agentCompanionPetCommands.ts b/src/web-ui/src/app/services/agentCompanionPetCommands.ts index 3414cb6f58..f8fae0d492 100644 --- a/src/web-ui/src/app/services/agentCompanionPetCommands.ts +++ b/src/web-ui/src/app/services/agentCompanionPetCommands.ts @@ -14,6 +14,8 @@ const log = createLogger('AgentCompanionPetCommands'); * `agent-companion://pet-command`. */ export type AgentCompanionPetCommand = + /** Right-click menu: open the companion appearance picker in Settings. */ + | { type: 'open-pet-settings' } /** Right-click menu: turn the companion off ("Show Agent companion"). */ | { type: 'close-desktop-pet' } /** Bubble right-click menu: the user acknowledged this bubble. */ @@ -43,6 +45,16 @@ async function closeAgentCompanionDesktopPet(): Promise { log.info('Agent companion disabled from pet context menu'); } +async function openAgentCompanionPetSettings(): Promise { + const [{ quickActions }, { invoke }] = await Promise.all([ + import('@/shared/services/ide-control'), + import('@tauri-apps/api/core'), + ]); + quickActions.openSettings('session-personalization'); + await invoke('show_main_window'); + log.info('Agent companion settings opened from pet context menu'); +} + /** * Clear the unread completion mark so the acknowledged bubble does not come * back with the next activity payload. Attention marks (`ask_user`, @@ -84,6 +96,9 @@ export async function handleAgentCompanionPetCommand( } switch (command.type) { + case 'open-pet-settings': + await openAgentCompanionPetSettings(); + return; case 'close-desktop-pet': await closeAgentCompanionDesktopPet(); return; diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss b/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss index 3b50204c2b..523378e1fa 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss @@ -169,84 +169,6 @@ } } - // Selectable approval-policy cards. - // Single column: three across a 560px modal left ~165px per card, which is - // not enough for a title plus a two-line description. - &__options { - display: grid; - grid-template-columns: 1fr; - gap: $size-gap-2; - min-width: 0; - margin: 0; - padding: 0; - border: 0; - } - - &__option { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: start; - gap: $size-gap-2; - min-width: 0; - padding: $size-gap-2 $size-gap-3; - border: 1px solid var(--bf-appearance-token-border-subtle); - border-radius: $size-radius-sm; - background: transparent; - color: var(--bf-appearance-token-color-text-primary); - text-align: left; - cursor: pointer; - transition: - background-color 140ms ease, - border-color 140ms ease, - transform 120ms ease-out; - - &:hover:not(:disabled), - &:focus-visible, - &[data-selected='true'] { - border-color: var(--bf-appearance-token-color-accent-500); - background: color-mix(in srgb, var(--bf-appearance-token-color-accent-500) 9%, transparent); - outline: none; - } - - &:disabled { - cursor: not-allowed; - opacity: 0.55; - } - - &:active:not(:disabled) { - transform: scale(0.99); - } - - // Keep the leading policy icon aligned with the text column. - > svg:first-child { - margin-top: 1px; - color: var(--bf-appearance-token-color-text-muted); - } - - > span { - display: flex; - min-width: 0; - flex-direction: column; - gap: 2px; - grid-column: 2 / 3; - } - - &[data-selected='true'] > svg:first-child { - color: var(--bf-appearance-token-color-accent-500); - } - - strong { - font-size: var(--bf-appearance-token-font-size-sm); - font-weight: 600; - } - - small { - color: var(--bf-appearance-token-color-text-muted); - font-size: var(--bf-appearance-token-font-size-xs); - line-height: 1.4; - } - } - &__checks { display: grid; gap: 0; @@ -364,12 +286,6 @@ } } -@media (prefers-reduced-motion: reduce) { - .dispatch-install-dialog__option { - transition-duration: 0.01ms; - } -} - @keyframes dispatch-install-spin { to { transform: rotate(360deg); diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx index 401624f501..a5ba4092df 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -12,15 +12,24 @@ import { DispatchInstallDialog } from './DispatchInstallDialog'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; +/** + * What a usable target advertises. The approval policy is switched in the + * composer between turns, so setup accepts a target only when it can serve + * every policy the user may later pick. + */ +const READY_CAPABILITIES = [ + ...BASE_DISPATCH_CAPABILITIES, + 'approval_auto', + 'approval_reject_and_report', + 'approval_remote', +]; + const mocks = vi.hoisted(() => ({ probeTarget: vi.fn(), installCliStart: vi.fn(), installCliPoll: vi.fn(), installCliCancel: vi.fn(), provisionTarget: vi.fn(), - syncModelConfig: vi.fn(), - confirmWarning: vi.fn(), - getConfig: vi.fn(), getFreshConfig: vi.fn(), resolveRevision: vi.fn(), modalOnClose: null as (() => void) | null, @@ -37,7 +46,6 @@ vi.mock('./dispatchApi', () => ({ installCliPoll: mocks.installCliPoll, installCliCancel: mocks.installCliCancel, provisionTarget: mocks.provisionTarget, - syncModelConfig: mocks.syncModelConfig, }, })); @@ -47,10 +55,6 @@ vi.mock('@/infrastructure/i18n', () => ({ }), })); -vi.mock('@/infrastructure/config', () => ({ - configManager: { getConfig: mocks.getConfig }, -})); - vi.mock('@/infrastructure/api/service-api/ConfigAPI', () => ({ configAPI: { getConfig: mocks.getFreshConfig }, })); @@ -59,11 +63,6 @@ vi.mock('@/infrastructure/api/service-api/GitAPI', () => ({ gitAPI: { resolveRevision: mocks.resolveRevision }, })); -vi.mock('@/infrastructure/config/services/modelConfigs', () => ({ - getModelDisplayName: (config: { name?: string; model_name?: string }) => - `${config.name ?? ''}/${config.model_name ?? ''}`, -})); - vi.mock('@/component-library', () => ({ Alert: ({ message }: { message: string }) =>
{message}
, Button: ({ @@ -118,7 +117,6 @@ vi.mock('@/component-library', () => ({ }; return isOpen ?
{children}
: null; }, - confirmWarning: mocks.confirmWarning, })); function createDeferred() { @@ -151,7 +149,6 @@ describe('DispatchInstallDialog target preparation', () => { sha256: 'abc123', }, }); - mocks.confirmWarning.mockResolvedValue(true); mocks.installCliStart.mockResolvedValue({ scriptPath: '/tmp/install.sh', version: '1.2.3', @@ -169,7 +166,6 @@ describe('DispatchInstallDialog target preparation', () => { accountStatus: 'skipped_not_logged_in', daemonInstalled: false, }); - mocks.getConfig.mockResolvedValue([]); mocks.getFreshConfig.mockResolvedValue(undefined); mocks.resolveRevision.mockResolvedValue('a'.repeat(40)); container = document.createElement('div'); @@ -201,7 +197,7 @@ describe('DispatchInstallDialog target preparation', () => { cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', - capabilities: [...BASE_DISPATCH_CAPABILITIES, 'approval_reject_and_report'], + capabilities: READY_CAPABILITIES, modelConfigured: true, availableModels: ['model-a'], }, @@ -272,12 +268,6 @@ describe('DispatchInstallDialog target preparation', () => { expect(mocks.provisionTarget).toHaveBeenCalledWith('ssh-1'); expect(container.textContent).toContain('dispatch.prepareSucceededCliOnly'); - await act(async () => { - Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('dispatch.approvalReject')) - ?.click(); - }); - await act(async () => { Array.from(container.querySelectorAll('button')) .find(button => button.textContent?.includes('dispatch.useTarget')) @@ -293,9 +283,9 @@ describe('DispatchInstallDialog target preparation', () => { expect(onReady).toHaveBeenCalledWith(expect.objectContaining({ baseRef: 'HEAD', includeUncommitted: true, - approvalPolicy: 'reject-and-report', request: { kind: 'ssh', connectionId: 'ssh-1', workspacePath: '' }, })); + expect(onReady.mock.calls[0][0]).not.toHaveProperty('approvalPolicy'); }); it('retries only account and service provisioning after the CLI is installed', async () => { @@ -315,7 +305,7 @@ describe('DispatchInstallDialog target preparation', () => { cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', - capabilities: BASE_DISPATCH_CAPABILITIES, + capabilities: READY_CAPABILITIES, modelConfigured: true, availableModels: ['model-a'], }, @@ -426,7 +416,7 @@ describe('DispatchInstallDialog target preparation', () => { cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', - capabilities: [...BASE_DISPATCH_CAPABILITIES, 'approval_reject_and_report'], + capabilities: READY_CAPABILITIES, modelConfigured: true, availableModels: ['model-a'], }, @@ -457,9 +447,6 @@ describe('DispatchInstallDialog target preparation', () => { )?.set?.call(baseRefInput, 'missing/ref'); baseRefInput.dispatchEvent(new Event('input', { bubbles: true })); } - Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('dispatch.approvalReject')) - ?.click(); }); await act(async () => { @@ -510,11 +497,6 @@ describe('DispatchInstallDialog target preparation', () => { expect(container.textContent).not.toContain('sourceBuild'); expect(container.textContent).not.toContain('dispatch.installAutomaticDescription'); - await act(async () => { - Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('dispatch.approvalReject')) - ?.click(); - }); const useTarget = Array.from(container.querySelectorAll('button')) .find(button => button.textContent?.includes('dispatch.useTarget')); expect( @@ -534,7 +516,7 @@ describe('DispatchInstallDialog target preparation', () => { cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', - capabilities: BASE_DISPATCH_CAPABILITIES.filter( + capabilities: READY_CAPABILITIES.filter( capability => capability !== 'workspace_git_sync', ), modelConfigured: true, @@ -602,7 +584,7 @@ describe('DispatchInstallDialog target preparation', () => { expect(container.textContent).not.toContain('dispatch.snapshotResultLocationHint'); }); - it('preserves target model facts without a delivery-mode choice', async () => { + it('carries the target model snapshot without asking about models or approval', async () => { const onReady = vi.fn(); mocks.probeTarget.mockResolvedValue({ cliInstalled: true, @@ -614,7 +596,7 @@ describe('DispatchInstallDialog target preparation', () => { cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', - capabilities: [...BASE_DISPATCH_CAPABILITIES, 'approval_remote'], + capabilities: READY_CAPABILITIES, modelConfigured: true, availableModels: ['model-a', 'model-b'], defaultModel: 'model-b', @@ -635,14 +617,18 @@ describe('DispatchInstallDialog target preparation', () => { await Promise.resolve(); }); - const remoteApproval = Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('dispatch.approvalRemote')); - await act(async () => { - remoteApproval?.click(); - }); + // Both are ordinary composer controls, so setup neither shows nor asks. + expect(container.textContent).not.toContain('dispatch.approval'); + expect(container.textContent).not.toContain('dispatch.modelStatus'); + expect(container.textContent).not.toContain('dispatch.syncModel'); + expect(container.querySelector('[role="radiogroup"]')).toBeNull(); + const useTarget = Array.from(container.querySelectorAll('button')) .find(button => button.textContent?.includes('dispatch.useTarget')); - expect(useTarget?.disabled).toBe(false); + expect( + useTarget?.disabled, + 'a ready target needs no further choice before it can be used', + ).toBe(false); await act(async () => { useTarget?.click(); @@ -651,272 +637,97 @@ describe('DispatchInstallDialog target preparation', () => { }); expect(onReady).toHaveBeenCalledWith(expect.objectContaining({ includeUncommitted: false, - approvalPolicy: 'remote', availableModels: ['model-a', 'model-b'], defaultModel: 'model-b', })); }); -}); - -describe('DispatchInstallDialog model configuration sync', () => { - let container: HTMLDivElement; - let root: Root; - let modelConfigured: boolean; - - const target = { - kind: 'ssh' as const, - connectionId: 'ssh-1', - displayName: 'build-host', - }; - - function probeResult() { - return { + it('rejects a target that cannot serve every approval policy', async () => { + // The policy is switched between turns, so a target that only supports the + // one the user happens to start with would break on the next turn. + mocks.probeTarget.mockResolvedValue({ cliInstalled: true, os: 'linux', arch: 'x86_64', - installSupported: true, + installSupported: false, protocol: { protocolVersion: DISPATCH_PROTOCOL_VERSION, cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', - capabilities: [...BASE_DISPATCH_CAPABILITIES], - modelConfigured, - availableModels: modelConfigured ? ['claude'] : [], - defaultModel: modelConfigured ? 'claude' : undefined, + capabilities: READY_CAPABILITIES.filter( + capability => capability !== 'approval_remote', + ), + modelConfigured: true, + availableModels: ['model-a'], }, - }; - } - - function syncButton() { - return Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('dispatch.syncModelConfirm')); - } + }); - async function mount(onClose = vi.fn()) { await act(async () => { root.render( , ); await Promise.resolve(); await Promise.resolve(); }); - } - - beforeEach(() => { - vi.clearAllMocks(); - modelConfigured = false; - mocks.modalOnClose = null; - mocks.probeTarget.mockImplementation(async () => probeResult()); - mocks.confirmWarning.mockResolvedValue(true); - mocks.getConfig.mockResolvedValue([ - { id: 'claude', enabled: true, api_key: 'secret' }, - ]); - mocks.getFreshConfig.mockResolvedValue(undefined); - mocks.resolveRevision.mockResolvedValue('a'.repeat(40)); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - }); - - it('hides model sync after the target matches this device', async () => { - await mount(); - expect(syncButton()).toBeDefined(); - - mocks.syncModelConfig.mockImplementation(async () => { - modelConfigured = true; - }); - const probesBeforeSync = mocks.probeTarget.mock.calls.length; - - await act(async () => { - syncButton()?.click(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(mocks.confirmWarning).toHaveBeenCalledTimes(1); - expect(mocks.syncModelConfig).toHaveBeenCalledWith('ssh-1'); - // The sync re-probes so the model check reflects the target, not the write. - expect(mocks.probeTarget.mock.calls.length).toBeGreaterThan(probesBeforeSync); - expect(syncButton()).toBeUndefined(); - }); - - it('does not write the credential-bearing config when the confirmation is declined', async () => { - await mount(); - mocks.confirmWarning.mockResolvedValue(false); - - await act(async () => { - syncButton()?.click(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(mocks.syncModelConfig).not.toHaveBeenCalled(); - expect(syncButton()).toBeDefined(); - }); - - it('keeps the dialog open while model sync is in progress', async () => { - const sync = createDeferred(); - const onClose = vi.fn(); - mocks.syncModelConfig.mockReturnValue(sync.promise); - await mount(onClose); - - await act(async () => { - syncButton()?.click(); - await Promise.resolve(); - await Promise.resolve(); - }); - expect(mocks.syncModelConfig).toHaveBeenCalledTimes(1); - const probesBeforeSettle = mocks.probeTarget.mock.calls.length; - - await act(async () => { - mocks.modalOnClose?.(); - await Promise.resolve(); - }); - - expect(onClose).not.toHaveBeenCalled(); - expect(mocks.modalLifecycleProps).toEqual({ - closeOnOverlayClick: false, - showCloseButton: false, - }); - - await act(async () => { - modelConfigured = true; - sync.resolve(undefined); - await Promise.resolve(); - await Promise.resolve(); - }); - expect(mocks.probeTarget.mock.calls.length).toBeGreaterThan(probesBeforeSettle); - expect(syncButton()).toBeUndefined(); + expect(container.textContent).toContain('dispatch.cliUpdateRequired'); + expect(container.textContent).not.toContain('approval_remote'); + const useTarget = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.useTarget')); + expect(useTarget?.disabled).toBe(true); }); -}); - -describe('DispatchInstallDialog target model readout', () => { - let container: HTMLDivElement; - let root: Root; - - const target = { - kind: 'ssh' as const, - connectionId: 'ssh-1', - displayName: 'build-host', - }; - - function localModel(id: string, modelName: string) { - return { - id, - name: 'Anthropic', - model_name: modelName, - provider: 'anthropic', - base_url: 'https://example.test', - api_key: 'secret', - enabled: true, - category: 'chat', - capabilities: [], - }; - } - function probeWith(availableModels: string[], defaultModel: string) { - return { + it('accepts a target with no model configured at all', async () => { + // Submission pushes this device's model configuration to a target that + // cannot serve the choice, so an empty target is a setup step the backend + // owns rather than a reason to refuse the target here. + const onReady = vi.fn(); + mocks.probeTarget.mockResolvedValue({ cliInstalled: true, os: 'linux', arch: 'x86_64', - installSupported: true, + installSupported: false, protocol: { protocolVersion: DISPATCH_PROTOCOL_VERSION, cliVersion: '1.2.3', os: 'linux', arch: 'x86_64', - capabilities: [...BASE_DISPATCH_CAPABILITIES], - modelConfigured: true, - availableModels, - defaultModel, + capabilities: READY_CAPABILITIES, + modelConfigured: false, + availableModels: [], }, - }; - } + }); - async function mount() { await act(async () => { root.render( , ); await Promise.resolve(); await Promise.resolve(); - await Promise.resolve(); }); - } - - beforeEach(() => { - vi.clearAllMocks(); - mocks.modalOnClose = null; - mocks.confirmWarning.mockResolvedValue(true); - mocks.getFreshConfig.mockResolvedValue(undefined); - mocks.resolveRevision.mockResolvedValue('a'.repeat(40)); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - }); - it('reports parity with this device instead of an opaque config id', async () => { - mocks.probeTarget.mockResolvedValue( - probeWith(['model_1', 'model_2'], 'model_2'), - ); - mocks.getConfig.mockResolvedValue([ - localModel('model_1', 'claude-haiku'), - localModel('model_2', 'claude-opus'), - ]); - - await mount(); - - expect(container.textContent).toContain('dispatch.modelMatchesLocal'); - expect(container.textContent).not.toContain('dispatch.modelDiffersFromLocal'); - // The id itself must never be what the user is asked to read. - expect(container.textContent).not.toContain('model_2'); - }); - - it('reports the target model count when the catalogs differ', async () => { - mocks.probeTarget.mockResolvedValue(probeWith(['model_1'], 'model_1')); - mocks.getConfig.mockResolvedValue([ - localModel('model_1', 'claude-haiku'), - localModel('model_2', 'claude-opus'), - ]); - - await mount(); - - expect(container.textContent).toContain('dispatch.modelDiffersFromLocal'); - expect(container.textContent).not.toContain('dispatch.modelMatchesLocal'); - }); - - it('claims no parity when the local catalog cannot be read', async () => { - mocks.probeTarget.mockResolvedValue(probeWith(['model_1'], 'model_1')); - mocks.getConfig.mockRejectedValue(new Error('config unavailable')); - - await mount(); + const useTarget = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.useTarget')); + expect(useTarget?.disabled).toBe(false); - expect(container.textContent).toContain('dispatch.modelReadyCount'); - expect(container.textContent).not.toContain('dispatch.modelMatchesLocal'); - expect(container.textContent).not.toContain('dispatch.modelDiffersFromLocal'); + await act(async () => { + useTarget?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(onReady).toHaveBeenCalledTimes(1); }); }); diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx index a3c614c2ce..a6ee172fb5 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx @@ -3,22 +3,12 @@ import { Alert, Button, Modal, - confirmWarning, } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { createLogger } from '@/shared/utils/logger'; -import { - Check, - Download, - Loader2, - RefreshCw, - ShieldAlert, - ShieldCheck, - ShieldQuestion, -} from 'lucide-react'; +import { Download, Loader2, RefreshCw } from 'lucide-react'; import { dispatchApi } from './dispatchApi'; import type { - DispatchApprovalPolicy, DispatchSelection, DispatchSshProbe, DispatchTargetOption, @@ -27,15 +17,8 @@ import { BASE_DISPATCH_CAPABILITIES, DISPATCH_PROTOCOL_VERSION, } from './dispatchPreflight'; -import { - compareDispatchModels, - syncableLocalModelIds, -} from './dispatchModelParity'; import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; import { gitAPI } from '@/infrastructure/api/service-api/GitAPI'; -import { configManager } from '@/infrastructure/config'; -import { getModelDisplayName } from '@/infrastructure/config/services/modelConfigs'; -import type { AIModelConfig } from '@/infrastructure/config/types'; import type { WorktreeSettings } from '@/infrastructure/api/service-api/WorktreeAPI'; import './DispatchInstallDialog.scss'; @@ -43,12 +26,17 @@ const log = createLogger('DispatchInstallDialog'); const DIALOG_TITLE_ID = 'dispatch-install-dialog-title'; type TargetPreparationPhase = 'installing' | 'provisioning' | 'cancelling'; -function approvalCapability(policy: DispatchApprovalPolicy | null): string | null { - if (policy === 'auto') return 'approval_auto'; - if (policy === 'reject-and-report') return 'approval_reject_and_report'; - if (policy === 'remote') return 'approval_remote'; - return null; -} +/** + * The approval policy is chosen in the composer, per turn, so a target is only + * usable when it can serve every policy the user may later switch to. This + * mirrors the capability-probe branch on the controller, which requires the + * complete approval surface whenever no single policy is named yet. + */ +const APPROVAL_DISPATCH_CAPABILITIES = [ + 'approval_auto', + 'approval_reject_and_report', + 'approval_remote', +]; interface DispatchInstallDialogProps { open: boolean; @@ -66,7 +54,6 @@ export const DispatchInstallDialog: React.FC = ({ onReady, }) => { const { t } = useI18n('common'); - const [approvalPolicy, setApprovalPolicy] = useState(null); const [includeUncommitted, setIncludeUncommitted] = useState(false); const [baseRef, setBaseRef] = useState('HEAD'); const [baseRefError, setBaseRefError] = useState(null); @@ -75,12 +62,10 @@ export const DispatchInstallDialog: React.FC = ({ const [probe, setProbe] = useState(null); const [probing, setProbing] = useState(false); const [probeError, setProbeError] = useState(false); - const [syncingModel, setSyncingModel] = useState(false); const [preparationPhase, setPreparationPhase] = useState(null); const [preparationOutcome, setPreparationOutcome] = useState<'synced' | 'cli-only' | null>(null); const [provisionRetryAvailable, setProvisionRetryAvailable] = useState(false); const [error, setError] = useState(null); - const [localModels, setLocalModels] = useState(null); const generationRef = useRef(0); const installActiveRef = useRef(false); const includeUncommittedTouchedRef = useRef(false); @@ -125,7 +110,6 @@ export const DispatchInstallDialog: React.FC = ({ useEffect(() => { if (!open || !targetId) return; - setApprovalPolicy(null); includeUncommittedTouchedRef.current = false; setIncludeUncommitted(false); setBaseRef('HEAD'); @@ -134,7 +118,6 @@ export const DispatchInstallDialog: React.FC = ({ setWorktreeSettingsLoading(true); setProbe(null); setProbeError(false); - setSyncingModel(false); setPreparationPhase(null); setPreparationOutcome(null); setProvisionRetryAvailable(false); @@ -143,28 +126,6 @@ export const DispatchInstallDialog: React.FC = ({ void runProbe(); }, [open, runProbe, targetId]); - // Reload on every open: the model catalog can change in settings while this - // dialog is closed, and a stale local list would report a false divergence. - useEffect(() => { - if (!open) return; - let cancelled = false; - void configManager.getConfig('ai.models') - .then(models => { - if (!cancelled) setLocalModels(Array.isArray(models) ? models : []); - }) - .catch(nextError => { - // Parity is advisory. Losing it degrades the readout to the target's - // own facts rather than blocking the dialog. - log.warn('Failed to read local model configuration for dispatch parity', { - error: nextError, - }); - if (!cancelled) setLocalModels(null); - }); - return () => { - cancelled = true; - }; - }, [open]); - // Dispatch uses the same baseline creation path as the regular worktree // control, so its initial copy-local-changes choice follows that setting. useEffect(() => { @@ -324,58 +285,23 @@ export const DispatchInstallDialog: React.FC = ({ } }, [connectionId, preparationPhase, runProbe, t]); - const syncModelConfiguration = useCallback(async () => { - if (!connectionId) return; - const generation = generationRef.current; - const confirmed = await confirmWarning( - t('dispatch.syncModelConfirmTitle'), - t('dispatch.syncModelConfirmMessage'), - { - confirmText: t('dispatch.syncModelConfirm'), - cancelText: t('dispatch.cancel'), - }, - ); - if (!confirmed || generation !== generationRef.current) return; - setSyncingModel(true); - setError(null); - try { - await dispatchApi.syncModelConfig(connectionId); - } catch (nextError) { - if (generation === generationRef.current) { - setSyncingModel(false); - setError(t('dispatch.syncModelFailed')); - log.warn('Failed to sync dispatch model configuration', { - connectionId, - error: nextError, - }); - } - return; - } - if (generation !== generationRef.current) return; - // runProbe advances the generation, so leave the syncing state first. - setSyncingModel(false); - await runProbe(); - }, [connectionId, runProbe, t]); - const closeDialog = useCallback(() => { if (preparationPhase) return; invalidatePendingWork(); - setSyncingModel(false); onClose(); }, [invalidatePendingWork, onClose, preparationPhase]); const handleModalClose = useCallback(() => { // Keep Escape, the close button, and backdrop clicks from silently // abandoning a target mutation that is already under way. - if (syncingModel || preparationPhase) return; + if (preparationPhase) return; closeDialog(); - }, [closeDialog, preparationPhase, syncingModel]); + }, [closeDialog, preparationPhase]); const protocol = probe?.protocol; - const selectedApprovalCapability = approvalCapability(approvalPolicy); const requiredCapabilities = [ ...BASE_DISPATCH_CAPABILITIES, - ...(selectedApprovalCapability ? [selectedApprovalCapability] : []), + ...APPROVAL_DISPATCH_CAPABILITIES, ]; const missingCapabilities = protocol ? requiredCapabilities.filter(capability => !protocol.capabilities.includes(capability)) @@ -389,7 +315,6 @@ export const DispatchInstallDialog: React.FC = ({ !probe.protocolError && protocolCompatible; const workspaceReady = !!sourceWorkspacePath?.trim(); - const modelReady = protocol?.modelConfigured === true; /** A compatible signed release makes one-click preparation available. */ const installPending = !cliReady @@ -401,42 +326,20 @@ export const DispatchInstallDialog: React.FC = ({ * say so here rather than letting submit fail on an unusable target. */ const installUnavailable = !cliReady && target?.kind === 'ssh' && !!probe && !installPending; - const ready = - approvalPolicy !== null - && workspaceReady - && cliReady - && modelReady; - - const localModelIds = syncableLocalModelIds(localModels); - const targetModelCount = protocol?.availableModels?.length ?? 0; - const modelParity = compareDispatchModels( - localModelIds, - protocol?.availableModels, - ); - const hasLocalModelsToSync = (localModelIds?.length ?? 0) > 0; - const offerModelSync = - target?.kind === 'ssh' - && !!protocol - && hasLocalModelsToSync - && (!modelReady || modelParity === 'diverged'); - // The probe carries ids, which name nothing a user recognizes. Resolve the - // target's default through the local catalog when the two agree; when they - // do not, the id would be misleading anyway and the count is the actionable - // fact. - const targetDefaultModelLabel = (() => { - const id = protocol?.defaultModel?.trim(); - if (!id) return t('dispatch.modelAutomatic'); - const local = localModels?.find(model => model.id?.trim() === id); - return local ? getModelDisplayName(local) : t('dispatch.modelAutomatic'); - })(); - const targetMutationInProgress = syncingModel || preparationPhase !== null; + /** + * Model readiness is deliberately absent: the model is chosen in the + * composer like any local session, and submission pushes this device's model + * configuration to a target that cannot serve the choice. Gating here would + * ask the user to solve a problem the backend already solves. + */ + const ready = workspaceReady && cliReady; + const targetMutationInProgress = preparationPhase !== null; const confirmTarget = async () => { if ( !target || target.kind === 'local' || !targetId - || !approvalPolicy || !ready ) return; const normalizedSourcePath = sourceWorkspacePath?.trim() || ''; @@ -484,7 +387,6 @@ export const DispatchInstallDialog: React.FC = ({ }, includeUncommitted, baseRef: normalizedBaseRef, - approvalPolicy, modelCatalog: protocol?.modelCatalog, availableModels: protocol?.availableModels, defaultModel: protocol?.defaultModel, @@ -598,22 +500,6 @@ export const DispatchInstallDialog: React.FC = ({ : t('dispatch.cliUnavailable')} -
- {t('dispatch.modelStatus')} - - {!protocol - ? t('dispatch.modelCheckPending') - : !modelReady - ? localModelIds?.length === 0 - ? t('dispatch.modelMissingOnBoth') - : t('dispatch.modelMissing') - : modelParity === 'match' - ? t('dispatch.modelMatchesLocal', { model: targetDefaultModelLabel }) - : modelParity === 'diverged' - ? t('dispatch.modelDiffersFromLocal', { count: targetModelCount }) - : t('dispatch.modelReadyCount', { count: targetModelCount })} - -
) : null} {installPending && probe?.release ? ( @@ -689,24 +575,6 @@ export const DispatchInstallDialog: React.FC = ({ ) : null} - {offerModelSync ? ( -
- - {t('dispatch.syncModelDescription')} - - -
- ) : null}
@@ -759,70 +627,6 @@ export const DispatchInstallDialog: React.FC = ({
- -
-

- {t('dispatch.approvalTitle')} -

- - {t('dispatch.approvalHint')} - -
- - - -
-
= ({ variant="secondary" size="small" disabled={ - syncingModel - || preparationPhase === 'provisioning' + preparationPhase === 'provisioning' || preparationPhase === 'cancelling' } onClick={ diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts index 21d065dc50..630ffab435 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts @@ -519,6 +519,59 @@ describe('DispatchJobObserver', () => { cleanup(); }); + it('renders an automatic model configuration push inside the transcript', async () => { + // Submission copies this device's API keys to a target that cannot serve + // the chosen model. That has to stay as visible as the manual button it + // replaced, including after a replay from byte zero. + registerRunningJob(); + const started: DispatchEvent = { + type: 'audit', + timestamp: '2026-08-09T00:00:00Z', + action: 'model-sync', + details: { stage: 'model-sync-started', sync: { requestedModel: 'model-a' } }, + }; + const succeeded: DispatchEvent = { + type: 'audit', + timestamp: '2026-08-09T00:00:02Z', + action: 'model-sync', + details: { stage: 'model-sync-succeeded', sync: { modelCount: 4 } }, + }; + mocks.status.mockResolvedValue(status({ + cursor: 2, + events: [started, succeeded], + })); + const cleanup = installDispatchJobObserver(createTerminalContext()); + + await vi.advanceTimersByTimeAsync(0); + + const turn = flowChatStore + .getState() + .sessions + .get('session-1') + ?.dialogTurns[0]; + const items = turn?.modelRounds[0].items ?? []; + expect(items).toEqual([ + expect.objectContaining({ + id: `dispatch-audit:${dispatchEventId(started)}`, + type: 'text', + content: expect.any(String), + }), + expect.objectContaining({ + id: `dispatch-audit:${dispatchEventId(succeeded)}`, + type: 'text', + content: expect.any(String), + }), + ]); + // Each stage says something of its own; a shared placeholder would hide + // whether the push actually landed. + const labels = items.map(item => 'content' in item ? item.content : ''); + expect(labels[0]).not.toBe(''); + expect(labels[0]).not.toBe(labels[1]); + // The synced payload carries API keys and must never reach the transcript. + expect(labels.join(' ')).not.toContain('model-a'); + cleanup(); + }); + it('marks a missing baseline worktree during observer reconciliation', async () => { registerRunningJob(); dispatchJobStore.getState().registerJob({ diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts index f86fa3660d..74961daeb8 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts @@ -368,15 +368,15 @@ function auditDetail( return typeof value === 'string' && value.trim() ? value.trim() : undefined; } +function auditStage(event: Extract): string { + return typeof event.details.stage === 'string' ? event.details.stage.trim() : ''; +} + function cliInstallAuditLabel( event: Extract, ): string | null { - if (event.action !== 'cli-install') return null; - const stage = typeof event.details.stage === 'string' - ? event.details.stage.trim() - : ''; const version = auditDetail(event.details, 'version'); - switch (stage) { + switch (auditStage(event)) { case 'cli-install-started': return i18nService.t('flow-chat:chatInput.dispatch.cliInstallStarted', { version: version || i18nService.t('flow-chat:chatInput.dispatch.cliInstallUnknownVersion'), @@ -392,18 +392,45 @@ function cliInstallAuditLabel( } } +/** + * The controller pushes this device's model configuration — API keys included + * — to a target that cannot serve the chosen model. That is worth a line in + * the transcript, so the automatic step stays as visible as the manual one it + * replaced, and stays visible after replay or a controller restart. + */ +function modelSyncAuditLabel( + event: Extract, +): string | null { + switch (auditStage(event)) { + case 'model-sync-succeeded': + return i18nService.t('flow-chat:chatInput.dispatch.modelSyncSucceeded'); + case 'model-sync-failed': + return i18nService.t('flow-chat:chatInput.dispatch.modelSyncFailed'); + default: + return i18nService.t('flow-chat:chatInput.dispatch.modelSyncStarted'); + } +} + +function setupAuditLabel( + event: Extract, +): string | null { + if (event.action === 'cli-install') return cliInstallAuditLabel(event); + if (event.action === 'model-sync') return modelSyncAuditLabel(event); + return null; +} + /** * Setup audits precede the target's SessionCreated/DialogTurnStarted events. * Project them into the optimistic turn so they remain visible after restart, * and so DialogTurnStarted can later adopt the same turn without duplication. */ -function applyCliInstallAudit( +function applySetupAudit( context: FlowChatContext, job: DispatchObserverJob, event: Extract, eventId: string, ): void { - const label = cliInstallAuditLabel(event); + const label = setupAuditLabel(event); if (!label) return; const session = context.flowChatStore.getState().sessions.get(job.sessionId); @@ -543,7 +570,7 @@ function applyEvent( eventId: string, ): boolean { if (event.type === 'audit') { - applyCliInstallAudit(context, job, event, eventId); + applySetupAudit(context, job, event, eventId); return true; } if (event.type === 'jobState') { diff --git a/src/web-ui/src/features/dispatch/README.md b/src/web-ui/src/features/dispatch/README.md index 46990544ed..8273895f69 100644 --- a/src/web-ui/src/features/dispatch/README.md +++ b/src/web-ui/src/features/dispatch/README.md @@ -8,7 +8,14 @@ dispatch. 1. A dispatch target is selected while creating a session and is immutable after the first turn. The model and approval policy are not: protocol v4 carries them per follow-up turn, and the target persists the effective values onto - the job. + the job. Because they are per-turn, neither is chosen in the setup dialog — + both are ordinary composer controls, identical to a local session's. The + setup dialog decides only what the target cannot change later: which target, + which base revision, and whether uncommitted changes travel. +1b. A new dispatch session's approval policy is the one this device's own + permission default implies (`ask` → `remote`, auto-approve or full access → + `auto`). Because the policy is editable per turn, a target is only usable + when it advertises all three approval capabilities, not just the current one. 1a. A dispatch session accepts follow-up messages. While a turn runs, a message is an `append` that steers it; once it has finished, a message is a `dispatch_continue` that queues the next turn against the same target @@ -74,8 +81,12 @@ dispatch. the normal permission panel. The selected policy is visible in the normal session controls; submit must not add a second confirmation dialog. 15. MiniApp and quick-input hosts do not expose the dispatch picker. -16. Controller-side model settings never leak into an SSH dispatch. The submit - omits `model` unless preflight recorded an explicit target model choice. +16. The model picker offers this controller's own catalog, because submission + guarantees the target can serve whatever it offers (invariant 25). The + target's probed list and default are a starting point, unioned in rather + than authoritative, so a projection restored without that snapshot still + has a working picker. Submit omits `model` only while the session has no + explicit choice, leaving the target on its own default. 17. One-click synchronization is available from `running` through terminal states. It commits target changes when needed, validates that both managed worktrees remain on the named job branch, and verifies the returned Git @@ -104,11 +115,21 @@ dispatch. 24. Listing jobs for an explicitly selected target adopts only outbound observer routing records. It never restores the target session into the controller's backend store or acquires local runtime ownership. -25. Model configuration sync is a separate, explicit, credential-bearing - operation with its own confirmation. It merges only the `ai` model keys - into the target's `app.json`, preserves every other target setting, aborts - rather than overwrite an unreadable or unparseable target config, and - writes owner-only via a temp-file rename. +25. SSH submission pushes this controller's model configuration to a target + that cannot serve the submission's model, before creating any baseline, and + re-probes. Choosing the target is the consent: it is the same credential + write the manual command performs, and it stays visible as `model-sync` + rows in the preparation journal and the projected transcript, exactly like + `cli-install`. It merges only the `ai` model keys into the target's + `app.json`, preserves every other target setting, aborts rather than + overwrite an unreadable or unparseable target config, and writes + owner-only via a temp-file rename. A failed push is not fatal by itself: + the submission then reports the target's own model diagnostic. Device + targets have no such repair path and still fail closed. +25a. Setup-audit rows are forwarded only when the target advertises the + matching capability. A target rejects an unknown audit action outright, so + `model-sync` rows are dropped for a CLI predating + `setup_audit_model_sync`; the controller journal keeps them regardless. 26. Dispatch target and status are session-scoped navigation metadata. Workspace navigation must not install a dispatch target or filter its session list by dispatch target. diff --git a/src/web-ui/src/features/dispatch/approvalPolicy.test.ts b/src/web-ui/src/features/dispatch/approvalPolicy.test.ts new file mode 100644 index 0000000000..30034f229b --- /dev/null +++ b/src/web-ui/src/features/dispatch/approvalPolicy.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { + DISPATCH_PERMISSION_MODES, + dispatchApprovalPolicyFromPermissionMode, + dispatchApprovalPolicyFromSessionMode, + permissionModeFromDispatchApprovalPolicy, +} from './approvalPolicy'; + +describe('dispatch approval policy', () => { + it('starts a dispatch session on the stored local permission default', () => { + expect(dispatchApprovalPolicyFromSessionMode('ask')).toBe('remote'); + expect(dispatchApprovalPolicyFromSessionMode('auto_approve')).toBe('auto'); + // Full access already resolves every ask, so nothing is worth forwarding. + expect(dispatchApprovalPolicyFromSessionMode('full_access')).toBe('auto'); + }); + + it('round-trips every mode the composer control offers', () => { + for (const mode of DISPATCH_PERMISSION_MODES) { + expect( + permissionModeFromDispatchApprovalPolicy( + dispatchApprovalPolicyFromPermissionMode(mode), + ), + ).toBe(mode); + } + }); + + it('renders an unset policy as the mode that keeps the user in the loop', () => { + // A projection restored from a record written before the policy existed + // must not silently read as auto-approve. + expect(permissionModeFromDispatchApprovalPolicy(undefined)).toBe('ask'); + expect(permissionModeFromDispatchApprovalPolicy('remote')).toBe('ask'); + }); +}); diff --git a/src/web-ui/src/features/dispatch/approvalPolicy.ts b/src/web-ui/src/features/dispatch/approvalPolicy.ts new file mode 100644 index 0000000000..b5326e1f48 --- /dev/null +++ b/src/web-ui/src/features/dispatch/approvalPolicy.ts @@ -0,0 +1,53 @@ +/** + * The composer's permission control and a dispatch job's approval policy are + * one user decision expressed on two sides of the transport. + * + * Both directions live here so they cannot drift: a dispatch session is + * created with the policy the local permission default implies, and the + * composer strip renders that policy back as the same control a local session + * shows. There is deliberately no separate place to choose it — protocol v4 + * carries the policy per turn, so the composer is the only editor. + */ + +import type { ChatInputPermissionMode } from '@/flow_chat/components/ChatInputWorkspaceStrip'; +import type { SessionPermissionMode } from '@/infrastructure/api/service-api/AgentAPI'; +import type { DispatchApprovalPolicy } from './types'; + +/** Control modes a dispatch session can express; `full_access` folds into `auto`. */ +export type DispatchPermissionMode = Extract< + ChatInputPermissionMode, + 'ask' | 'auto' | 'reject' +>; + +/** The three modes the dispatch permission control offers. */ +export const DISPATCH_PERMISSION_MODES: DispatchPermissionMode[] = ['ask', 'auto', 'reject']; + +export function dispatchApprovalPolicyFromPermissionMode( + mode: Exclude, +): DispatchApprovalPolicy { + // Full access already resolves every ask, so it reaches the target as the + // policy that answers without a round trip. + if (mode === 'auto' || mode === 'full_access') return 'auto'; + if (mode === 'reject') return 'reject-and-report'; + // `ask` keeps the user in the loop: the target forwards each request to this + // controller's normal permission panel. + return 'remote'; +} + +/** + * The policy a new dispatch session starts with, taken from the same stored + * default a local session in this workspace would resolve. + */ +export function dispatchApprovalPolicyFromSessionMode( + mode: SessionPermissionMode, +): DispatchApprovalPolicy { + return dispatchApprovalPolicyFromPermissionMode(mode === 'auto_approve' ? 'auto' : mode); +} + +export function permissionModeFromDispatchApprovalPolicy( + policy: DispatchApprovalPolicy | undefined, +): DispatchPermissionMode { + if (policy === 'auto') return 'auto'; + if (policy === 'reject-and-report') return 'reject'; + return 'ask'; +} diff --git a/src/web-ui/src/features/dispatch/dispatchApi.ts b/src/web-ui/src/features/dispatch/dispatchApi.ts index 0692c48eee..709ab66d60 100644 --- a/src/web-ui/src/features/dispatch/dispatchApi.ts +++ b/src/web-ui/src/features/dispatch/dispatchApi.ts @@ -79,6 +79,14 @@ export const dispatchApi = { }); }, + /** + * Push this device's model configuration, API keys included, to an SSH + * target. + * + * Submission does this on its own whenever the target cannot serve the + * chosen model, so this is the explicit form of an otherwise automatic + * step — kept for callers that want to re-push without starting a job. + */ async syncModelConfig(connectionId: string): Promise { return api.invoke('dispatch_sync_model_config', { request: { connectionId }, diff --git a/src/web-ui/src/features/dispatch/dispatchModelParity.test.ts b/src/web-ui/src/features/dispatch/dispatchModelParity.test.ts deleted file mode 100644 index f8e426fa41..0000000000 --- a/src/web-ui/src/features/dispatch/dispatchModelParity.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - compareDispatchModels, - syncableLocalModelIds, -} from './dispatchModelParity'; -import type { AIModelConfig } from '@/infrastructure/config/types'; - -function model(overrides: Partial & { id: string }): AIModelConfig { - return { - name: 'Anthropic', - provider: 'anthropic', - base_url: 'https://example.test', - model_name: 'claude', - api_key: 'secret', - enabled: true, - category: 'chat', - capabilities: [], - ...overrides, - } as AIModelConfig; -} - -describe('syncableLocalModelIds', () => { - it('keeps only what a target could construct a client for', () => { - expect( - syncableLocalModelIds([ - model({ id: 'ready' }), - model({ id: 'disabled', enabled: false }), - model({ id: 'no-key', api_key: ' ' }), - model({ id: ' ' }), - ]), - ).toEqual(['ready']); - }); - - it('keeps a subscription model without an inline key', () => { - expect( - syncableLocalModelIds([ - model({ id: 'oauth', api_key: '', auth: { type: 'subscription', provider: 'codex' } }), - ]), - ).toEqual(['oauth']); - }); - - it('reports an unreadable catalog as unknown rather than empty', () => { - expect(syncableLocalModelIds(null)).toBeNull(); - expect(syncableLocalModelIds(undefined)).toBeNull(); - expect(syncableLocalModelIds([])).toEqual([]); - }); -}); - -describe('compareDispatchModels', () => { - it('matches on the same id set regardless of order', () => { - expect(compareDispatchModels(['a', 'b'], ['b', 'a'])).toBe('match'); - }); - - it('diverges when the target is missing or carries an extra model', () => { - expect(compareDispatchModels(['a', 'b'], ['a'])).toBe('diverged'); - expect(compareDispatchModels(['a'], ['a', 'b'])).toBe('diverged'); - expect(compareDispatchModels(['a'], ['b'])).toBe('diverged'); - }); - - it('never claims parity without both sides', () => { - expect(compareDispatchModels(null, ['a'])).toBe('unknown'); - expect(compareDispatchModels(['a'], undefined)).toBe('unknown'); - }); - - it('treats two empty catalogs as matching', () => { - expect(compareDispatchModels([], [])).toBe('match'); - }); -}); diff --git a/src/web-ui/src/features/dispatch/dispatchModelParity.ts b/src/web-ui/src/features/dispatch/dispatchModelParity.ts deleted file mode 100644 index 4e7bdd72d7..0000000000 --- a/src/web-ui/src/features/dispatch/dispatchModelParity.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { AIModelConfig } from '@/infrastructure/config/types'; - -/** - * How the target's model catalog relates to this controller's. - * - * `unknown` is a real outcome, not an error: the local catalog may not have - * loaded yet, and claiming parity we have not established would be worse than - * reporting only what the target advertised. - */ -export type DispatchModelParity = 'match' | 'diverged' | 'unknown'; - -/** - * The local model ids a target should end up advertising after a model-config - * sync. - * - * A target's probe lists only the ids it could actually construct a client - * for, so the comparable local set applies the same two filters the target - * applies to the configuration it receives: the model must be enabled, and an - * api-key model must carry a key. A local model that fails either filter can - * never appear on the target, so counting it as a difference would report a - * divergence the user cannot resolve by syncing. - */ -export function syncableLocalModelIds( - models: AIModelConfig[] | null | undefined, -): string[] | null { - if (!Array.isArray(models)) return null; - const ids = new Set(); - for (const model of models) { - const id = model?.id?.trim(); - if (!id || !model.enabled) continue; - const usesApiKey = !model.auth || model.auth.type === 'api_key'; - if (usesApiKey && !model.api_key?.trim()) continue; - ids.add(id); - } - return Array.from(ids).sort(); -} - -/** - * Compare the target's ready model ids against the local ones. - * - * Ids are stable across a sync because the sync copies the local catalog - * verbatim, so set equality is what "same configuration" means here. - */ -export function compareDispatchModels( - localIds: string[] | null, - targetIds: string[] | null | undefined, -): DispatchModelParity { - if (!localIds || !Array.isArray(targetIds)) return 'unknown'; - const target = new Set(targetIds.map(id => id.trim()).filter(Boolean)); - if (target.size !== localIds.length) return 'diverged'; - return localIds.every(id => target.has(id)) ? 'match' : 'diverged'; -} diff --git a/src/web-ui/src/features/dispatch/types.ts b/src/web-ui/src/features/dispatch/types.ts index 6e2ce65521..ffe60d854c 100644 --- a/src/web-ui/src/features/dispatch/types.ts +++ b/src/web-ui/src/features/dispatch/types.ts @@ -292,7 +292,11 @@ export interface DispatchSelection { includeUncommitted: boolean; /** Git revision resolved when creating the controller baseline worktree. */ baseRef: string; - approvalPolicy: DispatchApprovalPolicy; + /** + * No approval policy and no model: both are ordinary composer controls that + * protocol v4 carries per turn, so picking a target decides only what the + * target cannot change later — where it runs and which code it gets. + */ model?: string; modelCatalog?: import('@/infrastructure/api/service-api/AIApi').AIModelCatalog; availableModels?: string[]; diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss index c010073b79..898e1d59b4 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss @@ -503,3 +503,8 @@ .ssh-connection-dialog__modal-overlay { z-index: 20000; } + +/** Portalled Select menus must remain above the dialog's raised overlay. */ +.select__dropdown.ssh-connection-dialog__select-dropdown { + z-index: 20001; +} diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx index 34095991b0..aedc9e365d 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx @@ -1,5 +1,7 @@ // @vitest-environment jsdom +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -99,17 +101,19 @@ vi.mock('@/component-library', () => ({ value, onChange, className, + placeholder, suffix, }: { label?: string; value?: string; onChange?: React.ChangeEventHandler; className?: string; + placeholder?: string; suffix?: React.ReactNode; }) => ( ), @@ -117,12 +121,18 @@ vi.mock('@/component-library', () => ({ options, value, onChange, + dropdownClassName, }: { options: Array<{ label: string; value: string }>; value: string; onChange: (value: string) => void; + dropdownClassName?: string; }) => ( - onChange(event.target.value)} + data-dropdown-class-name={dropdownClassName} + > {options.map((option) => ( ))} @@ -167,6 +177,36 @@ describe('SSHConnectionDialog', () => { }); } + function setInputValue(label: string, value: string): void { + const input = container.querySelector(`input[aria-label="${label}"]`); + expect(input).not.toBeNull(); + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter?.call(input, value); + input?.dispatchEvent(new Event('input', { bubbles: true })); + }); + } + + function setSelectValue(select: HTMLSelectElement | null, value: string): void { + expect(select).not.toBeNull(); + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set; + setter?.call(select, value); + select?.dispatchEvent(new Event('change', { bubbles: true })); + }); + } + + function findTargetSelect(): HTMLSelectElement | null { + return Array.from(container.querySelectorAll('select')).find((select) => ( + select.querySelector('option[value="localDocker"]') !== null + )) ?? null; + } + + function findConnectButton(): HTMLButtonElement | undefined { + return Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent?.includes('ssh.remote.connect')); + } + it('keeps optional connection fields collapsed for a new connection', async () => { await renderDialog(); @@ -185,6 +225,30 @@ describe('SSHConnectionDialog', () => { expect(container.querySelector('input[aria-label="ssh.remote.connectTimeout"]')).not.toBeNull(); }); + it('keeps portalled select menus above the raised dialog overlay', async () => { + await renderDialog(); + + const selects = Array.from(container.querySelectorAll('select')); + expect(selects.length).toBeGreaterThan(0); + expect(selects.every((select) => ( + select.dataset.dropdownClassName === 'ssh-connection-dialog__select-dropdown' + ))).toBe(true); + + const stylesheet = readFileSync( + resolve(process.cwd(), 'src/features/ssh-remote/SSHConnectionDialog.scss'), + 'utf8', + ); + const overlayZIndex = Number(stylesheet.match( + /\.ssh-connection-dialog__modal-overlay\s*\{[^}]*z-index:\s*(\d+)/, + )?.[1]); + const selectZIndex = Number(stylesheet.match( + /\.select__dropdown\.ssh-connection-dialog__select-dropdown\s*\{[^}]*z-index:\s*(\d+)/, + )?.[1]); + + expect(overlayZIndex).toBeGreaterThan(0); + expect(selectZIndex).toBeGreaterThan(overlayZIndex); + }); + it('reveals non-default settings when editing an existing connection', async () => { sshApiMock.listSavedConnections.mockResolvedValue([ { @@ -262,27 +326,11 @@ describe('SSHConnectionDialog', () => { remoteContextMock.connect.mockResolvedValue(undefined); await renderDialog(onClose); - const setValue = (label: string, value: string) => { - const input = container.querySelector(`input[aria-label="${label}"]`); - expect(input).not.toBeNull(); - act(() => { - if (input) { - const setter = Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - 'value', - )?.set; - setter?.call(input, value); - input.dispatchEvent(new Event('input', { bubbles: true })); - } - }); - }; - - setValue('ssh.remote.host', 'example.test'); - setValue('ssh.remote.username', 'dev'); - setValue('ssh.remote.password', 'secret'); + setInputValue('ssh.remote.host', 'example.test'); + setInputValue('ssh.remote.username', 'dev'); + setInputValue('ssh.remote.password', 'secret'); - const connectButton = Array.from(container.querySelectorAll('button')) - .find((button) => button.textContent?.includes('ssh.remote.connect')); + const connectButton = findConnectButton(); expect(connectButton).not.toBeUndefined(); await act(async () => { connectButton?.click(); @@ -298,6 +346,69 @@ describe('SSHConnectionDialog', () => { }), { browseAfterConnect: true }, ); + expect(remoteContextMock.connect.mock.calls[0]?.[1].container).toBeUndefined(); expect(onClose).toHaveBeenCalledTimes(1); }); + + it.each([ + ['remote Docker', 'remoteDocker', false, 'auto'], + ['local Docker', 'localDocker', true, 'auto'], + ['container sshd', 'containerSshd', false, 'sshd'], + ] as const)( + 'builds the expected connection config for %s', + async (_label, targetType, local, access) => { + remoteContextMock.connect.mockResolvedValue(undefined); + await renderDialog(); + + setSelectValue(findTargetSelect(), targetType); + const containerNameInput = container.querySelector( + 'input[placeholder="ssh.remote.containerNamePlaceholder"]', + ); + expect(containerNameInput).not.toBeNull(); + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter?.call(containerNameInput, 'devbox'); + containerNameInput?.dispatchEvent(new Event('input', { bubbles: true })); + }); + + if (!local) { + setInputValue('ssh.remote.host', 'example.test'); + setInputValue('ssh.remote.username', 'dev'); + setInputValue('ssh.remote.password', 'secret'); + } + + const connectButton = findConnectButton(); + expect(connectButton).not.toBeUndefined(); + await act(async () => { + connectButton?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const expectedId = local + ? 'docker-local-devbox' + : 'ssh-dev@example.test-container-devbox'; + expect(remoteContextMock.connect).toHaveBeenCalledWith( + expectedId, + expect.objectContaining({ + id: expectedId, + host: local ? 'local-docker' : 'example.test', + username: local ? 'docker' : 'dev', + auth: local + ? { type: 'PrivateKey', keyPath: '' } + : { type: 'Password', password: 'secret' }, + container: { + name: 'devbox', + access, + local, + dockerPath: 'docker', + shell: '/bin/sh', + user: undefined, + interactive: true, + }, + }), + { browseAfterConnect: true }, + ); + }, + ); }); diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx index 8d4b332644..9499f35c98 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx @@ -897,6 +897,7 @@ export const SSHConnectionDialog: React.FC = ({ value={formData.targetType} onChange={(value) => handleInputChange('targetType', String(value))} size="medium" + dropdownClassName="ssh-connection-dialog__select-dropdown" />
@@ -969,6 +970,7 @@ export const SSHConnectionDialog: React.FC = ({ value={formData.containerName} onChange={(value) => handleInputChange('containerName', String(value))} size="medium" + dropdownClassName="ssh-connection-dialog__select-dropdown" /> ) : ( = ({ value={formData.containerAccess} onChange={(value) => handleInputChange('containerAccess', String(value))} size="medium" + dropdownClassName="ssh-connection-dialog__select-dropdown" />
{t('ssh.remote.containerAccessHint')} @@ -1048,6 +1051,7 @@ export const SSHConnectionDialog: React.FC = ({ value={formData.authType} onChange={(value) => handleInputChange('authType', String(value))} size="medium" + dropdownClassName="ssh-connection-dialog__select-dropdown" />
diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 4f982d0013..7a39ce2cca 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -147,6 +147,11 @@ import { } from './ChatInputWorkspaceStrip'; import type { DispatchSelection, DispatchTarget } from '@/features/dispatch/types'; import { isNonLocalDispatchTarget } from '@/features/dispatch/types'; +import { + DISPATCH_PERMISSION_MODES, + dispatchApprovalPolicyFromPermissionMode, + permissionModeFromDispatchApprovalPolicy, +} from '@/features/dispatch/approvalPolicy'; import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; import { useComposerCapabilities } from '../session-drivers/useComposerCapabilities'; import { ComposerVoiceInputButton } from './voice/ComposerVoiceInputButton'; @@ -580,6 +585,7 @@ export const ChatInput: React.FC = ({ const { entries: acpPlanEntries } = useAcpPlan(acpSessionForInput?.sessionId ?? null); const threadGoalController = useThreadGoalController(effectiveTargetSession, { isBtwSession, + disabled: !caps.threadGoal, }); // Resolve via the i18n key (re-localizes on locale switch) instead of the // persisted localized session.title string, so this tab stays in sync with @@ -2166,11 +2172,9 @@ export const ChatInput: React.FC = ({ }, [applySessionPermissionMode, isAcpTargetSession, permissionModeSaving, sessionPermissionMode]); const dispatchPermissionMode: ChatInputPermissionMode = - effectiveTargetSession?.config.dispatchApprovalPolicy === 'auto' - ? 'auto' - : effectiveTargetSession?.config.dispatchApprovalPolicy === 'reject-and-report' - ? 'reject' - : 'ask'; + permissionModeFromDispatchApprovalPolicy( + effectiveTargetSession?.config.dispatchApprovalPolicy, + ); const dispatchSubmissionOptionsLocked = caps.submissionOptionsLocked; const handleDispatchPermissionModeChange = useCallback(( nextMode: Exclude, @@ -2178,12 +2182,7 @@ export const ChatInput: React.FC = ({ if (!effectiveTargetSessionId || dispatchSubmissionOptionsLocked) { return; } - const approvalPolicy = - nextMode === 'auto' || nextMode === 'full_access' - ? 'auto' - : nextMode === 'reject' - ? 'reject-and-report' - : 'remote'; + const approvalPolicy = dispatchApprovalPolicyFromPermissionMode(nextMode); FlowChatStore.getInstance().updateSessionDispatchApprovalPolicy( effectiveTargetSessionId, approvalPolicy, @@ -2265,11 +2264,16 @@ export const ChatInput: React.FC = ({ ...flowChatSessionConfigForCurrentWorkspace(workspace), dispatchTargetRequest: selection.request, dispatchTarget: selection.target, - dispatchApprovalPolicy: selection.approvalPolicy, + // Not asked for while picking a target: a dispatch session starts on + // the same permission default a local session here would, and the + // composer strip stays the one place to change it. + dispatchApprovalPolicy: dispatchApprovalPolicyFromPermissionMode( + permissionMode === 'acp' ? 'ask' : permissionMode, + ), dispatchIncludeUncommitted: selection.includeUncommitted, dispatchBaseRef: selection.baseRef, // Undefined is intentional: the target's probed default model wins - // unless a future preflight selector records an explicit choice. + // until the composer's model picker records an explicit choice. dispatchModel: selection.model, dispatchModelCatalog: selection.modelCatalog, dispatchAvailableModels: selection.availableModels, @@ -2281,7 +2285,7 @@ export const ChatInput: React.FC = ({ log.error('Failed to create dispatched session projection', { error }); notificationService.error(t('chatInput.dispatch.createFailed')); } - }, [effectiveSendAgentType, t, workspace]); + }, [effectiveSendAgentType, permissionMode, t, workspace]); const effectiveTargetSessionHasTurns = effectiveTargetSession ? !isProjectedSessionEmpty(effectiveTargetSession) @@ -2360,6 +2364,12 @@ export const ChatInput: React.FC = ({ defaultModelId: effectiveTargetSession.config.dispatchDefaultModel, reasoningCatalog: effectiveTargetSession.config.dispatchModelCatalog, selectedReasoningPreset: effectiveTargetSession.config.dispatchReasoningPreset, + // The probe snapshot above is a starting point, not the offer. Dispatch + // changes where a session runs, not which models this device has, and + // submission brings the target up to whatever is chosen here — so the + // picker offers the local catalog exactly as a local session would, and + // survives a projection restored without that snapshot. + includeLocalCatalog: true, providerLabel, disabled: caps.submissionOptionsLocked, onSelect: (modelId: string) => { @@ -6122,7 +6132,7 @@ export const ChatInput: React.FC = ({ ? { mode: dispatchPermissionMode, disabled: dispatchSubmissionOptionsLocked, - options: ['ask', 'auto', 'reject'], + options: DISPATCH_PERMISSION_MODES, scopeLabel: t('chatInput.dispatch.sessionScope'), onChange: handleDispatchPermissionModeChange, onHide: handleHidePermissionModeControl, diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index 029d1445bc..f4562b5737 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -67,6 +67,16 @@ export interface ExternalModelSelection { selectedReasoningPreset?: string; providerLabel: string; disabled?: boolean; + /** + * Also offer this device's own enabled models, and fall back to its catalog + * for reasoning presets. + * + * For a transport that only relays a session elsewhere, `models` is a probe + * snapshot rather than the set of choices the user has: the executing side + * is brought up to whatever is picked. Leave this off for a transport that + * owns a genuinely foreign model list. + */ + includeLocalCatalog?: boolean; onSelect: (modelId: string) => void | Promise; onSelectReasoningPreset?: (presetId: string | null) => void | Promise; } @@ -501,8 +511,12 @@ export const ModelSelector: React.FC = ({ const externalAvailableModels = useMemo((): ModelInfo[] => { if (!externalSelection) return []; + const localSelectable = externalSelection.includeLocalCatalog + ? allModels.filter(model => model.enabled).map(model => model.id) + : []; return Array.from(new Set([ ...externalSelection.models, + ...localSelectable, externalSelection.defaultModelId, externalSelection.selectedModelId, ].filter((model): model is string => !!model?.trim()))) @@ -530,20 +544,52 @@ export const ModelSelector: React.FC = ({ }); }, [allModels, externalSelection]); + /** + * This device's own default, resolved to the concrete id the executing side + * needs. Only consulted when the target reported no default of its own, so a + * session that has never been given a model still shows what a local session + * here would run rather than whichever id happens to sort first. + */ + const externalLocalDefaultModelId = useMemo((): string | undefined => { + if (!externalSelection?.includeLocalCatalog) return undefined; + const configured = modeDefaultModelId?.trim() || modeModel; + const concrete = resolveConcreteModelId(configured, defaultModels); + return concrete && allModels.some(model => model.id === concrete && model.enabled) + ? concrete + : undefined; + }, [ + allModels, + defaultModels, + externalSelection?.includeLocalCatalog, + modeDefaultModelId, + modeModel, + ]); const externalCurrentModelId = externalSelection?.selectedModelId?.trim() || externalSelection?.defaultModelId?.trim() + || externalLocalDefaultModelId || externalAvailableModels[0]?.id || ''; const externalCurrentModel = externalAvailableModels.find( model => model.id === externalCurrentModelId, ) ?? null; const externalReasoningProjection = useMemo((): ReasoningCatalogProjection | null => { - if (!externalSelection?.reasoningCatalog || !externalCurrentModelId) return null; - return externalSelection.reasoningCatalog.models.find( - model => model.id === externalCurrentModelId, - )?.reasoning ?? null; - }, [externalCurrentModelId, externalSelection?.reasoningCatalog]); + if (!externalSelection || !externalCurrentModelId) return null; + // The target's catalog wins where it has an entry: it is what the worker + // will actually execute. The local catalog covers a model this device just + // offered, and a projection restored without a probe snapshot at all. + const catalogs = [ + externalSelection.reasoningCatalog, + ...(externalSelection.includeLocalCatalog && modelCatalog ? [modelCatalog] : []), + ]; + for (const catalog of catalogs) { + const reasoning = catalog?.models.find( + model => model.id === externalCurrentModelId, + )?.reasoning; + if (reasoning) return reasoning; + } + return null; + }, [externalCurrentModelId, externalSelection, modelCatalog]); const acpFastMode = useMemo( () => resolveAcpFastModeState(acpOptions?.configOptions ?? []), diff --git a/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx index 563ecfa1f6..f09792fd54 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx @@ -395,6 +395,128 @@ describe('ModelSelector external transport reuse', () => { expect(onSelect).toHaveBeenCalledWith('model-b'); }); + it('offers this device\'s models when the transport only relays the session', async () => { + // A projection restored without a probe snapshot used to render nothing at + // all, leaving the user with no way to switch models in that session. + const onSelect = vi.fn(async () => undefined); + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + const trigger = container.querySelector( + '[data-testid="chat-model-selector-btn"]', + ); + expect(trigger, 'the picker must not disappear').not.toBeNull(); + // Falls back to this device's own default rather than list order. + expect(trigger?.textContent).toContain('friendly-model-a'); + + await act(async () => { + trigger?.click(); + }); + await act(async () => { + document.body.querySelector( + '[data-testid="chat-model-selector-option"][data-model-id="model-a"]', + )?.click(); + await Promise.resolve(); + }); + expect(onSelect).toHaveBeenCalledWith('model-a'); + }); + + it('keeps the local list out of a transport that owns a foreign catalog', async () => { + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + await act(async () => { + container.querySelector( + '[data-testid="chat-model-selector-btn"]', + )?.click(); + }); + expect(document.body.querySelector( + '[data-testid="chat-model-selector-option"][data-model-id="model-a"]', + )).toBeNull(); + }); + + it('reads reasoning presets from this device when the target reported none', async () => { + aiApiMocks.getModelCatalog.mockResolvedValueOnce({ + version: 1, + default_models: { primary: 'model-a' }, + models: [{ + id: 'model-a', + name: 'Synced provider', + provider: 'openai', + base_url: 'https://example.test/v1', + model_name: 'friendly-model-a', + enabled: true, + capabilities: ['text_chat'], + reasoning: { + status: 'known', + default_preset: 'high', + presets: [{ + id: 'high', + label: 'High', + order: 10, + source: 'models_dev', + actions: [{ type: 'effort', value: 'high' }], + }], + }, + }], + }); + const onSelectReasoningPreset = vi.fn(async () => undefined); + + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + const trigger = container.querySelector( + '[data-testid="chat-reasoning-preset-selector-btn"]', + ); + expect(trigger).not.toBeNull(); + await act(async () => { + trigger?.click(); + }); + await act(async () => { + document.body.querySelector('[data-preset-id="high"]')?.click(); + await Promise.resolve(); + }); + expect(onSelectReasoningPreset).toHaveBeenCalledWith('high'); + }); + it('hides reasoning when the target did not report a catalog', async () => { await act(async () => { root.render( diff --git a/src/web-ui/src/flow_chat/hooks/useThreadGoalController.ts b/src/web-ui/src/flow_chat/hooks/useThreadGoalController.ts index 7a964fe1b9..7de061f39b 100644 --- a/src/web-ui/src/flow_chat/hooks/useThreadGoalController.ts +++ b/src/web-ui/src/flow_chat/hooks/useThreadGoalController.ts @@ -105,11 +105,12 @@ function useStableThreadGoalSnapshot(sessionId: string | undefined): ThreadGoalS export function useThreadGoalController( session: Session | undefined, - options?: { isBtwSession?: boolean } + options?: { isBtwSession?: boolean; disabled?: boolean } ): ThreadGoalController { const { t } = useTranslation('flow-chat'); const sessionId = session?.sessionId; const isBtwSession = Boolean(options?.isBtwSession); + const disabled = isBtwSession || Boolean(options?.disabled); const storeGoal = useStableThreadGoalSnapshot(sessionId); @@ -139,7 +140,7 @@ export function useThreadGoalController( ); const refreshGoal = useCallback(async () => { - if (!sessionId || isBtwSession) return; + if (!sessionId || disabled) return; const current = flowChatStore.getState().sessions.get(sessionId); if (!current?.workspacePath) return; try { @@ -147,10 +148,10 @@ export function useThreadGoalController( } catch { // best-effort; UI still works from events } - }, [isBtwSession, sessionId]); + }, [disabled, sessionId]); useEffect(() => { - if (!sessionId || isBtwSession) return; + if (!sessionId || disabled) return; if (session?.isHistorical) { const timeoutId = globalThis.setTimeout(() => { void refreshGoal(); @@ -158,14 +159,20 @@ export function useThreadGoalController( return () => globalThis.clearTimeout(timeoutId); } void refreshGoal(); - }, [session?.isHistorical, sessionId, isBtwSession, refreshGoal]); + }, [session?.isHistorical, sessionId, disabled, refreshGoal]); const goalId = goal?.goalId; const goalStatus = goal?.status; const goalUpdatedAt = goal?.updatedAt; useEffect(() => { - if (!sessionId || !goalId || !goalStatus || !threadGoalStatusNeedsResumePrompt(goalStatus)) { + if ( + disabled + || !sessionId + || !goalId + || !goalStatus + || !threadGoalStatusNeedsResumePrompt(goalStatus) + ) { return; } if (!goal || isResumePromptDismissed(sessionId, goal)) { @@ -177,7 +184,7 @@ export function useThreadGoalController( } lastResumePromptKey.current = key; setResumeOpen(true); - }, [goal, goalId, goalStatus, goalUpdatedAt, sessionId]); + }, [disabled, goal, goalId, goalStatus, goalUpdatedAt, sessionId]); const openMenu = useCallback(() => { setMenuOpen(true); @@ -206,18 +213,18 @@ export function useThreadGoalController( ); const openGoalEntry = useCallback(async () => { - if (!session?.workspacePath || isBtwSession) return; + if (!session?.workspacePath || disabled) return; const latest = await fetchSessionThreadGoal(session); if (latest) { setMenuOpen(true); } else { openEdit('create'); } - }, [isBtwSession, openEdit, session]); + }, [disabled, openEdit, session]); const runSlashAction = useCallback( async (message: string) => { - if (!session) return null; + if (!session || disabled) return null; const parsed = parseGoalCommand(message); if (!parsed) return null; @@ -240,12 +247,12 @@ export function useThreadGoalController( }, }); }, - [confirmReplaceGoal, openEdit, session, titles] + [confirmReplaceGoal, disabled, openEdit, session, titles] ); const runUiAction = useCallback( async (action: 'clear' | 'pause' | 'resume') => { - if (!session) return; + if (!session || disabled) return; try { await runThreadGoalUiAction(session, action, titles); if (action === 'clear') { @@ -259,12 +266,12 @@ export function useThreadGoalController( notificationService.error(message, { title: titles.failedTitle, duration: 5000 }); } }, - [session, titles] + [disabled, session, titles] ); const saveEdit = useCallback( async (objective: string) => { - if (!session) return; + if (!session || disabled) return; try { const saved = await saveThreadGoalObjective(session, objective, editMode, titles, { confirmReplaceGoal: editMode === 'create' ? confirmReplaceGoal : undefined, @@ -282,7 +289,7 @@ export function useThreadGoalController( notificationService.error(message, { title: titles.failedTitle, duration: 5000 }); } }, - [confirmReplaceGoal, editMode, session, titles] + [confirmReplaceGoal, disabled, editMode, session, titles] ); const confirmResume = useCallback(async () => { diff --git a/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.test.ts b/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.test.ts new file mode 100644 index 0000000000..fcc13d908c --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { sessionSupportsThreadGoal } from './useComposerCapabilities'; + +describe('sessionSupportsThreadGoal', () => { + it('supports BitFun runtime primary agents, including Claw', () => { + expect(sessionSupportsThreadGoal({ config: { agentType: 'Claw' }, mode: 'Claw' })).toBe(true); + }); + + it('does not advertise BitFun thread goals for ACP-owned agents', () => { + expect( + sessionSupportsThreadGoal({ config: { agentType: 'acp:codex' }, mode: 'acp:codex' }), + ).toBe(false); + expect(sessionSupportsThreadGoal({ config: {}, mode: 'acp:claude-code' })).toBe(false); + }); +}); diff --git a/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts b/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts index 1ca0c893b9..feb398eb57 100644 --- a/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts +++ b/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts @@ -12,6 +12,7 @@ import { useRuntimeStatusStore } from '../store/runtimeStatusStore'; import type { Session } from '../types/flow-chat'; +import { isAcpFlowSession } from '../utils/acpSession'; import { resolveSessionDriverId, type SessionDriverId } from './resolve'; export const DISPATCH_TRANSFER_ROUND_PREFIX = 'dispatch-transfer:'; @@ -22,6 +23,9 @@ export type ComposerSlashOp = 'btw' | 'compact' | 'goal' | 'usage' | 'init' | 'r const LOCAL_SLASH_OPS: ReadonlySet = new Set([ 'btw', 'compact', 'goal', 'usage', 'init', 'review', ]); +const LOCAL_SLASH_OPS_WITHOUT_THREAD_GOAL: ReadonlySet = new Set([ + 'btw', 'compact', 'usage', 'init', 'review', +]); /** Ops a detached target serves via its durable turn mailbox / query verb. */ const DISPATCH_SLASH_OPS: ReadonlySet = new Set(['compact', 'usage']); @@ -59,10 +63,20 @@ export interface ComposerCapabilityInput { displayAsChild: boolean; } +export function sessionSupportsThreadGoal( + session: Pick | undefined, +): boolean { + // ACP agents own their execution loop and tool surface. Until the protocol + // exposes the BitFun thread-goal lifecycle, advertising the local goal UI + // would create state the external agent cannot inspect or complete. + return !isAcpFlowSession(session); +} + export function useComposerCapabilities(input: ComposerCapabilityInput): ComposerCapabilities { const { sessionId, session, hostMasksDispatch, displayAsChild } = input; const driverId = resolveSessionDriverId(sessionId ?? '', session); const dispatchTransport = !hostMasksDispatch && driverId === 'dispatch'; + const threadGoalSupported = sessionSupportsThreadGoal(session); const transferInFlight = useRuntimeStatusStore(state => { const status = sessionId ? state.bySessionId.get(sessionId) : undefined; @@ -85,9 +99,13 @@ export function useComposerCapabilities(input: ComposerCapabilityInput): Compose driverId, dispatchTransport, localSlashCommands: !dispatchTransport, - ops: dispatchTransport ? DISPATCH_SLASH_OPS : LOCAL_SLASH_OPS, + ops: dispatchTransport + ? DISPATCH_SLASH_OPS + : threadGoalSupported + ? LOCAL_SLASH_OPS + : LOCAL_SLASH_OPS_WITHOUT_THREAD_GOAL, usageReport: true, - threadGoal: !displayAsChild && !dispatchTransport, + threadGoal: threadGoalSupported && !displayAsChild && !dispatchTransport, transferInFlight, submissionOptionsLocked, sessionScopedApproval: dispatchTransport, diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts index ba1e9f0afa..c80b1ea376 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts @@ -43,48 +43,6 @@ function surface(catalog: Record) { }; } -function applicationSurfaceV2(overrides: Record = {}) { - return { - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - effectiveConnectionScope: 'workspace_override', - refreshGeneration: 7, - preferenceRevision: 11, - safeMode: false, - hostCapabilities: { - canReadSnapshot: true, - canReadReview: true, - canMutate: true, - canManageUserDefault: true, - canManageWorkspaceOverride: true, - canRefresh: true, - canSetSafeMode: true, - }, - applications: [{ - applicationId: 'opencode', - ecosystemId: 'opencode', - displayName: 'OpenCode', - discovery: 'discovered', - connection: 'connected', - desiredConnection: 'connected', - health: 'healthy', - effectiveStatus: 'connected', - primaryAction: 'view', - defaultConnectionPolicy: 'connect', - defaultConnectionReason: 'supported_by_product', - enabledCount: 2, - pendingReviewCount: 0, - blockedCount: 0, - conflictCount: 0, - riskSummary: { reasonCodes: [] }, - userDecision: 'connected', - recoveryActions: [], - }], - ...overrides, - }; -} - vi.mock('../adapters', async importOriginal => ({ ...await importOriginal(), getTransportAdapter: () => adapterMocks, @@ -108,312 +66,6 @@ describe('ExternalSourcesAPI', () => { adapterMocks.isConnected.mockReturnValue(true); }); - it('provides an application-level V2 negotiation read', () => { - expect('getApplicationSurface' in externalSourcesAPI).toBe(true); - }); - - it('prefers a strict Host-authoritative V2 application snapshot', async () => { - invokeMock.mockResolvedValueOnce(applicationSurfaceV2()); - - await expect(externalSourcesAPI.getApplicationSurface(' D:/workspace/project ', true)) - .resolves.toMatchObject({ - protocol: 'v2', - snapshot: { - schemaVersion: 2, - executionDomainId: 'host-a', - applications: [{ - effectiveStatus: 'connected', - primaryAction: 'view', - }], - }, - }); - expect(invokeMock).toHaveBeenCalledWith('get_external_application_snapshot_v2', { - request: { workspacePath: 'D:/workspace/project', forceRefresh: true }, - }); - }); - - it('fails closed for an unknown V2 application status without falling back to V1', async () => { - invokeMock.mockResolvedValueOnce(applicationSurfaceV2({ - applications: [{ - ...applicationSurfaceV2().applications[0], - effectiveStatus: 'future_status', - }], - })); - - await expect(externalSourcesAPI.getApplicationSurface()).rejects.toMatchObject({ - code: 'invalid_response', - }); - expect(invokeMock).toHaveBeenCalledTimes(1); - }); - - it('normalizes risk reason codes omitted by the Rust empty-vector wire format', async () => { - invokeMock.mockResolvedValueOnce(applicationSurfaceV2({ - applications: [{ - ...applicationSurfaceV2().applications[0], - riskSummary: {}, - }], - reviewSummary: { - reviewId: 'review-a', - totalCount: 0, - categoryCounts: [], - maxSelectionCount: 0, - riskSummary: {}, - recommendationSummary: { recommendedCount: 0, optionalCount: 0, blockedCount: 0 }, - safetyCeiling: 'automatic', - }, - })); - - await expect(externalSourcesAPI.getApplicationSurface()).resolves.toMatchObject({ - protocol: 'v2', - snapshot: { - applications: [{ riskSummary: { reasonCodes: [] } }], - reviewSummary: { riskSummary: { reasonCodes: [] } }, - }, - }); - }); - - it('accepts a workspace context whose effective decision is inherited from the user default', async () => { - invokeMock.mockResolvedValueOnce(applicationSurfaceV2({ - effectiveConnectionScope: 'user_default', - })); - - await expect(externalSourcesAPI.getApplicationSurface()).resolves.toMatchObject({ - protocol: 'v2', - snapshot: { - workspaceScopeId: 'workspace:0123456789abcdef', - effectiveConnectionScope: 'user_default', - }, - }); - }); - - it('falls back to the unchanged V1 surface only when the V2 method is unavailable', async () => { - invokeMock - .mockRejectedValueOnce( - "command 'get_external_application_snapshot_v2' is not supported on CLI peer host", - ) - .mockResolvedValueOnce(surface({ - generation: 3, - discoveryPending: false, - preferenceRevision: 2, - sources: [], - commands: [], - integrationPolicy: { - schemaMajor: 1, - status: 'compatible', - userDefaults: { enabled: false, ecosystems: {} }, - globalEffective: { enabled: false, ecosystems: {} }, - effective: { enabled: false, ecosystems: {} }, - registeredEcosystems: [], - }, - })); - - await expect(externalSourcesAPI.getApplicationSurface('D:/workspace/project')) - .resolves.toMatchObject({ - protocol: 'v1', - snapshot: { generation: 3 }, - }); - expect(invokeMock).toHaveBeenNthCalledWith(2, 'get_external_source_control_snapshot', { - request: { workspacePath: 'D:/workspace/project', forceRefresh: false }, - }); - }); - - it('reads a strict bounded V2 review page with the Host scope kept outside the domain request', async () => { - const request = { - schemaVersion: 2 as const, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override' as const, - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool' as const, generation: 7 }], - pageSize: 64, - }; - invokeMock.mockResolvedValueOnce({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 7 }], - nextCursor: 'page-2', - totalCount: 2, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-a' }, - displayName: 'Tool A', - displaySummary: 'Read repository files', - riskLevel: 'moderate', - riskReasonCodes: ['reads_workspace'], - recommended: true, - safetyCeiling: 'review_required', - }], - }); - - await expect(externalSourcesAPI.getApplicationReviewPage( - ' D:/workspace/project ', - request, - )).resolves.toMatchObject({ - nextCursor: 'page-2', - items: [{ riskLevel: 'moderate', recommended: true }], - }); - expect(invokeMock).toHaveBeenCalledWith('get_external_application_review_page_v2', { - request: { workspacePath: 'D:/workspace/project', request }, - }); - }); - - it('accepts an authoritative review rebind only when opening the first page', async () => { - invokeMock.mockResolvedValueOnce({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-current', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 8 }], - totalCount: 1, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-current' }, - displayName: 'Current Tool', - displaySummary: 'Current item', - riskLevel: 'low', - riskReasonCodes: [], - recommended: true, - safetyCeiling: 'automatic', - }], - }); - - await expect(externalSourcesAPI.getApplicationReviewPage('D:/workspace/project', { - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-stale', - preferenceRevision: 11, - expectedGenerations: [], - pageSize: 64, - })).resolves.toMatchObject({ - reviewId: 'review-current', - expectedGenerations: [{ owner: 'tool', generation: 8 }], - }); - }); - - it('fails closed when a V2 review page contains an unknown risk level', async () => { - invokeMock.mockResolvedValueOnce({ - schemaVersion: 2, - executionDomainId: 'host-a', - targetScope: 'user_default', - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [], - totalCount: 1, - items: [{ - itemRef: { kind: 'command', stableId: 'command-a' }, - displayName: 'Command A', - displaySummary: 'Run command A', - riskLevel: 'medium', - riskReasonCodes: [], - recommended: true, - safetyCeiling: 'automatic', - }], - }); - - await expect(externalSourcesAPI.getApplicationReviewPage(undefined, { - schemaVersion: 2, - executionDomainId: 'host-a', - targetScope: 'user_default', - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [], - pageSize: 64, - })).rejects.toMatchObject({ code: 'invalid_response' }); - }); - - it('rejects a validly shaped review page from a different execution domain', async () => { - invokeMock.mockResolvedValueOnce({ - schemaVersion: 2, - executionDomainId: 'host-b', - targetScope: 'user_default', - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [], - totalCount: 0, - items: [], - }); - - await expect(externalSourcesAPI.getApplicationReviewPage(undefined, { - schemaVersion: 2, - executionDomainId: 'host-a', - targetScope: 'user_default', - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [], - pageSize: 64, - })).rejects.toMatchObject({ code: 'invalid_response' }); - }); - - it('applies a V2 action and preserves typed partial or stale item feedback', async () => { - const request = { - schemaVersion: 2 as const, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override' as const, - operationId: 'operation-a', - expectedPreferenceRevision: 11, - action: { - type: 'submit_application_review' as const, - reviewId: 'review-a', - expectedGenerations: [{ owner: 'tool' as const, generation: 7 }], - selectionBaseline: 'recommended' as const, - selectionOverrides: [{ - itemRef: { kind: 'tool' as const, stableId: 'tool-a' }, - selected: false, - }], - }, - }; - invokeMock.mockResolvedValueOnce({ - schemaVersion: 2, - operationId: 'operation-a', - preferenceRevision: 12, - outcome: 'stale', - itemResults: [{ - itemRef: { kind: 'tool', stableId: 'tool-a' }, - outcome: 'stale', - reasonCode: 'owner_generation_changed', - recoveryActions: [{ type: 'refresh' }], - }], - }); - - await expect(externalSourcesAPI.applyApplicationAction( - 'D:/workspace/project', - request, - )).resolves.toMatchObject({ - outcome: 'stale', - itemResults: [{ recoveryActions: [{ type: 'refresh' }] }], - }); - expect(invokeMock).toHaveBeenCalledWith('apply_external_application_action_v2', { - request: { workspacePath: 'D:/workspace/project', request }, - }); - }); - - it('rejects a V2 action result for a different idempotency operation', async () => { - invokeMock.mockResolvedValueOnce({ - schemaVersion: 2, - operationId: 'operation-b', - preferenceRevision: 12, - outcome: 'applied', - itemResults: [], - }); - - await expect(externalSourcesAPI.applyApplicationAction(undefined, { - schemaVersion: 2, - executionDomainId: 'host-a', - targetScope: 'user_default', - operationId: 'operation-a', - expectedPreferenceRevision: 11, - action: { type: 'refresh' }, - })).rejects.toMatchObject({ code: 'invalid_response' }); - }); - it('reads and acknowledges backend-owned ecosystem awareness', async () => { invokeMock .mockResolvedValueOnce({ unacknowledgedEcosystemIds: ['opencode', 'codex'] }) diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts index 4641a313e9..05e0dd3218 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -196,225 +196,6 @@ export interface ExternalSourceCatalogSnapshot { control?: ExternalSourceControlSnapshot; } -export type ExternalApplicationTargetScopeV2 = 'user_default' | 'workspace_override'; -export type ExternalApplicationDesiredConnectionV2 = - | 'unspecified' - | 'connected' - | 'disconnected' - | 'deferred' - | 'needs_review'; -export type ExternalApplicationUserDecisionV2 = - | 'none' - | 'connected' - | 'disconnected' - | 'deferred' - | 'needs_review'; -export type ExternalApplicationDiscoveryStateV2 = 'not_discovered' | 'discovered'; -export type ExternalApplicationConnectionStateV2 = 'disconnected' | 'connected'; -export type ExternalApplicationHealthV2 = 'healthy' | 'degraded' | 'unavailable'; -export type ExternalApplicationEffectiveStatusV2 = - | 'connected' - | 'configuration_available' - | 'no_configuration' - | 'needs_attention' - | 'temporarily_unavailable'; -export type ExternalApplicationPrimaryActionV2 = - | 'none' - | 'view' - | 'connect' - | 'review' - | 'retry' - | 'view_reason'; -export type ExternalApplicationDefaultConnectionPolicyV2 = - | 'connect' - | 'discover_only' - | 'unsupported'; -export type ExternalApplicationRiskLevelV2 = 'low' | 'moderate' | 'high'; -export type ExternalApplicationSafetyCeilingV2 = 'blocked' | 'review_required' | 'automatic'; -export type ExternalApplicationRecoveryActionV2 = { - type: - | 'refresh' - | 'retry' - | 'reconnect_host' - | 'review' - | 'upgrade_host' - | 'view_reason' - | 'exit_safe_mode' - | 'resolve_conflict' - | 'install_runtime'; -}; - -export interface ExternalApplicationHostCapabilitiesV2 { - canReadSnapshot: boolean; - canReadReview: boolean; - canMutate: boolean; - canManageUserDefault: boolean; - canManageWorkspaceOverride: boolean; - canRefresh: boolean; - canSetSafeMode: boolean; -} - -export interface ExternalApplicationRiskSummaryV2 { - highestLevel?: ExternalApplicationRiskLevelV2; - reasonCodes: string[]; -} - -export type ExternalApplicationReviewItemKindV2 = - | 'command' - | 'tool' - | 'subagent' - | 'mcp' - | 'conflict'; - -export interface ExternalApplicationReviewSummaryV2 { - reviewId: string; - totalCount: number; - categoryCounts: Array<{ kind: ExternalApplicationReviewItemKindV2; count: number }>; - maxSelectionCount: number; - riskSummary: ExternalApplicationRiskSummaryV2; - recommendationSummary: { - recommendedCount: number; - optionalCount: number; - blockedCount: number; - }; - safetyCeiling: ExternalApplicationSafetyCeilingV2; -} - -export interface ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2; - stableId: string; -} - -export interface ExternalApplicationOwnerGenerationV2 { - owner: ExternalApplicationReviewItemKindV2; - generation: number; -} - -export interface ExternalApplicationReviewItemV2 { - itemRef: ExternalApplicationReviewItemRefV2; - displayName: string; - displaySummary: string; - riskLevel: ExternalApplicationRiskLevelV2; - riskReasonCodes: string[]; - recommended: boolean; - safetyCeiling: ExternalApplicationSafetyCeilingV2; -} - -export interface ExternalApplicationReviewPageRequestV2 { - schemaVersion: 2; - executionDomainId: string; - workspaceScopeId?: string; - targetScope: ExternalApplicationTargetScopeV2; - reviewId: string; - preferenceRevision: number; - expectedGenerations: ExternalApplicationOwnerGenerationV2[]; - cursor?: string; - pageSize: number; -} - -export interface ExternalApplicationReviewPageV2 { - schemaVersion: 2; - executionDomainId: string; - workspaceScopeId?: string; - targetScope: ExternalApplicationTargetScopeV2; - reviewId: string; - preferenceRevision: number; - expectedGenerations: ExternalApplicationOwnerGenerationV2[]; - cursor?: string; - nextCursor?: string; - totalCount: number; - items: ExternalApplicationReviewItemV2[]; -} - -export type ExternalApplicationReviewSelectionBaselineV2 = 'recommended' | 'none'; - -export interface ExternalApplicationReviewSelectionOverrideV2 { - itemRef: ExternalApplicationReviewItemRefV2; - selected: boolean; -} - -export type ExternalApplicationControlActionV2 = - | { type: 'connect_application'; applicationId: string } - | { type: 'disconnect_application'; applicationId: string } - | { type: 'set_application_deferred'; applicationId: string } - | { - type: 'submit_application_review'; - reviewId: string; - expectedGenerations: ExternalApplicationOwnerGenerationV2[]; - selectionBaseline: ExternalApplicationReviewSelectionBaselineV2; - selectionOverrides: ExternalApplicationReviewSelectionOverrideV2[]; - } - | { type: 'refresh' } - | { type: 'set_source_enabled'; sourceKey: string; enabled: boolean } - | { type: 'set_safe_mode'; enabled: boolean }; - -export interface ExternalApplicationControlRequestV2 { - schemaVersion: 2; - executionDomainId: string; - workspaceScopeId?: string; - targetScope: ExternalApplicationTargetScopeV2; - operationId: string; - expectedPreferenceRevision: number; - action: ExternalApplicationControlActionV2; -} - -export type ExternalApplicationOperationOutcomeV2 = - | 'applied' - | 'rejected' - | 'blocked' - | 'stale' - | 'failed'; - -export interface ExternalApplicationReviewItemResultV2 { - itemRef: ExternalApplicationReviewItemRefV2; - outcome: ExternalApplicationOperationOutcomeV2; - reasonCode?: string; - recoveryActions: ExternalApplicationRecoveryActionV2[]; -} - -export interface ExternalApplicationControlResultV2 { - schemaVersion: 2; - operationId: string; - preferenceRevision: number; - outcome: ExternalApplicationOperationOutcomeV2; - itemResults: ExternalApplicationReviewItemResultV2[]; -} - -export interface ExternalApplicationSummaryV2 { - applicationId: string; - ecosystemId: string; - displayName: string; - discovery: ExternalApplicationDiscoveryStateV2; - connection: ExternalApplicationConnectionStateV2; - desiredConnection: ExternalApplicationDesiredConnectionV2; - health: ExternalApplicationHealthV2; - effectiveStatus: ExternalApplicationEffectiveStatusV2; - primaryAction: ExternalApplicationPrimaryActionV2; - defaultConnectionPolicy: ExternalApplicationDefaultConnectionPolicyV2; - defaultConnectionReason: string; - enabledCount: number; - pendingReviewCount: number; - blockedCount: number; - conflictCount: number; - riskSummary: ExternalApplicationRiskSummaryV2; - noticeKey?: string; - userDecision: ExternalApplicationUserDecisionV2; - recoveryActions: ExternalApplicationRecoveryActionV2[]; -} - -export interface ExternalApplicationSnapshotV2 { - schemaVersion: 2; - executionDomainId: string; - workspaceScopeId?: string; - effectiveConnectionScope: ExternalApplicationTargetScopeV2; - refreshGeneration: number; - preferenceRevision: number; - safeMode: boolean; - hostCapabilities: ExternalApplicationHostCapabilitiesV2; - applications: ExternalApplicationSummaryV2[]; - reviewSummary?: ExternalApplicationReviewSummaryV2; -} - export interface PromptCommandShellReviewPlan { schemaVersion: number; planFingerprint: string; @@ -981,399 +762,6 @@ function isNonNegativeInteger(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; } -const APPLICATION_TARGET_SCOPES = new Set([ - 'user_default', 'workspace_override', -]); -const APPLICATION_DESIRED_CONNECTIONS = new Set([ - 'unspecified', 'connected', 'disconnected', 'deferred', 'needs_review', -]); -const APPLICATION_USER_DECISIONS = new Set([ - 'none', 'connected', 'disconnected', 'deferred', 'needs_review', -]); -const APPLICATION_DISCOVERY_STATES = new Set([ - 'not_discovered', 'discovered', -]); -const APPLICATION_CONNECTION_STATES = new Set([ - 'disconnected', 'connected', -]); -const APPLICATION_HEALTH_STATES = new Set([ - 'healthy', 'degraded', 'unavailable', -]); -const APPLICATION_EFFECTIVE_STATUSES = new Set([ - 'connected', - 'configuration_available', - 'no_configuration', - 'needs_attention', - 'temporarily_unavailable', -]); -const APPLICATION_PRIMARY_ACTIONS = new Set([ - 'none', 'view', 'connect', 'review', 'retry', 'view_reason', -]); -const APPLICATION_DEFAULT_POLICIES = new Set([ - 'connect', 'discover_only', 'unsupported', -]); -const APPLICATION_RISK_LEVELS = new Set([ - 'low', 'moderate', 'high', -]); -const APPLICATION_SAFETY_CEILINGS = new Set([ - 'blocked', 'review_required', 'automatic', -]); -const APPLICATION_REVIEW_KINDS = new Set([ - 'command', 'tool', 'subagent', 'mcp', 'conflict', -]); -const APPLICATION_RECOVERY_ACTIONS = new Set([ - 'refresh', - 'retry', - 'reconnect_host', - 'review', - 'upgrade_host', - 'view_reason', - 'exit_safe_mode', - 'resolve_conflict', - 'install_runtime', -]); -const APPLICATION_OPERATION_OUTCOMES = new Set([ - 'applied', 'rejected', 'blocked', 'stale', 'failed', -]); - -function isExactRecord( - value: unknown, - allowedKeys: readonly string[], - requiredKeys: readonly string[] = allowedKeys, -): value is Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - const record = value as Record; - return Object.keys(record).every((key) => allowedKeys.includes(key)) - && requiredKeys.every((key) => Object.prototype.hasOwnProperty.call(record, key)); -} - -function isApplicationRiskSummary(value: unknown): value is ExternalApplicationRiskSummaryV2 { - if (!isExactRecord(value, ['highestLevel', 'reasonCodes'], [])) return false; - return (value.highestLevel === undefined - || value.highestLevel === null - || isOneOf(value.highestLevel, APPLICATION_RISK_LEVELS)) - && (value.reasonCodes === undefined - || (Array.isArray(value.reasonCodes) - && value.reasonCodes.every((reason) => typeof reason === 'string'))); -} - -function isApplicationRecoveryAction( - value: unknown, -): value is ExternalApplicationRecoveryActionV2 { - return isExactRecord(value, ['type']) && isOneOf(value.type, APPLICATION_RECOVERY_ACTIONS); -} - -function isApplicationReviewItemRef( - value: unknown, -): value is ExternalApplicationReviewItemRefV2 { - return isExactRecord(value, ['kind', 'stableId']) - && isOneOf(value.kind, APPLICATION_REVIEW_KINDS) - && typeof value.stableId === 'string'; -} - -function isApplicationOwnerGeneration( - value: unknown, -): value is ExternalApplicationOwnerGenerationV2 { - return isExactRecord(value, ['owner', 'generation']) - && isOneOf(value.owner, APPLICATION_REVIEW_KINDS) - && isNonNegativeInteger(value.generation); -} - -function isApplicationReviewItem(value: unknown): value is ExternalApplicationReviewItemV2 { - return isExactRecord(value, [ - 'itemRef', - 'displayName', - 'displaySummary', - 'riskLevel', - 'riskReasonCodes', - 'recommended', - 'safetyCeiling', - ]) - && isApplicationReviewItemRef(value.itemRef) - && typeof value.displayName === 'string' - && typeof value.displaySummary === 'string' - && isOneOf(value.riskLevel, APPLICATION_RISK_LEVELS) - && Array.isArray(value.riskReasonCodes) - && value.riskReasonCodes.every((reason) => typeof reason === 'string') - && typeof value.recommended === 'boolean' - && isOneOf(value.safetyCeiling, APPLICATION_SAFETY_CEILINGS); -} - -function isApplicationHostCapabilities( - value: unknown, -): value is ExternalApplicationHostCapabilitiesV2 { - const keys = [ - 'canReadSnapshot', - 'canReadReview', - 'canMutate', - 'canManageUserDefault', - 'canManageWorkspaceOverride', - 'canRefresh', - 'canSetSafeMode', - ] as const; - return isExactRecord(value, keys) && keys.every((key) => typeof value[key] === 'boolean'); -} - -function isApplicationSummary(value: unknown): value is ExternalApplicationSummaryV2 { - const keys = [ - 'applicationId', - 'ecosystemId', - 'displayName', - 'discovery', - 'connection', - 'desiredConnection', - 'health', - 'effectiveStatus', - 'primaryAction', - 'defaultConnectionPolicy', - 'defaultConnectionReason', - 'enabledCount', - 'pendingReviewCount', - 'blockedCount', - 'conflictCount', - 'riskSummary', - 'noticeKey', - 'userDecision', - 'recoveryActions', - ] as const; - if (!isExactRecord(value, keys, keys.filter((key) => key !== 'noticeKey'))) return false; - return typeof value.applicationId === 'string' - && typeof value.ecosystemId === 'string' - && typeof value.displayName === 'string' - && isOneOf(value.discovery, APPLICATION_DISCOVERY_STATES) - && isOneOf(value.connection, APPLICATION_CONNECTION_STATES) - && isOneOf(value.desiredConnection, APPLICATION_DESIRED_CONNECTIONS) - && isOneOf(value.health, APPLICATION_HEALTH_STATES) - && isOneOf(value.effectiveStatus, APPLICATION_EFFECTIVE_STATUSES) - && isOneOf(value.primaryAction, APPLICATION_PRIMARY_ACTIONS) - && isOneOf(value.defaultConnectionPolicy, APPLICATION_DEFAULT_POLICIES) - && typeof value.defaultConnectionReason === 'string' - && isNonNegativeInteger(value.enabledCount) - && isNonNegativeInteger(value.pendingReviewCount) - && isNonNegativeInteger(value.blockedCount) - && isNonNegativeInteger(value.conflictCount) - && isApplicationRiskSummary(value.riskSummary) - && (value.noticeKey === undefined || value.noticeKey === null || typeof value.noticeKey === 'string') - && isOneOf(value.userDecision, APPLICATION_USER_DECISIONS) - && Array.isArray(value.recoveryActions) - && value.recoveryActions.every(isApplicationRecoveryAction); -} - -function isApplicationReviewSummary( - value: unknown, -): value is ExternalApplicationReviewSummaryV2 { - const keys = [ - 'reviewId', - 'totalCount', - 'categoryCounts', - 'maxSelectionCount', - 'riskSummary', - 'recommendationSummary', - 'safetyCeiling', - ] as const; - if (!isExactRecord(value, keys) - || typeof value.reviewId !== 'string' - || !isNonNegativeInteger(value.totalCount) - || !isNonNegativeInteger(value.maxSelectionCount) - || value.maxSelectionCount > value.totalCount - || !isApplicationRiskSummary(value.riskSummary) - || !isOneOf(value.safetyCeiling, APPLICATION_SAFETY_CEILINGS) - || !Array.isArray(value.categoryCounts) - || !value.categoryCounts.every((entry) => ( - isExactRecord(entry, ['kind', 'count']) - && isOneOf(entry.kind, APPLICATION_REVIEW_KINDS) - && isNonNegativeInteger(entry.count) - )) - || !isExactRecord( - value.recommendationSummary, - ['recommendedCount', 'optionalCount', 'blockedCount'], - )) return false; - return isNonNegativeInteger(value.recommendationSummary.recommendedCount) - && isNonNegativeInteger(value.recommendationSummary.optionalCount) - && isNonNegativeInteger(value.recommendationSummary.blockedCount); -} - -export function normalizeExternalApplicationSnapshotV2( - value: unknown, -): ExternalApplicationSnapshotV2 { - const keys = [ - 'schemaVersion', - 'executionDomainId', - 'workspaceScopeId', - 'effectiveConnectionScope', - 'refreshGeneration', - 'preferenceRevision', - 'safeMode', - 'hostCapabilities', - 'applications', - 'reviewSummary', - ] as const; - const required = keys.filter( - (key) => key !== 'workspaceScopeId' && key !== 'reviewSummary', - ); - if (!isExactRecord(value, keys, required) - || value.schemaVersion !== 2 - || typeof value.executionDomainId !== 'string' - || (value.workspaceScopeId !== undefined - && value.workspaceScopeId !== null - && typeof value.workspaceScopeId !== 'string') - || !isOneOf(value.effectiveConnectionScope, APPLICATION_TARGET_SCOPES) - || !isNonNegativeInteger(value.refreshGeneration) - || !isNonNegativeInteger(value.preferenceRevision) - || typeof value.safeMode !== 'boolean' - || !isApplicationHostCapabilities(value.hostCapabilities) - || !Array.isArray(value.applications) - || !value.applications.every(isApplicationSummary) - || (value.reviewSummary !== undefined - && value.reviewSummary !== null - && !isApplicationReviewSummary(value.reviewSummary))) { - throw new ExternalSourceApiError( - 'invalid_response', - 'External application V2 snapshot schema was invalid', - false, - ); - } - return { - ...value, - workspaceScopeId: typeof value.workspaceScopeId === 'string' - ? value.workspaceScopeId - : undefined, - applications: value.applications.map((application) => ({ - ...application, - noticeKey: typeof application.noticeKey === 'string' ? application.noticeKey : undefined, - riskSummary: { - ...(application.riskSummary.highestLevel - ? { highestLevel: application.riskSummary.highestLevel } - : {}), - reasonCodes: [...(application.riskSummary.reasonCodes ?? [])], - }, - recoveryActions: [...application.recoveryActions], - })), - reviewSummary: value.reviewSummary && isApplicationReviewSummary(value.reviewSummary) - ? { - ...value.reviewSummary, - categoryCounts: [...value.reviewSummary.categoryCounts], - riskSummary: { - ...(value.reviewSummary.riskSummary.highestLevel - ? { highestLevel: value.reviewSummary.riskSummary.highestLevel } - : {}), - reasonCodes: [...(value.reviewSummary.riskSummary.reasonCodes ?? [])], - }, - recommendationSummary: { ...value.reviewSummary.recommendationSummary }, - } - : undefined, - } as ExternalApplicationSnapshotV2; -} - -export function normalizeExternalApplicationReviewPageV2( - value: unknown, -): ExternalApplicationReviewPageV2 { - const keys = [ - 'schemaVersion', - 'executionDomainId', - 'workspaceScopeId', - 'targetScope', - 'reviewId', - 'preferenceRevision', - 'expectedGenerations', - 'cursor', - 'nextCursor', - 'totalCount', - 'items', - ] as const; - const required = keys.filter( - (key) => key !== 'workspaceScopeId' && key !== 'cursor' && key !== 'nextCursor', - ); - if (!isExactRecord(value, keys, required) - || value.schemaVersion !== 2 - || typeof value.executionDomainId !== 'string' - || (value.workspaceScopeId !== undefined - && value.workspaceScopeId !== null - && typeof value.workspaceScopeId !== 'string') - || !isOneOf(value.targetScope, APPLICATION_TARGET_SCOPES) - || (value.targetScope === 'workspace_override' && typeof value.workspaceScopeId !== 'string') - || (value.targetScope === 'user_default' && value.workspaceScopeId != null) - || typeof value.reviewId !== 'string' - || !isNonNegativeInteger(value.preferenceRevision) - || !Array.isArray(value.expectedGenerations) - || !value.expectedGenerations.every(isApplicationOwnerGeneration) - || (value.cursor !== undefined && value.cursor !== null && typeof value.cursor !== 'string') - || (value.nextCursor !== undefined - && value.nextCursor !== null - && typeof value.nextCursor !== 'string') - || !isNonNegativeInteger(value.totalCount) - || !Array.isArray(value.items) - || value.items.length > 128 - || value.items.length > value.totalCount - || !value.items.every(isApplicationReviewItem)) { - throw new ExternalSourceApiError( - 'invalid_response', - 'External application V2 review page schema was invalid', - false, - ); - } - return { - ...value, - workspaceScopeId: typeof value.workspaceScopeId === 'string' - ? value.workspaceScopeId - : undefined, - cursor: typeof value.cursor === 'string' ? value.cursor : undefined, - nextCursor: typeof value.nextCursor === 'string' ? value.nextCursor : undefined, - expectedGenerations: value.expectedGenerations.map((entry) => ({ ...entry })), - items: value.items.map((item) => ({ - ...item, - itemRef: { ...item.itemRef }, - riskReasonCodes: [...item.riskReasonCodes], - })), - } as ExternalApplicationReviewPageV2; -} - -export function normalizeExternalApplicationControlResultV2( - value: unknown, -): ExternalApplicationControlResultV2 { - if (!isExactRecord(value, [ - 'schemaVersion', - 'operationId', - 'preferenceRevision', - 'outcome', - 'itemResults', - ]) - || value.schemaVersion !== 2 - || typeof value.operationId !== 'string' - || !isNonNegativeInteger(value.preferenceRevision) - || !isOneOf(value.outcome, APPLICATION_OPERATION_OUTCOMES) - || !Array.isArray(value.itemResults) - || !value.itemResults.every((result) => ( - isExactRecord( - result, - ['itemRef', 'outcome', 'reasonCode', 'recoveryActions'], - ['itemRef', 'outcome', 'recoveryActions'], - ) - && isApplicationReviewItemRef(result.itemRef) - && isOneOf(result.outcome, APPLICATION_OPERATION_OUTCOMES) - && (result.reasonCode === undefined - || result.reasonCode === null - || typeof result.reasonCode === 'string') - && Array.isArray(result.recoveryActions) - && result.recoveryActions.every(isApplicationRecoveryAction) - ))) { - throw new ExternalSourceApiError( - 'invalid_response', - 'External application V2 action result schema was invalid', - false, - ); - } - return { - ...value, - itemResults: value.itemResults.map((result) => ({ - ...result, - itemRef: { ...result.itemRef }, - reasonCode: typeof result.reasonCode === 'string' ? result.reasonCode : undefined, - recoveryActions: [...result.recoveryActions], - })), - } as ExternalApplicationControlResultV2; -} - function isHostCapabilities( value: unknown, ): value is ExternalSourceCatalogSnapshot['hostCapabilities'] { @@ -1934,85 +1322,6 @@ function emitExternalAgentCatalogUpdated(workspacePath?: string) { } export const externalSourcesAPI = { - async getApplicationReviewPage( - workspacePath: string | undefined, - request: ExternalApplicationReviewPageRequestV2, - ) { - const page = normalizeExternalApplicationReviewPageV2( - await invokeExternalSourceCommand( - 'get_external_application_review_page_v2', - { - request: { - workspacePath: normalizeOptionalWorkspacePath(workspacePath), - request, - }, - }, - ), - ); - const openingRequest = request.cursor === undefined - && request.expectedGenerations.length === 0; - if (page.executionDomainId !== request.executionDomainId - || page.workspaceScopeId !== request.workspaceScopeId - || page.targetScope !== request.targetScope - || page.preferenceRevision !== request.preferenceRevision - || page.cursor !== request.cursor - || (!openingRequest && page.reviewId !== request.reviewId)) { - throw new ExternalSourceApiError( - 'invalid_response', - 'External application V2 review page did not match its request', - false, - ); - } - return page; - }, - - async applyApplicationAction( - workspacePath: string | undefined, - request: ExternalApplicationControlRequestV2, - ) { - const result = normalizeExternalApplicationControlResultV2( - await invokeExternalSourceCommand( - 'apply_external_application_action_v2', - { - request: { - workspacePath: normalizeOptionalWorkspacePath(workspacePath), - request, - }, - }, - ), - ); - if (result.operationId !== request.operationId) { - throw new ExternalSourceApiError( - 'invalid_response', - 'External application V2 action result did not match its operation', - false, - ); - } - return result; - }, - - async getApplicationSurface(workspacePath?: string, forceRefresh = false) { - const request = { - workspacePath: normalizeOptionalWorkspacePath(workspacePath), - forceRefresh, - }; - try { - const snapshot = normalizeExternalApplicationSnapshotV2( - await invokeExternalSourceCommand( - 'get_external_application_snapshot_v2', - { request }, - ), - ); - return { protocol: 'v2' as const, snapshot }; - } catch (error) { - if (!(error instanceof ExternalSourceApiError) || error.code !== 'incompatible_version') { - throw error; - } - const snapshot = (await invokeCompatibleSurfaceSnapshot({ request })).catalog; - return { protocol: 'v1' as const, snapshot }; - } - }, - planMcpImport(workspacePath?: string) { return invokeExternalSourceCommand( 'plan_external_mcp_import_command', diff --git a/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.scss b/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.scss index 880c4b8f4d..d83f700783 100644 --- a/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.scss @@ -81,22 +81,8 @@ white-space: nowrap; } - &__row-control--model { - width: 100%; - align-items: stretch; - - .model-selection-radio { - width: auto; - justify-content: flex-end; - } - - .model-selection-radio--horizontal .model-selection-radio__option--custom { - flex-grow: 0; - } - } - - .bitfun-config-page-row.bitfun-func-agent-config__model-row { - grid-template-columns: minmax(0, 4fr) minmax(0, 6fr); + &__appearance-host { + display: contents; } &__warning { diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.appearance.ts index 31fe7d3e8b..9472d1fc14 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.appearance.ts +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.appearance.ts @@ -17,6 +17,8 @@ export const acpAgentsConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'status' }, { id: 'confirmation' }, { id: 'remoteList' }, + { id: 'hiddenRemoteList' }, + { id: 'hiddenRemoteRow' }, { id: 'remoteServer' }, { id: 'remoteHeader' }, { id: 'remoteAgents' }, diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss index 3dcbc2d96a..0822e72688 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss @@ -253,6 +253,29 @@ gap: $size-gap-3; } + &__hidden-remote-list { + display: flex; + flex-direction: column; + margin-top: $size-gap-3; + overflow: hidden; + border: 1px solid var(--bf-appearance-token-border-subtle); + border-radius: $size-radius-base; + } + + &__hidden-remote-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: $size-gap-3; + align-items: center; + min-width: 0; + padding: $size-gap-3; + border-bottom: 1px solid var(--bf-appearance-token-border-subtle); + + &:last-child { + border-bottom: 0; + } + } + &__remote-server { display: flex; flex-direction: column; @@ -328,7 +351,8 @@ @media (max-width: 860px) { &__toolbar, &__registry-row, - &__remote-head { + &__remote-head, + &__hidden-remote-row { grid-template-columns: 1fr; } diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx index 31575d6cfe..64bb96d2e4 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx @@ -41,6 +41,22 @@ vi.mock('@/component-library', () => ({ {children} ), + IconButton: ({ + children, + disabled, + isLoading, + onClick, + tooltip: _tooltip, + ...props + }: React.ButtonHTMLAttributes & { + children: React.ReactNode; + isLoading?: boolean; + tooltip?: React.ReactNode; + }) => ( + + ), Input: ({ value, onChange, @@ -84,13 +100,18 @@ vi.mock('./common', () => ({ children, title, description, + extra, }: { children: React.ReactNode; title: string; description?: string; + extra?: React.ReactNode; }) => (
-

{title}

+
+

{title}

+ {extra} +
{description ?

{description}

: null} {children}
@@ -141,6 +162,7 @@ describe('AcpAgentsConfig', () => { beforeEach(() => { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + localStorage.clear(); loadJsonConfigMock.mockResolvedValue(JSON.stringify({ acpClients: { opencode: { @@ -233,6 +255,116 @@ describe('AcpAgentsConfig', () => { }); }); + it('hides a saved remote server without deleting its SSH connection', async () => { + listSavedConnectionsMock.mockResolvedValue([{ + id: 'huawei-server', + name: 'Huawei Server', + host: '119.8.182.138', + port: 22, + username: 'ssh-root', + authType: { type: 'Password' }, + }]); + + await act(async () => { + root.render(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const hideButton = container.querySelector( + 'button[aria-label="remote.hideConnection"]' + ); + expect(hideButton).not.toBeNull(); + + await act(async () => { + hideButton?.click(); + await Promise.resolve(); + }); + + expect(listSavedConnectionsMock).toHaveBeenCalledTimes(1); + expect(container.textContent).not.toContain('Huawei Server'); + expect(JSON.parse(localStorage.getItem('bitfun:settings:acp-agents:hidden-remote-connections:v1') || '[]')) + .toEqual(['huawei-server']); + expect(container.textContent).toContain('remote.showHiddenConnections'); + }); + + it('restores a hidden remote server from the hidden list', async () => { + localStorage.setItem( + 'bitfun:settings:acp-agents:hidden-remote-connections:v1', + JSON.stringify(['huawei-server']) + ); + listSavedConnectionsMock.mockResolvedValue([{ + id: 'huawei-server', + name: 'Huawei Server', + host: '119.8.182.138', + port: 22, + username: 'ssh-root', + authType: { type: 'Password' }, + }]); + + await act(async () => { + root.render(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const showHiddenButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('remote.showHiddenConnections')); + expect(showHiddenButton).not.toBeUndefined(); + + await act(async () => { + showHiddenButton?.click(); + await Promise.resolve(); + }); + + const restoreButton = container.querySelector( + 'button[aria-label="remote.restoreConnection"]' + ); + expect(restoreButton).not.toBeNull(); + + await act(async () => { + restoreButton?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(localStorage.getItem('bitfun:settings:acp-agents:hidden-remote-connections:v1')) + .toBe('[]'); + expect(container.textContent).toContain('Huawei Server'); + }); + + it('does not probe hidden remote servers until they are restored', async () => { + localStorage.setItem( + 'bitfun:settings:acp-agents:hidden-remote-connections:v1', + JSON.stringify(['huawei-server']) + ); + listSavedConnectionsMock.mockResolvedValue([{ + id: 'huawei-server', + name: 'Huawei Server', + host: '119.8.182.138', + port: 22, + username: 'ssh-root', + authType: { type: 'Password' }, + }]); + + await act(async () => { + root.render(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(probeClientRequirementsMock).not.toHaveBeenCalledWith({ + remoteConnectionId: 'huawei-server', + force: undefined, + }); + }); + it('configures a preset adapter when the CLI is ready but the ACP layer is missing', async () => { probeClientRequirementsMock.mockResolvedValue([ { diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx index be35d42ca4..6f291eed76 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx @@ -4,6 +4,8 @@ import { Bot, CircleAlert, Download, + Eye, + EyeOff, ExternalLink, FileJson, LoaderCircle, @@ -14,7 +16,7 @@ import { Server, Terminal, } from 'lucide-react'; -import { Button, Input, Select, Textarea } from '@/component-library'; +import { Button, IconButton, Input, Select, Textarea } from '@/component-library'; import { ConfigPageContent, ConfigPageHeader, @@ -37,6 +39,31 @@ import { createLogger } from '@/shared/utils/logger'; import './AcpAgentsConfig.scss'; const log = createLogger('AcpAgentsConfig'); +const HIDDEN_REMOTE_CONNECTION_IDS_STORAGE_KEY = + 'bitfun:settings:acp-agents:hidden-remote-connections:v1'; + +function loadHiddenRemoteConnectionIds(): Set { + try { + const stored = localStorage.getItem(HIDDEN_REMOTE_CONNECTION_IDS_STORAGE_KEY); + if (!stored) return new Set(); + const parsed = JSON.parse(stored); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)); + } catch { + return new Set(); + } +} + +function persistHiddenRemoteConnectionIds(connectionIds: Set): void { + try { + localStorage.setItem( + HIDDEN_REMOTE_CONNECTION_IDS_STORAGE_KEY, + JSON.stringify(Array.from(connectionIds).sort()) + ); + } catch { + // Keep the preference in memory when browser storage is unavailable. + } +} interface AcpClientConfig { name?: string; @@ -379,6 +406,8 @@ const AcpAgentsConfig: React.FC = () => { const [registryFilter, setRegistryFilter] = useState('all'); const [installingClientIds, setInstallingClientIds] = useState>(() => new Set()); const [installingRemoteClientIds, setInstallingRemoteClientIds] = useState>(() => new Set()); + const [hiddenRemoteConnectionIds, setHiddenRemoteConnectionIds] = useState(loadHiddenRemoteConnectionIds); + const [showHiddenRemoteConnections, setShowHiddenRemoteConnections] = useState(false); const requirementProbeRequestIdRef = useRef(0); const savingConfigRef = useRef(false); const loadedRemoteProbeIdsRef = useRef>(new Set()); @@ -393,6 +422,14 @@ const AcpAgentsConfig: React.FC = () => { return (left.name || left.id).localeCompare(right.name || right.id); }); }, [savedConnections]); + const visibleRemoteConnectionRows = useMemo( + () => remoteConnectionRows.filter(connection => !hiddenRemoteConnectionIds.has(connection.id)), + [hiddenRemoteConnectionIds, remoteConnectionRows] + ); + const hiddenRemoteConnectionRows = useMemo( + () => remoteConnectionRows.filter(connection => hiddenRemoteConnectionIds.has(connection.id)), + [hiddenRemoteConnectionIds, remoteConnectionRows] + ); const probesById = useMemo( () => new Map(requirementProbes.map(probe => [probe.id, probe])), [requirementProbes] @@ -579,6 +616,30 @@ const AcpAgentsConfig: React.FC = () => { } }, [notifyError, refreshRequirementProbes, t]); + const hideRemoteConnection = useCallback((connection: SavedConnection) => { + const connectionName = connection.name || connection.id; + setHiddenRemoteConnectionIds(prev => { + const next = new Set(prev).add(connection.id); + persistHiddenRemoteConnectionIds(next); + return next; + }); + notifySuccess(t('notifications.connectionHidden', { name: connectionName })); + }, [notifySuccess, t]); + + const restoreRemoteConnection = useCallback((connection: SavedConnection) => { + const connectionName = connection.name || connection.id; + if (hiddenRemoteConnectionRows.length <= 1) { + setShowHiddenRemoteConnections(false); + } + setHiddenRemoteConnectionIds(prev => { + const next = new Set(prev); + next.delete(connection.id); + persistHiddenRemoteConnectionIds(next); + return next; + }); + notifySuccess(t('notifications.connectionRestored', { name: connectionName })); + }, [hiddenRemoteConnectionRows.length, notifySuccess, t]); + useEffect(() => { void loadConfig(); }, [loadConfig]); @@ -598,10 +659,10 @@ const AcpAgentsConfig: React.FC = () => { useEffect(() => { if (loading) return; - for (const connection of remoteConnectionRows) { + for (const connection of visibleRemoteConnectionRows) { void refreshRemoteRequirementProbes(connection.id, { notifyOnError: false }); } - }, [loading, refreshRemoteRequirementProbes, remoteConnectionRows, remoteProbeRefreshNonce]); + }, [loading, refreshRemoteRequirementProbes, remoteProbeRefreshNonce, visibleRemoteConnectionRows]); const patchClientConfig = (clientId: string, patch: Partial) => { setConfig(prev => { @@ -1375,10 +1436,29 @@ const AcpAgentsConfig: React.FC = () => { )} - - {remoteConnectionRows.length === 0 ? ( + 0 ? ( + + ) : undefined} + > + {visibleRemoteConnectionRows.length === 0 ? (
- {t('remote.empty')} + {t(remoteConnectionRows.length === 0 ? 'remote.empty' : 'remote.emptyVisible')}
) : (
{ data-bf-component="acp-agents-config" data-bf-part="remoteList" > - {remoteConnectionRows.map(connection => { + {visibleRemoteConnectionRows.map(connection => { const hostLabel = [connection.username, connection.host] .filter(Boolean) .join('@'); @@ -1502,6 +1582,19 @@ const AcpAgentsConfig: React.FC = () => { {t('remote.refreshDetection')} + hideRemoteConnection(connection)} + > + +
{ })}
)} + {showHiddenRemoteConnections && hiddenRemoteConnectionRows.length > 0 && ( +
+ {hiddenRemoteConnectionRows.map(connection => { + const hostLabel = [connection.username, connection.host] + .filter(Boolean) + .join('@'); + return ( +
+
+ + + +
+ + {connection.name || connection.id} + +

+ {hostLabel || connection.id} +

+
+
+ restoreRemoteConnection(connection)} + > + + +
+ ); + })} +
+ )}
diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts index 354f94e237..30c96a0717 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts @@ -33,15 +33,9 @@ export const externalSourcesConfigAppearanceDescriptor: AppearanceSurfaceDescrip { id: 'ecosystemHeading' }, { id: 'ecosystemName' }, { id: 'ecosystemState' }, - { id: 'attentionSummary' }, { id: 'application' }, - { id: 'applicationFacts' }, + { id: 'appAttention' }, { id: 'applicationToggle' }, - { id: 'appCapabilities' }, - { id: 'appCapability' }, - { id: 'reviewItem' }, - { id: 'loadMoreReview' }, - { id: 'submitReview' }, { id: 'hooksSection' }, { id: 'hooksSummary' }, ], diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss index 998faf596f..a42f0550c0 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss @@ -110,55 +110,6 @@ padding-left: var(--bf-appearance-token-size-gap-4); } - &__app-detail { - display: grid; - gap: var(--bf-appearance-token-size-gap-4); - } - - &__app-detail-heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--bf-appearance-token-size-gap-4); - - h2 { margin: 0; color: var(--bf-appearance-token-color-text-primary); font-size: 20px; } - p { margin: 5px 0 0; color: var(--bf-appearance-token-color-text-secondary); font-size: 12px; } - small { display: block; margin-top: 4px; color: var(--bf-appearance-token-color-text-muted); font-size: 11px; } - } - - &__app-attention { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--bf-appearance-token-size-gap-3); - width: 100%; - padding: 12px 14px; - border: 1px solid color-mix(in srgb, var(--bf-appearance-token-color-warning) 45%, transparent); - border-radius: var(--bf-appearance-token-size-radius-sm); - color: var(--bf-appearance-token-color-warning); - background: color-mix(in srgb, var(--bf-appearance-token-color-warning) 7%, transparent); - text-align: left; - cursor: pointer; - - small { display: block; margin-top: 4px; color: var(--bf-appearance-token-color-text-secondary); } - } - - &__app-capabilities { overflow: hidden; border: 1px solid var(--bf-appearance-token-border-subtle); border-radius: var(--bf-appearance-token-size-radius-md); } - &__app-capability { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 13px 14px; - border-bottom: 1px solid var(--bf-appearance-token-border-subtle); - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - - &:last-child { border-bottom: 0; } - strong, small { display: block; } - strong { color: var(--bf-appearance-token-color-text-primary); font-size: 13px; } - small { margin-top: 3px; } - } &__app-list { display: grid; overflow: hidden; @@ -166,208 +117,66 @@ border-radius: var(--bf-appearance-token-size-radius-md); } - &__review { - display: block; - } - - &__review-toolbar { - display: flex; - align-items: center; - gap: var(--bf-appearance-token-size-gap-3); - padding: var(--bf-appearance-token-size-gap-2) var(--bf-appearance-token-size-gap-4); - border-bottom: 1px solid var(--bf-appearance-token-border-subtle); - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - } - - &__review-actions { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - align-items: center; - gap: var(--bf-appearance-token-size-gap-2); - } - - &__review-loading { - color: var(--bf-appearance-token-color-text-secondary); - } - - &__review-adjustments { - padding: 0 var(--bf-appearance-token-size-gap-4) var(--bf-appearance-token-size-gap-4); - border-top: 1px solid var(--bf-appearance-token-border-subtle); - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - - > summary { - width: fit-content; - padding-top: var(--bf-appearance-token-size-gap-3); - cursor: pointer; - - &:focus-visible { - outline: 2px solid var(--bf-appearance-token-color-accent-500); - outline-offset: 2px; - } - } - - &[open] > summary { margin-bottom: var(--bf-appearance-token-size-gap-3); } - - > .bitfun-external-sources-config__review-actions { - margin-top: var(--bf-appearance-token-size-gap-3); - } - } - - &__review .bitfun-external-sources-config__app-row { - cursor: pointer; - - > input { flex-shrink: 0; } - > .bitfun-external-sources-config__app-copy { flex: 1; } - } - - &__attention-summary { - display: flex; - align-items: center; - justify-content: flex-start; - gap: var(--bf-appearance-token-size-gap-3); - width: 100%; - margin-bottom: var(--bf-appearance-token-size-gap-3); - padding: 12px 14px; - border: 1px solid color-mix(in srgb, var(--bf-appearance-token-color-warning) 45%, transparent); - border-radius: var(--bf-appearance-token-size-radius-sm); - color: var(--bf-appearance-token-color-warning); - background: color-mix(in srgb, var(--bf-appearance-token-color-warning) 7%, transparent); - text-align: left; - cursor: pointer; - - &:focus-visible { - outline: 2px solid var(--bf-appearance-token-color-accent-500); - outline-offset: 2px; - } - } - &__app-row { - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; - justify-content: space-between; gap: var(--bf-appearance-token-size-gap-3); - padding: 12px var(--bf-appearance-token-size-gap-4); + min-height: 48px; + padding: 10px var(--bf-appearance-token-size-gap-4); border-bottom: 1px solid var(--bf-appearance-token-border-subtle); &:last-child { border-bottom: 0; } } - &__app-expand { + &__app-name { + min-width: 0; + overflow: hidden; + color: var(--bf-appearance-token-color-text-primary); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__app-attention { display: grid; - flex-shrink: 0; - width: 26px; - height: 26px; + width: 28px; + height: 28px; padding: 0; place-items: center; border: 0; - border-radius: 6px; - color: var(--bf-appearance-token-color-text-secondary); + border-radius: var(--bf-appearance-token-size-radius-sm); + color: var(--bf-appearance-token-color-warning); background: transparent; cursor: pointer; - &:hover, - &:focus-visible, - &[aria-expanded='true'] { - color: var(--bf-appearance-token-color-text-primary); - background: var(--bf-appearance-token-color-bg-secondary); - } - + &:hover { background: var(--bf-appearance-token-color-bg-secondary); } &:focus-visible { outline: 2px solid var(--bf-appearance-token-color-accent-500); outline-offset: 1px; } } - &__app-facts { - display: inline-flex; - align-items: center; - color: var(--bf-appearance-token-color-warning); - - &:focus-visible { - outline: 2px solid var(--bf-appearance-token-color-accent-500); - outline-offset: 2px; - border-radius: 2px; - } - } - &__app-toggle { flex-shrink: 0; - } - - &__app-capabilities { - overflow: hidden; - padding: 0 var(--bf-appearance-token-size-gap-6); - border-bottom: 1px solid var(--bf-appearance-token-border-subtle); - - &:last-child { border-bottom: 0; } - } - - &__app-capability { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 11px 0; - border-bottom: 1px solid var(--bf-appearance-token-border-subtle); - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - - &:last-child { border-bottom: 0; } - strong, small { display: block; } - strong { color: var(--bf-appearance-token-color-text-primary); font-size: 13px; } - small { margin-top: 3px; } - } - - &__app-capability-access { - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - } - &__app-capability-empty { - padding: 11px 0; - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - } - - &__app-capability-manage { - display: inline-flex; - align-items: center; - gap: 6px; - margin: 10px 0; - padding: 0; - border: 0; - color: var(--bf-appearance-token-color-accent-500); - background: transparent; - font: inherit; - cursor: pointer; + &[role='button'] { + border-radius: var(--bf-appearance-token-size-radius-sm); + cursor: pointer; + } - &:hover { text-decoration: underline; } - &:focus-visible { + &[role='button']:focus-visible { outline: 2px solid var(--bf-appearance-token-color-accent-500); outline-offset: 2px; - border-radius: 2px; } } - &__app-copy { min-width: 0; } - &__app-heading { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--bf-appearance-token-size-gap-2); - } - &__app-name { color: var(--bf-appearance-token-color-text-primary); font-weight: 600; } - &__app-status { + &__app-empty { + padding: 12px var(--bf-appearance-token-size-gap-4); color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - } - &__app-status { - &.is-connected, &.is-connected_custom { color: var(--bf-appearance-token-color-success); } - &.is-needs_attention { color: var(--bf-appearance-token-color-warning); } + font-size: 13px; } + &__ecosystem-heading, &__policy-actions, &__ecosystem-name { @@ -890,35 +699,6 @@ font-size: 12px; } - &__review-summary { - display: flex; - flex-wrap: wrap; - gap: 4px 12px; - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - } - - &__review-risk { - margin-top: 6px; - } - - &__review-details { - margin-top: 8px; - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - - > summary { - width: fit-content; - color: var(--bf-appearance-token-color-accent-500); - cursor: pointer; - user-select: none; - } - - &[open] > summary { - margin-bottom: 8px; - } - } - &__diagnostic-code { color: var(--bf-appearance-token-color-text-muted); font-size: 12px; @@ -972,10 +752,6 @@ } @container external-sources (max-width: 720px) { - &__review-decision .bitfun-config-page-row__control { - justify-content: flex-start; - } - &__source-group.bitfun-config-page-row { grid-template-columns: minmax(0, 1fr); gap: var(--bf-appearance-token-size-gap-2); diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx index 5c52547233..4514f39678 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -8,9 +8,6 @@ import ExternalSourcesConfig from './ExternalSourcesConfig'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; const getSnapshotMock = vi.hoisted(() => vi.fn()); -const getApplicationSurfaceMock = vi.hoisted(() => vi.fn()); -const getApplicationReviewPageMock = vi.hoisted(() => vi.fn()); -const applyApplicationActionMock = vi.hoisted(() => vi.fn()); const hookPanelMountedMock = vi.hoisted(() => vi.fn()); const setSourceEnabledMock = vi.hoisted(() => vi.fn()); const setSafeModeMock = vi.hoisted(() => vi.fn()); @@ -74,9 +71,6 @@ vi.mock('@/shared/types', () => ({ vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ externalSourcesAPI: { getSnapshot: getSnapshotMock, - getApplicationSurface: getApplicationSurfaceMock, - getApplicationReviewPage: getApplicationReviewPageMock, - applyApplicationAction: applyApplicationActionMock, setSourceEnabled: setSourceEnabledMock, setSafeMode: setSafeModeMock, setConflictChoice: setConflictChoiceMock, @@ -248,45 +242,6 @@ const integrationPolicy = { }], }; -const applicationSnapshotV2 = { - schemaVersion: 2 as const, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - effectiveConnectionScope: 'workspace_override' as const, - refreshGeneration: 7, - preferenceRevision: 11, - safeMode: false, - hostCapabilities: { - canReadSnapshot: true, - canReadReview: true, - canMutate: true, - canManageUserDefault: true, - canManageWorkspaceOverride: true, - canRefresh: true, - canSetSafeMode: true, - }, - applications: [{ - applicationId: 'opencode', - ecosystemId: 'opencode', - displayName: 'OpenCode', - discovery: 'discovered' as const, - connection: 'disconnected' as const, - desiredConnection: 'unspecified' as const, - health: 'healthy' as const, - effectiveStatus: 'configuration_available' as const, - primaryAction: 'connect' as const, - defaultConnectionPolicy: 'connect' as const, - defaultConnectionReason: 'supported_by_product', - enabledCount: 0, - pendingReviewCount: 0, - blockedCount: 0, - conflictCount: 0, - riskSummary: { reasonCodes: [] }, - userDecision: 'none' as const, - recoveryActions: [], - }], -}; - describe('ExternalSourcesConfig', () => { let container: HTMLDivElement; let root: Root; @@ -297,17 +252,6 @@ describe('ExternalSourcesConfig', () => { workspaceState.kind = 'normal'; peerState.deviceId = ''; getSnapshotMock.mockResolvedValue(snapshot); - getApplicationSurfaceMock.mockImplementation(async (...args: unknown[]) => ({ - protocol: 'v1', - snapshot: await getSnapshotMock(...args), - })); - applyApplicationActionMock.mockResolvedValue({ - schemaVersion: 2, - operationId: 'operation-result', - preferenceRevision: 12, - outcome: 'applied', - itemResults: [], - }); setSourceEnabledMock.mockResolvedValue(snapshot); setSafeModeMock.mockResolvedValue(snapshot); setConflictChoiceMock.mockResolvedValue({ @@ -371,11 +315,30 @@ describe('ExternalSourcesConfig', () => { }); }); - it('shows one review entry and keeps advanced capability controls collapsed by default', async () => { + it('opens the existing owner controls from the application permission hint', async () => { + const scrolledElements: Element[] = []; + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value(this: Element) { + scrolledElements.push(this); + }, + }); const policySnapshot = { ...snapshot, preferenceRevision: 4, integrationPolicy, + control: { + schemaVersion: 1 as const, + executionDomainId: 'local-user', + refreshGeneration: 1, + preferenceRevision: 4, + safeMode: true, + hostCapabilities: snapshot.hostCapabilities, + sources: [], + capabilities: [], + diagnostics: [], + recoveryActions: [], + }, sources: [{ ...snapshot.sources[0], record: { @@ -408,16 +371,24 @@ describe('ExternalSourcesConfig', () => { await Promise.resolve(); }); - expect(container.querySelectorAll('[data-bf-part="attentionSummary"]')).toHaveLength(1); - expect(container.textContent).toContain('applications.review.title'); + expect(container.querySelectorAll('[data-bf-part="appAttention"]')).toHaveLength(1); const advanced = container.querySelector( '.bitfun-external-sources-config__advanced', ); expect(advanced?.open).toBe(false); - const openReview = container.querySelector('[data-bf-part="attentionSummary"]'); - await act(async () => openReview?.click()); + const openPermissions = container.querySelector('[data-bf-part="appAttention"]'); + await act(async () => { + openPermissions?.click(); + await vi.runAllTimersAsync(); + }); expect(advanced?.open).toBe(true); + const matchingApplicationAction = container.querySelector( + '[data-bf-part="toolCard"][data-external-attention="true"]' + + '[data-external-ecosystem="opencode"]', + ); + expect(matchingApplicationAction).not.toBeNull(); + expect(scrolledElements).toContain(matchingApplicationAction); }); it('uses each application switch as the recommended connection control without a dialog', async () => { @@ -481,814 +452,47 @@ describe('ExternalSourcesConfig', () => { }); }); - it('renders V2 Host application state and uses only the Host connect action', async () => { - getApplicationSurfaceMock.mockResolvedValue({ - protocol: 'v2', - snapshot: { ...applicationSnapshotV2, effectiveConnectionScope: 'user_default' }, - }); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('applications.status.configuration_available'); - expect(container.textContent).not.toContain('applications.summary.enabledCount'); - expect(container.textContent).not.toContain('hooksManagement.description'); - expect(container.textContent).not.toContain('applications.advanced.description'); - const hooksSummary = container.querySelector( - '.bitfun-external-sources-config__hooks-summary', - ); - expect(hooksSummary?.getAttribute('aria-expanded')).toBe('false'); - expect( - hooksSummary?.querySelector('.bitfun-external-sources-config__disclosure-icon'), - ).not.toBeNull(); - const advanced = container.querySelector( - '.bitfun-external-sources-config__advanced', - ); - const advancedSummary = advanced?.querySelector('summary'); - expect(advanced?.open).toBe(false); - expect(advancedSummary?.getAttribute('aria-expanded')).toBe('false'); - expect( - advancedSummary?.querySelector('.bitfun-external-sources-config__disclosure-icon'), - ).not.toBeNull(); - expect(advanced?.textContent).toContain('safeMode.title'); - await act(async () => { - advancedSummary?.click(); - advanced?.dispatchEvent(new Event('toggle')); - await Promise.resolve(); - }); - expect(advanced?.open).toBe(true); - expect(advancedSummary?.getAttribute('aria-expanded')).toBe('true'); - expect(container.textContent).toContain('safeMode.title'); - const applicationToggle = container.querySelector( - '[data-bf-part="applicationToggle"] input[type="checkbox"]', - ); - expect(applicationToggle?.checked).toBe(false); - - await act(async () => { - applicationToggle?.click(); - await Promise.resolve(); - }); - - expect(applyApplicationActionMock).toHaveBeenCalledWith( - 'D:/workspace/project', - expect.objectContaining({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - expectedPreferenceRevision: 11, - action: { type: 'connect_application', applicationId: 'opencode' }, - }), - ); - expect(updateIntegrationPolicyMock).not.toHaveBeenCalled(); - }); - - it('keeps the refreshed V2 review current while loading the V1 compatibility catalog', async () => { - let generation = 7; - const reviewSnapshot = () => ({ - ...applicationSnapshotV2, - refreshGeneration: generation, - applications: [{ - ...applicationSnapshotV2.applications[0], - effectiveStatus: 'needs_attention' as const, - primaryAction: 'review' as const, - pendingReviewCount: 1, - }], - reviewSummary: { - reviewId: `review-${generation}`, - totalCount: 1, - categoryCounts: [{ kind: 'tool' as const, count: 1 }], - maxSelectionCount: 1, - riskSummary: { reasonCodes: [] }, - recommendationSummary: { recommendedCount: 1, optionalCount: 0, blockedCount: 0 }, - safetyCeiling: 'automatic' as const, - }, - }); - getApplicationSurfaceMock.mockImplementation(async ( - _workspacePath: string, - forceRefresh: boolean, - ) => { - if (forceRefresh) generation += 1; - return { protocol: 'v2', snapshot: reviewSnapshot() }; - }); - getSnapshotMock.mockImplementation(async ( - _workspacePath: string, - forceRefresh: boolean, - ) => { - if (forceRefresh) generation += 1; - return { ...snapshot, generation }; - }); - getApplicationReviewPageMock.mockImplementation(async ( - _workspacePath: string, - request: { reviewId: string }, - ) => { - if (request.reviewId !== `review-${generation}`) { - throw new Error('stale review'); - } - return { - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: request.reviewId, - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation }], - totalCount: 1, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-current' }, - displayName: 'Current Tool', - displaySummary: 'Current review item', - riskLevel: 'low', - riskReasonCodes: [], - recommended: true, - safetyCeiling: 'automatic', - }], - }; - }); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('button[aria-label="actions.refresh"]')?.click(); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('[data-bf-part="attentionSummary"]')?.click(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('Current Tool'); - expect(getSnapshotMock).toHaveBeenLastCalledWith('D:/workspace/project', false); - }); - - it('opens the authoritative first review page when discovery settles after the snapshot', async () => { - const currentSnapshot = { - ...applicationSnapshotV2, - applications: [{ - ...applicationSnapshotV2.applications[0], - effectiveStatus: 'needs_attention' as const, - primaryAction: 'review' as const, - pendingReviewCount: 2, - }], - reviewSummary: { - reviewId: 'review-current', - totalCount: 2, - categoryCounts: [{ kind: 'tool' as const, count: 2 }], - maxSelectionCount: 2, - riskSummary: { reasonCodes: [] }, - recommendationSummary: { recommendedCount: 0, optionalCount: 2, blockedCount: 0 }, - safetyCeiling: 'automatic' as const, - }, - }; - getApplicationSurfaceMock.mockResolvedValueOnce({ - protocol: 'v2', - snapshot: { - ...applicationSnapshotV2, - applications: [{ - ...applicationSnapshotV2.applications[0], - effectiveStatus: 'needs_attention', - primaryAction: 'review', - pendingReviewCount: 1, - }], - reviewSummary: { - reviewId: 'review-stale', - totalCount: 1, - categoryCounts: [{ kind: 'conflict', count: 1 }], - maxSelectionCount: 0, - riskSummary: { reasonCodes: [] }, - recommendationSummary: { recommendedCount: 0, optionalCount: 0, blockedCount: 1 }, - safetyCeiling: 'blocked', - }, - }, - }).mockResolvedValue({ protocol: 'v2', snapshot: currentSnapshot }); - getApplicationReviewPageMock.mockResolvedValueOnce({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-current', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 8 }], - nextCursor: 'page-2', - totalCount: 2, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-current' }, - displayName: 'Current Tool', - displaySummary: 'Current review item', - riskLevel: 'low', - riskReasonCodes: [], - recommended: false, - safetyCeiling: 'automatic', - }], - }).mockResolvedValueOnce({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-current', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 8 }], - cursor: 'page-2', - totalCount: 2, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-next' }, - displayName: 'Next Tool', - displaySummary: 'Next review item', - riskLevel: 'moderate', - riskReasonCodes: [], - recommended: false, - safetyCeiling: 'review_required', - }], - }); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('[data-bf-part="attentionSummary"]')?.click(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('Current Tool'); - expect(container.querySelector('input[type="checkbox"]')?.disabled).toBe(false); - await act(async () => { - container.querySelector('[data-bf-part="loadMoreReview"]')?.click(); - await Promise.resolve(); - }); - expect(container.textContent).toContain('Next Tool'); - expect(getApplicationReviewPageMock).toHaveBeenLastCalledWith( - 'D:/workspace/project', - expect.objectContaining({ - reviewId: 'review-current', - expectedGenerations: [{ owner: 'tool', generation: 8 }], - cursor: 'page-2', - }), - ); - }); - - it('returns to the application list when the first review page cannot be loaded', async () => { - getApplicationSurfaceMock.mockResolvedValue({ - protocol: 'v2', - snapshot: { - ...applicationSnapshotV2, - applications: [{ - ...applicationSnapshotV2.applications[0], - effectiveStatus: 'needs_attention', - primaryAction: 'review', - pendingReviewCount: 1, - }], - reviewSummary: { - reviewId: 'review-a', - totalCount: 1, - categoryCounts: [{ kind: 'tool', count: 1 }], - maxSelectionCount: 1, - riskSummary: { reasonCodes: [] }, - recommendationSummary: { recommendedCount: 1, optionalCount: 0, blockedCount: 0 }, - safetyCeiling: 'automatic', - }, - }, - }); - getApplicationReviewPageMock.mockRejectedValue(Object.assign(new Error('stale review'), { - code: 'stale_revision', - recoveryActions: [{ type: 'refresh' }], - })); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('[data-bf-part="attentionSummary"]')?.click(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(container.querySelector('[data-bf-part="application"]')).not.toBeNull(); - expect(container.querySelector('.bitfun-external-sources-config__review')).toBeNull(); - expect(container.textContent).toContain('operationErrors.refreshRequired'); - expect(container.textContent).toContain('recoveryActions.refresh'); - }); - - it('does not regress a V2 Host generation on a later refresh response', async () => { - const connected = { - ...applicationSnapshotV2, - refreshGeneration: 8, - applications: [{ - ...applicationSnapshotV2.applications[0], - connection: 'connected' as const, - effectiveStatus: 'connected' as const, - primaryAction: 'view' as const, - }], - }; - const regressed = { - ...applicationSnapshotV2, - refreshGeneration: 7, - applications: [{ - ...applicationSnapshotV2.applications[0], - discovery: 'not_discovered' as const, - effectiveStatus: 'no_configuration' as const, - primaryAction: 'none' as const, - }], - }; - getApplicationSurfaceMock - .mockResolvedValueOnce({ protocol: 'v2', snapshot: connected }) - .mockResolvedValueOnce({ protocol: 'v2', snapshot: regressed }); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - window.dispatchEvent(new Event('focus')); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('applications.status.connected'); - expect(container.textContent).not.toContain('applications.status.no_configuration'); - }); - - it('does not admit a review page after its Host review identity is replaced', async () => { - const summary = { - reviewId: 'review-a', - totalCount: 1, - categoryCounts: [{ kind: 'tool' as const, count: 1 }], - maxSelectionCount: 1, - riskSummary: { reasonCodes: [] }, - recommendationSummary: { recommendedCount: 1, optionalCount: 0, blockedCount: 0 }, - safetyCeiling: 'automatic' as const, - }; - getApplicationSurfaceMock - .mockResolvedValueOnce({ - protocol: 'v2', - snapshot: { ...applicationSnapshotV2, reviewSummary: summary }, - }) - .mockResolvedValueOnce({ - protocol: 'v2', - snapshot: { - ...applicationSnapshotV2, - refreshGeneration: 8, - preferenceRevision: 12, - reviewSummary: { ...summary, reviewId: 'review-b', totalCount: 0 }, - }, - }); - let resolveReviewPage: ((value: Record) => void) | undefined; - getApplicationReviewPageMock.mockReturnValue(new Promise((resolve) => { - resolveReviewPage = resolve; - })); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('[data-bf-part="attentionSummary"]')?.click(); - await Promise.resolve(); - }); - await act(async () => { - window.dispatchEvent(new Event('focus')); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - resolveReviewPage?.({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 7 }], - totalCount: 1, - items: [{ - itemRef: { kind: 'tool', stableId: 'old-tool' }, - displayName: 'Old Tool', - displaySummary: 'Stale page', - riskLevel: 'low', - riskReasonCodes: [], - recommended: true, - safetyCeiling: 'automatic', - }], - }); - await Promise.resolve(); - }); - - expect(container.textContent).not.toContain('Old Tool'); - }); - - it('uses the V2 Host safe-mode state and mutation instead of the legacy projection', async () => { - getApplicationSurfaceMock.mockResolvedValue({ - protocol: 'v2', - snapshot: { ...applicationSnapshotV2, safeMode: true }, - }); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('safeMode.activeNotice'); - const safeModeToggle = container.querySelector( - 'input[aria-label="safeMode.toggleLabel"]', - ); - expect(safeModeToggle?.checked).toBe(true); - await act(async () => { - safeModeToggle?.click(); - await Promise.resolve(); - }); - expect(applyApplicationActionMock).toHaveBeenCalledWith( - 'D:/workspace/project', - expect.objectContaining({ action: { type: 'set_safe_mode', enabled: false } }), - ); - expect(setSafeModeMock).not.toHaveBeenCalled(); - }); - - it('reviews V2 items with a recommended baseline and bounded advanced overrides', async () => { - const reviewSnapshot = { - ...applicationSnapshotV2, - applications: [{ - ...applicationSnapshotV2.applications[0], - effectiveStatus: 'needs_attention' as const, - primaryAction: 'review' as const, - pendingReviewCount: 2, - }], - reviewSummary: { - reviewId: 'review-a', - totalCount: 2, - categoryCounts: [{ kind: 'tool' as const, count: 2 }], - maxSelectionCount: 2, - riskSummary: { highestLevel: 'moderate' as const, reasonCodes: [] }, - recommendationSummary: { - recommendedCount: 1, - optionalCount: 1, - blockedCount: 0, - }, - safetyCeiling: 'review_required' as const, + it('focuses the master setting when an application switch is disabled by it', async () => { + const scrolledElements: Element[] = []; + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value(this: Element) { + scrolledElements.push(this); }, - }; - getApplicationSurfaceMock - .mockResolvedValueOnce({ protocol: 'v2', snapshot: reviewSnapshot }) - .mockResolvedValue({ - protocol: 'v2', - snapshot: { - ...reviewSnapshot, - preferenceRevision: 12, - refreshGeneration: 8, - reviewSummary: undefined, - }, - }); - getApplicationReviewPageMock.mockResolvedValueOnce({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 7 }], - nextCursor: 'page-2', - totalCount: 2, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-a' }, - displayName: 'Tool A', - displaySummary: 'Read repository files', - riskLevel: 'low', - riskReasonCodes: [], - recommended: true, - safetyCeiling: 'automatic', - }], - }).mockResolvedValueOnce({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-a', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 7 }], - cursor: 'page-2', - totalCount: 2, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-b' }, - displayName: 'Tool B', - displaySummary: 'Run a local process', - riskLevel: 'moderate', - riskReasonCodes: ['process_execution'], - recommended: false, - safetyCeiling: 'review_required', - }], - }); - applyApplicationActionMock.mockResolvedValueOnce({ - schemaVersion: 2, - operationId: 'operation-review', - preferenceRevision: 12, - outcome: 'applied', - itemResults: [{ - itemRef: { kind: 'tool', stableId: 'tool-b' }, - outcome: 'rejected', - reasonCode: 'runtime_unavailable', - recoveryActions: [{ type: 'install_runtime' }], - }], - }); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - const reviewEntry = container.querySelector( - '[data-bf-part="attentionSummary"]', - ); - expect(container.querySelectorAll('[data-bf-part="attentionSummary"]')).toHaveLength(1); - await act(async () => { - reviewEntry?.click(); - await Promise.resolve(); - }); - - expect(getApplicationReviewPageMock).toHaveBeenCalledWith( - 'D:/workspace/project', - expect.objectContaining({ - reviewId: 'review-a', - targetScope: 'workspace_override', - expectedGenerations: [], - pageSize: 64, - }), - ); - await act(async () => { - container.querySelector( - '.bitfun-external-sources-config__review-adjustments', - )?.querySelector('summary')?.click(); - }); - const selections = container.querySelectorAll( - '[data-bf-part="reviewItem"] input[type="checkbox"]', - ); - expect(selections).toHaveLength(1); - const loadMore = container.querySelector('[data-bf-part="loadMoreReview"]'); - await act(async () => { - loadMore?.click(); - await Promise.resolve(); - }); - const pagedSelections = container.querySelectorAll( - '[data-bf-part="reviewItem"] input[type="checkbox"]', - ); - expect(pagedSelections).toHaveLength(2); - expect(pagedSelections[0].checked).toBe(true); - expect(pagedSelections[1].checked).toBe(false); - expect(getApplicationReviewPageMock).toHaveBeenLastCalledWith( - 'D:/workspace/project', - expect.objectContaining({ - cursor: 'page-2', - expectedGenerations: [{ owner: 'tool', generation: 7 }], - }), - ); - await act(async () => pagedSelections[1].click()); - - const submit = container.querySelector( - '[data-bf-part="submitReview"][data-review-baseline="recommended"]', - ); - await act(async () => { - submit?.click(); - await Promise.resolve(); - await Promise.resolve(); }); - - expect(applyApplicationActionMock).toHaveBeenCalledWith( - 'D:/workspace/project', - expect.objectContaining({ - action: { - type: 'submit_application_review', - reviewId: 'review-a', - expectedGenerations: [{ owner: 'tool', generation: 7 }], - selectionBaseline: 'recommended', - selectionOverrides: [{ - itemRef: { kind: 'tool', stableId: 'tool-b' }, - selected: true, - }], - }, - }), - ); - expect(container.textContent).toContain('applications.review.outcome.partial'); - expect(container.textContent).toContain('applications.review.itemOutcome.rejected'); - expect(container.textContent).toContain('"selected":2,"maximum":2'); - }); - - it('lets the user decline every pending item without editing individual choices', async () => { - const reviewSnapshot = { - ...applicationSnapshotV2, - applications: [{ - ...applicationSnapshotV2.applications[0], - effectiveStatus: 'needs_attention' as const, - primaryAction: 'review' as const, - pendingReviewCount: 1, - }], - reviewSummary: { - reviewId: 'review-decline', - totalCount: 1, - categoryCounts: [{ kind: 'tool' as const, count: 1 }], - maxSelectionCount: 1, - riskSummary: { reasonCodes: [] }, - recommendationSummary: { - recommendedCount: 1, - optionalCount: 0, - blockedCount: 0, - }, - safetyCeiling: 'automatic' as const, + getSnapshotMock.mockResolvedValue({ + ...snapshot, + integrationPolicy: { + ...integrationPolicy, + userDefaults: { ...integrationPolicy.userDefaults, enabled: false }, + globalEffective: { ...integrationPolicy.globalEffective, enabled: false }, + effective: { ...integrationPolicy.effective, enabled: false }, }, - }; - getApplicationSurfaceMock.mockResolvedValue({ protocol: 'v2', snapshot: reviewSnapshot }); - getApplicationReviewPageMock.mockResolvedValue({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-decline', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 7 }], - totalCount: 1, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-a' }, - displayName: 'Tool A', - displaySummary: 'Run a local process', - riskLevel: 'moderate', - riskReasonCodes: ['process_execution'], - recommended: true, - safetyCeiling: 'review_required', + sources: [{ + ...snapshot.sources[0], + record: { ...snapshot.sources[0].record, ecosystemId: 'opencode' }, }], + commandConflicts: [], + diagnostics: [], }); await act(async () => { root.render(); await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('[data-bf-part="attentionSummary"]')?.click(); - await Promise.resolve(); }); - const decline = container.querySelector( - '[data-bf-part="submitReview"][data-review-baseline="none"]', + const disabledApplicationToggle = container.querySelector( + '[data-bf-part="applicationToggle"]', ); - expect(decline?.textContent).toBe('applications.review.doNotEnable'); await act(async () => { - decline?.click(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(applyApplicationActionMock).toHaveBeenCalledWith( - 'D:/workspace/project', - expect.objectContaining({ - action: { - type: 'submit_application_review', - reviewId: 'review-decline', - expectedGenerations: [{ owner: 'tool', generation: 7 }], - selectionBaseline: 'none', - selectionOverrides: [], - }, - }), - ); - }); - - it('lets the user retry a review submission after a transport failure', async () => { - getApplicationSurfaceMock.mockResolvedValue({ - protocol: 'v2', - snapshot: { - ...applicationSnapshotV2, - reviewSummary: { - reviewId: 'review-retry', - totalCount: 1, - categoryCounts: [{ kind: 'tool' as const, count: 1 }], - maxSelectionCount: 1, - riskSummary: { reasonCodes: [] }, - recommendationSummary: { recommendedCount: 1, optionalCount: 0, blockedCount: 0 }, - safetyCeiling: 'automatic' as const, - }, - }, + disabledApplicationToggle?.click(); + await vi.runAllTimersAsync(); }); - getApplicationReviewPageMock.mockResolvedValue({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-retry', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 7 }], - totalCount: 1, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-a' }, - displayName: 'Tool A', - displaySummary: 'Read repository files', - riskLevel: 'low', - riskReasonCodes: [], - recommended: true, - safetyCeiling: 'automatic', - }], - }); - applyApplicationActionMock.mockRejectedValueOnce(new Error('connection lost')); - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('[data-bf-part="attentionSummary"]')?.click(); - await Promise.resolve(); - }); - const submit = container.querySelector('[data-bf-part="submitReview"]'); - await act(async () => { - submit?.click(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(submit?.disabled).toBe(false); - }); - - it('drops stale review cursors and generations before the user can review again', async () => { - const staleSnapshot = { - ...applicationSnapshotV2, - applications: [{ - ...applicationSnapshotV2.applications[0], - effectiveStatus: 'needs_attention' as const, - primaryAction: 'review' as const, - pendingReviewCount: 1, - }], - reviewSummary: { - reviewId: 'review-stale', - totalCount: 1, - categoryCounts: [{ kind: 'tool' as const, count: 1 }], - maxSelectionCount: 1, - riskSummary: { reasonCodes: [] }, - recommendationSummary: { recommendedCount: 1, optionalCount: 0, blockedCount: 0 }, - safetyCeiling: 'automatic' as const, - }, - }; - getApplicationSurfaceMock.mockResolvedValue({ protocol: 'v2', snapshot: staleSnapshot }); - getApplicationReviewPageMock.mockResolvedValue({ - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - targetScope: 'workspace_override', - reviewId: 'review-stale', - preferenceRevision: 11, - expectedGenerations: [{ owner: 'tool', generation: 7 }], - totalCount: 1, - items: [{ - itemRef: { kind: 'tool', stableId: 'tool-a' }, - displayName: 'Tool A', - displaySummary: 'Read repository files', - riskLevel: 'low', - riskReasonCodes: [], - recommended: true, - safetyCeiling: 'automatic', - }], - }); - applyApplicationActionMock.mockResolvedValueOnce({ - schemaVersion: 2, - operationId: 'operation-stale', - preferenceRevision: 12, - outcome: 'stale', - itemResults: [], - }); - - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('[data-bf-part="attentionSummary"]')?.click(); - await Promise.resolve(); - }); - await act(async () => { - container.querySelector('[data-bf-part="submitReview"]')?.click(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('applications.review.outcome.stale'); - expect(container.querySelector('[data-bf-part="reviewItem"]')).toBeNull(); - await act(async () => { - container.querySelector('[data-bf-part="attentionSummary"]')?.click(); - await Promise.resolve(); - }); - expect(getApplicationReviewPageMock).toHaveBeenLastCalledWith( - 'D:/workspace/project', - expect.objectContaining({ expectedGenerations: [] }), - ); + const policyCard = container.querySelector('[data-bf-part="policyCard"]'); + const masterSwitch = policyCard?.querySelector('input[type="checkbox"]'); + expect(scrolledElements).toContain(policyCard); + expect(document.activeElement).toBe(masterSwitch); }); it('defers Hook owner reads until the Hook disclosure opens', async () => { diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index 0cc504ff2c..250ff624b7 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -33,12 +33,6 @@ import { type ExternalIntegrationAccess, type ExternalIntegrationMode, type ExternalIntegrationPolicyMutation, - type ExternalApplicationControlActionV2, - type ExternalApplicationControlResultV2, - type ExternalApplicationOwnerGenerationV2, - type ExternalApplicationReviewItemResultV2, - type ExternalApplicationReviewItemV2, - type ExternalApplicationSnapshotV2, type ExternalMcpDefinition, type ExternalSourceCatalogSnapshot, type ExternalSourceRecoveryAction, @@ -69,7 +63,6 @@ import { ExternalCommandConflicts, ExternalSourceSection, buildExternalApplicationsView, - buildExternalApplicationsViewV2, type ExternalApplicationView, } from './external-sources'; import './ExternalSourcesConfig.scss'; @@ -134,32 +127,20 @@ type SnapshotLoadResult = | { status: 'ignored' } | { status: 'error' }; -type ApplicationReviewState = { - reviewId: string; - preferenceRevision: number; - loading: boolean; - items: ExternalApplicationReviewItemV2[]; - expectedGenerations: ExternalApplicationOwnerGenerationV2[]; - nextCursor?: string; - totalCount: number; - recommendedCount: number; - maxSelectionCount: number; - overrides: Record; - itemResults: ExternalApplicationReviewItemResultV2[]; - submitted: boolean; -}; - -let applicationOperationSequence = 0; - -function nextApplicationOperationId(): string { - const randomId = globalThis.crypto?.randomUUID?.(); - return randomId - ? `external-app-${randomId}` - : `external-app-${Date.now()}-${++applicationOperationSequence}`; +function sourceEcosystemId( + snapshot: ExternalSourceCatalogSnapshot | null, + source: { providerId: string; sourceId: string } | undefined, +): string | undefined { + if (!snapshot || !source) return undefined; + return snapshot.sources.find((candidate) => ( + candidate.record.key.providerId === source.providerId + && candidate.record.key.sourceId === source.sourceId + ))?.record.ecosystemId; } -function applicationReviewItemKey(item: ExternalApplicationReviewItemV2): string { - return `${item.itemRef.kind}:${item.itemRef.stableId}`; +function onlyEcosystemId(values: Array): string | undefined { + const ecosystems = new Set(values.filter((value): value is string => Boolean(value))); + return ecosystems.size === 1 ? ecosystems.values().next().value : undefined; } function abbreviatedLocation(location: string): string { @@ -425,11 +406,9 @@ const ExternalSourcesConfig: React.FC = ({ const [agentChangeNotice, setAgentChangeNotice] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); const [hooksOpen, setHooksOpen] = useState(initialFocus === 'hooks'); - const [applicationReviewState, setApplicationReviewState] = useState(null); const hooksSummaryRef = useRef(null); const handledHookFocusRequestRef = useRef(null); const snapshotRef = useRef(null); - const applicationSnapshotRef = useRef(null); const agentChangeNoticeRef = useRef(null); const requestSequence = useRef(0); const acceptedSequence = useRef(0); @@ -451,13 +430,6 @@ const ExternalSourcesConfig: React.FC = ({ snapshot: ExternalSourceCatalogSnapshot; } | null>(null); const snapshot = snapshotState?.scope === requestScope ? snapshotState.snapshot : null; - const [applicationSnapshotState, setApplicationSnapshotState] = useState<{ - scope: string; - snapshot: ExternalApplicationSnapshotV2; - } | null>(null); - const applicationSnapshot = applicationSnapshotState?.scope === requestScope - ? applicationSnapshotState.snapshot - : null; const requestScopeRef = useRef(requestScope); useLayoutEffect(() => { if (requestScopeRef.current !== requestScope) { @@ -465,7 +437,6 @@ const ExternalSourcesConfig: React.FC = ({ requestSequence.current += 1; acceptedSequence.current = requestSequence.current; snapshotRef.current = null; - applicationSnapshotRef.current = null; agentChangeNoticeRef.current = null; } }, [requestScope]); @@ -567,23 +538,6 @@ const ExternalSourcesConfig: React.FC = ({ return true; }, [applySnapshot]); - const acceptApplicationSnapshot = useCallback(( - next: ExternalApplicationSnapshotV2, - scope: string, - sequence: number, - ): boolean => { - if (requestScopeRef.current !== scope || sequence < acceptedSequence.current) return false; - if (Array.from(pendingMutations.current.values()).includes(scope)) return false; - const current = applicationSnapshotRef.current; - if (current?.executionDomainId === next.executionDomainId - && (next.refreshGeneration < current.refreshGeneration - || next.preferenceRevision < current.preferenceRevision)) return false; - acceptedSequence.current = sequence; - applicationSnapshotRef.current = next; - setApplicationSnapshotState({ scope, snapshot: next }); - return true; - }, []); - const acceptMutationSnapshot = useCallback(( next: ExternalSourceCatalogSnapshot, scope: string, @@ -608,26 +562,10 @@ const ExternalSourcesConfig: React.FC = ({ setRefreshing(true); } try { - const surface = await externalSourcesAPI.getApplicationSurface(workspacePath, forceRefresh); - if (surface.protocol === 'v1') { - if (!acceptReadSnapshot(surface.snapshot, scope, sequence)) return { status: 'ignored' }; - applicationSnapshotRef.current = null; - setApplicationSnapshotState(null); - setError(null); - return { status: 'accepted', snapshot: surface.snapshot }; - } - if (!acceptApplicationSnapshot(surface.snapshot, scope, sequence)) { - return { status: 'ignored' }; - } + const next = await externalSourcesAPI.getSnapshot(workspacePath, forceRefresh); + if (!acceptReadSnapshot(next, scope, sequence)) return { status: 'ignored' }; setError(null); - void externalSourcesAPI.getSnapshot(workspacePath, false) - .then((legacySnapshot) => { - acceptReadSnapshot(legacySnapshot, scope, sequence); - }) - .catch(() => { - // The V1 catalog is secondary on a V2 Host; the application home remains usable. - }); - return { status: 'accepted' }; + return { status: 'accepted', snapshot: next }; } catch (loadError) { if (requestScopeRef.current !== scope || sequence < acceptedSequence.current @@ -646,13 +584,10 @@ const ExternalSourcesConfig: React.FC = ({ } } } - }, [acceptApplicationSnapshot, acceptReadSnapshot, requestScope, workspacePath]); + }, [acceptReadSnapshot, requestScope, workspacePath]); useEffect(() => { setSnapshotState(null); - setApplicationSnapshotState(null); - applicationSnapshotRef.current = null; - setApplicationReviewState(null); snapshotRef.current = null; agentChangeNoticeRef.current = null; setAgentChangeNotice(null); @@ -727,259 +662,10 @@ const ExternalSourcesConfig: React.FC = ({ () => snapshot ? catalogDiagnosticsWithoutSourceDuplicates(snapshot, sourceGroups) : [], [snapshot, sourceGroups], ); - const applicationsView = useMemo( - () => applicationSnapshot - ? buildExternalApplicationsViewV2(applicationSnapshot) - : buildExternalApplicationsView(snapshot, sourceGroups, policyScope), - [applicationSnapshot, policyScope, snapshot, sourceGroups], + const applications = useMemo( + () => buildExternalApplicationsView(snapshot, policyScope), + [policyScope, snapshot], ); - const applicationTargetScope = applicationSnapshot?.workspaceScopeId - ? 'workspace_override' - : 'user_default'; - const canMutateApplicationScope = applicationSnapshot - ? applicationSnapshot.hostCapabilities.canMutate - && (applicationTargetScope === 'workspace_override' - ? applicationSnapshot.hostCapabilities.canManageWorkspaceOverride - : applicationSnapshot.hostCapabilities.canManageUserDefault) - : false; - - const loadApplicationReviewPage = useCallback(async (cursor?: string) => { - const current = applicationSnapshot; - const summary = current?.reviewSummary; - if (!current || !summary || !current.hostCapabilities.canReadReview) return; - const scope = requestScope; - const append = cursor !== undefined; - const reviewId = append ? applicationReviewState?.reviewId : summary.reviewId; - if (!reviewId) return; - const preferenceRevision = append - ? applicationReviewState?.preferenceRevision ?? current.preferenceRevision - : current.preferenceRevision; - const expectedGenerations = append - ? applicationReviewState?.expectedGenerations ?? [] - : []; - setApplicationReviewState((previous) => ({ - reviewId, - preferenceRevision, - loading: true, - items: append && previous?.reviewId === reviewId ? previous.items : [], - expectedGenerations, - nextCursor: append ? previous?.nextCursor : undefined, - totalCount: summary.totalCount, - recommendedCount: summary.recommendationSummary.recommendedCount, - maxSelectionCount: summary.maxSelectionCount, - overrides: append && previous?.reviewId === reviewId ? previous.overrides : {}, - itemResults: append && previous?.reviewId === reviewId ? previous.itemResults : [], - submitted: append && previous?.reviewId === reviewId ? previous.submitted : false, - })); - try { - const page = await externalSourcesAPI.getApplicationReviewPage(workspacePath, { - schemaVersion: 2, - executionDomainId: current.executionDomainId, - ...(current.workspaceScopeId ? { workspaceScopeId: current.workspaceScopeId } : {}), - targetScope: current.workspaceScopeId ? 'workspace_override' : 'user_default', - reviewId, - preferenceRevision, - expectedGenerations, - ...(cursor ? { cursor } : {}), - pageSize: 64, - }); - let authoritativeSummary = summary; - let reboundSnapshot: ExternalApplicationSnapshotV2 | null = null; - if (!append && page.reviewId !== summary.reviewId) { - const surface = await externalSourcesAPI.getApplicationSurface(workspacePath, false); - if (surface.protocol !== 'v2') { - throw new Error('The current Host no longer supports application review.'); - } - const reboundSummary = surface.snapshot.reviewSummary; - if (!reboundSummary - || surface.snapshot.executionDomainId !== page.executionDomainId - || surface.snapshot.workspaceScopeId !== page.workspaceScopeId - || surface.snapshot.preferenceRevision !== page.preferenceRevision - || reboundSummary.reviewId !== page.reviewId) { - throw new Error('The application review changed while it was opening.'); - } - authoritativeSummary = reboundSummary; - reboundSnapshot = surface.snapshot; - } - if (requestScopeRef.current !== scope) return; - const latest = applicationSnapshotRef.current; - if (!latest - || latest.preferenceRevision !== preferenceRevision - || latest.reviewSummary?.reviewId !== summary.reviewId - || Array.from(pendingMutations.current.values()).includes(scope)) return; - if (reboundSnapshot - && !acceptApplicationSnapshot(reboundSnapshot, scope, acceptedSequence.current)) return; - setApplicationReviewState((previous) => { - if (!previous || (append && previous.reviewId !== page.reviewId)) return previous; - const items = new Map( - (append ? previous.items : []).map((item) => [applicationReviewItemKey(item), item]), - ); - page.items.forEach((item) => items.set(applicationReviewItemKey(item), item)); - return { - ...previous, - reviewId: page.reviewId, - preferenceRevision: page.preferenceRevision, - loading: false, - items: Array.from(items.values()), - expectedGenerations: page.expectedGenerations, - nextCursor: page.nextCursor, - totalCount: page.totalCount, - recommendedCount: append - ? previous.recommendedCount - : authoritativeSummary.recommendationSummary.recommendedCount, - maxSelectionCount: append - ? previous.maxSelectionCount - : authoritativeSummary.maxSelectionCount, - }; - }); - } catch (reviewError) { - if (requestScopeRef.current !== scope) return; - setApplicationReviewState((previous) => ( - append && previous ? { ...previous, loading: false } : null - )); - setError({ kind: 'load', ...externalOperationErrorFacts(reviewError) }); - } - }, [acceptApplicationSnapshot, applicationReviewState?.expectedGenerations, - applicationReviewState?.preferenceRevision, applicationReviewState?.reviewId, - applicationSnapshot, requestScope, workspacePath]); - - useEffect(() => { - setApplicationReviewState((previous) => { - if (!previous || previous.submitted) return previous; - return applicationSnapshot?.reviewSummary?.reviewId === previous.reviewId - && applicationSnapshot.preferenceRevision === previous.preferenceRevision - ? previous - : null; - }); - }, [applicationSnapshot?.preferenceRevision, applicationSnapshot?.reviewSummary?.reviewId]); - - const selectedApplicationReviewCount = useMemo(() => { - if (!applicationReviewState) return 0; - let selectedCount = applicationReviewState.recommendedCount; - Object.entries(applicationReviewState.overrides).forEach(([key, selected]) => { - const item = applicationReviewState.items.find( - (candidate) => applicationReviewItemKey(candidate) === key, - ); - if (item && selected !== item.recommended) selectedCount += selected ? 1 : -1; - }); - return selectedCount; - }, [applicationReviewState]); - - const setApplicationReviewItemSelected = useCallback(( - item: ExternalApplicationReviewItemV2, - selected: boolean, - ) => { - const maximum = applicationReviewState?.maxSelectionCount ?? 0; - if (selected && selectedApplicationReviewCount >= maximum) { - setOperationStatus(t('applications.review.selectionLimit')); - return; - } - const key = applicationReviewItemKey(item); - setApplicationReviewState((previous) => { - if (!previous) return previous; - const overrides = { ...previous.overrides }; - if (selected === item.recommended) delete overrides[key]; - else overrides[key] = selected; - return { ...previous, overrides }; - }); - }, [applicationReviewState?.maxSelectionCount, selectedApplicationReviewCount, t]); - - const runApplicationAction = useCallback(async ( - action: ExternalApplicationControlActionV2, - mutationKey: string, - ): Promise => { - const current = applicationSnapshot; - if (!current || !canMutateApplicationScope) return null; - const scope = requestScope; - const sequence = ++requestSequence.current; - pendingMutations.current.set(sequence, scope); - latestMutationByScope.current.set(scope, sequence); - activeMutation.current = { scope, sequence }; - setBusyKey(mutationKey); - setOperationStatus(null); - setError(null); - let result: ExternalApplicationControlResultV2 | null = null; - try { - result = await externalSourcesAPI.applyApplicationAction(workspacePath, { - schemaVersion: 2, - executionDomainId: current.executionDomainId, - ...(current.workspaceScopeId ? { workspaceScopeId: current.workspaceScopeId } : {}), - targetScope: current.workspaceScopeId ? 'workspace_override' : 'user_default', - operationId: nextApplicationOperationId(), - expectedPreferenceRevision: current.preferenceRevision, - action, - }); - if (requestScopeRef.current === scope - && (latestMutationByScope.current.get(scope) ?? sequence) <= sequence) { - acceptedSequence.current = Math.max(acceptedSequence.current, sequence); - const partial = result.itemResults.some((item) => item.outcome !== 'applied'); - setOperationStatus(t(`applications.review.outcome.${partial ? 'partial' : result.outcome}`)); - } - } catch (mutationError) { - if (requestScopeRef.current === scope) { - setError({ kind: 'mutation', ...externalOperationErrorFacts(mutationError) }); - } - } finally { - pendingMutations.current.delete(sequence); - if (activeMutation.current?.scope === scope - && activeMutation.current.sequence === sequence) { - activeMutation.current = null; - setBusyKey(null); - } - } - if (result && requestScopeRef.current === scope) await loadSnapshot(true, false); - return result; - }, [applicationSnapshot, canMutateApplicationScope, loadSnapshot, requestScope, t, workspacePath]); - - const submitApplicationReview = useCallback(async ( - selectionBaseline: 'recommended' | 'none', - immediateSelection?: { item: ExternalApplicationReviewItemV2; selected: boolean }, - ) => { - const current = applicationReviewState; - if (!current) return; - const itemByKey = new Map( - current.items.map((item) => [applicationReviewItemKey(item), item]), - ); - const effectiveOverrides = new Map(Object.entries(current.overrides)); - if (immediateSelection) { - effectiveOverrides.set( - applicationReviewItemKey(immediateSelection.item), - immediateSelection.selected, - ); - } - const selectionOverrides = selectionBaseline === 'recommended' - ? Array.from(effectiveOverrides.entries()).flatMap(([key, selected]) => { - const item = itemByKey.get(key); - return item ? [{ itemRef: item.itemRef, selected }] : []; - }) - : []; - setApplicationReviewState((previous) => previous - ? { ...previous, submitted: true } - : previous); - const result = await runApplicationAction({ - type: 'submit_application_review', - reviewId: current.reviewId, - expectedGenerations: current.expectedGenerations, - selectionBaseline, - selectionOverrides, - }, 'application-review'); - if (result) { - setApplicationReviewState((previous) => result.outcome === 'stale' - ? null - : previous - ? { - ...previous, - itemResults: result.itemResults, - nextCursor: undefined, - submitted: true, - } - : previous); - } else { - setApplicationReviewState((previous) => previous - ? { ...previous, submitted: false } - : previous); - } - }, [applicationReviewState, runApplicationAction]); const commandConflicts = useMemo( () => unresolvedFirst(snapshot?.commandConflicts ?? []), @@ -1011,12 +697,9 @@ const ExternalSourcesConfig: React.FC = ({ canRevealSourceLocation: false, }; const control = snapshot?.control; - const canRefresh = applicationSnapshot?.hostCapabilities.canRefresh - ?? hostCapabilities.canRefresh; - const safeModeEnabled = applicationSnapshot?.safeMode ?? control?.safeMode; - const canSetSafeMode = applicationSnapshot - ? canMutateApplicationScope && applicationSnapshot.hostCapabilities.canSetSafeMode - : hostCapabilities.canSetSafeMode; + const canRefresh = hostCapabilities.canRefresh; + const safeModeEnabled = control?.safeMode; + const canSetSafeMode = hostCapabilities.canSetSafeMode; const policyStatus = snapshot?.integrationPolicy?.status; const policyCompatible = policyStatus === 'compatible'; const policyIncompatible = policyStatus === 'incompatible_schema'; @@ -1025,9 +708,6 @@ const ExternalSourcesConfig: React.FC = ({ && !hostCapabilities.canManageSources && !hostCapabilities.canApproveRuntime && !hostCapabilities.canSetSafeMode; - const applicationHostReadOnly = Boolean(applicationSnapshot) - && !canMutateApplicationScope - && !applicationSnapshot?.hostCapabilities.canSetSafeMode; const remoteWorkspace = workspace?.workspaceKind === WorkspaceKind.Remote; const readOnlyHintKey = remoteWorkspace ? 'policy.remoteReadOnlyHint' @@ -1133,11 +813,6 @@ const ExternalSourcesConfig: React.FC = ({ }, [runMutation, workspacePath]); const setSafeMode = useCallback(async (enabled: boolean) => { - if (applicationSnapshot) { - if (!canSetSafeMode) return; - await runApplicationAction({ type: 'set_safe_mode', enabled }, 'external-safe-mode'); - return; - } const currentSnapshot = snapshotRef.current; if (!currentSnapshot?.control) return; await runMutation( @@ -1153,7 +828,7 @@ const ExternalSourcesConfig: React.FC = ({ 'canSetSafeMode', 'none', ); - }, [applicationSnapshot, canSetSafeMode, runApplicationAction, runMutation, t, workspacePath]); + }, [runMutation, t, workspacePath]); const chooseConflict = useCallback(async (conflictKey: string, candidateId: string) => { if (!snapshot) return; @@ -1452,13 +1127,6 @@ const ExternalSourcesConfig: React.FC = ({ application: ExternalApplicationView, enabled: boolean, ) => { - if (applicationSnapshot && application.applicationId) { - await runApplicationAction({ - type: enabled ? 'connect_application' : 'disconnect_application', - applicationId: application.applicationId, - }, `application:${application.applicationId}`); - return; - } if (!snapshot) return; const storedPolicy = ecosystemPolicies.find( (ecosystem) => ecosystem.ecosystemId === application.ecosystemId, @@ -1475,9 +1143,7 @@ const ExternalSourcesConfig: React.FC = ({ mode, }); }, [ - applicationSnapshot, ecosystemPolicies, - runApplicationAction, snapshot, updatePolicy, ]); @@ -1514,10 +1180,16 @@ const ExternalSourcesConfig: React.FC = ({ ); }, [requestScope, runMutation, t]); - const scrollToFirstAttentionItem = useCallback(() => { - const target = document.querySelector( - '[data-external-attention="true"]', - ); + const scrollToFirstAttentionItem = useCallback((ecosystemId?: string) => { + const matchingEcosystemElements = ecosystemId + ? Array.from(document.querySelectorAll('[data-external-ecosystem]')) + .filter((element) => element.dataset.externalEcosystem === ecosystemId) + : []; + const target = ecosystemId + ? matchingEcosystemElements.find( + (element) => element.dataset.externalAttention === 'true', + ) ?? matchingEcosystemElements[0] + : document.querySelector('[data-external-attention="true"]'); if (!target) return; target.scrollIntoView({ block: 'center', behavior: 'smooth' }); if (target instanceof HTMLDetailsElement) { @@ -1534,11 +1206,22 @@ const ExternalSourcesConfig: React.FC = ({ target.focus(); }, []); - const openAdvanced = useCallback(() => { + const openAdvancedAttention = useCallback((ecosystemId: string) => { setAdvancedOpen(true); - window.requestAnimationFrame(scrollToFirstAttentionItem); + setExpandedEcosystems((current) => new Set(current).add(ecosystemId)); + window.requestAnimationFrame(() => scrollToFirstAttentionItem(ecosystemId)); }, [scrollToFirstAttentionItem]); + const openAdvancedPolicy = useCallback(() => { + setAdvancedOpen(true); + window.requestAnimationFrame(() => { + const policyCard = document.querySelector('[data-bf-part="policyCard"]'); + if (!policyCard) return; + policyCard.scrollIntoView({ block: 'center', behavior: 'smooth' }); + policyCard.querySelector('input[type="checkbox"]')?.focus(); + }); + }, []); + const revealSourceLocation = useCallback(async (sourceKey: string): Promise => { const scope = requestScope; if (snapshotRef.current?.hostCapabilities.canRevealSourceLocation !== true) { @@ -1852,7 +1535,7 @@ const ExternalSourcesConfig: React.FC = ({ key={action.type} size="small" variant="secondary" - onClick={scrollToFirstAttentionItem} + onClick={() => scrollToFirstAttentionItem()} > {t(`recoveryActions.${action.type}`)} @@ -1879,7 +1562,7 @@ const ExternalSourcesConfig: React.FC = ({ ) : null} ) : null} - {(snapshot && hostReadOnly) || applicationHostReadOnly ? ( + {snapshot && hostReadOnly ? (
) : null} {safeModeEnabled ? safeModeSection : null} - {snapshot || applicationSnapshot ? ( + {snapshot ? ( void toggleApplication(application, enabled)} - onOpenAdvanced={openAdvanced} - onOpenReview={applicationSnapshot?.reviewSummary - ? () => void loadApplicationReviewPage() - : undefined} - review={applicationSnapshot && applicationReviewState ? { - open: true, - loading: applicationReviewState.loading, - items: applicationReviewState.items, - selected: applicationReviewState.overrides, - selectedCount: selectedApplicationReviewCount, - recommendedCount: applicationReviewState.recommendedCount, - totalCount: applicationReviewState.totalCount, - maxSelectionCount: applicationReviewState.maxSelectionCount, - applicationNames: applicationSnapshot.applications - .filter((application) => application.pendingReviewCount > 0) - .map((application) => application.displayName), - nextCursor: applicationReviewState.nextCursor, - itemResults: applicationReviewState.itemResults, - completed: applicationReviewState.submitted, - canSubmit: canMutateApplicationScope, - onClose: () => setApplicationReviewState(null), - onToggleItem: setApplicationReviewItemSelected, - onLoadMore: () => { - if (applicationReviewState.nextCursor) { - void loadApplicationReviewPage(applicationReviewState.nextCursor); - } - }, - onSubmit: (baseline, immediateSelection) => void submitApplicationReview( - baseline, - immediateSelection, - ), - } : undefined} + onOpenAttention={openAdvancedAttention} + onOpenPolicy={openAdvancedPolicy} /> ) : null} {hookManagement} - {snapshot || applicationSnapshot ? ( + {snapshot ? (
= ({ - - ) : null} -
- ) : null} - +
+); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalCommandConflicts.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalCommandConflicts.tsx index 679887b859..14357b635f 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalCommandConflicts.tsx +++ b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalCommandConflicts.tsx @@ -32,6 +32,9 @@ export const ExternalCommandConflicts: React.FC = title={t('conflicts.title')} > {conflicts.map((conflict) => { + const ecosystemIds = new Set( + conflict.candidates.map((candidate) => candidate.ecosystemId), + ); const selectedChoiceUnavailable = conflict.candidates.some((candidate) => ( candidate.candidateId === conflict.selectedCandidateId && candidate.availability.state !== 'available' @@ -43,6 +46,9 @@ export const ExternalCommandConflicts: React.FC = data-bf-part="conflict" key={conflict.conflictKey} data-external-attention={!conflict.selectedCandidateId ? 'true' : undefined} + data-external-ecosystem={ecosystemIds.size === 1 + ? ecosystemIds.values().next().value + : undefined} >
{t('conflicts.commandName', { name: conflict.commandName })} diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.tsx index c590bc59b8..b0ada96993 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.tsx +++ b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalSourceSection.tsx @@ -84,6 +84,7 @@ export const ExternalSourceSection: React.FC = ({ data-bf-component="external-sources-config" data-bf-part="notice" data-external-attention="true" + data-external-ecosystem={group.ecosystemId} > {t('diagnostics.sourceSummary', { diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts index 26254608b7..35c4f0d3c7 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts +++ b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.test.ts @@ -3,14 +3,11 @@ import type { ExternalSourceCatalogSnapshot, ExternalSourceRecord, } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; -import { buildExternalSourcePresentationGroups } from '../../externalSourcePresentation'; import { buildExternalApplicationsView } from './applicationModel'; -const OPENCODE_CAPABILITIES = [ +const CAPABILITIES = [ { capabilityId: 'command', recommendedAccess: 'auto' as const, safetyCeiling: 'auto' as const }, { capabilityId: 'tool', recommendedAccess: 'ask_before_use' as const, safetyCeiling: 'auto' as const }, - { capabilityId: 'subagent', recommendedAccess: 'ask_before_use' as const, safetyCeiling: 'auto' as const }, - { capabilityId: 'mcp', recommendedAccess: 'ask_before_use' as const, safetyCeiling: 'auto' as const }, ]; function policy( @@ -23,42 +20,27 @@ function policy( globalEffective: { enabled: true, ecosystems: {} }, effective: { enabled: true, ecosystems: {} }, registeredEcosystems: [ - { ecosystemId: 'opencode', displayName: 'OpenCode', adapterRevision: 'r1', capabilities: OPENCODE_CAPABILITIES }, - { ecosystemId: 'claude-code', displayName: 'Claude Code', adapterRevision: 'r1', capabilities: OPENCODE_CAPABILITIES }, + { ecosystemId: 'opencode', displayName: 'OpenCode', adapterRevision: 'r1', capabilities: CAPABILITIES }, + { ecosystemId: 'claude-code', displayName: 'Claude Code', adapterRevision: 'r1', capabilities: CAPABILITIES }, ], ...overrides, }; } -function withMode( - ecosystemId: string, - mode: 'recommended' | 'discover_only' | 'disabled' | 'custom', -): ExternalSourceCatalogSnapshot['integrationPolicy'] { - const ecosystems = { - [ecosystemId]: { ecosystemId, mode, capabilities: {} }, - }; - return policy({ - effective: { enabled: true, ecosystems }, - globalEffective: { enabled: true, ecosystems }, - }); -} - function source( - stableKey: string, ecosystemId: string, overrides: Partial = {}, ): ExternalSourceCatalogSnapshot['sources'][number] { return { - stableKey, - presentationGroupId: `${ecosystemId}-config`, + stableKey: `${ecosystemId}-user`, lifecycle: 'available', record: { - key: { providerId: `${ecosystemId}.commands`, sourceId: 'user-configuration' }, + key: { providerId: `${ecosystemId}.commands`, sourceId: 'user' }, ecosystemId, - displayName: `${ecosystemId} configuration`, + displayName: ecosystemId, sourceKind: 'configuration', scope: 'user_global', - location: `~/.config/${ecosystemId}/config.json`, + location: `~/.config/${ecosystemId}`, executionDomainId: 'local', health: 'available', contentVersion: 'v1', @@ -92,146 +74,60 @@ function snapshot( }; } -function view(input: ExternalSourceCatalogSnapshot) { - return buildExternalApplicationsView( - input, - buildExternalSourcePresentationGroups(input), - 'workspace', - ); -} - describe('external application model', () => { - it('lists every registered ecosystem even when nothing was discovered', () => { - const result = view(snapshot()); - - expect(result.applications.map((application) => application.ecosystemId)) - .toEqual(['opencode', 'claude-code']); - expect(result.applications[0].status).toBe('no_configuration'); - expect(result.applications[0].primaryAction).toBe('none'); + it('hides registered ecosystems that were neither discovered nor explicitly configured', () => { + expect(buildExternalApplicationsView(snapshot(), 'workspace')).toEqual([]); }); - it('reports checking while discovery is still running', () => { - const result = view(snapshot({ discoveryPending: true })); - - expect(result.applications[0].status).toBe('checking'); + it('shows a discovered application with the effective policy state', () => { + const opencodePolicy = { + ecosystemId: 'opencode', + mode: 'recommended' as const, + capabilities: {}, + }; + const result = buildExternalApplicationsView(snapshot({ + sources: [source('opencode')], + integrationPolicy: policy({ + globalEffective: { enabled: true, ecosystems: { opencode: opencodePolicy } }, + effective: { enabled: true, ecosystems: { opencode: opencodePolicy } }, + }), + }), 'workspace'); + + expect(result).toEqual([{ + ecosystemId: 'opencode', + displayName: 'OpenCode', + enabled: true, + attentionCount: 0, + }]); }); - it('treats a recommended ecosystem with sources as connected', () => { - const result = view(snapshot({ - sources: [source('opencode-user', 'opencode')], - integrationPolicy: withMode('opencode', 'recommended'), - })); - - const opencode = result.applications[0]; - expect(opencode.status).toBe('connected'); - expect(opencode.primaryAction).toBe('manage'); - expect(opencode.enabled).toBe(true); - }); - - it('reports only capability types that are active under the effective policy', () => { - const result = view(snapshot({ - sources: [source('opencode-user', 'opencode')], - integrationPolicy: withMode('opencode', 'recommended'), - commands: [{ - candidateId: 'command-1', - definition: { - id: { - source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, - localId: 'review', - }, - name: 'review', - description: 'Review', - availability: { state: 'available' }, - contentVersion: 'v1', + it('keeps an explicitly disabled application visible after its source disappears', () => { + const disabled = { ecosystemId: 'opencode', mode: 'disabled' as const, capabilities: {} }; + const result = buildExternalApplicationsView(snapshot({ + integrationPolicy: policy({ + userDefaults: { + enabled: true, + ecosystems: { opencode: { mode: 'disabled' } }, }, - }], - })); - - expect(result.applications[0].activeCapabilities).toEqual([ - { capabilityId: 'command', count: 1 }, - ]); + globalEffective: { enabled: true, ecosystems: { opencode: disabled } }, + effective: { enabled: true, ecosystems: { opencode: disabled } }, + }), + }), 'workspace'); + + expect(result).toMatchObject([{ + ecosystemId: 'opencode', + enabled: false, + }]); }); - it('keeps recommended access separate from the authoritative effective access', () => { - const disabledCommandPolicy = policy({ - effective: { - enabled: true, - ecosystems: { - opencode: { - ecosystemId: 'opencode', - mode: 'custom', - capabilities: { command: 'disabled' }, - }, - }, - }, - globalEffective: { - enabled: true, - ecosystems: { - opencode: { - ecosystemId: 'opencode', - mode: 'custom', - capabilities: { command: 'disabled' }, - }, - }, - }, - }); - const result = view(snapshot({ - sources: [source('opencode-user', 'opencode')], - integrationPolicy: disabledCommandPolicy, - commands: [{ - candidateId: 'command-1', - definition: { - id: { - source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, - localId: 'review', - }, - name: 'review', - description: 'Review', - availability: { state: 'available' }, - contentVersion: 'v1', - }, - }], - })); - - expect(result.applications[0].connectPlan.find( - (entry) => entry.capabilityId === 'command', - )).toMatchObject({ - recommendedAccess: 'auto', - effectiveAccess: 'disabled', - count: 1, - }); - }); - - it('keeps custom ecosystems on manage so a two-state toggle cannot flatten them', () => { - const result = view(snapshot({ - sources: [source('opencode-user', 'opencode')], - integrationPolicy: withMode('opencode', 'custom'), - })); - - expect(result.applications[0].status).toBe('connected_custom'); - expect(result.applications[0].primaryAction).toBe('manage'); - }); - - it('offers connect for a discovered but discover-only ecosystem', () => { - const result = view(snapshot({ - sources: [source('opencode-user', 'opencode')], - integrationPolicy: withMode('opencode', 'discover_only'), - })); - - expect(result.applications[0].status).toBe('discovered'); - expect(result.applications[0].primaryAction).toBe('connect'); - expect(result.applications[0].enabled).toBe(false); - }); - - it('attributes tool approvals to the owning ecosystem', () => { - const result = view(snapshot({ - sources: [source('opencode-user', 'opencode')], - integrationPolicy: withMode('opencode', 'recommended'), + it('signals an owner permission without exposing a review workflow', () => { + const result = buildExternalApplicationsView(snapshot({ + sources: [source('opencode')], toolApprovalRequests: [{ approvalKey: 'approval-1', decisionKey: 'decision-1', targetId: { - source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, + source: { providerId: 'opencode.commands', sourceId: 'user' }, localId: 'tool-a', }, sourceDisplayName: 'OpenCode', @@ -243,112 +139,48 @@ describe('external application model', () => { capabilities: ['file_system'], contentVersion: 'v1', }], - })); - - const opencode = result.applications[0]; - expect(opencode.attentionCount).toBe(1); - expect(opencode.status).toBe('needs_attention'); - expect(opencode.primaryAction).toBe('review'); - expect(result.unattributedAttentionCount).toBe(0); - }); - - it('keeps catalog diagnostics and policy incompatibility out of per-application counts', () => { - const result = view(snapshot({ - sources: [source('opencode-user', 'opencode')], - integrationPolicy: policy({ status: 'incompatible_schema' }), - })); + }), 'workspace'); - expect(result.applications.every((application) => application.attentionCount === 0)) - .toBe(true); - expect(result.unattributedAttentionCount).toBe(0); - expect(result.totalAttentionCount).toBe(0); + expect(result[0]).toMatchObject({ + ecosystemId: 'opencode', + attentionCount: 1, + }); }); - it('does not attribute a conflict that spans two ecosystems', () => { - const result = view(snapshot({ - sources: [source('opencode-user', 'opencode'), source('claude-user', 'claude-code')], + it('keeps a cross-application conflict out of either application row', () => { + const result = buildExternalApplicationsView(snapshot({ + sources: [source('opencode'), source('claude-code')], commandConflicts: [{ conflictKey: 'conflict-1', - commandName: 'review', + commandName: 'test', candidates: [ { - candidateId: 'candidate-opencode', - source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, + candidateId: 'opencode-test', + source: { providerId: 'opencode.commands', sourceId: 'user' }, sourceDisplayName: 'OpenCode', ecosystemId: 'opencode', contentVersion: 'v1', - commandDescription: 'Review', + commandDescription: 'Test', sourceScope: 'user_global', sourceLocation: '~/.config/opencode', availability: { state: 'available' }, }, { - candidateId: 'candidate-claude', - source: { providerId: 'claude-code.commands', sourceId: 'user-configuration' }, + candidateId: 'claude-test', + source: { providerId: 'claude-code.commands', sourceId: 'user' }, sourceDisplayName: 'Claude Code', ecosystemId: 'claude-code', contentVersion: 'v1', - commandDescription: 'Review', + commandDescription: 'Test', sourceScope: 'user_global', sourceLocation: '~/.claude', availability: { state: 'available' }, }, ], }], - })); + }), 'workspace'); - expect(result.applications.every((application) => application.attentionCount === 0)) + expect(result.every((application) => application.attentionCount === 0)) .toBe(true); - expect(result.unattributedAttentionCount).toBe(1); - }); - - it('ignores conflicts the user already resolved', () => { - const result = view(snapshot({ - sources: [source('opencode-user', 'opencode')], - commandConflicts: [{ - conflictKey: 'conflict-1', - commandName: 'review', - selectedCandidateId: 'candidate-opencode', - candidates: [{ - candidateId: 'candidate-opencode', - source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, - sourceDisplayName: 'OpenCode', - ecosystemId: 'opencode', - contentVersion: 'v1', - commandDescription: 'Review', - sourceScope: 'user_global', - sourceLocation: '~/.config/opencode', - availability: { state: 'available' }, - }], - }], - })); - - expect(result.totalAttentionCount).toBe(0); - }); - - it('exposes what connecting would enable so the dialog never hard-codes access levels', () => { - const result = view(snapshot({ - sources: [source('opencode-user', 'opencode')], - integrationPolicy: withMode('opencode', 'discover_only'), - commands: [{ - candidateId: 'command-1', - definition: { - id: { - source: { providerId: 'opencode.commands', sourceId: 'user-configuration' }, - localId: 'review', - }, - name: 'review', - description: 'Review', - availability: { state: 'available' }, - contentVersion: 'v1', - }, - }], - })); - - const plan = result.applications[0].connectPlan; - expect(plan.find((entry) => entry.capabilityId === 'command')) - .toMatchObject({ recommendedAccess: 'auto', count: 1 }); - expect(plan.find((entry) => entry.capabilityId === 'tool')) - .toMatchObject({ recommendedAccess: 'ask_before_use', count: 0 }); }); }); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts index 4325977878..db939151a8 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts +++ b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts @@ -1,350 +1,137 @@ import type { - ExternalApplicationHealthV2, - ExternalApplicationRecoveryActionV2, - ExternalApplicationSnapshotV2, - ExternalIntegrationAccess, - ExternalIntegrationMode, ExternalSourceCatalogSnapshot, } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; -import type { - ExternalSourceCapabilityCounts, - ExternalSourcePresentationGroup, -} from '../../externalSourcePresentation'; - -/** - * Application status shown on the overview. - * - * V1 has no application connection facts, so `connected` is derived from the - * effective integration mode rather than a real connection lifecycle. The - * design's "temporarily unavailable" state is intentionally absent: V1 cannot - * separate "not installed" from "probe failed", and guessing would mislead. - */ -export type ExternalApplicationStatus = - | 'needs_attention' - | 'connected' - | 'connected_custom' - | 'configuration_available' - | 'temporarily_unavailable' - | 'discovered' - | 'checking' - | 'no_configuration'; - -export type ExternalApplicationAction = - | 'connect' - | 'manage' - | 'review' - | 'view' - | 'retry' - | 'view_reason' - | 'none'; - -export interface ExternalApplicationCapabilityPlan { - capabilityId: string; - /** Access this capability reaches once the ecosystem switches to recommended. */ - recommendedAccess: ExternalIntegrationAccess; - /** Access currently enforced by the authoritative effective policy. */ - effectiveAccess: ExternalIntegrationAccess; - count: number; -} - -export interface ExternalApplicationActiveCapability { - capabilityId: string; - count: number; -} export interface ExternalApplicationView { - applicationId?: string; ecosystemId: string; displayName: string; - mode?: ExternalIntegrationMode; - status: ExternalApplicationStatus; - primaryAction: ExternalApplicationAction; enabled: boolean; - /** Host-authoritative aggregate for V2; V1 continues to expose capability counts. */ - enabledCount?: number; - /** Host-authoritative V2 secondary facts; omitted for the inferred V1 projection. */ - health?: ExternalApplicationHealthV2; - blockedCount?: number; - conflictCount?: number; - recoveryActions?: ExternalApplicationRecoveryActionV2[]; - counts: ExternalSourceCapabilityCounts; - activeCapabilities: ExternalApplicationActiveCapability[]; - sourceCount: number; - locations: string[]; - /** Attention items that could be attributed to this ecosystem. */ attentionCount: number; - /** What switching to `recommended` would enable, used by the connect dialog. */ - connectPlan: ExternalApplicationCapabilityPlan[]; -} - -export interface ExternalApplicationsView { - applications: ExternalApplicationView[]; - /** Attention items with no ecosystem identity (catalog diagnostics, policy). */ - unattributedAttentionCount: number; - totalAttentionCount: number; -} - -export function buildExternalApplicationsViewV2( - snapshot: ExternalApplicationSnapshotV2, -): ExternalApplicationsView { - return { - applications: snapshot.applications.map((application) => ({ - applicationId: application.applicationId, - ecosystemId: application.ecosystemId, - displayName: application.displayName, - status: application.effectiveStatus, - primaryAction: application.primaryAction, - enabled: application.connection === 'connected', - enabledCount: application.enabledCount, - health: application.health, - blockedCount: application.blockedCount, - conflictCount: application.conflictCount, - recoveryActions: application.recoveryActions, - counts: { commands: 0, tools: 0, agents: 0, mcps: 0 }, - activeCapabilities: [], - sourceCount: 0, - locations: [], - attentionCount: application.pendingReviewCount, - connectPlan: [], - })), - unattributedAttentionCount: 0, - totalAttentionCount: snapshot.reviewSummary?.totalCount ?? 0, - }; } -const CAPABILITY_COUNT_FIELD: Record = { - command: 'commands', - tool: 'tools', - subagent: 'agents', - mcp: 'mcps', -}; - function sourcePairKey(providerId: string, sourceId: string): string { return `${providerId}\u0000${sourceId}`; } -/** - * Maps every discovered source pair to its ecosystem so attention items that - * only carry a source identity can still be attributed to an application. - */ function ecosystemBySourcePair(snapshot: ExternalSourceCatalogSnapshot): Map { - const bySource = new Map(); - for (const source of snapshot.sources) { - bySource.set( - sourcePairKey(source.record.key.providerId, source.record.key.sourceId), - source.record.ecosystemId, - ); - } - return bySource; + return new Map(snapshot.sources.map((source) => [ + sourcePairKey(source.record.key.providerId, source.record.key.sourceId), + source.record.ecosystemId, + ])); } -function addAttention(counts: Map, ecosystemId: string | undefined): boolean { - if (!ecosystemId) return false; +function addAttention(counts: Map, ecosystemId: string | undefined): void { + if (!ecosystemId) return; counts.set(ecosystemId, (counts.get(ecosystemId) ?? 0) + 1); - return true; } -/** - * Attributes pending approvals and unresolved conflicts to ecosystems. - * - * Items that cannot be attributed — catalog-level diagnostics, policy - * incompatibility, conflict candidates without a source — are counted - * separately instead of being spread across applications. - */ function attentionByEcosystem( snapshot: ExternalSourceCatalogSnapshot, -): { byEcosystem: Map; unattributed: number } { +): Map { const byEcosystem = new Map(); const bySource = ecosystemBySourcePair(snapshot); - // Diagnostics and policy incompatibility are system status, not user - // decisions. They must not inflate the review count shown in the overview. - let unattributed = 0; for (const request of snapshot.toolApprovalRequests ?? []) { - const ecosystemId = bySource.get(sourcePairKey( + addAttention(byEcosystem, bySource.get(sourcePairKey( request.targetId.source.providerId, request.targetId.source.sourceId, - )); - if (!addAttention(byEcosystem, ecosystemId)) unattributed += 1; + ))); } for (const request of snapshot.mcpApprovalRequests ?? []) { - const ecosystemId = bySource.get(sourcePairKey( + addAttention(byEcosystem, bySource.get(sourcePairKey( request.definition.id.source.providerId, request.definition.id.source.sourceId, - )); - if (!addAttention(byEcosystem, ecosystemId)) unattributed += 1; + ))); } const subagentById = new Map( (snapshot.subagents ?? []).map((agent) => [agent.candidateId, agent]), ); for (const candidateId of snapshot.pendingSubagentApprovals ?? []) { - const agent = subagentById.get(candidateId); - // A subagent may span several sources; the first resolvable one owns the - // item so a single approval is never counted twice. - const ecosystemId = agent?.sourceKeys + const ecosystemId = subagentById.get(candidateId)?.sourceKeys .map((key) => bySource.get(sourcePairKey(key.providerId, key.sourceId))) .find((value): value is string => Boolean(value)); - if (!addAttention(byEcosystem, ecosystemId)) unattributed += 1; + addAttention(byEcosystem, ecosystemId); } - for (const conflict of snapshot.commandConflicts ?? []) { - if (conflict.selectedCandidateId) continue; - const ecosystemIds = new Set(conflict.candidates.map((candidate) => candidate.ecosystemId)); + const attributeConflict = (ecosystemIds: Set) => { if (ecosystemIds.size === 1) { addAttention(byEcosystem, [...ecosystemIds][0]); - } else { - // Cross-ecosystem collisions belong to no single application. - unattributed += 1; + } + }; + + for (const conflict of snapshot.commandConflicts ?? []) { + if (!conflict.selectedCandidateId) { + attributeConflict(new Set(conflict.candidates.map((candidate) => candidate.ecosystemId))); } } for (const conflict of snapshot.toolConflicts ?? []) { - if (conflict.selectedCandidateId) continue; - const ecosystemIds = new Set( - conflict.candidates - .map((candidate) => (candidate.source - ? bySource.get(sourcePairKey(candidate.source.providerId, candidate.source.sourceId)) - : undefined)) - .filter((value): value is string => Boolean(value)), - ); - if (ecosystemIds.size === 1) { - addAttention(byEcosystem, [...ecosystemIds][0]); - } else { - unattributed += 1; + if (!conflict.selectedCandidateId) { + attributeConflict(new Set(conflict.candidates.flatMap((candidate) => { + if (!candidate.source) return []; + const ecosystemId = bySource.get(sourcePairKey( + candidate.source.providerId, + candidate.source.sourceId, + )); + return ecosystemId ? [ecosystemId] : []; + }))); } } for (const conflict of snapshot.mcpConflicts ?? []) { - if (conflict.selectedCandidateId) continue; - const ecosystemIds = new Set( - conflict.candidates - .map((candidate) => (candidate.source - ? bySource.get(sourcePairKey(candidate.source.providerId, candidate.source.sourceId)) - : undefined)) - .filter((value): value is string => Boolean(value)), - ); - if (ecosystemIds.size === 1) { - addAttention(byEcosystem, [...ecosystemIds][0]); - } else { - unattributed += 1; + if (!conflict.selectedCandidateId) { + attributeConflict(new Set(conflict.candidates.flatMap((candidate) => { + if (!candidate.source) return []; + const ecosystemId = bySource.get(sourcePairKey( + candidate.source.providerId, + candidate.source.sourceId, + )); + return ecosystemId ? [ecosystemId] : []; + }))); } } - for (const conflict of snapshot.subagentConflicts ?? []) { - if (conflict.selectedCandidateId) continue; - // Subagent conflict candidates carry no source identity in V1. - unattributed += 1; - } - - return { byEcosystem, unattributed }; -} - -function statusFor( - mode: ExternalIntegrationMode, - sourceCount: number, - attentionCount: number, - discoveryPending: boolean, -): ExternalApplicationStatus { - if (attentionCount > 0) return 'needs_attention'; - if (sourceCount === 0) return discoveryPending ? 'checking' : 'no_configuration'; - if (mode === 'recommended') return 'connected'; - if (mode === 'custom') return 'connected_custom'; - return 'discovered'; -} - -function actionFor(status: ExternalApplicationStatus): ExternalApplicationAction { - switch (status) { - case 'needs_attention': - return 'review'; - case 'connected': - case 'connected_custom': - return 'manage'; - case 'discovered': - return 'connect'; - default: - return 'none'; - } + return byEcosystem; } /** - * Builds the application-level overview from a V1 snapshot. - * - * Pure derivation: no host calls, no policy decisions beyond reading the - * effective mode the host already computed. + * Builds the application overview from the existing source catalog. Registration + * alone is not user-visible: an application appears only after discovery, an + * owner action, or an explicit user policy exists for it. */ export function buildExternalApplicationsView( snapshot: ExternalSourceCatalogSnapshot | null, - groups: ExternalSourcePresentationGroup[], policyScope: 'user' | 'workspace', -): ExternalApplicationsView { - if (!snapshot) { - return { applications: [], unattributedAttentionCount: 0, totalAttentionCount: 0 }; - } +): ExternalApplicationView[] { + if (!snapshot) return []; const policy = snapshot.integrationPolicy; const effective = policyScope === 'workspace' ? policy.effective : policy.globalEffective; - const { byEcosystem, unattributed } = attentionByEcosystem(snapshot); + const byEcosystem = attentionByEcosystem(snapshot); - const applications = policy.registeredEcosystems.map((descriptor) => { + const applications = policy.registeredEcosystems.flatMap((descriptor) => { const ecosystemId = descriptor.ecosystemId; - const ecosystemGroups = groups.filter((group) => group.ecosystemId === ecosystemId); - const sources = snapshot.sources.filter( + const discovered = snapshot.sources.some( (source) => source.record.ecosystemId === ecosystemId, ); - const counts = ecosystemGroups.reduce((total, group) => ({ - commands: total.commands + group.counts.commands, - tools: total.tools + group.counts.tools, - agents: total.agents + group.counts.agents, - mcps: total.mcps + group.counts.mcps, - }), { commands: 0, tools: 0, agents: 0, mcps: 0 }); + const explicitlyConfigured = Boolean( + policy.userDefaults.ecosystems?.[ecosystemId] + || policy.workspaceOverride?.ecosystems?.[ecosystemId], + ); + const attentionCount = byEcosystem.get(ecosystemId) ?? 0; + if (!discovered && !explicitlyConfigured && attentionCount === 0) return []; const ecosystemPolicy = effective.ecosystems[ecosystemId]; const mode = ecosystemPolicy?.mode ?? 'recommended'; - const attentionCount = byEcosystem.get(ecosystemId) ?? 0; - const status = statusFor(mode, sources.length, attentionCount, snapshot.discoveryPending); - const enabled = effective.enabled && (mode === 'recommended' || mode === 'custom'); - const activeCapabilities = descriptor.capabilities.flatMap((capability) => { - const countField = CAPABILITY_COUNT_FIELD[capability.capabilityId]; - const count = countField ? counts[countField] : 0; - const access = ecosystemPolicy?.capabilities?.[capability.capabilityId]; - return enabled && count > 0 && access !== 'disabled' && access !== 'discover_only' - ? [{ capabilityId: capability.capabilityId, count }] - : []; - }); - - return { + return [{ ecosystemId, displayName: descriptor.displayName, - mode, - status, - primaryAction: actionFor(status), - enabled, - counts, - activeCapabilities, - sourceCount: sources.length, - locations: Array.from(new Set(sources.map((source) => source.record.location))), + enabled: effective.enabled && (mode === 'recommended' || mode === 'custom'), attentionCount, - connectPlan: descriptor.capabilities.map((capability) => ({ - capabilityId: capability.capabilityId, - recommendedAccess: capability.recommendedAccess, - effectiveAccess: ecosystemPolicy?.capabilities?.[capability.capabilityId] ?? 'disabled', - count: CAPABILITY_COUNT_FIELD[capability.capabilityId] - ? counts[CAPABILITY_COUNT_FIELD[capability.capabilityId]] - : 0, - })), - }; + }]; }); - const totalAttentionCount = applications.reduce( - (total, application) => total + application.attentionCount, - unattributed, - ); - - return { - applications, - unattributedAttentionCount: unattributed, - totalAttentionCount, - }; + return applications; } diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.v2.test.ts b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.v2.test.ts deleted file mode 100644 index ad5a98bb00..0000000000 --- a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.v2.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildExternalApplicationsViewV2 } from './applicationModel'; - -function application(overrides: Record = {}) { - return { - applicationId: 'opencode', - ecosystemId: 'opencode', - displayName: 'OpenCode', - discovery: 'discovered', - connection: 'connected', - desiredConnection: 'connected', - health: 'healthy', - effectiveStatus: 'connected', - primaryAction: 'view', - defaultConnectionPolicy: 'connect', - defaultConnectionReason: 'supported_by_product', - enabledCount: 2, - pendingReviewCount: 0, - blockedCount: 0, - conflictCount: 0, - riskSummary: { highestLevel: null, reasonCodes: [] }, - noticeKey: null, - userDecision: 'connected', - recoveryActions: [], - ...overrides, - }; -} - -function snapshot(applications: Array>, totalAttentionCount = 0) { - return { - schemaVersion: 2, - executionDomainId: 'host-a', - workspaceScopeId: 'workspace:0123456789abcdef', - effectiveConnectionScope: 'workspace_override', - refreshGeneration: 7, - preferenceRevision: 11, - safeMode: false, - hostCapabilities: {}, - applications, - reviewSummary: totalAttentionCount > 0 - ? { - reviewId: 'review-7', - totalCount: totalAttentionCount, - categoryCounts: [], - maxSelectionCount: totalAttentionCount, - riskSummary: { highestLevel: 'high', reasonCodes: ['process_execution'] }, - recommendationSummary: { - recommendedCount: 1, - optionalCount: 0, - blockedCount: 0, - }, - safetyCeiling: 'review_required', - } - : null, - }; -} - -describe('external application V2 model', () => { - it('projects the Host status, primary action, and connection without V1 policy inference', () => { - const result = buildExternalApplicationsViewV2(snapshot([ - application({ - effectiveStatus: 'needs_attention', - primaryAction: 'review', - pendingReviewCount: 3, - }), - ], 7) as never); - - expect(result.totalAttentionCount).toBe(7); - expect(result.applications[0]).toMatchObject({ - ecosystemId: 'opencode', - status: 'needs_attention', - primaryAction: 'review', - enabled: true, - enabledCount: 2, - attentionCount: 3, - }); - }); - - it.each([ - ['connected', 'view'], - ['configuration_available', 'connect'], - ['no_configuration', 'none'], - ['needs_attention', 'review'], - ['temporarily_unavailable', 'retry'], - ])('preserves the authoritative %s state', (effectiveStatus, primaryAction) => { - const result = buildExternalApplicationsViewV2(snapshot([ - application({ effectiveStatus, primaryAction }), - ]) as never); - - expect(result.applications[0].status).toBe(effectiveStatus); - expect(result.applications[0].primaryAction).toBe(primaryAction); - }); - - it('preserves secondary Host health, issue counts, and recovery facts', () => { - const result = buildExternalApplicationsViewV2(snapshot([ - application({ - health: 'degraded', - blockedCount: 2, - conflictCount: 1, - recoveryActions: [{ type: 'refresh' }], - }), - ]) as never); - - expect(result.applications[0]).toMatchObject({ - status: 'connected', - primaryAction: 'view', - health: 'degraded', - blockedCount: 2, - conflictCount: 1, - recoveryActions: [{ type: 'refresh' }], - }); - }); -}); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/index.ts b/src/web-ui/src/infrastructure/config/components/external-sources/index.ts index 51a81c6c63..23314ea833 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/index.ts +++ b/src/web-ui/src/infrastructure/config/components/external-sources/index.ts @@ -12,13 +12,5 @@ export type { ExternalSourceSectionProps } from './ExternalSourceSection'; export { useExternalAppAwareness } from './useExternalAppAwareness'; export { ExternalAppsOverview } from './ExternalAppsOverview'; export type { ExternalAppsOverviewProps } from './ExternalAppsOverview'; -export { - buildExternalApplicationsView, - buildExternalApplicationsViewV2, -} from './applicationModel'; -export type { - ExternalApplicationView, - ExternalApplicationsView, - ExternalApplicationStatus, - ExternalApplicationAction, -} from './applicationModel'; +export { buildExternalApplicationsView } from './applicationModel'; +export type { ExternalApplicationView } from './applicationModel'; diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index ce0a8ee1fb..5efc7e28b4 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -1645,7 +1645,7 @@ }, "dispatch": { "configureTitle": "Run on {{target}}", - "configureSubtitle": "Choose what to send and how this task should handle permission requests.", + "configureSubtitle": "Choose the code to send. Model and permission handling stay in the composer, exactly as for a local session.", "readinessTitle": "Target check", "checkingTarget": "Checking target…", "probeFailed": "Could not check this target. Make sure it is online and the connection works.", @@ -1660,7 +1660,6 @@ "includeUncommittedHint": "Only Git-visible changes are sent. Ignored files, including local .env files and build output, stay on this device.", "cliStatus": "$t(shared:product.name)", "cliReady": "Ready ({{version}})", - "cliWillInstall": "Will be prepared when the task starts", "cliCanDeploy": "Ready for one-click setup", "installingCli": "Installing BitFun CLI…", "provisioningDaemon": "Syncing the account and starting the service…", @@ -1668,21 +1667,6 @@ "cliUpdateRequired": "Needs an update before it can run tasks", "cliUnavailable": "BitFun is not installed on this target", "deviceUpdateRequired": "Update BitFun on this device, then check again.", - "modelStatus": "Model", - "modelMatchesLocal": "Ready. Default: {{model}}", - "modelDiffersFromLocal": "Ready. {{count}} model(s) are available; settings differ from this device.", - "modelReadyCount": "Ready. {{count}} model(s) are available.", - "modelAutomatic": "Target default", - "modelCheckPending": "Will be checked after BitFun is ready", - "modelMissing": "No usable model is configured on the target", - "modelMissingOnBoth": "No usable model is configured here or on the target. Add a model in Settings first.", - "syncModelDescription": "Copies this device's model settings and API credentials to the target, replacing its current model settings.", - "syncModelConfirmTitle": "Sync model configuration to this target?", - "syncModelConfirmMessage": "This device's model settings and API credentials will be written to the target user's BitFun configuration, replacing its current model settings.", - "syncModelConfirm": "Sync", - "syncingModel": "Syncing…", - "syncModelFailed": "Could not sync model settings. Check the target connection and try again.", - "installAutomaticDescription": "When you send the task, BitFun will prepare this target automatically. No manual installation is needed.", "oneClickDeploy": "Set up BitFun", "oneClickDeployDescription": "Installs the signature-verified BitFun CLI. If this device is signed in, it also syncs the current account and starts an auto-start service.", "prepareSucceededWithAccount": "BitFun CLI is installed, the account was synced securely, and the persistent service is connected.", @@ -1698,14 +1682,6 @@ "version": "Version", "downloadUrl": "Source", "integrity": "Integrity check", - "approvalTitle": "Permission requests", - "approvalHint": "Choose how this task should handle actions that need confirmation.", - "approvalReject": "Reject automatically", - "approvalRejectDescription": "Reject the action and explain it in the conversation.", - "approvalRemote": "Ask this device", - "approvalRemoteDescription": "Pause the task and wait for you to decide here.", - "approvalAuto": "Approve automatically", - "approvalAutoDescription": "Allow requested actions without asking. Use only on a target you trust.", "useTarget": "Use this target", "cancel": "Cancel", "eventHistoryIncomplete": "Some task history is no longer available. The visible conversation may be incomplete.", diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 34a1e6fbac..6120daec6d 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -411,6 +411,17 @@ "completed": "Completed", "failed": "Failed", "interrupted": "Interrupted" + }, + "menu": { + "closePet": "Close pet", + "closeBubble": "Close this bubble" + }, + "composer": { + "openTitle": "Send a message to this session", + "ariaLabel": "Send a message to this session", + "placeholder": "Type a message, Enter to send", + "cancel": "Cancel", + "send": "Send" } }, "session": { @@ -653,6 +664,9 @@ "cliInstallFailed": "Could not prepare the remote environment. Check the SSH connection and try again.", "cliInstallInProgress": "Preparing the remote environment…", "cliInstallUnknownVersion": "required version", + "modelSyncStarted": "Copying this device’s model configuration to the remote device…", + "modelSyncSucceeded": "Model configuration copied; the remote device can run the selected model.", + "modelSyncFailed": "Could not copy the model configuration to the remote device.", "errors": { "attachmentUnavailable": "This image cannot be sent to the remote device. Add it again and retry.", "deviceAttachmentTooLarge": "Images sent to an account device can total at most 192 KB. Compress them or use an SSH target.", diff --git a/src/web-ui/src/locales/en-US/settings/acp-agents.json b/src/web-ui/src/locales/en-US/settings/acp-agents.json index ad4a4f580e..a7d0fe7cf9 100644 --- a/src/web-ui/src/locales/en-US/settings/acp-agents.json +++ b/src/web-ui/src/locales/en-US/settings/acp-agents.json @@ -69,8 +69,13 @@ "title": "Remote Servers", "description": "Saved SSH servers reuse the same ACP agent list and probe each remote host automatically.", "empty": "No saved SSH servers.", + "emptyVisible": "No visible SSH servers.", "noAgents": "Add an ACP agent before checking remote servers.", "refreshDetection": "Refresh detection", + "hideConnection": "Hide {{name}} from ACP Agents", + "restoreConnection": "Show {{name}} in ACP Agents", + "showHiddenConnections": "Hidden servers ({{count}})", + "hideHiddenConnections": "Hide hidden servers", "summary": "{{available}} / {{total}} available", "issueSummary": "{{count}} issue(s)" }, @@ -112,6 +117,8 @@ "downloadSuccess": "ACP agent CLI downloaded", "downloadFailed": "Failed to download ACP agent CLI", "predownloadSuccess": "ACP adapter downloaded", - "predownloadFailed": "Failed to download ACP adapter" + "predownloadFailed": "Failed to download ACP adapter", + "connectionHidden": "{{name}} is hidden from ACP Agents", + "connectionRestored": "{{name}} is shown in ACP Agents" } } diff --git a/src/web-ui/src/locales/en-US/settings/ai-model.json b/src/web-ui/src/locales/en-US/settings/ai-model.json index 27233d7f43..038fed194d 100644 --- a/src/web-ui/src/locales/en-US/settings/ai-model.json +++ b/src/web-ui/src/locales/en-US/settings/ai-model.json @@ -27,10 +27,9 @@ "sessionTitle": { "title": "Auto Session Title", "subtitle": "AI automatically generates concise titles for new conversations", - "enable": "Enable", "loadFailed": "Failed to load session title settings", "model": { - "label": "Model", + "label": "Session Title Generation Model", "primary": "Primary Model", "fast": "Fast Model" }, diff --git a/src/web-ui/src/locales/en-US/settings/external-sources.json b/src/web-ui/src/locales/en-US/settings/external-sources.json index 65a7b11e2c..671d0bea54 100644 --- a/src/web-ui/src/locales/en-US/settings/external-sources.json +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -6,140 +6,13 @@ "checkingNonBlocking": "Checking for updates…", "applications": { "title": "Discovered applications", - "status": { - "configuration_available": "Configuration available", - "temporarily_unavailable": "Temporarily unavailable", - "needs_attention": "Needs attention", - "connected": "Connected", - "connected_custom": "Connected · Custom", - "discovered": "Configuration found", - "checking": "Checking", - "no_configuration": "No configuration found" - }, - "summary": { - "blockedCount": "{{count}} item blocked", - "blockedCount_other": "{{count}} items blocked", - "conflictCount": "{{count}} conflict", - "conflictCount_other": "{{count}} conflicts", - "health": { - "degraded": "Partially available", - "unavailable": "Unavailable" - }, - "checking": "Looking for configuration", - "noContent": "Nothing available yet" - }, - "counts": { - "commands_one": "{{count}} command", - "commands_other": "{{count}} commands", - "tools_one": "{{count}} tool", - "tools_other": "{{count}} tools", - "agents_one": "{{count}} agent", - "agents_other": "{{count}} agents", - "mcps_one": "{{count}} MCP server", - "mcps_other": "{{count}} MCP servers" - }, - "actions": { - "connect": "Connect", - "manage": "Manage", - "review": "Review" - }, - "expand": "Show or hide {{name}} capabilities", + "empty": "No compatible app settings found.", "toggleLabel": "Enable or disable {{name}}", - "review": { - "back": "Back to applications", - "selectionCount": "{{selected}} of {{maximum}} selected", - "selectionLimit": "Selection limit reached", - "loading": "Loading review items…", - "loadMore": "Load more", - "useRecommended": "Use recommended", - "doNotEnable": "Don't enable", - "adjustItems": "Adjust individual items", - "enableThisItem": "Enable this item", - "doNotEnableAny": "Don't enable any", - "enableRecommended": "Enable recommended items", - "enableSelected": "Enable selected ({{count}})", - "keepDisabled": "Keep disabled", - "customize": "Choose individually", - "unknownApplication": "External application", - "category": { - "command": "Command", - "tool": "Tool", - "subagent": "Agent", - "mcp": "MCP server", - "conflict": "Conflict" - }, - "recommendation": { - "enable": "BitFun recommends enabling this item.", - "keepDisabled": "BitFun recommends keeping this item disabled. Choose Enable this item only if you need it.", - "blocked": "The current safety policy does not allow this item, so it will remain disabled.", - "multiple": "{{count}} items need confirmation. BitFun recommends enabling {{recommended}} of them; the rest stay disabled. Open individual choices to review or change them." - }, - "riskReason": { - "processOrResourceAccess": "Enabling it lets BitFun run this tool and give it access to local resources.", - "processOrNetworkAccess": "Enabling it may start a local process or connect to an external service.", - "delegatedToolAccess": "Enabling it lets this Agent use the tools configured for it.", - "ambiguousRuntimeRoute": "More than one source has this name. Choose a source in Advanced settings first." - }, - "risk": { - "low": "Low risk", - "moderate": "Moderate risk", - "high": "High risk" - }, - "safety": { - "blocked": "Blocked by safety policy" - }, - "outcome": { - "applied": "Changes applied", - "partial": "Some changes could not be applied. Review the item results below.", - "rejected": "The changes were rejected", - "blocked": "The changes were blocked by safety policy", - "stale": "The configuration changed. The latest state has been loaded.", - "failed": "The changes could not be applied" - }, - "itemOutcome": { - "applied": "Applied", - "rejected": "Not applied", - "blocked": "Blocked", - "stale": "Changed since review", - "failed": "Could not apply" - }, - "title_one": "{{count}} item needs review", - "title_other": "{{count}} items need review" - }, - "capabilities": { - "command": "Commands", - "tool": "Tools", - "agents": "Agents", - "mcps": "MCP servers" - }, - "capabilityAccess": { - "auto": "Available automatically", - "ask_before_use": "Ask before use", - "discover_only": "Discovered only", - "disabled": "Disabled" - }, + "enableInAdvanced": "Enable external applications in Advanced settings first.", + "attentionRequired": "Needs attention. Open Advanced settings to continue.", + "openAdvanced": "Open Advanced settings for {{name}}", "advanced": { "title": "Advanced settings" - }, - "detail": { - "back": "Back to applications", - "reviewTitle_one": "{{count}} item needs review", - "reviewTitle_other": "{{count}} items need review", - "reviewDescription": "Executable capabilities do not run until they are confirmed.", - "sourceSummary_one": "{{count}} configuration source", - "sourceSummary_other": "{{count}} configuration sources", - "usingTitle": "Available content", - "usingDescription": "Low-risk content can be available automatically; executable content remains controlled.", - "foundCount_one": "{{count}} found", - "foundCount_other": "{{count}} found", - "autoAvailable": "Available automatically", - "managed": "Managed", - "capabilities": { - "commands": "Commands", - "tools": "Tools", - "agents": "Agents", - "mcps": "MCP servers" - } } }, "hooksManagement": { diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index ae47c85d92..ce90acf68b 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -1645,7 +1645,7 @@ }, "dispatch": { "configureTitle": "在 {{target}} 上运行", - "configureSubtitle": "选择要发送的代码,以及任务遇到权限请求时的处理方式。", + "configureSubtitle": "选择要发送的代码。模型和权限处理仍在会话输入框里设置,与本地会话完全一致。", "readinessTitle": "目标检查", "checkingTarget": "正在检查目标…", "probeFailed": "无法检查此目标。请确认设备在线且连接可用。", @@ -1660,7 +1660,6 @@ "includeUncommittedHint": "只会发送 Git 可见的改动;.env、构建产物等忽略文件会保留在本机。", "cliStatus": "$t(shared:product.name)", "cliReady": "就绪({{version}})", - "cliWillInstall": "发送任务时自动准备", "cliCanDeploy": "可一键部署", "installingCli": "正在安装 BitFun CLI…", "provisioningDaemon": "正在同步账号并启动常驻服务…", @@ -1668,21 +1667,6 @@ "cliUpdateRequired": "需要更新后才能运行任务", "cliUnavailable": "此目标尚未安装 BitFun", "deviceUpdateRequired": "请在此设备上更新 BitFun,然后重新检查。", - "modelStatus": "模型", - "modelMatchesLocal": "可用,默认使用 {{model}}", - "modelDiffersFromLocal": "可用,目标有 {{count}} 个模型,与本机配置不同", - "modelReadyCount": "可用,目标有 {{count}} 个模型", - "modelAutomatic": "目标默认模型", - "modelCheckPending": "BitFun 就绪后再检查", - "modelMissing": "目标上没有可用模型", - "modelMissingOnBoth": "本机和目标均没有可用模型,请先在设置中添加模型。", - "syncModelDescription": "将本机模型配置和 API 密钥复制到目标,并替换目标现有的模型配置。", - "syncModelConfirmTitle": "同步模型配置到此目标?", - "syncModelConfirmMessage": "本机模型配置和 API 密钥将写入目标用户的 BitFun 配置,并替换目标现有的模型设置。", - "syncModelConfirm": "同步", - "syncingModel": "正在同步…", - "syncModelFailed": "无法同步模型配置。请检查目标连接后重试。", - "installAutomaticDescription": "发送任务时,BitFun 会自动准备目标,无需手动安装。", "oneClickDeploy": "一键部署 BitFun", "oneClickDeployDescription": "安装经过签名校验的 BitFun CLI;若本机已登录,还会为目标同步当前账号并启动开机常驻服务。", "prepareSucceededWithAccount": "BitFun CLI 已安装,账号已安全同步,常驻服务已启动并连接。", @@ -1698,14 +1682,6 @@ "version": "版本", "downloadUrl": "下载来源", "integrity": "完整性校验", - "approvalTitle": "权限请求", - "approvalHint": "选择任务遇到需要确认的操作时如何处理。", - "approvalReject": "自动拒绝", - "approvalRejectDescription": "拒绝该操作,并在会话中说明。", - "approvalRemote": "在本机询问", - "approvalRemoteDescription": "暂停任务,等待你在本机处理。", - "approvalAuto": "自动批准", - "approvalAutoDescription": "无需询问即可允许请求的操作。请仅对可信目标使用。", "useTarget": "使用此目标", "cancel": "取消", "eventHistoryIncomplete": "部分任务记录已无法加载,当前内容可能不完整。", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 2566fd3f53..9be20c459d 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -411,6 +411,18 @@ "completed": "已完成", "failed": "执行失败", "interrupted": "已中断" + }, + "menu": { + "switchPet": "切换宠物", + "closePet": "关闭宠物", + "closeBubble": "关闭该气泡" + }, + "composer": { + "openTitle": "发送消息到该会话", + "ariaLabel": "发送消息到该会话", + "placeholder": "输入消息,回车发送", + "cancel": "取消", + "send": "发送" } }, "session": { @@ -653,6 +665,9 @@ "cliInstallFailed": "无法准备远程运行环境。请检查 SSH 连接后重试。", "cliInstallInProgress": "正在准备远程运行环境…", "cliInstallUnknownVersion": "所需版本", + "modelSyncStarted": "正在把本机的模型配置复制到远程设备…", + "modelSyncSucceeded": "模型配置已复制,远程设备可以运行所选模型。", + "modelSyncFailed": "无法把模型配置复制到远程设备。", "errors": { "attachmentUnavailable": "这张图片无法发送到远程设备。请重新添加后再试。", "deviceAttachmentTooLarge": "通过账号设备发送的图片总大小不能超过 192 KB。请压缩图片,或改用 SSH 目标。", diff --git a/src/web-ui/src/locales/zh-CN/settings/acp-agents.json b/src/web-ui/src/locales/zh-CN/settings/acp-agents.json index 690d86ab0d..9cc3406ac0 100644 --- a/src/web-ui/src/locales/zh-CN/settings/acp-agents.json +++ b/src/web-ui/src/locales/zh-CN/settings/acp-agents.json @@ -69,8 +69,13 @@ "title": "远程服务器", "description": "已保存的 SSH 服务器会复用同一份 ACP Agent 列表,并自动检测每台远端主机的状态。", "empty": "没有已保存的 SSH 服务器。", + "emptyVisible": "没有显示中的 SSH 服务器。", "noAgents": "请先添加 ACP Agent,再检测远程服务器。", "refreshDetection": "刷新检测", + "hideConnection": "在 ACP Agents 中隐藏「{{name}}」", + "restoreConnection": "在 ACP Agents 中显示「{{name}}」", + "showHiddenConnections": "已隐藏的服务器({{count}})", + "hideHiddenConnections": "收起已隐藏的服务器", "summary": "{{available}} / {{total}} 可用", "issueSummary": "{{count}} 个异常" }, @@ -112,6 +117,8 @@ "downloadSuccess": "ACP Agent CLI 已下载", "downloadFailed": "下载 ACP Agent CLI 失败", "predownloadSuccess": "ACP 适配器已下载", - "predownloadFailed": "下载 ACP 适配器失败" + "predownloadFailed": "下载 ACP 适配器失败", + "connectionHidden": "已在 ACP Agents 中隐藏「{{name}}」", + "connectionRestored": "已在 ACP Agents 中显示「{{name}}」" } } diff --git a/src/web-ui/src/locales/zh-CN/settings/ai-model.json b/src/web-ui/src/locales/zh-CN/settings/ai-model.json index 117582efa7..b1f092bf71 100644 --- a/src/web-ui/src/locales/zh-CN/settings/ai-model.json +++ b/src/web-ui/src/locales/zh-CN/settings/ai-model.json @@ -27,10 +27,9 @@ "sessionTitle": { "title": "会话标题自动生成", "subtitle": "新对话时 AI 自动生成简洁标题", - "enable": "启用", "loadFailed": "加载会话标题配置失败", "model": { - "label": "模型", + "label": "会话标题生成模型", "primary": "主力模型", "fast": "快速模型" }, diff --git a/src/web-ui/src/locales/zh-CN/settings/external-sources.json b/src/web-ui/src/locales/zh-CN/settings/external-sources.json index 914720b86d..8a120a879e 100644 --- a/src/web-ui/src/locales/zh-CN/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -6,140 +6,13 @@ "checkingNonBlocking": "正在检查更新…", "applications": { "title": "已发现的应用", - "status": { - "configuration_available": "有可用配置", - "temporarily_unavailable": "暂时不可用", - "needs_attention": "需要处理", - "connected": "已连接", - "connected_custom": "已连接 · 自定义", - "discovered": "发现可用配置", - "checking": "正在检查", - "no_configuration": "未发现配置" - }, - "summary": { - "blockedCount": "{{count}} 项受阻", - "blockedCount_other": "{{count}} 项受阻", - "conflictCount": "{{count}} 个冲突", - "conflictCount_other": "{{count}} 个冲突", - "health": { - "degraded": "部分可用", - "unavailable": "不可用" - }, - "checking": "正在查找配置", - "noContent": "暂时没有可用内容" - }, - "counts": { - "commands_one": "{{count}} 个命令", - "commands_other": "{{count}} 个命令", - "tools_one": "{{count}} 个工具", - "tools_other": "{{count}} 个工具", - "agents_one": "{{count}} 个 Agent", - "agents_other": "{{count}} 个 Agent", - "mcps_one": "{{count}} 个 MCP 服务器", - "mcps_other": "{{count}} 个 MCP 服务器" - }, - "actions": { - "connect": "连接", - "manage": "管理", - "review": "检查" - }, - "expand": "显示或隐藏 {{name}} 的能力", + "empty": "未发现兼容的应用设置。", "toggleLabel": "启用或停用 {{name}}", - "review": { - "back": "返回应用列表", - "selectionCount": "已选择 {{selected}} / {{maximum}} 项", - "selectionLimit": "已达到选择上限", - "loading": "正在加载审核项…", - "loadMore": "加载更多", - "useRecommended": "使用推荐设置", - "doNotEnable": "暂不启用", - "adjustItems": "调整单项", - "enableThisItem": "启用此项", - "doNotEnableAny": "全部不启用", - "enableRecommended": "启用建议项", - "enableSelected": "启用已选项({{count}})", - "keepDisabled": "保持停用", - "customize": "逐项选择", - "unknownApplication": "外部应用", - "category": { - "command": "命令", - "tool": "工具", - "subagent": "Agent", - "mcp": "MCP 服务器", - "conflict": "冲突" - }, - "recommendation": { - "enable": "BitFun 建议启用此项。", - "keepDisabled": "BitFun 建议保持此项停用;确实需要时再选择“启用此项”。", - "blocked": "当前安全策略不允许启用此项,它将保持停用。", - "multiple": "有 {{count}} 项需要确认。BitFun 建议启用其中 {{recommended}} 项,其余保持停用;可展开“逐项选择”查看或修改。" - }, - "riskReason": { - "processOrResourceAccess": "启用后,BitFun 可以运行此工具并让它访问本机资源。", - "processOrNetworkAccess": "启用后,可能会启动本机进程或连接外部服务。", - "delegatedToolAccess": "启用后,此 Agent 可以调用为它配置的工具。", - "ambiguousRuntimeRoute": "存在多个同名来源,请先在高级设置中选择一个来源。" - }, - "risk": { - "low": "低风险", - "moderate": "中等风险", - "high": "高风险" - }, - "safety": { - "blocked": "已被安全策略阻止" - }, - "outcome": { - "applied": "更改已应用", - "partial": "部分更改未能应用,请查看下方结果。", - "rejected": "更改被拒绝", - "blocked": "更改被安全策略阻止", - "stale": "配置已变化,已加载最新状态。", - "failed": "更改未能应用" - }, - "itemOutcome": { - "applied": "已应用", - "rejected": "未应用", - "blocked": "已阻止", - "stale": "审核后已变化", - "failed": "未能应用" - }, - "title_one": "{{count}} 项需要确认", - "title_other": "{{count}} 项需要确认" - }, - "capabilities": { - "command": "命令", - "tool": "工具", - "agents": "代理", - "mcps": "MCP 服务器" - }, - "capabilityAccess": { - "auto": "自动可用", - "ask_before_use": "使用前询问", - "discover_only": "仅发现", - "disabled": "已停用" - }, + "enableInAdvanced": "请先在高级设置中启用外部应用。", + "attentionRequired": "需要处理。请打开高级设置继续。", + "openAdvanced": "打开 {{name}} 的高级设置", "advanced": { "title": "高级设置" - }, - "detail": { - "back": "返回应用列表", - "reviewTitle_one": "{{count}} 项等待确认", - "reviewTitle_other": "{{count}} 项等待确认", - "reviewDescription": "可执行能力在确认前不会运行。", - "sourceSummary_one": "{{count}} 个配置来源", - "sourceSummary_other": "{{count}} 个配置来源", - "usingTitle": "可用内容", - "usingDescription": "低风险内容可以自动可用;可执行内容仍受控。", - "foundCount_one": "发现 {{count}} 项", - "foundCount_other": "发现 {{count}} 项", - "autoAvailable": "自动可用", - "managed": "管理", - "capabilities": { - "commands": "命令", - "tools": "工具", - "agents": "Agent", - "mcps": "MCP 服务器" - } } }, "hooksManagement": { diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index f2bcf7a937..d1ef03a69d 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -1645,7 +1645,7 @@ }, "dispatch": { "configureTitle": "在 {{target}} 上執行", - "configureSubtitle": "選擇要傳送的程式碼,以及任務遇到權限要求時的處理方式。", + "configureSubtitle": "選擇要傳送的程式碼。模型與權限處理仍在對話輸入框裡設定,與本機工作階段完全一致。", "readinessTitle": "目標檢查", "checkingTarget": "正在檢查目標…", "probeFailed": "無法檢查此目標。請確認裝置在線且連線可用。", @@ -1660,7 +1660,6 @@ "includeUncommittedHint": "只會傳送 Git 可見的變更;.env、建置產物等忽略檔案會保留在本機。", "cliStatus": "$t(shared:product.name)", "cliReady": "就緒({{version}})", - "cliWillInstall": "傳送任務時自動準備", "cliCanDeploy": "可一鍵部署", "installingCli": "正在安裝 BitFun CLI…", "provisioningDaemon": "正在同步帳號並啟動常駐服務…", @@ -1668,21 +1667,6 @@ "cliUpdateRequired": "需要更新後才能執行任務", "cliUnavailable": "此目標尚未安裝 BitFun", "deviceUpdateRequired": "請在此裝置上更新 BitFun,然後重新檢查。", - "modelStatus": "模型", - "modelMatchesLocal": "可用,預設使用 {{model}}", - "modelDiffersFromLocal": "可用,目標有 {{count}} 個模型,與本機設定不同", - "modelReadyCount": "可用,目標有 {{count}} 個模型", - "modelAutomatic": "目標預設模型", - "modelCheckPending": "BitFun 就緒後再檢查", - "modelMissing": "目標上沒有可用模型", - "modelMissingOnBoth": "本機和目標均沒有可用模型,請先在設定中新增模型。", - "syncModelDescription": "將本機模型設定和 API 金鑰複製到目標,並取代目標現有的模型設定。", - "syncModelConfirmTitle": "同步模型設定到此目標?", - "syncModelConfirmMessage": "本機模型設定和 API 金鑰將寫入目標使用者的 BitFun 設定,並取代目標現有的模型設定。", - "syncModelConfirm": "同步", - "syncingModel": "正在同步…", - "syncModelFailed": "無法同步模型設定。請檢查目標連線後重試。", - "installAutomaticDescription": "傳送任務時,BitFun 會自動準備目標,無需手動安裝。", "oneClickDeploy": "一鍵部署 BitFun", "oneClickDeployDescription": "安裝經過簽章驗證的 BitFun CLI;若本機已登入,還會為目標同步目前帳號並啟動開機常駐服務。", "prepareSucceededWithAccount": "BitFun CLI 已安裝,帳號已安全同步,常駐服務已啟動並連線。", @@ -1698,14 +1682,6 @@ "version": "版本", "downloadUrl": "下載來源", "integrity": "完整性驗證", - "approvalTitle": "權限要求", - "approvalHint": "選擇任務遇到需要確認的操作時如何處理。", - "approvalReject": "自動拒絕", - "approvalRejectDescription": "拒絕該操作,並在工作階段中說明。", - "approvalRemote": "在本機詢問", - "approvalRemoteDescription": "暫停任務,等待你在本機處理。", - "approvalAuto": "自動核准", - "approvalAutoDescription": "無需詢問即可允許要求的操作。請僅對可信目標使用。", "useTarget": "使用此目標", "cancel": "取消", "eventHistoryIncomplete": "部分任務記錄已無法載入,目前內容可能不完整。", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 120cc7936c..b21f048167 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -411,6 +411,18 @@ "completed": "已完成", "failed": "執行失敗", "interrupted": "已中斷" + }, + "menu": { + "switchPet": "切換寵物", + "closePet": "關閉寵物", + "closeBubble": "關閉該氣泡" + }, + "composer": { + "openTitle": "傳送訊息到該工作階段", + "ariaLabel": "傳送訊息到該工作階段", + "placeholder": "輸入訊息,Enter 送出", + "cancel": "取消", + "send": "傳送" } }, "session": { @@ -653,6 +665,9 @@ "cliInstallFailed": "無法準備遠端執行環境。請檢查 SSH 連線後重試。", "cliInstallInProgress": "正在準備遠端執行環境…", "cliInstallUnknownVersion": "所需版本", + "modelSyncStarted": "正在將本機的模型設定複製到遠端裝置…", + "modelSyncSucceeded": "模型設定已複製,遠端裝置可以執行所選模型。", + "modelSyncFailed": "無法將模型設定複製到遠端裝置。", "errors": { "attachmentUnavailable": "這張圖片無法傳送到遠端裝置。請重新加入後再試。", "deviceAttachmentTooLarge": "透過帳號裝置傳送的圖片總大小不能超過 192 KB。請壓縮圖片,或改用 SSH 目標。", diff --git a/src/web-ui/src/locales/zh-TW/settings/acp-agents.json b/src/web-ui/src/locales/zh-TW/settings/acp-agents.json index 0a0db0a2ae..051c67e05e 100644 --- a/src/web-ui/src/locales/zh-TW/settings/acp-agents.json +++ b/src/web-ui/src/locales/zh-TW/settings/acp-agents.json @@ -69,8 +69,13 @@ "title": "遠端伺服器", "description": "已儲存的 SSH 伺服器會複用同一份 ACP Agent 列表,並自動檢測每台遠端主機的狀態。", "empty": "沒有已儲存的 SSH 伺服器。", + "emptyVisible": "沒有顯示中的 SSH 伺服器。", "noAgents": "請先新增 ACP Agent,再檢測遠端伺服器。", "refreshDetection": "重新整理檢測", + "hideConnection": "在 ACP Agents 中隱藏「{{name}}」", + "restoreConnection": "在 ACP Agents 中顯示「{{name}}」", + "showHiddenConnections": "已隱藏的伺服器({{count}})", + "hideHiddenConnections": "收起已隱藏的伺服器", "summary": "{{available}} / {{total}} 可用", "issueSummary": "{{count}} 個異常" }, @@ -112,6 +117,8 @@ "downloadSuccess": "ACP Agent CLI 已下載", "downloadFailed": "下載 ACP Agent CLI 失敗", "predownloadSuccess": "ACP 適配器已下載", - "predownloadFailed": "下載 ACP 適配器失敗" + "predownloadFailed": "下載 ACP 適配器失敗", + "connectionHidden": "已在 ACP Agents 中隱藏「{{name}}」", + "connectionRestored": "已在 ACP Agents 中顯示「{{name}}」" } } diff --git a/src/web-ui/src/locales/zh-TW/settings/ai-model.json b/src/web-ui/src/locales/zh-TW/settings/ai-model.json index e68ca2882d..040d14fdc6 100644 --- a/src/web-ui/src/locales/zh-TW/settings/ai-model.json +++ b/src/web-ui/src/locales/zh-TW/settings/ai-model.json @@ -27,10 +27,9 @@ "sessionTitle": { "title": "會話標題自動生成", "subtitle": "新對話時 AI 自動生成簡潔標題", - "enable": "啟用", "loadFailed": "載入會話標題設定失敗", "model": { - "label": "模型", + "label": "會話標題生成模型", "primary": "主力模型", "fast": "快速模型" }, diff --git a/src/web-ui/src/locales/zh-TW/settings/external-sources.json b/src/web-ui/src/locales/zh-TW/settings/external-sources.json index 405ea14508..86725b02d1 100644 --- a/src/web-ui/src/locales/zh-TW/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -6,140 +6,13 @@ "checkingNonBlocking": "正在檢查更新…", "applications": { "title": "已發現的應用", - "status": { - "configuration_available": "有可用設定", - "temporarily_unavailable": "暫時無法使用", - "needs_attention": "需要處理", - "connected": "已連線", - "connected_custom": "已連線 · 自訂", - "discovered": "發現可用設定", - "checking": "正在檢查", - "no_configuration": "未發現設定" - }, - "summary": { - "blockedCount": "{{count}} 項受阻", - "blockedCount_other": "{{count}} 項受阻", - "conflictCount": "{{count}} 個衝突", - "conflictCount_other": "{{count}} 個衝突", - "health": { - "degraded": "部分可用", - "unavailable": "無法使用" - }, - "checking": "正在尋找設定", - "noContent": "暫時沒有可用內容" - }, - "counts": { - "commands_one": "{{count}} 個命令", - "commands_other": "{{count}} 個命令", - "tools_one": "{{count}} 個工具", - "tools_other": "{{count}} 個工具", - "agents_one": "{{count}} 個 Agent", - "agents_other": "{{count}} 個 Agent", - "mcps_one": "{{count}} 個 MCP 伺服器", - "mcps_other": "{{count}} 個 MCP 伺服器" - }, - "actions": { - "connect": "連線", - "manage": "管理", - "review": "檢查" - }, - "expand": "顯示或隱藏 {{name}} 的能力", + "empty": "未發現相容的應用設定。", "toggleLabel": "啟用或停用 {{name}}", - "review": { - "back": "返回應用列表", - "selectionCount": "已選擇 {{selected}} / {{maximum}} 項", - "selectionLimit": "已達到選擇上限", - "loading": "正在載入審核項目…", - "loadMore": "載入更多", - "useRecommended": "使用建議設定", - "doNotEnable": "暫不啟用", - "adjustItems": "調整單項", - "enableThisItem": "啟用此項", - "doNotEnableAny": "全部不啟用", - "enableRecommended": "啟用建議項", - "enableSelected": "啟用已選項({{count}})", - "keepDisabled": "保持停用", - "customize": "逐項選擇", - "unknownApplication": "外部應用", - "category": { - "command": "命令", - "tool": "工具", - "subagent": "Agent", - "mcp": "MCP 伺服器", - "conflict": "衝突" - }, - "recommendation": { - "enable": "BitFun 建議啟用此項。", - "keepDisabled": "BitFun 建議保持此項停用;確實需要時再選擇「啟用此項」。", - "blocked": "目前安全策略不允許啟用此項,它將保持停用。", - "multiple": "有 {{count}} 項需要確認。BitFun 建議啟用其中 {{recommended}} 項,其餘保持停用;可展開「逐項選擇」查看或修改。" - }, - "riskReason": { - "processOrResourceAccess": "啟用後,BitFun 可以執行此工具並讓它存取本機資源。", - "processOrNetworkAccess": "啟用後,可能會啟動本機程序或連線至外部服務。", - "delegatedToolAccess": "啟用後,此 Agent 可以呼叫為它設定的工具。", - "ambiguousRuntimeRoute": "存在多個同名來源,請先在進階設定中選擇一個來源。" - }, - "risk": { - "low": "低風險", - "moderate": "中等風險", - "high": "高風險" - }, - "safety": { - "blocked": "已被安全策略阻止" - }, - "outcome": { - "applied": "變更已套用", - "partial": "部分變更未能套用,請查看下方結果。", - "rejected": "變更遭到拒絕", - "blocked": "變更遭安全策略阻止", - "stale": "設定已變更,已載入最新狀態。", - "failed": "變更未能套用" - }, - "itemOutcome": { - "applied": "已套用", - "rejected": "未套用", - "blocked": "已阻止", - "stale": "審核後已變更", - "failed": "未能套用" - }, - "title_one": "{{count}} 項需要確認", - "title_other": "{{count}} 項需要確認" - }, - "capabilities": { - "command": "命令", - "tool": "工具", - "agents": "代理", - "mcps": "MCP 伺服器" - }, - "capabilityAccess": { - "auto": "自動可用", - "ask_before_use": "使用前詢問", - "discover_only": "僅探索", - "disabled": "已停用" - }, + "enableInAdvanced": "請先在進階設定中啟用外部應用。", + "attentionRequired": "需要處理。請開啟進階設定繼續。", + "openAdvanced": "開啟 {{name}} 的進階設定", "advanced": { "title": "進階設定" - }, - "detail": { - "back": "返回應用清單", - "reviewTitle_one": "{{count}} 項等待確認", - "reviewTitle_other": "{{count}} 項等待確認", - "reviewDescription": "可執行能力在確認前不會執行。", - "sourceSummary_one": "{{count}} 個設定來源", - "sourceSummary_other": "{{count}} 個設定來源", - "usingTitle": "可用內容", - "usingDescription": "低風險內容可以自動可用;可執行內容仍受控。", - "foundCount_one": "發現 {{count}} 項", - "foundCount_other": "發現 {{count}} 項", - "autoAvailable": "自動可用", - "managed": "管理", - "capabilities": { - "commands": "命令", - "tools": "工具", - "agents": "Agent", - "mcps": "MCP 伺服器" - } } }, "hooksManagement": {