diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index fa98bb87d..f08fd8621 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -185,7 +185,7 @@ CLI 通过 `DeliveryProfile::Cli` 消费经过校验的产品 Runtime parts。 CLI 只消费 typed summary 与 typed action: -> **目标状态,尚未交付:** 当前 `/extensions` 只支持来源状态、刷新、Safe Mode 和来源开关;没有应用级连接动作、`/extensions review` 或任务相关 `action-required`。以下入口必须完成执行计划 P1-P6 及端到端验证后,才能更新为当前能力。 +> **实现状态:部分交付。** 交互式 TUI 已通过 Host 返回的 V2 快照提供应用级状态、连接、断开、暂不使用和分页批量确认;Embedded 连接旧 Host 时回退既有 V1 只读状态,未接线的 Shared Runtime 明确不支持且绝不回退到控制进程本地执行。任务相关 `action-required` 与非交互 CLI 结果仍未交付。 - `/extensions` 是应用级摘要、首次连接和状态恢复入口;`/extensions review` 提供与 GUI 等价的单页批量确认。 - `/tools`、`/agent`、`/mcp` 和 `/hooks` 保留能力专项或高级管理职责,不复制应用级连接流程。 diff --git a/docs/architecture/extensions/external-ai-app-connection-experience-design.md b/docs/architecture/extensions/external-ai-app-connection-experience-design.md index 7b39596ab..3d55dfb88 100644 --- a/docs/architecture/extensions/external-ai-app-connection-experience-design.md +++ b/docs/architecture/extensions/external-ai-app-connection-experience-design.md @@ -4,7 +4,7 @@ 本文只描述交互、应用级读模型、动作语义和宿主投影,不重定义生态解析、能力归属、执行权限或插件运行时。 -> **实现状态:部分交付。** 当前生产协议仍以严格校验的 `ExternalSourceControlSnapshotV1` 为主,Desktop 已交付应用级首页和详情投影;共享应用连接、批量确认和任务相关 `action-required` 仍须完成对应执行计划并取得端到端证据。Hook 管理沿用独立 owner 和现有安全审核契约,其产品入口与展示规则见第 5.5、7.1 和 8.3 节。 +> **实现状态:部分交付。** 当前分支保留严格 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. 问题与设计目标 @@ -209,6 +209,7 @@ Tool、Subagent、MCP、Hook 和无法自动决定的真实冲突进入同一个 - stale revision、无效 generation 或宿主能力整体不兼容时,整个请求不应用; - owner 允许逐项业务拒绝时,响应返回逐项结果;宿主只把成功项标为已启用; +- “整个请求不应用”只保证分派前的 identity、revision 和 generation 预检;开始分派后若 owner 状态并发变化,可以同时返回已应用项和类型化 stale/failed 项,不承诺跨 owner 回滚; - 未知结果不能假定成功; - 失败项保留可行动原因与恢复动作。 @@ -356,7 +357,7 @@ ExternalApplicationReviewPageV2 items[] # 每页最多 128,只含 item reference、显示摘要、推荐与安全上限 ``` -分页游标必须绑定作用域、`review_id`、偏好版本和发现代次;任一事实变化都返回过期并重新读取,不能把旧页与新页拼接。详细页通过稳定项目引用关联现有 Tool、Subagent、MCP 和冲突投影;总量继续服从现有归属模块上限,完整提示词、命令正文、凭据和可执行载荷不进入分页响应。 +首次打开确认页时,请求不带 cursor 和 expected generations;若后台发现已在首页快照后完成,Host 可以返回当前只读确认计划,并以响应中的 `review_id` 和 generations 作为后续翻页与提交的唯一基准。偏好版本、执行域、工作区和目标作用域仍必须完全匹配。首次响应之后,分页游标严格绑定作用域、`review_id`、偏好版本和发现代次;任一事实变化都返回过期并重新读取,不能把旧页与新页拼接。详细页通过稳定项目引用关联现有 Tool、Subagent、MCP 和冲突投影;总量继续服从现有归属模块上限,完整提示词、命令正文、凭据和可执行载荷不进入分页响应。 状态和主操作由共享归属模块派生;React、TUI、Peer 和 Server 不重复实现优先级规则。 @@ -408,7 +409,7 @@ AgenticEvent::ExternalDependencyActionRequired 现有 `ExternalSourceControlSnapshotV1`、`ExternalSourceControlActionV1`、`ExternalSourceRecoveryActionV1` 和 V1 `hostCapabilities` 保持字段与闭合枚举不变。应用级快照、连接动作、批量确认、`upgrade-host` 语义以及新增能力位不得追加到 V1 对象。 -`get_external_application_snapshot_v2` 本身无副作用,直接承担版本探测,不再增加单独的版本信息接口: +`get_external_application_snapshot_v2` 不提交用户决定或运行能力写动作,直接承担版本探测,不再增加单独的版本信息接口。首次激活对应 owner 时允许执行可重入偏好迁移并启动既有后台发现;当前 V2 偏好读取不得重复写回。确认分页只读取已激活 owner 的不可变结果,不能冷启动服务或发现: - 新宿主返回严格的 V2 快照和 `host_capabilities`;客户端校验成功后,才可读取分页确认项或发送 V2 写操作; - 旧宿主对 V2 快照返回传输层 method-not-found 时,客户端回退显示 V1 来源/能力管理并禁用 V2 写操作; @@ -420,7 +421,7 @@ AgenticEvent::ExternalDependencyActionRequired ### 8.2 性能与演进约束 -- 应用快照和确认分页必须从当前不可变发现结果派生;读取不能重新扫描文件、启动外部进程或持有偏好写锁。 +- 应用快照和确认分页必须从当前不可变发现结果派生;首次快照可触发既有 owner 的迁移和后台发现,确认分页不得重新扫描文件、冷启动 owner、启动外部进程或持有偏好写锁。 - 首页只返回摘要,确认页每页最多 128 项。完整候选总量继续服从各归属模块已有上限,不建立第二套无界缓存。 - 共享缓存只允许按执行域、工作区作用域、发现代次和偏好版本精确失效;React、TUI、Peer 与 Server 不得各自维护产品状态机。 - 归属模块的加载与卸载在锁外执行;迁移关口只阻塞外部来源读写,不阻塞项目打开或无关 Agent 任务。 diff --git a/docs/plans/external-ai-app-connection-experience-plan.md b/docs/plans/external-ai-app-connection-experience-plan.md index 0bfab7b5b..9fc57e62b 100644 --- a/docs/plans/external-ai-app-connection-experience-plan.md +++ b/docs/plans/external-ai-app-connection-experience-plan.md @@ -2,7 +2,7 @@ > 本计划把[外部 AI 工作内容总体架构](../architecture/extensions/external-ai-work-sources-design.md)和[外部 AI 应用连接与管理详细设计](../architecture/extensions/external-ai-app-connection-experience-design.md)拆成可独立评审、验证和回退的实施阶段。本文不扩大任何生态的能力兼容范围;OpenCode 具体能力路线仍以[OpenCode 扩展兼容计划](opencode-extension-compatibility-plan.md)为准。 -> **实现状态:全部为目标工作。** 当前生产只提供严格 V1 来源/能力控制协议;应用级连接、V2 协议、批量确认、跨宿主应用快照和任务相关 `action-required` 尚未交付。只有完成对应阶段的生产接线和退出条件,架构现状文档才能更新。 +> **实现状态:分阶段交付。** 当前分支已完成共享 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. 目标与执行原则 @@ -19,7 +19,7 @@ - 每个阶段形成可独立评审的纵向结果,不能用仅有 DTO、固定假数据或未接线组件宣称完成; - 先以测试冻结共享契约和策略,再接宿主,再替换信息架构; -- 当前 `ExternalSourceControlSnapshotV1`、V1 动作/恢复闭合枚举、V1 宿主能力和能力专属 DTO 保持字段与行为不变;应用级读写使用独立版本化 V2 接口,无副作用 V2 快照直接用于能力探测; +- 当前 `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 重算默认连接、推荐集合或应用级状态; @@ -81,7 +81,7 @@ P1-P4 是共享语义和协议前置;P5 与 P6 可以在 P4 稳定后并行, - `enabled`、`pending_review`、`blocked`、`conflict` 数量; - 风险摘要和恢复动作; - 确认摘要、稳定 `review_id`、推荐数量/风险、`max_selection_count` 和总数,不内嵌项目列表或可执行载荷。 -2. 另行定义 `ExternalApplicationReviewPageV2`:分页游标绑定执行域、工作区作用域、`review_id`、偏好版本和发现代次;每页最多 128 项,只携带稳定项目引用、显示摘要、推荐和安全上限。读取分页不能触发重新发现或能力加载。 +2. 另行定义 `ExternalApplicationReviewPageV2`:首次无 cursor/no-generation 打开允许 Host 在后台发现刚完成时返回当前只读计划,客户端从该响应接续;其余分页游标严格绑定执行域、工作区作用域、`review_id`、偏好版本和发现代次。每页最多 128 项,只携带稳定项目引用、显示摘要、推荐和安全上限。读取分页不能触发重新发现或能力加载,提交仍必须绑定首次响应的权威计划。 3. 将应用级状态优先级固定在共享归属模块: `需要处理 > 暂时不可用 > 已连接 > 发现可用配置 > 未发现配置`;Safe Mode 独立投影。 4. 在 Product Assembly 中定义默认连接事实及原因: @@ -258,6 +258,8 @@ cargo check --workspace 6. owner 可以逐项拒绝业务请求;响应必须返回每项 `applied / rejected / blocked / stale / failed` 等闭合结果及恢复动作,未知结果不得视为成功。 7. 只持久化实际成功且仍与 decision key/behavior version 匹配的决定;返回与最终 preference revision 同代的新快照。 +这里的零应用保证止于分派前预检。分派开始后若某个 owner 的事实并发变化,响应可以同时包含已应用项与类型化 stale/failed 项;本阶段不增加跨 owner 事务或回滚管理器,也不宣称批量业务执行原子化。 + ### 测试优先顺序 - stale revision、generation 或 Host capability 导致整批零应用; @@ -325,7 +327,7 @@ cargo check --workspace - `interfaces/app-server-client` 与 TypeScript translation 保持 V1 wire shape,Server Host 绑定其真实 workspace,不读取浏览器或控制端路径; - Server 不注册 write handler;未知/写方法在反序列化 mutation payload 前以 method-not-found/host-capability-unavailable 拒绝; - 用真实 `/ws` transport 做 Server bootstrap → `BitfunAppServer::serve` → handler → owner → client 的端到端 round-trip。该切片通过前,Server 不进入 V2 共享 fixture,也不得标记为只读 external-source Host。 -3. P4a 后新增无副作用 `get_external_application_snapshot_v2`,直接作为版本探测:成功响应必须是严格 V2 数据结构,并携带宿主读写能力;旧宿主的传输层 method-not-found 等价于“仅 V1”。不增加独立版本信息接口,也不引入“声明支持但接口不可用”的第二种状态。 +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,不回退本机。 @@ -401,7 +403,7 @@ pnpm run type-check:web 3. 首页使用现有 `ConfigPageLayout` 的 760px 单列阅读轴:标题、应用列表、高级设置。真实的任务相关待办通过就地提示或状态变化处理,不把无法归属的系统诊断聚合成首页数量。 4. 每个应用行只显示应用名、一个状态、一句结果摘要和唯一主操作;有工作区时主操作明确标注“仅当前工作区”,没有工作区时先进入详情选择范围。来源路径、能力清单、冲突和诊断进入详情。 5. 详情按“结果优先、控制后置”排列;连接完成显示生效范围、已启用、待确认和受限摘要。`user_default` 只在详情/高级设置中提供,并在提交前再次展示会影响同一执行域的所有工作区。 -6. 批量确认页面先使用快照摘要,再按需分页读取项目引用;按类别展示数量、主要风险、共享推荐状态和安全上限,技术详情按需展开,高风险默认未选。提交使用同代推荐/空集合基线和用户改动项,不为提交强制读取全部页面;首页轮询不读取项目页面。 +6. 批量确认页面先使用快照摘要,再按需分页读取项目引用;默认只显示确认数量和“使用推荐/暂不启用”两个决定,单项名称、风险和安全上限放在折叠的调整区,不展示内部错误码、处理阶段或任意载荷。高风险默认未选。提交使用同代推荐/空集合基线和用户改动项,不为提交强制读取全部页面;首页轮询不读取项目页面。 7. Safe Mode 在首页和详情显著展示,高级设置保留现有 source、scope、冲突、诊断和能力级管理。 8. 首次发现只使用一次性轻提示和 Settings 导航状态;不增加启动 Modal 或常驻 banner。 9. 所有文案进入现有 i18n namespace,颜色与状态复用主题 token,不提高主题治理基线。 diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index f44871619..ad4378da9 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -829,6 +829,8 @@ export const externalSourceContractPublicApiEntries = [ export const externalSourceControlPublicApiEntries = [ 'EXTERNAL_SOURCE_CONTROL_SCHEMA_V1', + 'EXTERNAL_APPLICATION_SCHEMA_V2', + 'EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS', 'ExternalSourceOperationStage', 'ExternalSourceRecoveryActionV1', 'ExternalSourceDiscoveryState', @@ -844,6 +846,39 @@ 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, @@ -985,6 +1020,9 @@ 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, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 24a30dfac..ff6465a51 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -1527,6 +1527,12 @@ export function runManifestParserSelfTest({ 'ExternalSourceControlRequestV1', 'ExternalSourceOperationStage', 'ExternalSourceRecoveryActionV1', + 'EXTERNAL_APPLICATION_SCHEMA_V2', + 'ExternalApplicationSnapshotV2', + 'ExternalApplicationReviewPageV2', + 'ExternalApplicationControlActionV2', + 'ExternalApplicationControlRequestV2', + 'ExternalApplicationControlResultV2', ]) { if (!externalSourceControlPublicApiRule?.allowedSymbolEntries.some( (entry) => entry.symbol === requiredSymbol @@ -1577,6 +1583,9 @@ 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/agent/tui_client.rs b/src/apps/cli/src/agent/tui_client.rs index 7c85d763d..bec380e23 100644 --- a/src/apps/cli/src/agent/tui_client.rs +++ b/src/apps/cli/src/agent/tui_client.rs @@ -22,7 +22,11 @@ use bitfun_app_server_protocol::workspace::*; use bitfun_app_server_protocol::worktree::*; use bitfun_core_types::SessionUsageReport; use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; -use bitfun_product_domains::external_source_control::ExternalSourceControlRequestV1; +use bitfun_product_domains::external_source_control::{ + ExternalApplicationControlRequestV2, ExternalApplicationControlResultV2, + ExternalApplicationReviewPageRequestV2, ExternalApplicationReviewPageV2, + ExternalApplicationSnapshotV2, ExternalSourceControlRequestV1, +}; use bitfun_product_domains::external_sources::{ ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourcePublicSnapshot, NativePromptCommandDescriptor, PromptCommandShellReviewDecision, @@ -463,6 +467,49 @@ 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 8f293a984..c5f41ffa3 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -558,6 +558,7 @@ pub(crate) struct ChatMode { external_tool_review_snapshot: Option, external_tool_mutation_rx: 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,6 +624,7 @@ impl ChatMode { external_tool_review_snapshot: None, external_tool_mutation_rx: 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 1892fcdaf..9e680b9a7 100644 --- a/src/apps/cli/src/modes/chat/external_review.rs +++ b/src/apps/cli/src/modes/chat/external_review.rs @@ -1,7 +1,16 @@ // Pure projections and review text derived from the external-source catalog. use bitfun_product_domains::external_source_control::{ - ExternalSourceDesiredState, ExternalSourceEffectiveStatus, ExternalSourceRecoveryActionV1, - ExternalSourceSupportState, + 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, }; fn external_command_projections( @@ -283,8 +292,726 @@ fn parse_external_control_action(arguments: &str) -> Result, + 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, + ), + 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_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()); + } + 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, 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}")); + } + } + } + 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 can_mutate { + lines.push("Adjust: /extensions review ".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, @@ -385,7 +1112,7 @@ fn external_control_review_text( )); } } - if !control.recovery_actions.is_empty() { + 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) { @@ -397,13 +1124,20 @@ fn external_control_review_text( } lines.push(String::new()); lines.push("Refresh: /extensions refresh".to_string()); - lines.push(if control.safe_mode { - "Exit Safe Mode: /extensions safe-mode off".to_string() + 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 { - "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()); + lines.push( + "Read-only compatibility status: upgrade or reconnect the Host to manage applications." + .to_string(), + ); + } lines.join("\n") } @@ -1735,6 +2469,581 @@ 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 86d05f76f..385f0bb45 100644 --- a/src/apps/cli/src/modes/chat/external_sources.rs +++ b/src/apps/cli/src/modes/chat/external_sources.rs @@ -376,6 +376,267 @@ 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, @@ -467,14 +728,161 @@ 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 false, + Some(Err(MpscTryRecvError::Empty)) | None => return application_changed, Some(Err(MpscTryRecvError::Disconnected)) => { self.external_control_mutation_rx = None; chat_view.set_status(Some( diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index bc48ac197..3e6e4f0d0 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -11,7 +11,8 @@ mod tests { command_route, consume_selected_native_command_once, context_compression_tool_event, extension_command_help_request, external_agent_attention, external_agent_diagnostic_lines, external_agent_pending_notice_key, external_agent_result_is_stale, - external_agent_review_text, external_command_projections, external_control_review_text, + external_agent_review_text, external_command_projections, + external_control_read_only_review_text, external_control_review_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, @@ -233,6 +234,13 @@ mod tests { assert!(text.contains("source disable ")); assert!(text.contains("Tools: 2 items, 1 review, 0 conflicts, inactive")); assert!(text.contains("/extensions safe-mode off")); + + 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 ")); } #[test] 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 07c9ff545..fb0f42218 100644 --- a/src/apps/cli/src/peer_host/commands/external_sources.rs +++ b/src/apps/cli/src/peer_host/commands/external_sources.rs @@ -3,8 +3,9 @@ use std::path::PathBuf; use bitfun_core::external_sources::{ - apply_external_source_control_action, choose_external_mcp_conflict, - choose_external_subagent_conflict, external_source_snapshot, + 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, 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, @@ -14,11 +15,38 @@ 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}'")) @@ -133,6 +161,32 @@ 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, @@ -156,6 +210,36 @@ 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, @@ -319,6 +403,19 @@ 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 56a103d09..4921847a4 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -39,20 +39,7 @@ 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, - "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" => { + command if external_sources::supports(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 1b37d4a33..e754f9611 100644 --- a/src/apps/cli/src/tui_backend.rs +++ b/src/apps/cli/src/tui_backend.rs @@ -49,6 +49,16 @@ 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 { @@ -287,6 +297,24 @@ 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, @@ -753,6 +781,34 @@ 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, @@ -833,6 +889,15 @@ fn map_client(result: Result) -> Result { fn map_protocol_error(error: ProtocolError) -> TuiBackendError { let message = error.to_string(); + if i32::from(error.code) == -32601 { + return TuiBackendError { + message, + outcome_unknown: false, + kind: TuiBackendErrorKind::Unsupported { + capability: "appServer.method".to_string(), + }, + }; + } if let Some(value) = error.data { if let Ok(external) = serde_json::from_value::(value.clone()) { let kind = match external.app.capability { @@ -893,7 +958,10 @@ fn backend_error_from_data(message: String, data: AppServerErrorData) -> TuiBack #[cfg(test)] mod tests { - use super::{map_protocol_error, TuiBackendErrorKind, TuiEffect, TuiEffectRoute}; + use super::{ + external_application_v2_unsupported, 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::{ @@ -914,6 +982,29 @@ 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 = + map_protocol_error(bitfun_app_server_client::ProtocolError::method_not_found()); + assert!(matches!( + mapped.kind, + TuiBackendErrorKind::Unsupported { .. } + )); + assert!(!mapped.outcome_unknown); + } + #[test] fn protocol_unsupported_preserves_the_capability_id() { let error = bitfun_app_server_client::ProtocolError::new( diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index 0890159ba..a863d02d4 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -1,10 +1,13 @@ //! Desktop host API for ecosystem-neutral external AI application sources. use bitfun_core::external_sources::{ - 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, + 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, 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, @@ -24,6 +27,11 @@ 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, }; @@ -42,6 +50,28 @@ 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 { @@ -233,6 +263,9 @@ 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; @@ -281,6 +314,44 @@ 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, @@ -303,6 +374,54 @@ 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>, @@ -706,6 +825,37 @@ 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!({ @@ -876,4 +1026,52 @@ 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 a4842d365..6a59a2239 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -193,6 +193,10 @@ 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, @@ -578,6 +582,14 @@ 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, @@ -2185,6 +2197,25 @@ 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 9b566aac5..304c97c03 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1241,6 +1241,9 @@ 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/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 6fc9c3956..72d9ff991 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -9,6 +9,25 @@ 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, @@ -127,6 +146,7 @@ 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; @@ -673,6 +693,8 @@ 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>, @@ -784,6 +806,8 @@ 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())), @@ -818,6 +842,8 @@ 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())), @@ -845,6 +871,8 @@ 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, @@ -880,9 +908,49 @@ 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 @@ -946,6 +1014,12 @@ 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", @@ -1113,6 +1187,348 @@ impl ExternalSourcePreferenceStore { .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 { @@ -1121,37 +1537,38 @@ fn decode_mcp_revision_key(value: &str) -> Option { Some(ExternalMcpRevisionKey::new(bytes)) } -async fn external_sources_config_with_mcp_revision_key( +fn legacy_config_after_migration_failure( + config: ExternalSourcesConfig, + migration_error: String, ) -> Result<(ExternalSourcesConfig, ExternalMcpRevisionKey), String> { - let store = ExternalSourcePreferenceStore::global()?; - let config = store.read().await?; - if let Some(revision_key) = config + let 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 { - 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 + 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); + } }; - 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() @@ -2887,60 +3304,511 @@ impl WorkspaceExternalSourceService { } } - 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( + 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, self.execution_domain_id.as_str(), - &workspace_route_key(self.workspace_root.as_deref()), - enabled, + 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) } - async fn set_safe_mode( + fn application_review_page_v2( &self, - enabled: bool, - expected_preference_revision: Option, - ) -> Result { - let _refresh_guard = self.refresh_gate.lock().await; - let previous = self.safe_mode_enabled(); - if let Some(expected_revision) = expected_preference_revision { - let (accepted, _) = ExternalSourcePreferenceStore::global()? - .update(|config| { - if config.preference_revision != expected_revision { - return false; - } - // Safe Mode is process-local, but its CAS linearization - // point must share the authoritative preference file lock - // with every persisted source/review mutation. - self.write_safe_mode(enabled); - true - }) - .await?; - if !accepted { - return Err(stale_operation_error( - "External source preferences changed; refresh before changing Safe Mode", - )); - } - if let Err(error) = sync_service_preferences(self).await { - self.write_safe_mode(previous); - return Err(error); - } + 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 { - self.write_safe_mode(enabled); + 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", + )); } - if previous == enabled { - return Ok(self.snapshot()); + let catalog = self.snapshot(); + let plan = external_application_review_plan( + &catalog, + 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) + } - let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); - match self.rebuild_product_snapshot(command_snapshot).await { - Ok(snapshot) => { + 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, + ); + } + + async fn set_safe_mode( + &self, + enabled: bool, + expected_preference_revision: Option, + ) -> Result { + let _refresh_guard = self.refresh_gate.lock().await; + let previous = self.safe_mode_enabled(); + if let Some(expected_revision) = expected_preference_revision { + let (accepted, _) = ExternalSourcePreferenceStore::global()? + .update(|config| { + if config.preference_revision != expected_revision { + return false; + } + // Safe Mode is process-local, but its CAS linearization + // point must share the authoritative preference file lock + // with every persisted source/review mutation. + self.write_safe_mode(enabled); + true + }) + .await?; + if !accepted { + return Err(stale_operation_error( + "External source preferences changed; refresh before changing Safe Mode", + )); + } + if let Err(error) = sync_service_preferences(self).await { + self.write_safe_mode(previous); + return Err(error); + } + } else { + self.write_safe_mode(enabled); + } + if previous == enabled { + return Ok(self.snapshot()); + } + + let command_snapshot = lock_coordinator(&self.control_plane).snapshot(); + match self.rebuild_product_snapshot(command_snapshot).await { + Ok(snapshot) => { let _ = self.updates.send(snapshot.clone()); Ok(snapshot) } @@ -3930,6 +4798,763 @@ impl WorkspaceExternalSourceService { } } +#[derive(Default)] +struct ExternalApplicationAggregateCounts { + enabled: usize, + pending_review: usize, + blocked: usize, + conflicts: usize, +} + +struct ExternalApplicationReviewPlan { + review_id: String, + expected_generations: Vec, + items: Vec, + summary_items: Vec, +} + +struct ExternalApplicationReviewSummaryItem { + item_ref: ExternalApplicationReviewItemRefV2, + recommended: bool, + safety_ceiling: ExternalApplicationSafetyCeilingV2, +} + +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 cursor(&self, offset: usize) -> String { + format!("{}:{offset}", self.review_id) + } + + 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) + } +} + +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 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 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 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> { @@ -4584,6 +6209,24 @@ 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) @@ -5524,6 +7167,27 @@ 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); } @@ -6654,7 +8318,60 @@ pub async fn get_external_source_control_snapshot( } else { service.ensure_background_refresh(); } - Ok(service.surface_snapshot(host_capabilities)) + 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( @@ -7018,6 +8735,7 @@ 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, @@ -8837,6 +10555,561 @@ 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(); @@ -9287,6 +11560,32 @@ 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 3f1905107..721a0866a 100644 --- a/src/crates/contracts/product-domains/src/external_source_control.rs +++ b/src/crates/contracts/product-domains/src/external_source_control.rs @@ -13,6 +13,8 @@ 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")] @@ -209,6 +211,637 @@ 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 e9f9cc074..80173dcd1 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -6,9 +6,27 @@ use bitfun_product_domains::external_integration_policy::{ ExternalIntegrationPolicyStatus, }; use bitfun_product_domains::external_source_control::{ - ExternalSourceControlActionV1, ExternalSourceControlRequestV1, ExternalSourceControlSnapshotV1, - ExternalSourceDesiredState, ExternalSourceDiscoveryState, ExternalSourceOperationStage, - ExternalSourceRecoveryActionV1, ExternalSourceReviewState, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, + 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, }; use bitfun_product_domains::external_sources::{ external_mcp_approval_key, external_mcp_conflict_key, external_tool_approval_key, @@ -2072,3 +2090,381 @@ 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 155e327fe..9c13b7a79 100644 --- a/src/crates/interfaces/app-server-client/src/lib.rs +++ b/src/crates/interfaces/app-server-client/src/lib.rs @@ -215,6 +215,28 @@ 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, @@ -789,3 +811,15 @@ 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/schemas/external_source.rs b/src/crates/interfaces/app-server-protocol/src/schemas/external_source.rs index 2fa52d9be..079c79abb 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,8 +7,10 @@ use std::collections::{BTreeMap, BTreeSet}; use agent_client_protocol::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse}; use bitfun_product_domains::external_source_control::{ - ExternalSourceControlRequestV1, ExternalSourceControlSnapshotV1, - ExternalSourceSurfaceSnapshotV1, + ExternalApplicationControlRequestV2, ExternalApplicationControlResultV2, + ExternalApplicationReviewPageRequestV2 as DomainExternalApplicationReviewPageRequestV2, + ExternalApplicationReviewPageV2, ExternalApplicationSnapshotV2, ExternalSourceControlRequestV1, + ExternalSourceControlSnapshotV1, ExternalSourceSurfaceSnapshotV1, }; use bitfun_product_domains::external_sources::{ ExternalSourceOperationError, ExternalSourcePublicSnapshot, @@ -42,6 +44,51 @@ 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)] @@ -238,4 +285,113 @@ 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 712744748..2c6e2d04a 100644 --- a/src/crates/interfaces/app-server/src/management.rs +++ b/src/crates/interfaces/app-server/src/management.rs @@ -398,4 +398,34 @@ 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 28c7993c8..12452dc49 100644 --- a/src/crates/interfaces/app-server/src/management/service.rs +++ b/src/crates/interfaces/app-server/src/management/service.rs @@ -1025,6 +1025,55 @@ 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 70437c6b8..e749cea24 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,6 +13,33 @@ 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 c80b1ea37..ba1e9f0af 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,6 +43,48 @@ 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, @@ -66,6 +108,312 @@ 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 05e0dd321..4641a313e 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -196,6 +196,225 @@ 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; @@ -762,6 +981,399 @@ 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'] { @@ -1322,6 +1934,85 @@ 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 e26a68363..354f94e23 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts @@ -35,10 +35,13 @@ export const externalSourcesConfigAppearanceDescriptor: AppearanceSurfaceDescrip { id: 'ecosystemState' }, { id: 'attentionSummary' }, { id: 'application' }, - { id: 'appAttention' }, + { id: 'applicationFacts' }, { 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 ebba998b2..998faf596 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss @@ -14,15 +14,40 @@ align-items: center; justify-content: space-between; gap: var(--bf-appearance-token-size-gap-3); - padding: 14px 2px; + margin-inline: calc(-1 * var(--bf-appearance-token-size-gap-2)); + padding: 12px var(--bf-appearance-token-size-gap-2); + border-radius: var(--bf-appearance-token-size-radius-sm); color: var(--bf-appearance-token-color-text-primary); font-size: 13px; font-weight: 600; + transition: + background-color var(--bf-appearance-token-motion-fast) var(--bf-appearance-token-easing-standard), + color var(--bf-appearance-token-motion-fast) var(--bf-appearance-token-easing-standard); - span:last-child { - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - font-weight: 400; + &:focus-visible { + outline: 2px solid var(--bf-appearance-token-color-accent-500); + outline-offset: 2px; + } + } + + &__disclosure-icon { + flex-shrink: 0; + color: var(--bf-appearance-token-color-text-muted); + transition: + transform var(--bf-appearance-token-motion-fast) var(--bf-appearance-token-easing-standard), + color var(--bf-appearance-token-motion-fast) var(--bf-appearance-token-easing-standard); + } + + &__advanced[open] > &__advanced-summary &__disclosure-icon, + &__hooks[open] > &__hooks-summary &__disclosure-icon { + color: var(--bf-appearance-token-color-text-secondary); + transform: rotate(90deg); + } + + @media (hover: hover) and (pointer: fine) { + &__advanced-summary:hover, + &__hooks-summary:hover { + background: var(--bf-appearance-token-element-bg-subtle); } } @@ -141,10 +166,67 @@ 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: space-between; + justify-content: flex-start; gap: var(--bf-appearance-token-size-gap-3); width: 100%; margin-bottom: var(--bf-appearance-token-size-gap-3); @@ -156,32 +238,12 @@ text-align: left; cursor: pointer; - small { - display: block; - margin-top: 4px; - color: var(--bf-appearance-token-color-text-secondary); - } - &:focus-visible { outline: 2px solid var(--bf-appearance-token-color-accent-500); outline-offset: 2px; } } - &__attention-count { - display: inline-grid; - min-width: 22px; - height: 22px; - padding: 0 6px; - place-items: center; - border: 1px solid color-mix(in srgb, var(--bf-appearance-token-color-warning) 45%, transparent); - border-radius: 999px; - color: var(--bf-appearance-token-color-warning); - background: color-mix(in srgb, var(--bf-appearance-token-color-warning) 7%, transparent); - font-size: 12px; - font-weight: 600; - } - &__app-row { display: flex; align-items: center; @@ -219,31 +281,15 @@ } } - &__app-attention-dot { - display: inline-grid; - place-items: center; - min-width: 18px; - height: 18px; - padding: 0 5px; - border: 1px solid color-mix(in srgb, var(--bf-appearance-token-color-warning) 45%, transparent); - border-radius: 999px; - color: var(--bf-appearance-token-color-warning); - background: color-mix(in srgb, var(--bf-appearance-token-color-warning) 7%, transparent); - font-size: 11px; - font-weight: 600; - } - - &__app-capability-chip { + &__app-facts { display: inline-flex; align-items: center; - gap: 4px; - padding: 2px 8px; - border: 1px solid var(--bf-appearance-token-border-subtle); - border-radius: 999px; + color: var(--bf-appearance-token-color-warning); - span { - color: var(--bf-appearance-token-color-text-muted); - font-size: 11px; + &:focus-visible { + outline: 2px solid var(--bf-appearance-token-color-accent-500); + outline-offset: 2px; + border-radius: 2px; } } @@ -314,7 +360,7 @@ gap: var(--bf-appearance-token-size-gap-2); } &__app-name { color: var(--bf-appearance-token-color-text-primary); font-weight: 600; } - &__app-status, &__app-summary { + &__app-status { color: var(--bf-appearance-token-color-text-secondary); font-size: 12px; } @@ -322,8 +368,6 @@ &.is-connected, &.is-connected_custom { color: var(--bf-appearance-token-color-success); } &.is-needs_attention { color: var(--bf-appearance-token-color-warning); } } - &__app-summary { margin-top: 4px; overflow-wrap: anywhere; } - &__ecosystem-heading, &__policy-actions, &__ecosystem-name { @@ -928,6 +972,10 @@ } @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 af3e66932..5c5254723 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -8,6 +8,9 @@ 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()); @@ -71,6 +74,9 @@ 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, @@ -242,6 +248,45 @@ 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; @@ -252,6 +297,17 @@ 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({ @@ -425,6 +481,816 @@ 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, + }, + }; + 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, + }, + }; + 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', + }], + }); + + 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"]', + ); + 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, + }, + }, + }); + 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: [] }), + ); + }); + it('defers Hook owner reads until the Hook disclosure opens', async () => { await import('./HooksConfig'); getSnapshotMock.mockResolvedValue({ @@ -703,8 +1569,12 @@ describe('ExternalSourcesConfig', () => { await Promise.resolve(); }); - expect(container.textContent).toContain('safeMode.title'); expect(container.textContent).toContain('sources.commandCount:{"count":1}'); + await act(async () => { + container.querySelector( + '.bitfun-external-sources-config__advanced', + )?.querySelector('summary')?.click(); + }); const toggle = container.querySelector( 'input[aria-label="safeMode.toggleLabel"]', ) as HTMLInputElement; @@ -747,6 +1617,11 @@ describe('ExternalSourcesConfig', () => { await Promise.resolve(); }); + await act(async () => { + container.querySelector( + '.bitfun-external-sources-config__advanced', + )?.querySelector('summary')?.click(); + }); const toggle = container.querySelector( 'input[aria-label="safeMode.toggleLabel"]', ) as HTMLInputElement; @@ -787,6 +1662,11 @@ describe('ExternalSourcesConfig', () => { expect(container.textContent).toContain('legacyHostNotice'); expect(container.textContent).toContain('recoveryActions.reconnect_host'); + await act(async () => { + container.querySelector( + '.bitfun-external-sources-config__advanced', + )?.querySelector('summary')?.click(); + }); const toggle = container.querySelector( 'input[aria-label="safeMode.toggleLabel"]', ) as HTMLInputElement; @@ -2397,7 +3277,8 @@ describe('ExternalSourcesConfig', () => { }); expect(container.textContent).toContain('policy.attentionSummary:{"count":1}'); - expect(container.textContent?.match(/opencode\.configuration\.invalid/g)).toHaveLength(1); + expect(container.textContent?.match(/diagnostics\.category\.invalidSettings/g)).toHaveLength(1); + expect(container.textContent).not.toContain('opencode.configuration.invalid'); }); it('keeps grouped OpenCode diagnostics and lifecycle states visible', async () => { @@ -2430,11 +3311,12 @@ describe('ExternalSourcesConfig', () => { }); expect(container.textContent).toContain('lifecycle.degraded'); - expect(container.textContent).toContain('opencode.configuration.invalid'); + expect(container.textContent).toContain('diagnostics.category.invalidSettings'); + expect(container.textContent).not.toContain('opencode.configuration.invalid'); expect(container.querySelector( '.bitfun-external-sources-config__opencode-card [data-external-attention="true"]', )).not.toBeNull(); - expect(container.textContent?.match(/opencode\.configuration\.invalid/g)).toHaveLength(1); + expect(container.textContent?.match(/diagnostics\.category\.invalidSettings/g)).toHaveLength(1); const sourceToggle = container.querySelector( '.bitfun-external-sources-config__opencode-card input[aria-label^="sources.toggleLabel"]', ) as HTMLInputElement; @@ -2815,7 +3697,9 @@ describe('ExternalSourcesConfig', () => { expect(firstDiagnostic.open).toBe(true); expect(document.activeElement).toBe(firstDiagnostic.querySelector('summary')); expect(scrollIntoView).toHaveBeenCalled(); - expect(container.textContent).toContain('opencode.command.source_warning'); + expect(container.textContent).toContain('diagnostics.category.sourceIssue'); + expect(container.textContent).not.toContain('opencode.command.source_warning'); + expect(container.textContent).not.toContain('common.technicalDetails'); }); it('shows a stable reference without exposing an internal mutation message', async () => { @@ -2843,8 +3727,10 @@ describe('ExternalSourcesConfig', () => { expect(container.textContent).toContain('operationErrors.internal'); expect(container.textContent).toContain('external-source-ref-7'); - expect(container.textContent).toContain('operationErrors.stage:{"stage":"activate_runtime"}'); - expect(container.textContent).toContain('operationErrors.causationId:{"id":"refresh-generation-6"}'); + expect(container.textContent).not.toContain('common.technicalDetails'); + expect(container.textContent).not.toContain('operationErrors.errorCode'); + expect(container.textContent).not.toContain('activate_runtime'); + expect(container.textContent).not.toContain('refresh-generation-6'); expect(container.textContent).toContain('recoveryActions.retry'); expect(container.textContent).toContain('recoveryActions.reconnect_host'); expect(container.textContent).not.toContain('database connection string'); diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index b35adc0fa..0cc504ff2 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { AlertTriangle, CheckCircle2, + ChevronRight, CircleDashed, FolderKanban, Globe2, @@ -32,6 +33,12 @@ 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, @@ -62,6 +69,7 @@ import { ExternalCommandConflicts, ExternalSourceSection, buildExternalApplicationsView, + buildExternalApplicationsViewV2, type ExternalApplicationView, } from './external-sources'; import './ExternalSourcesConfig.scss'; @@ -122,10 +130,38 @@ function McpTimeoutSummary({ } type SnapshotLoadResult = - | { status: 'accepted'; snapshot: ExternalSourceCatalogSnapshot } + | { status: 'accepted'; snapshot?: ExternalSourceCatalogSnapshot } | { 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 applicationReviewItemKey(item: ExternalApplicationReviewItemV2): string { + return `${item.itemRef.kind}:${item.itemRef.stableId}`; +} + function abbreviatedLocation(location: string): string { const normalized = location.replace(/\\/g, '/'); const segments = normalized.split('/').filter(Boolean); @@ -263,11 +299,8 @@ function executionLocationLabel(t: TFunction, executionDomainId?: string): strin type ExternalSourcesError = { kind: 'load' | 'mutation'; code?: string; - detail: string; retryable: boolean; correlationId?: string; - causationId?: string; - stage?: string; recoveryActions: ExternalSourceRecoveryAction[]; }; @@ -279,37 +312,28 @@ type AgentChangeNotice = { function externalOperationErrorFacts(error: unknown): Pick< ExternalSourcesError, -'code' | 'detail' | 'retryable' | 'correlationId' | 'causationId' | 'stage' | 'recoveryActions' +'code' | 'retryable' | 'correlationId' | 'recoveryActions' > { if (error && typeof error === 'object') { const candidate = error as { code?: unknown; - message?: unknown; retryable?: unknown; correlationId?: unknown; - causationId?: unknown; - stage?: unknown; recoveryActions?: unknown; }; const code = typeof candidate.code === 'string' ? candidate.code : undefined; return { code, - detail: code && code !== 'internal' && typeof candidate.message === 'string' - ? candidate.message - : 'External source operation failed', retryable: candidate.retryable === true, correlationId: typeof candidate.correlationId === 'string' ? candidate.correlationId : undefined, - causationId: typeof candidate.causationId === 'string' ? candidate.causationId : undefined, - stage: typeof candidate.stage === 'string' ? candidate.stage : undefined, recoveryActions: Array.isArray(candidate.recoveryActions) ? candidate.recoveryActions as ExternalSourceRecoveryAction[] : [], }; } return { - detail: 'External source operation failed', retryable: false, recoveryActions: [], }; @@ -401,9 +425,11 @@ 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); @@ -425,6 +451,13 @@ 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) { @@ -432,6 +465,7 @@ const ExternalSourcesConfig: React.FC = ({ requestSequence.current += 1; acceptedSequence.current = requestSequence.current; snapshotRef.current = null; + applicationSnapshotRef.current = null; agentChangeNoticeRef.current = null; } }, [requestScope]); @@ -533,6 +567,23 @@ 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, @@ -557,10 +608,26 @@ const ExternalSourcesConfig: React.FC = ({ setRefreshing(true); } try { - const next = await externalSourcesAPI.getSnapshot(workspacePath, forceRefresh); - if (!acceptReadSnapshot(next, scope, sequence)) return { status: 'ignored' }; + 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' }; + } setError(null); - return { status: 'accepted', snapshot: next }; + 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' }; } catch (loadError) { if (requestScopeRef.current !== scope || sequence < acceptedSequence.current @@ -579,10 +646,13 @@ const ExternalSourcesConfig: React.FC = ({ } } } - }, [acceptReadSnapshot, requestScope, workspacePath]); + }, [acceptApplicationSnapshot, acceptReadSnapshot, requestScope, workspacePath]); useEffect(() => { setSnapshotState(null); + setApplicationSnapshotState(null); + applicationSnapshotRef.current = null; + setApplicationReviewState(null); snapshotRef.current = null; agentChangeNoticeRef.current = null; setAgentChangeNotice(null); @@ -623,7 +693,7 @@ const ExternalSourcesConfig: React.FC = ({ timer = window.setTimeout(async () => { const result = await loadSnapshot(false, false); if (cancelled) return; - if (result.status === 'accepted' && !result.snapshot.discoveryPending) return; + if (result.status === 'accepted' && !result.snapshot?.discoveryPending) return; attempt += 1; schedulePoll(); }, delay); @@ -658,9 +728,258 @@ const ExternalSourcesConfig: React.FC = ({ [snapshot, sourceGroups], ); const applicationsView = useMemo( - () => buildExternalApplicationsView(snapshot, sourceGroups, policyScope), - [policyScope, snapshot, sourceGroups], + () => applicationSnapshot + ? buildExternalApplicationsViewV2(applicationSnapshot) + : buildExternalApplicationsView(snapshot, sourceGroups, policyScope), + [applicationSnapshot, policyScope, snapshot, sourceGroups], ); + 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 ?? []), @@ -692,6 +1011,12 @@ 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 policyStatus = snapshot?.integrationPolicy?.status; const policyCompatible = policyStatus === 'compatible'; const policyIncompatible = policyStatus === 'incompatible_schema'; @@ -700,6 +1025,9 @@ 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' @@ -805,6 +1133,11 @@ 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( @@ -820,7 +1153,7 @@ const ExternalSourcesConfig: React.FC = ({ 'canSetSafeMode', 'none', ); - }, [runMutation, t, workspacePath]); + }, [applicationSnapshot, canSetSafeMode, runApplicationAction, runMutation, t, workspacePath]); const chooseConflict = useCallback(async (conflictKey: string, candidateId: string) => { if (!snapshot) return; @@ -1119,6 +1452,13 @@ 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, @@ -1134,7 +1474,13 @@ const ExternalSourcesConfig: React.FC = ({ ecosystemId: application.ecosystemId, mode, }); - }, [ecosystemPolicies, snapshot, updatePolicy]); + }, [ + applicationSnapshot, + ecosystemPolicies, + runApplicationAction, + snapshot, + updatePolicy, + ]); const updateCapabilityAccess = useCallback(( ecosystemId: string, @@ -1342,9 +1688,14 @@ const ExternalSourcesConfig: React.FC = ({ className="bitfun-external-sources-config__hooks-summary" data-bf-component="external-sources-config" data-bf-part="hooksSummary" + aria-expanded={hooksOpen} > {t('hooksManagement.title')} - {t('hooksManagement.description')} +