From a09a96598fc4bbc4d505a6663cb6c9ba4f88a0bc Mon Sep 17 00:00:00 2001 From: limityan Date: Sun, 9 Aug 2026 12:31:15 +0800 Subject: [PATCH] refactor(external-sources): simplify integration controls Remove the parallel application connection and batch review surface, keeping the existing source policy and owner-specific permission controls as the single path. Reduce Web and TUI output, preserve remote and owner guards, and consume retired automatic defaults once without overwriting later user choices. --- docs/architecture/cli-product-line-design.md | 10 +- .../capability-runtime-integration-design.md | 4 +- ...nal-ai-app-connection-experience-design.md | 551 ---- .../external-ai-work-sources-design.md | 89 +- docs/architecture/product-architecture.md | 18 +- ...ernal-ai-app-connection-experience-plan.md | 637 ---- .../rules/source/public-api-rules.mjs | 48 +- scripts/core-boundaries/self-test.mjs | 9 - src/apps/cli/src/actions.rs | 26 +- src/apps/cli/src/agent/tui_client.rs | 49 +- src/apps/cli/src/modes/chat.rs | 5 +- .../cli/src/modes/chat/external_review.rs | 1477 +-------- .../cli/src/modes/chat/external_sources.rs | 489 +-- src/apps/cli/src/modes/chat/tests.rs | 70 +- .../peer_host/commands/external_sources.rs | 101 +- src/apps/cli/src/peer_host/commands/mod.rs | 15 +- src/apps/cli/src/tui_backend.rs | 73 +- src/apps/cli/src/ui/command_palette.rs | 1 - .../desktop/src/api/external_sources_api.rs | 206 +- .../src/api/remote_workspace_policy.rs | 31 - src/apps/desktop/src/lib.rs | 3 - .../src/tests/protocol_contracts.rs | 5 + .../assembly/core/src/external_sources.rs | 2744 +++-------------- .../src/external_source_control.rs | 633 ---- .../tests/external_source_contracts.rs | 402 +-- .../interfaces/app-server-client/src/lib.rs | 34 - .../interfaces/app-server-protocol/src/lib.rs | 10 + .../src/schemas/external_source.rs | 160 +- .../interfaces/app-server/src/management.rs | 30 - .../app-server/src/management/service.rs | 49 - .../src/server/handlers/external_source.rs | 27 - .../service-api/ExternalSourcesAPI.test.ts | 348 --- .../api/service-api/ExternalSourcesAPI.ts | 691 ----- .../ExternalSourcesConfig.appearance.ts | 8 +- .../components/ExternalSourcesConfig.scss | 280 +- .../components/ExternalSourcesConfig.test.tsx | 912 +----- .../components/ExternalSourcesConfig.tsx | 500 +-- .../ExternalAppsOverview.test.tsx | 331 +- .../external-sources/ExternalAppsOverview.tsx | 549 +--- .../ExternalCommandConflicts.tsx | 6 + .../ExternalSourceSection.tsx | 1 + .../external-sources/applicationModel.test.ts | 300 +- .../external-sources/applicationModel.ts | 327 +- .../applicationModel.v2.test.ts | 113 - .../components/external-sources/index.ts | 12 +- .../en-US/settings/external-sources.json | 135 +- .../zh-CN/settings/external-sources.json | 135 +- .../zh-TW/settings/external-sources.json | 135 +- 48 files changed, 1074 insertions(+), 11715 deletions(-) delete mode 100644 docs/architecture/extensions/external-ai-app-connection-experience-design.md delete mode 100644 docs/plans/external-ai-app-connection-experience-plan.md delete mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.v2.test.ts diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index f08fd86217..f3c800abe3 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 内部边界 @@ -185,10 +183,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/product-architecture.md b/docs/architecture/product-architecture.md index ed904873d6..2e9e5d15ec 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 分支行为。 @@ -817,10 +815,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;远程能力未接入时不回退本机 | +| Desktop | 使用 `product-full`;Settings 从现有来源目录和 integration policy 生成简短应用概览,具体审批与冲突仍进入 Tool、Agent、MCP 或 Hook owner | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | +| CLI / TUI | 使用显式 Core owner feature closure(`agent-runtime`、`canvas-runtime`、`external-sources`、`plugin-runtime`、`ssh-remote`);`/extensions` 只提供状态、启停和刷新,`/hooks`、`/tools`、`/agent` 和 `/mcp` 处理各自能力 | 非交互不等待权限输入,也不从全局状态或错误文本推断特殊任务结果;生态解析仍在适配器;远程能力未接入时不回退本机 | | 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 未接入时返回不支持 | +| 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 +835,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/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/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/self-test.mjs b/scripts/core-boundaries/self-test.mjs index ff6465a514..24a30dfac2 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -1527,12 +1527,6 @@ export function runManifestParserSelfTest({ 'ExternalSourceControlRequestV1', 'ExternalSourceOperationStage', 'ExternalSourceRecoveryActionV1', - 'EXTERNAL_APPLICATION_SCHEMA_V2', - 'ExternalApplicationSnapshotV2', - 'ExternalApplicationReviewPageV2', - 'ExternalApplicationControlActionV2', - 'ExternalApplicationControlRequestV2', - 'ExternalApplicationControlResultV2', ]) { if (!externalSourceControlPublicApiRule?.allowedSymbolEntries.some( (entry) => entry.symbol === requiredSymbol @@ -1583,9 +1577,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 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/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/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..56a103d09f 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -39,7 +39,20 @@ pub(crate) async fn dispatch( "set_config" => config::set_config(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/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/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/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/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 6a59a22398..a4842d365c 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -193,10 +193,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, @@ -582,14 +578,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, @@ -2197,25 +2185,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 304c97c036..9b566aac53 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1241,9 +1241,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, 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/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/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/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/src/management.rs b/src/crates/interfaces/app-server/src/management.rs index 2c6e2d04ac..712744748e 100644 --- a/src/crates/interfaces/app-server/src/management.rs +++ b/src/crates/interfaces/app-server/src/management.rs @@ -398,34 +398,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..28c7993c8e 100644 --- a/src/crates/interfaces/app-server/src/management/service.rs +++ b/src/crates/interfaces/app-server/src/management/service.rs @@ -1025,55 +1025,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, 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/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/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} - - {review?.open ? ( -
-
- -
- {openingReview ? ( - {t('applications.review.loading')}} - multiline - > - {null} - - ) : null} - {!openingReview ? ( - <> - + {application.displayName} + + {application.attentionCount > 0 ? ( + + - {singleReviewItem.safetyCeiling !== 'blocked' ? ( - - ) : null} - - ) : ( - <> - {review.selectedCount > 0 ? ( - - ) : null} - - - )} -
- - {canCustomizeReview ? ( -
- {t('applications.review.customize')} -
- {t('applications.review.selectionCount', { - selected: review.selectedCount, - maximum: review.maxSelectionCount, - })} -
-
- {review.items.map((item) => { - const key = reviewItemKey(item); - const selected = review.selected[key] ?? item.recommended; - const result = review.itemResults.find( - (candidate) => reviewItemRefKey(candidate.itemRef) === key, - ); - return ( - - ); - })} -
- {review.nextCursor ? ( -
- -
- ) : null} -
- ) : null} - +