From dded0dfb59610a7b8681db9b3f1c1865d918d8d9 Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 8 Aug 2026 18:00:43 +0800 Subject: [PATCH 1/2] feat(external-sources): add application connection control plane --- docs/architecture/cli-product-line-design.md | 2 +- ...nal-ai-app-connection-experience-design.md | 7 +- ...ernal-ai-app-connection-experience-plan.md | 8 +- .../rules/source/public-api-rules.mjs | 38 + scripts/core-boundaries/self-test.mjs | 9 + src/apps/cli/src/agent/tui_client.rs | 49 +- src/apps/cli/src/modes/chat.rs | 2 + .../cli/src/modes/chat/external_review.rs | 1124 ++++++++- .../cli/src/modes/chat/external_sources.rs | 412 +++- src/apps/cli/src/modes/chat/tests.rs | 10 +- .../peer_host/commands/external_sources.rs | 101 +- src/apps/cli/src/peer_host/commands/mod.rs | 15 +- src/apps/cli/src/tui_backend.rs | 93 +- .../desktop/src/api/external_sources_api.rs | 206 +- .../src/api/remote_workspace_policy.rs | 31 + src/apps/desktop/src/lib.rs | 3 + .../assembly/core/src/external_sources.rs | 2107 ++++++++++++++++- .../src/external_source_control.rs | 633 +++++ .../tests/external_source_contracts.rs | 402 +++- .../interfaces/app-server-client/src/lib.rs | 34 + .../src/schemas/external_source.rs | 160 +- .../interfaces/app-server/src/management.rs | 30 + .../app-server/src/management/service.rs | 49 + .../src/server/handlers/external_source.rs | 27 + .../service-api/ExternalSourcesAPI.test.ts | 312 +++ .../api/service-api/ExternalSourcesAPI.ts | 689 ++++++ .../ExternalSourcesConfig.appearance.ts | 3 + .../components/ExternalSourcesConfig.scss | 41 + .../components/ExternalSourcesConfig.test.tsx | 521 ++++ .../components/ExternalSourcesConfig.tsx | 400 +++- .../ExternalAppsOverview.test.tsx | 94 + .../external-sources/ExternalAppsOverview.tsx | 387 ++- .../external-sources/applicationModel.ts | 52 +- .../applicationModel.v2.test.ts | 113 + .../components/external-sources/index.ts | 5 +- .../en-US/settings/external-sources.json | 45 +- .../zh-CN/settings/external-sources.json | 45 +- .../zh-TW/settings/external-sources.json | 45 +- 38 files changed, 8074 insertions(+), 230 deletions(-) create mode 100644 src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.v2.test.ts diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index fa98bb87d1..f08fd86217 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 7b39596ab5..3b01a97a02 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 回滚; - 未知结果不能假定成功; - 失败项保留可行动原因与恢复动作。 @@ -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 0bfab7b5bf..18a77edf6a 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 重算默认连接、推荐集合或应用级状态; @@ -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,不回退本机。 diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index f44871619e..ad4378da94 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 24a30dfac2..ff6465a514 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 7c85d763d0..bec380e236 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 8f293a9841..c5f41ffa3e 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 1892fcdaf2..1d9466a042 100644 --- a/src/apps/cli/src/modes/chat/external_review.rs +++ b/src/apps/cli/src/modes/chat/external_review.rs @@ -1,7 +1,15 @@ // 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, ExternalApplicationSnapshotV2, + ExternalApplicationTargetScopeV2, ExternalSourceDesiredState, ExternalSourceEffectiveStatus, + ExternalSourceRecoveryActionV1, ExternalSourceSupportState, + EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS, EXTERNAL_APPLICATION_SCHEMA_V2, }; fn external_command_projections( @@ -283,8 +291,667 @@ 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, + }, + ApplyReview, + DeferReview, +} + +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); + if page.execution_domain_id != snapshot.execution_domain_id + || page.workspace_scope_id != expected_workspace_scope_id + || page.target_scope != expected_scope + || page.review_id != summary.review_id + || page.preference_revision != snapshot.preference_revision + { + 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_apply_request( + &self, + operation_id: &str, + ) -> Result { + let review = self + .review + .as_ref() + .ok_or_else(|| "Open /extensions review before applying it.".to_string())?; + self.control_request( + operation_id, + ExternalApplicationControlActionV2::SubmitApplicationReview { + review_id: review.page.review_id.clone(), + expected_generations: review.page.expected_generations.clone(), + selection_baseline: ExternalApplicationReviewSelectionBaselineV2::Recommended, + selection_overrides: review + .selection_overrides + .iter() + .map( + |(item_ref, selected)| ExternalApplicationReviewSelectionOverrideV2 { + item_ref: item_ref.clone(), + selected: *selected, + }, + ) + .collect(), + }, + ) + } +} + +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("apply") && parts.next().is_none() { + state.can_mutate()?; + return Ok(ExternalApplicationUiAction::ApplyReview); + } + if review_command.eq_ignore_ascii_case("defer") && parts.next().is_none() { + return Ok(ExternalApplicationUiAction::DeferReview); + } + } + Err("usage: /extensions [status | refresh | connect | disconnect | defer | review [next | previous | include | exclude | apply | defer]]".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(), + format!( + "Safe Mode: {}", + if snapshot.safe_mode { "on" } else { "off" } + ), + ]; + 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) + )); + lines.push(format!( + " {} enabled, {} pending review, {} blocked, {} conflicts", + application.enabled_count, + application.pending_review_count, + application.blocked_count, + application.conflict_count + )); + lines.push(format!( + " Health: {}", + external_application_health_label(application.health) + )); + if !application.recovery_actions.is_empty() { + lines.push(format!( + " Recovery: {}", + application + .recovery_actions + .iter() + .map(external_application_recovery_label) + .collect::>() + .join(", ") + )); + } + if scope_can_mutate { + match application.primary_action { + ExternalApplicationPrimaryActionV2::Connect => { + lines.push(format!(" Next: /extensions connect {number}")) + } + ExternalApplicationPrimaryActionV2::Review => { + lines.push(" Next: /extensions review".to_string()) + } + 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 application.effective_status == ExternalApplicationEffectiveStatusV2::NeedsAttention + { + lines.push(format!(" Later: /extensions defer {number}")); + } + } + } + if snapshot.review_summary.is_some() && snapshot.host_capabilities.can_read_review { + lines.push(String::new()); + lines.push("Review: /extensions review".to_string()); + } + if snapshot.host_capabilities.can_refresh { + lines.push("Refresh: /extensions refresh".to_string()); + } + lines.join("\n") +} + +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), + ]; + 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, + item.display_summary + )); + lines.push(format!( + " Baseline: {}", + if item.recommended { + "recommended" + } else { + "optional" + } + )); + } + 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()); + } + let can_mutate = state.can_mutate().is_ok(); + if can_mutate { + lines.push("Include: /extensions review include ".to_string()); + lines.push("Exclude: /extensions review exclude ".to_string()); + lines.push("Apply: /extensions review apply".to_string()); + } + lines.push("Later (no changes): /extensions review defer".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 +1052,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 +1064,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 +2409,438 @@ 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")); + + let read_only = external_application_overview_text(&snapshot( + ExternalApplicationHostCapabilitiesV2::read_only(), + )); + for forbidden in [ + "/extensions connect", + "/extensions disconnect", + "/extensions defer", + "/extensions review apply", + ] { + 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_apply_request("operation-1").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); + } + + #[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 review_commands_resolve_current_page_numbers_and_offer_a_non_mutating_defer() { + 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::ApplyReview + ); + assert_eq!( + parse_external_application_action("review defer", &state).unwrap(), + ExternalApplicationUiAction::DeferReview + ); + } +} + 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 86d05f76f6..db343ec763 100644 --- a/src/apps/cli/src/modes/chat/external_sources.rs +++ b/src/apps/cli/src/modes/chat/external_sources.rs @@ -376,6 +376,269 @@ 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 locally; run /extensions review apply to save." + .to_string(), + )); + } + Err(error) => chat_view.set_status(Some(error)), + } + return; + } + if matches!(action, ExternalApplicationUiAction::DeferReview) { + self.external_application_ui.review = None; + chat_view.set_status(Some( + "Review closed with no changes applied; it remains pending.".to_string(), + )); + 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::ApplyReview => { + match self + .external_application_ui + .review_apply_request(&format!("tui-{}", uuid::Uuid::new_v4())) + { + Ok(request) => ExternalApplicationPendingRequest::Mutation(request), + Err(error) => { + chat_view.set_status(Some(error)); + return; + } + } + } + ExternalApplicationUiAction::SetReviewItem { .. } + | ExternalApplicationUiAction::DeferReview => 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::ApplyReview => "Applying external application review", + ExternalApplicationUiAction::SetReviewItem { .. } + | ExternalApplicationUiAction::DeferReview => 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 +730,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::ApplyReview => { + "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 bc48ac197e..3e6e4f0d04 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 07c9ff545a..fb0f422184 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 56a103d09f..4921847a41 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 1b37d4a33d..e754f96111 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 0890159bac..a863d02d4c 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 a4842d365c..6a59a22398 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 9b566aac53..304c97c036 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 6fc9c39560..d60909ab22 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,6 +3304,438 @@ impl WorkspaceExternalSourceService { } } + fn application_snapshot_v2( + &self, + preferences: &ExternalSourcesConfig, + host_capabilities: ExternalApplicationHostCapabilitiesV2, + ) -> Result { + let catalog = self.snapshot(); + let workspace_scope_id = workspace_policy_key(self.workspace_root.as_deref()); + let target_scope = if workspace_scope_id.is_some() { + ExternalApplicationTargetScopeV2::WorkspaceOverride + } else { + ExternalApplicationTargetScopeV2::UserDefault + }; + let 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, + ) + }) + .collect::>(); + let review_plan = external_application_review_plan( + &catalog, + self.execution_domain_id.as_str(), + workspace_scope_id.as_deref(), + target_scope, + preferences.preference_revision, + ); + 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: review_plan.summary(), + }; + snapshot + .validate() + .map_err(|error| format!("invalid external application projection: {error}"))?; + Ok(snapshot) + } + + fn application_review_page_v2( + &self, + preferences: &ExternalSourcesConfig, + request: ExternalApplicationReviewPageRequestV2, + ) -> Result { + request + .validate() + .map_err(|error| invalid_operation_error(error))?; + let workspace_scope_id = workspace_policy_key(self.workspace_root.as_deref()); + let target_scope = if workspace_scope_id.is_some() { + ExternalApplicationTargetScopeV2::WorkspaceOverride + } else { + ExternalApplicationTargetScopeV2::UserDefault + }; + if request.execution_domain_id != self.execution_domain_id + || request.workspace_scope_id != workspace_scope_id + || request.target_scope != target_scope + { + return Err(stale_operation_error( + "External application review belongs to a different Host or workspace", + )); + } + let catalog = self.snapshot(); + let plan = external_application_review_plan( + &catalog, + 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.review_id != plan.review_id + || request.preference_revision != preferences.preference_revision + || (!opening_request && request.expected_generations != plan.expected_generations) + { + return Err(stale_operation_error( + "External application review changed; refresh before continuing", + )); + } + let offset = match request.cursor.as_deref() { + None => 0, + Some(cursor) => plan.parse_cursor(cursor)?, + }; + let end = offset + .saturating_add(request.page_size) + .min(plan.items.len()); + let items = plan.items[offset..end].to_vec(); + let next_cursor = (end < plan.items.len()).then(|| plan.cursor(end)); + let page = ExternalApplicationReviewPageV2 { + schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, + execution_domain_id: self.execution_domain_id.clone(), + workspace_scope_id, + target_scope, + review_id: plan.review_id, + preference_revision: preferences.preference_revision, + expected_generations: plan.expected_generations, + cursor: request.cursor, + next_cursor, + total_count: plan.items.len(), + items, + }; + page.validate() + .map_err(|error| format!("invalid external application review projection: {error}"))?; + Ok(page) + } + + async fn apply_application_action_v2( + self: &Arc, + request: ExternalApplicationControlRequestV2, + ) -> Result { + request + .validate() + .map_err(|error| invalid_operation_error(error))?; + let workspace_scope_id = workspace_policy_key(self.workspace_root.as_deref()); + if request.execution_domain_id != self.execution_domain_id + || !external_application_action_scope_matches( + workspace_scope_id.as_deref(), + request.target_scope, + request.workspace_scope_id.as_deref(), + ) + { + return Err(stale_operation_error( + "External application action belongs to a different Host or workspace", + )); + } + let operation_id = request.operation_id; + let expected_preference_revision = request.expected_preference_revision; + let item_results = match request.action { + ExternalApplicationControlActionV2::Refresh => { + self.refresh_with_runtime_invalidation().await?; + Vec::new() + } + ExternalApplicationControlActionV2::SetSafeMode { enabled } => { + self.set_safe_mode(enabled, Some(expected_preference_revision)) + .await?; + Vec::new() + } + ExternalApplicationControlActionV2::SetSourceEnabled { + source_key, + enabled, + } => { + self.set_source_enabled(&source_key, enabled, expected_preference_revision) + .await?; + Vec::new() + } + ExternalApplicationControlActionV2::ConnectApplication { application_id } => { + self.persist_application_connection( + request.target_scope, + request.workspace_scope_id.as_deref(), + &application_id, + StoredExternalApplicationDesiredConnection::Connected, + expected_preference_revision, + ) + .await?; + Vec::new() + } + ExternalApplicationControlActionV2::DisconnectApplication { application_id } => { + self.persist_application_connection( + request.target_scope, + request.workspace_scope_id.as_deref(), + &application_id, + StoredExternalApplicationDesiredConnection::Disconnected, + expected_preference_revision, + ) + .await?; + Vec::new() + } + ExternalApplicationControlActionV2::SetApplicationDeferred { application_id } => { + self.persist_application_connection( + request.target_scope, + request.workspace_scope_id.as_deref(), + &application_id, + StoredExternalApplicationDesiredConnection::Deferred, + expected_preference_revision, + ) + .await?; + Vec::new() + } + ExternalApplicationControlActionV2::SubmitApplicationReview { + review_id, + expected_generations, + selection_baseline, + selection_overrides, + } => { + self.apply_application_review_v2( + request.target_scope, + request.workspace_scope_id.as_deref(), + expected_preference_revision, + &review_id, + expected_generations, + selection_baseline, + selection_overrides, + ) + .await? + } + }; + let preferences = read_external_sources_config().await?; + let outcome = if item_results + .iter() + .any(|item| item.outcome != ExternalApplicationOperationOutcomeV2::Applied) + && !item_results + .iter() + .any(|item| item.outcome == ExternalApplicationOperationOutcomeV2::Applied) + { + item_results + .iter() + .map(|item| item.outcome) + .next() + .unwrap_or(ExternalApplicationOperationOutcomeV2::Applied) + } else { + ExternalApplicationOperationOutcomeV2::Applied + }; + let result = ExternalApplicationControlResultV2 { + schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, + operation_id, + preference_revision: preferences.preference_revision, + outcome, + item_results, + }; + result + .validate() + .map_err(|error| format!("invalid external application action result: {error}"))?; + Ok(result) + } + + async fn persist_application_connection( + self: &Arc, + target_scope: ExternalApplicationTargetScopeV2, + workspace_scope_id: Option<&str>, + application_id: &str, + desired_connection: StoredExternalApplicationDesiredConnection, + expected_preference_revision: u64, + ) -> Result<(), String> { + let workspace_scope_id = workspace_scope_id.map(str::to_string); + let execution_domain_id = self.execution_domain_id.to_string(); + let application_id = application_id.to_string(); + let (result, preferences) = ExternalSourcePreferenceStore::global()? + .update(move |config| { + apply_external_application_connection_decision( + config, + &execution_domain_id, + target_scope, + workspace_scope_id.as_deref(), + &application_id, + desired_connection, + expected_preference_revision, + ) + }) + .await?; + result?; + propagate_integration_policy_preferences(&preferences, self); + self.refresh_preserving_worker_recovery().await?; + Ok(()) + } + + async fn apply_application_review_v2( + &self, + target_scope: ExternalApplicationTargetScopeV2, + workspace_scope_id: Option<&str>, + expected_preference_revision: u64, + review_id: &str, + expected_generations: Vec, + selection_baseline: ExternalApplicationReviewSelectionBaselineV2, + selection_overrides: Vec< + bitfun_product_domains::external_source_control::ExternalApplicationReviewSelectionOverrideV2, + >, + ) -> Result, String> { + if selection_overrides.len() > EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS { + return Err(invalid_operation_error( + "External application review has too many selection overrides", + )); + } + let catalog = self.snapshot(); + let plan = external_application_review_plan( + &catalog, + self.execution_domain_id.as_str(), + workspace_scope_id, + target_scope, + expected_preference_revision, + ); + if review_id != plan.review_id + || expected_generations != plan.expected_generations + || expected_preference_revision != catalog.preference_revision + { + return Err(stale_operation_error( + "External application review changed; refresh before applying it", + )); + } + let plan_refs = plan + .items + .iter() + .map(|item| item.item_ref.clone()) + .collect::>(); + let mut overrides = BTreeMap::new(); + for selection in selection_overrides { + if !plan_refs.contains(&selection.item_ref) + || overrides + .insert(selection.item_ref, selection.selected) + .is_some() + { + return Err(stale_operation_error( + "External application review selections no longer match the current plan", + )); + } + } + let mut current_revision = expected_preference_revision; + let mut results = Vec::with_capacity(plan.items.len()); + for item in plan.items { + if item.safety_ceiling == ExternalApplicationSafetyCeilingV2::Blocked { + results.push(ExternalApplicationReviewItemResultV2 { + item_ref: item.item_ref, + outcome: ExternalApplicationOperationOutcomeV2::Blocked, + reason_code: Some("resolve_conflict".to_string()), + recovery_actions: vec![ExternalApplicationRecoveryActionV2::ResolveConflict], + }); + continue; + } + let selected = + overrides + .get(&item.item_ref) + .copied() + .unwrap_or(match selection_baseline { + ExternalApplicationReviewSelectionBaselineV2::Recommended => { + item.recommended + } + ExternalApplicationReviewSelectionBaselineV2::None => false, + }); + let outcome = self + .apply_application_review_item(&catalog, &item.item_ref, selected, current_revision) + .await; + match outcome { + Ok(snapshot) => { + current_revision = snapshot.preference_revision; + results.push(ExternalApplicationReviewItemResultV2 { + item_ref: item.item_ref, + outcome: ExternalApplicationOperationOutcomeV2::Applied, + reason_code: None, + recovery_actions: Vec::new(), + }); + } + Err(error) => { + let (outcome, reason_code, recovery_actions) = + external_application_item_failure(&error); + results.push(ExternalApplicationReviewItemResultV2 { + item_ref: item.item_ref, + outcome, + reason_code: Some(reason_code), + recovery_actions, + }); + } + } + } + Ok(results) + } + + async fn apply_application_review_item( + &self, + catalog: &ExternalSourceCatalogSnapshot, + item_ref: &ExternalApplicationReviewItemRefV2, + selected: bool, + expected_preference_revision: u64, + ) -> Result { + match item_ref.kind { + ExternalApplicationReviewItemKindV2::Tool => { + let request = catalog + .tool_approval_requests + .iter() + .find(|request| request.approval_key == item_ref.stable_id) + .ok_or_else(|| { + missing_candidate_error("External tool review item is no longer available") + })?; + self.set_tool_target_decision( + &request.approval_key, + &request.decision_key, + selected, + expected_preference_revision, + ) + .await + } + ExternalApplicationReviewItemKindV2::Mcp => { + let request = catalog + .mcp_approval_requests + .iter() + .find(|request| request.decision_key == item_ref.stable_id) + .ok_or_else(|| { + missing_candidate_error("External MCP review item is no longer available") + })?; + self.set_mcp_server_decision( + &request.candidate_id, + &request.decision_key, + selected, + catalog.mcp_generation, + expected_preference_revision, + ) + .await + } + ExternalApplicationReviewItemKindV2::Subagent => { + let summary = catalog + .subagents + .iter() + .find(|summary| summary.decision_key == item_ref.stable_id) + .ok_or_else(|| { + missing_candidate_error( + "External subagent review item is no longer available", + ) + })?; + self.set_subagent_activation( + &summary.candidate_id, + selected, + catalog.subagent_generation, + expected_preference_revision, + &summary.decision_key, + ) + .await + } + ExternalApplicationReviewItemKindV2::Command + | ExternalApplicationReviewItemKindV2::Conflict => Err(conflict_operation_error( + "External conflict review requires an explicit owner choice", + )), + } + } + fn safe_mode_enabled(&self) -> bool { external_source_safe_mode_enabled_for( self.execution_domain_id.as_str(), @@ -3930,6 +4779,605 @@ 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, +} + +impl ExternalApplicationReviewPlan { + fn summary(&self) -> Option { + if self.items.is_empty() { + return None; + } + let mut counts = BTreeMap::new(); + for item in &self.items { + *counts.entry(item.item_ref.kind).or_insert(0usize) += 1; + } + let recommended_count = self.items.iter().filter(|item| item.recommended).count(); + let blocked_count = self + .items + .iter() + .filter(|item| item.safety_ceiling == ExternalApplicationSafetyCeilingV2::Blocked) + .count(); + Some(ExternalApplicationReviewSummaryV2 { + review_id: self.review_id.clone(), + total_count: self.items.len(), + category_counts: counts + .into_iter() + .map(|(kind, count)| ExternalApplicationReviewCategoryCountV2 { kind, count }) + .collect(), + max_selection_count: self.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 + .items + .len() + .saturating_sub(recommended_count) + .saturating_sub(blocked_count), + blocked_count, + }, + safety_ceiling: if blocked_count == self.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 mut items = Vec::new(); + for request in &catalog.tool_approval_requests { + items.push(ExternalApplicationReviewItemV2 { + item_ref: ExternalApplicationReviewItemRefV2 { + kind: ExternalApplicationReviewItemKindV2::Tool, + stable_id: request.approval_key.clone(), + }, + 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 { + items.push(ExternalApplicationReviewItemV2 { + item_ref: ExternalApplicationReviewItemRefV2 { + kind: ExternalApplicationReviewItemKindV2::Mcp, + stable_id: request.decision_key.clone(), + }, + 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 approval_key in &catalog.pending_subagent_approvals { + let summary = catalog + .subagents + .iter() + .find(|summary| summary.decision_key == *approval_key); + items.push(ExternalApplicationReviewItemV2 { + item_ref: ExternalApplicationReviewItemRefV2 { + kind: ExternalApplicationReviewItemKindV2::Subagent, + stable_id: approval_key.clone(), + }, + display_name: summary + .map(|summary| summary.display_name.clone()) + .unwrap_or_else(|| "External subagent".to_string()), + 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_key, display_name, display_summary) in catalog + .command_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .map(|conflict| { + ( + conflict.conflict_key.clone(), + format!("Resolve command conflict: {}", conflict.command_name), + "Choose one compatible command source".to_string(), + ) + }) + .chain( + catalog + .tool_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .map(|conflict| { + ( + conflict.conflict_key.clone(), + format!("Resolve tool conflict: {}", conflict.tool_name), + "Choose one compatible tool source".to_string(), + ) + }), + ) + .chain( + catalog + .mcp_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .map(|conflict| { + ( + conflict.conflict_key.clone(), + format!("Resolve MCP conflict: {}", conflict.server_name), + "Choose one compatible MCP server".to_string(), + ) + }), + ) + .chain( + catalog + .subagent_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .map(|conflict| { + ( + conflict.conflict_key.clone(), + format!("Resolve subagent conflict: {}", conflict.logical_id), + "Choose one compatible subagent source".to_string(), + ) + }), + ) + { + items.push(ExternalApplicationReviewItemV2 { + item_ref: ExternalApplicationReviewItemRefV2 { + kind: ExternalApplicationReviewItemKindV2::Conflict, + stable_id: conflict_key, + }, + display_name, + display_summary, + risk_level: ExternalApplicationRiskLevelV2::High, + risk_reason_codes: vec!["ambiguous_runtime_route".to_string()], + recommended: false, + safety_ceiling: ExternalApplicationSafetyCeilingV2::Blocked, + }); + } + items.sort_by(|left, right| left.item_ref.cmp(&right.item_ref)); + 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 &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, + } +} + +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, +) -> 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); + 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, +) -> ExternalApplicationAggregateCounts { + let source_belongs = |source_key: &SourceKey| { + catalog.sources.iter().any(|source| { + source.record.key == *source_key && source.record.ecosystem_id == *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| { + catalog + .subagents + .iter() + .find(|subagent| subagent.candidate_id == candidate.candidate_id) + .is_some_and(|subagent| subagent.source_keys.iter().any(&source_belongs)) + }) + }) + .count(); + counts +} + fn lock_coordinator( control_plane: &ExternalSourceControlPlane, ) -> MutexGuard<'_, bitfun_external_sources::ExternalSourceCoordinator> { @@ -4579,8 +6027,26 @@ async fn service_for_profile( } }; service.touch(); - service.ensure_idle_keepalive(); - sync_service_preferences(&service).await?; + service.ensure_idle_keepalive(); + sync_service_preferences(&service).await?; + 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) } @@ -5524,6 +6990,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); } @@ -6657,6 +8144,59 @@ pub async fn get_external_source_control_snapshot( Ok(service.surface_snapshot(host_capabilities)) } +pub async fn get_external_application_snapshot_v2( + workspace_root: Option<&Path>, + force_refresh: bool, + host_capabilities: ExternalApplicationHostCapabilitiesV2, +) -> Result { + if !host_capabilities.can_read_snapshot { + return Err(unavailable_operation_error( + "This Host cannot read external application state", + )); + } + let service = if host_capabilities.can_mutate { + service_for(workspace_root).await? + } else { + read_only_service_for(workspace_root).await? + }; + if force_refresh { + if host_capabilities.can_refresh && host_capabilities.can_mutate { + service.refresh_with_runtime_invalidation().await?; + } else { + service.refresh().await?; + } + } else { + service.ensure_background_refresh(); + } + let preferences = ExternalSourcePreferenceStore::global()? + .ensure_application_connection_schema(service.execution_domain_id.as_str()) + .await?; + service.application_snapshot_v2(&preferences, host_capabilities) +} + +pub async fn get_external_application_review_page_v2( + workspace_root: Option<&Path>, + request: ExternalApplicationReviewPageRequestV2, +) -> Result { + let service = + existing_service_for_profile(workspace_root, ExternalSourceServiceProfile::LocalExecution) + .await?; + let preferences = ExternalSourcePreferenceStore::global()? + .ensure_application_connection_schema(service.execution_domain_id.as_str()) + .await?; + service.application_review_page_v2(&preferences, request) +} + +pub async fn apply_external_application_action_v2( + workspace_root: Option<&Path>, + request: ExternalApplicationControlRequestV2, +) -> Result { + service_for(workspace_root) + .await? + .apply_application_action_v2(request) + .await +} + pub async fn apply_external_source_control_action( workspace_root: Option<&Path>, request: ExternalSourceControlRequestV1, @@ -7018,6 +8558,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 +10378,492 @@ 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 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, + }; + 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_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); + } + #[tokio::test] async fn acknowledging_an_ecosystem_survives_a_reload_and_stays_idempotent() { let temp = tempfile::tempdir().unwrap(); @@ -9287,6 +11314,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 3f19051079..721a0866a5 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 e9f9cc074b..80173dcd15 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 155e327fe7..9c13b7a79a 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 2fa52d9be2..079c79abbf 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 712744748e..2c6e2d04ac 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 28c7993c8e..12452dc49b 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 70437c6b82..e749cea243 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 c80b1ea376..7e7b23eba8 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,276 @@ 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('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 05e0dd3218..ddb20250d0 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,83 @@ 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, + }, + }, + ), + ); + if (page.executionDomainId !== request.executionDomainId + || page.workspaceScopeId !== request.workspaceScopeId + || page.targetScope !== request.targetScope + || page.reviewId !== request.reviewId + || page.preferenceRevision !== request.preferenceRevision + || page.cursor !== request.cursor) { + 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 e26a68363f..fd3120e3db 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts @@ -39,6 +39,9 @@ export const externalSourcesConfigAppearanceDescriptor: AppearanceSurfaceDescrip { 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 ebba998b2b..e886495dec 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss @@ -141,6 +141,47 @@ border-radius: var(--bf-appearance-token-size-radius-md); } + &__review { + display: grid; + gap: var(--bf-appearance-token-size-gap-3); + } + + &__review-toolbar, + &__review-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--bf-appearance-token-size-gap-3); + color: var(--bf-appearance-token-color-text-secondary); + font-size: 12px; + + button { + min-height: 30px; + padding: 5px 10px; + border: 1px solid var(--bf-appearance-token-border-subtle); + border-radius: var(--bf-appearance-token-size-radius-sm); + color: var(--bf-appearance-token-color-text-primary); + background: var(--bf-appearance-token-color-bg-secondary); + font: inherit; + cursor: pointer; + + &:disabled { cursor: not-allowed; opacity: 0.55; } + &:focus-visible { + outline: 2px solid var(--bf-appearance-token-color-accent-500); + outline-offset: 2px; + } + } + } + + &__review-actions { justify-content: flex-end; } + + &__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; 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 af3e669320..5fecc5dd2a 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,471 @@ 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).toContain('applications.summary.enabledCount'); + 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('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 inline with a recommended baseline and bounded 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, + }), + ); + 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"]'); + 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 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({ diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index b35adc0fad..79695852b7 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -32,6 +32,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 +68,7 @@ import { ExternalCommandConflicts, ExternalSourceSection, buildExternalApplicationsView, + buildExternalApplicationsViewV2, type ExternalApplicationView, } from './external-sources'; import './ExternalSourcesConfig.scss'; @@ -122,10 +129,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); @@ -401,9 +436,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 +462,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 +476,7 @@ const ExternalSourcesConfig: React.FC = ({ requestSequence.current += 1; acceptedSequence.current = requestSequence.current; snapshotRef.current = null; + applicationSnapshotRef.current = null; agentChangeNoticeRef.current = null; } }, [requestScope]); @@ -533,6 +578,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 +619,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, forceRefresh) + .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 +657,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 +704,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 +739,211 @@ 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 expectedGenerations = append + && applicationReviewState?.reviewId === summary.reviewId + ? applicationReviewState.expectedGenerations + : []; + setApplicationReviewState((previous) => ({ + reviewId: summary.reviewId, + preferenceRevision: current.preferenceRevision, + loading: true, + items: append && previous?.reviewId === summary.reviewId ? previous.items : [], + expectedGenerations, + nextCursor: append ? previous?.nextCursor : undefined, + totalCount: summary.totalCount, + recommendedCount: summary.recommendationSummary.recommendedCount, + maxSelectionCount: summary.maxSelectionCount, + overrides: append && previous?.reviewId === summary.reviewId ? previous.overrides : {}, + itemResults: append && previous?.reviewId === summary.reviewId ? previous.itemResults : [], + submitted: append && previous?.reviewId === summary.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: summary.reviewId, + preferenceRevision: current.preferenceRevision, + expectedGenerations, + ...(cursor ? { cursor } : {}), + pageSize: 64, + }); + if (requestScopeRef.current !== scope) return; + const latest = applicationSnapshotRef.current; + if (!latest + || latest.preferenceRevision !== current.preferenceRevision + || latest.reviewSummary?.reviewId !== summary.reviewId + || Array.from(pendingMutations.current.values()).includes(scope)) return; + setApplicationReviewState((previous) => { + if (!previous || 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, + loading: false, + items: Array.from(items.values()), + expectedGenerations: page.expectedGenerations, + nextCursor: page.nextCursor, + totalCount: page.totalCount, + }; + }); + } catch (reviewError) { + if (requestScopeRef.current !== scope) return; + setApplicationReviewState((previous) => previous ? { ...previous, loading: false } : previous); + setError({ kind: 'load', ...externalOperationErrorFacts(reviewError) }); + } + }, [applicationReviewState?.expectedGenerations, 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 () => { + const current = applicationReviewState; + if (!current) return; + const itemByKey = new Map( + current.items.map((item) => [applicationReviewItemKey(item), item]), + ); + const selectionOverrides = Object.entries(current.overrides).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: 'recommended', + 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 +975,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 +989,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 +1097,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 +1117,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 +1416,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 +1438,13 @@ const ExternalSourcesConfig: React.FC = ({ ecosystemId: application.ecosystemId, mode, }); - }, [ecosystemPolicies, snapshot, updatePolicy]); + }, [ + applicationSnapshot, + ecosystemPolicies, + runApplicationAction, + snapshot, + updatePolicy, + ]); const updateCapabilityAccess = useCallback(( ecosystemId: string, @@ -1373,7 +1683,7 @@ const ExternalSourcesConfig: React.FC = ({ disabled={refreshing || (hostUnavailable ? !hostUnavailableCanRetry - : (!hostCapabilities.canRefresh && !error))} + : (!canRefresh && !error))} onClick={() => { void loadSnapshot(true, true); }} @@ -1455,7 +1765,7 @@ const ExternalSourcesConfig: React.FC = ({ ); } - if (action.type === 'exit_safe_mode' && control?.safeMode) { + if (action.type === 'exit_safe_mode' && safeModeEnabled) { return ( ) : null} -
- {applications.map((application) => { - const isExpanded = expanded.has(application.ecosystemId); - const capabilityRows = application.connectPlan - .filter((entry) => entry.count > 0); - return ( -
-
+
+ + + {t('applications.review.selectionCount', { + selected: review.selectedCount, + maximum: review.maxSelectionCount, + })} + +
+
+ {review.items.map((item) => { + const key = reviewItemKey(item); + const selected = review.selected[key] ?? item.recommended; + const result = review.itemResults.find( + (candidate) => reviewItemRefKey(candidate.itemRef) === key, + ); + return ( + + ); + })} +
+ {review.loading ? ( +
{t('applications.review.loading')}
+ ) : null} +
+ {review.nextCursor ? ( + + ) : null} + +
+
+ ) : ( +
+ {applications.map((application) => { + const isExpanded = expanded.has(application.ecosystemId); + const hasCapabilityDetails = application.enabledCount === undefined; + const capabilityRows = application.connectPlan + .filter((entry) => entry.count > 0); + return ( +
+
- {isExpanded - ?
- ); - })} -
+ {hasCapabilityDetails && isExpanded ? ( +
+ {capabilityRows.length > 0 ? ( + capabilityRows.map((capability) => ( +
+ + + {t(CAPABILITY_LABEL[capability.capabilityId] ?? capability.capabilityId)} + + + {t('applications.detail.foundCount', { count: capability.count })} + + + + {capabilityAccessLabel(capability, t)} + +
+ )) + ) : ( +
+ {t('applications.summary.noContent')} +
+ )} + +
+ ) : null} +
+ ); + })} +
+ )} ); }; diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts index 37dec9453e..4325977878 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts +++ b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.ts @@ -1,4 +1,7 @@ import type { + ExternalApplicationHealthV2, + ExternalApplicationRecoveryActionV2, + ExternalApplicationSnapshotV2, ExternalIntegrationAccess, ExternalIntegrationMode, ExternalSourceCatalogSnapshot, @@ -20,11 +23,20 @@ export type ExternalApplicationStatus = | 'needs_attention' | 'connected' | 'connected_custom' + | 'configuration_available' + | 'temporarily_unavailable' | 'discovered' | 'checking' | 'no_configuration'; -export type ExternalApplicationAction = 'connect' | 'manage' | 'review' | 'none'; +export type ExternalApplicationAction = + | 'connect' + | 'manage' + | 'review' + | 'view' + | 'retry' + | 'view_reason' + | 'none'; export interface ExternalApplicationCapabilityPlan { capabilityId: string; @@ -41,12 +53,20 @@ export interface ExternalApplicationActiveCapability { } export interface ExternalApplicationView { + applicationId?: string; ecosystemId: string; displayName: string; - mode: ExternalIntegrationMode; + mode?: ExternalIntegrationMode; status: ExternalApplicationStatus; primaryAction: ExternalApplicationAction; enabled: boolean; + /** Host-authoritative aggregate for V2; V1 continues to expose capability counts. */ + enabledCount?: number; + /** Host-authoritative V2 secondary facts; omitted for the inferred V1 projection. */ + health?: ExternalApplicationHealthV2; + blockedCount?: number; + conflictCount?: number; + recoveryActions?: ExternalApplicationRecoveryActionV2[]; counts: ExternalSourceCapabilityCounts; activeCapabilities: ExternalApplicationActiveCapability[]; sourceCount: number; @@ -64,6 +84,34 @@ export interface ExternalApplicationsView { totalAttentionCount: number; } +export function buildExternalApplicationsViewV2( + snapshot: ExternalApplicationSnapshotV2, +): ExternalApplicationsView { + return { + applications: snapshot.applications.map((application) => ({ + applicationId: application.applicationId, + ecosystemId: application.ecosystemId, + displayName: application.displayName, + status: application.effectiveStatus, + primaryAction: application.primaryAction, + enabled: application.connection === 'connected', + enabledCount: application.enabledCount, + health: application.health, + blockedCount: application.blockedCount, + conflictCount: application.conflictCount, + recoveryActions: application.recoveryActions, + counts: { commands: 0, tools: 0, agents: 0, mcps: 0 }, + activeCapabilities: [], + sourceCount: 0, + locations: [], + attentionCount: application.pendingReviewCount, + connectPlan: [], + })), + unattributedAttentionCount: 0, + totalAttentionCount: snapshot.reviewSummary?.totalCount ?? 0, + }; +} + const CAPABILITY_COUNT_FIELD: Record = { command: 'commands', tool: 'tools', diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.v2.test.ts b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.v2.test.ts new file mode 100644 index 0000000000..ad5a98bb00 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/external-sources/applicationModel.v2.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { buildExternalApplicationsViewV2 } from './applicationModel'; + +function application(overrides: Record = {}) { + return { + applicationId: 'opencode', + ecosystemId: 'opencode', + displayName: 'OpenCode', + discovery: 'discovered', + connection: 'connected', + desiredConnection: 'connected', + health: 'healthy', + effectiveStatus: 'connected', + primaryAction: 'view', + defaultConnectionPolicy: 'connect', + defaultConnectionReason: 'supported_by_product', + enabledCount: 2, + pendingReviewCount: 0, + blockedCount: 0, + conflictCount: 0, + riskSummary: { highestLevel: null, reasonCodes: [] }, + noticeKey: null, + userDecision: 'connected', + recoveryActions: [], + ...overrides, + }; +} + +function snapshot(applications: Array>, totalAttentionCount = 0) { + return { + schemaVersion: 2, + executionDomainId: 'host-a', + workspaceScopeId: 'workspace:0123456789abcdef', + effectiveConnectionScope: 'workspace_override', + refreshGeneration: 7, + preferenceRevision: 11, + safeMode: false, + hostCapabilities: {}, + applications, + reviewSummary: totalAttentionCount > 0 + ? { + reviewId: 'review-7', + totalCount: totalAttentionCount, + categoryCounts: [], + maxSelectionCount: totalAttentionCount, + riskSummary: { highestLevel: 'high', reasonCodes: ['process_execution'] }, + recommendationSummary: { + recommendedCount: 1, + optionalCount: 0, + blockedCount: 0, + }, + safetyCeiling: 'review_required', + } + : null, + }; +} + +describe('external application V2 model', () => { + it('projects the Host status, primary action, and connection without V1 policy inference', () => { + const result = buildExternalApplicationsViewV2(snapshot([ + application({ + effectiveStatus: 'needs_attention', + primaryAction: 'review', + pendingReviewCount: 3, + }), + ], 7) as never); + + expect(result.totalAttentionCount).toBe(7); + expect(result.applications[0]).toMatchObject({ + ecosystemId: 'opencode', + status: 'needs_attention', + primaryAction: 'review', + enabled: true, + enabledCount: 2, + attentionCount: 3, + }); + }); + + it.each([ + ['connected', 'view'], + ['configuration_available', 'connect'], + ['no_configuration', 'none'], + ['needs_attention', 'review'], + ['temporarily_unavailable', 'retry'], + ])('preserves the authoritative %s state', (effectiveStatus, primaryAction) => { + const result = buildExternalApplicationsViewV2(snapshot([ + application({ effectiveStatus, primaryAction }), + ]) as never); + + expect(result.applications[0].status).toBe(effectiveStatus); + expect(result.applications[0].primaryAction).toBe(primaryAction); + }); + + it('preserves secondary Host health, issue counts, and recovery facts', () => { + const result = buildExternalApplicationsViewV2(snapshot([ + application({ + health: 'degraded', + blockedCount: 2, + conflictCount: 1, + recoveryActions: [{ type: 'refresh' }], + }), + ]) as never); + + expect(result.applications[0]).toMatchObject({ + status: 'connected', + primaryAction: 'view', + health: 'degraded', + blockedCount: 2, + conflictCount: 1, + recoveryActions: [{ type: 'refresh' }], + }); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/index.ts b/src/web-ui/src/infrastructure/config/components/external-sources/index.ts index 2ae0ae34a6..51a81c6c63 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/index.ts +++ b/src/web-ui/src/infrastructure/config/components/external-sources/index.ts @@ -12,7 +12,10 @@ export type { ExternalSourceSectionProps } from './ExternalSourceSection'; export { useExternalAppAwareness } from './useExternalAppAwareness'; export { ExternalAppsOverview } from './ExternalAppsOverview'; export type { ExternalAppsOverviewProps } from './ExternalAppsOverview'; -export { buildExternalApplicationsView } from './applicationModel'; +export { + buildExternalApplicationsView, + buildExternalApplicationsViewV2, +} from './applicationModel'; export type { ExternalApplicationView, ExternalApplicationsView, diff --git a/src/web-ui/src/locales/en-US/settings/external-sources.json b/src/web-ui/src/locales/en-US/settings/external-sources.json index 6450a00b34..a688d38683 100644 --- a/src/web-ui/src/locales/en-US/settings/external-sources.json +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -8,6 +8,8 @@ "title": "Discovered applications", "description": "BitFun continuously reads connected application settings and checks changes before they become available.", "status": { + "configuration_available": "Configuration available", + "temporarily_unavailable": "Temporarily unavailable", "needs_attention": "Needs attention", "connected": "Connected", "connected_custom": "Connected · Custom", @@ -16,6 +18,16 @@ "no_configuration": "No configuration found" }, "summary": { + "enabledCount": "{{count}} item enabled", + "enabledCount_other": "{{count}} items enabled", + "blockedCount": "{{count}} item blocked", + "blockedCount_other": "{{count}} items blocked", + "conflictCount": "{{count}} conflict", + "conflictCount_other": "{{count}} conflicts", + "health": { + "degraded": "Partially available", + "unavailable": "Unavailable" + }, "attention": "{{count}} item needs review", "attention_other": "{{count}} items need review", "checking": "Looking for configuration", @@ -39,6 +51,35 @@ "expand": "Show or hide {{name}} capabilities", "toggleLabel": "Enable or disable {{name}}", "review": { + "back": "Back to applications", + "selectionCount": "{{selected}} of {{maximum}} selected", + "selectionLimit": "Selection limit reached", + "loading": "Loading review items…", + "loadMore": "Load more", + "apply": "Apply selected", + "risk": { + "low": "Low risk", + "moderate": "Moderate risk", + "high": "High risk" + }, + "safety": { + "blocked": "Blocked by safety policy" + }, + "outcome": { + "applied": "Changes applied", + "partial": "Some changes could not be applied. Review the item results below.", + "rejected": "The changes were rejected", + "blocked": "The changes were blocked by safety policy", + "stale": "The configuration changed. The latest state has been loaded.", + "failed": "The changes could not be applied" + }, + "itemOutcome": { + "applied": "Applied", + "rejected": "Not applied", + "blocked": "Blocked", + "stale": "Changed since review", + "failed": "Could not apply" + }, "title_one": "{{count}} item needs review", "title_other": "{{count}} items need review", "description": "BitFun handles the safe defaults automatically. Open to confirm the items that need a decision." @@ -196,7 +237,9 @@ "resolve_conflict": "Resolve conflict", "install_runtime": "Install or repair the required runtime, then refresh.", "reconnect_host": "Reconnect or upgrade the execution Host, then retry.", - "exit_safe_mode": "Exit Safe Mode" + "exit_safe_mode": "Exit Safe Mode", + "upgrade_host": "Upgrade Host", + "view_reason": "View reason" }, "safeMode": { "title": "External Safe Mode", diff --git a/src/web-ui/src/locales/zh-CN/settings/external-sources.json b/src/web-ui/src/locales/zh-CN/settings/external-sources.json index 9c3e236b67..e22710d9c1 100644 --- a/src/web-ui/src/locales/zh-CN/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -8,6 +8,8 @@ "title": "已发现的应用", "description": "连接后 BitFun 会持续读取来源配置,并在内容可用前检查变更。", "status": { + "configuration_available": "有可用配置", + "temporarily_unavailable": "暂时不可用", "needs_attention": "需要处理", "connected": "已连接", "connected_custom": "已连接 · 自定义", @@ -16,6 +18,16 @@ "no_configuration": "未发现配置" }, "summary": { + "enabledCount": "已启用 {{count}} 项", + "blockedCount": "{{count}} 项受阻", + "blockedCount_other": "{{count}} 项受阻", + "conflictCount": "{{count}} 个冲突", + "conflictCount_other": "{{count}} 个冲突", + "health": { + "degraded": "部分可用", + "unavailable": "不可用" + }, + "enabledCount_other": "已启用 {{count}} 项", "attention": "{{count}} 项等待确认", "attention_other": "{{count}} 项等待确认", "checking": "正在查找配置", @@ -39,6 +51,35 @@ "expand": "显示或隐藏 {{name}} 的能力", "toggleLabel": "启用或停用 {{name}}", "review": { + "back": "返回应用列表", + "selectionCount": "已选择 {{selected}} / {{maximum}} 项", + "selectionLimit": "已达到选择上限", + "loading": "正在加载审核项…", + "loadMore": "加载更多", + "apply": "应用所选项", + "risk": { + "low": "低风险", + "moderate": "中等风险", + "high": "高风险" + }, + "safety": { + "blocked": "已被安全策略阻止" + }, + "outcome": { + "applied": "更改已应用", + "partial": "部分更改未能应用,请查看下方结果。", + "rejected": "更改被拒绝", + "blocked": "更改被安全策略阻止", + "stale": "配置已变化,已加载最新状态。", + "failed": "更改未能应用" + }, + "itemOutcome": { + "applied": "已应用", + "rejected": "未应用", + "blocked": "已阻止", + "stale": "审核后已变化", + "failed": "未能应用" + }, "title_one": "{{count}} 项需要确认", "title_other": "{{count}} 项需要确认", "description": "BitFun 会自动处理安全默认项。打开后确认需要你决定的内容。" @@ -196,7 +237,9 @@ "resolve_conflict": "解决冲突", "install_runtime": "安装或修复所需运行时,然后刷新。", "reconnect_host": "重新连接或升级执行 Host,然后重试。", - "exit_safe_mode": "退出安全模式" + "exit_safe_mode": "退出安全模式", + "upgrade_host": "升级 Host", + "view_reason": "查看原因" }, "safeMode": { "title": "外部来源安全模式", diff --git a/src/web-ui/src/locales/zh-TW/settings/external-sources.json b/src/web-ui/src/locales/zh-TW/settings/external-sources.json index 3a6bb3fc91..8beff206cf 100644 --- a/src/web-ui/src/locales/zh-TW/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -8,6 +8,8 @@ "title": "已發現的應用", "description": "連線後 BitFun 會持續讀取來源設定,並在內容可用前檢查變更。", "status": { + "configuration_available": "有可用設定", + "temporarily_unavailable": "暫時無法使用", "needs_attention": "需要處理", "connected": "已連線", "connected_custom": "已連線 · 自訂", @@ -16,6 +18,16 @@ "no_configuration": "未發現設定" }, "summary": { + "enabledCount": "已啟用 {{count}} 項", + "blockedCount": "{{count}} 項受阻", + "blockedCount_other": "{{count}} 項受阻", + "conflictCount": "{{count}} 個衝突", + "conflictCount_other": "{{count}} 個衝突", + "health": { + "degraded": "部分可用", + "unavailable": "無法使用" + }, + "enabledCount_other": "已啟用 {{count}} 項", "attention": "{{count}} 項等待確認", "attention_other": "{{count}} 項等待確認", "checking": "正在尋找設定", @@ -39,6 +51,35 @@ "expand": "顯示或隱藏 {{name}} 的能力", "toggleLabel": "啟用或停用 {{name}}", "review": { + "back": "返回應用列表", + "selectionCount": "已選擇 {{selected}} / {{maximum}} 項", + "selectionLimit": "已達到選擇上限", + "loading": "正在載入審核項目…", + "loadMore": "載入更多", + "apply": "套用所選項目", + "risk": { + "low": "低風險", + "moderate": "中等風險", + "high": "高風險" + }, + "safety": { + "blocked": "已被安全策略阻止" + }, + "outcome": { + "applied": "變更已套用", + "partial": "部分變更未能套用,請查看下方結果。", + "rejected": "變更遭到拒絕", + "blocked": "變更遭安全策略阻止", + "stale": "設定已變更,已載入最新狀態。", + "failed": "變更未能套用" + }, + "itemOutcome": { + "applied": "已套用", + "rejected": "未套用", + "blocked": "已阻止", + "stale": "審核後已變更", + "failed": "未能套用" + }, "title_one": "{{count}} 項需要確認", "title_other": "{{count}} 項需要確認", "description": "BitFun 會自動處理安全預設項。開啟後確認需要你決定的內容。" @@ -196,7 +237,9 @@ "resolve_conflict": "解決衝突", "install_runtime": "安裝或修復所需執行階段,然後重新整理。", "reconnect_host": "重新連線或升級執行 Host,然後重試。", - "exit_safe_mode": "退出安全模式" + "exit_safe_mode": "退出安全模式", + "upgrade_host": "升級 Host", + "view_reason": "檢視原因" }, "safeMode": { "title": "外部來源安全模式", From 1b956c831096b67e64f6579b2268d1aa5ef0c43d Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 8 Aug 2026 22:36:42 +0800 Subject: [PATCH 2/2] fix(external-sources): simplify application setup UX Make review decisions direct and authoritative across GUI and TUI, clarify disclosure and empty states, and avoid unnecessary snapshot projection work. --- ...nal-ai-app-connection-experience-design.md | 2 +- ...ernal-ai-app-connection-experience-plan.md | 4 +- .../cli/src/modes/chat/external_review.rs | 347 +++++++++--- .../cli/src/modes/chat/external_sources.rs | 38 +- .../assembly/core/src/external_sources.rs | 504 +++++++++++++----- .../service-api/ExternalSourcesAPI.test.ts | 36 ++ .../api/service-api/ExternalSourcesAPI.ts | 6 +- .../ExternalSourcesConfig.appearance.ts | 2 +- .../components/ExternalSourcesConfig.scss | 139 ++--- .../components/ExternalSourcesConfig.test.tsx | 385 ++++++++++++- .../components/ExternalSourcesConfig.tsx | 219 ++++---- .../config/components/HooksConfig.test.tsx | 13 + .../config/components/HooksConfig.tsx | 6 +- .../ExternalAppsOverview.test.tsx | 183 ++++++- .../external-sources/ExternalAppsOverview.tsx | 366 +++++++++---- .../ExternalSourceSection.tsx | 4 - .../en-US/settings/external-sources.json | 56 +- .../src/locales/en-US/settings/hooks.json | 2 +- .../zh-CN/settings/external-sources.json | 56 +- .../src/locales/zh-CN/settings/hooks.json | 2 +- .../zh-TW/settings/external-sources.json | 56 +- .../src/locales/zh-TW/settings/hooks.json | 2 +- 22 files changed, 1844 insertions(+), 584 deletions(-) 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 3b01a97a02..3d55dfb884 100644 --- a/docs/architecture/extensions/external-ai-app-connection-experience-design.md +++ b/docs/architecture/extensions/external-ai-app-connection-experience-design.md @@ -357,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 不重复实现优先级规则。 diff --git a/docs/plans/external-ai-app-connection-experience-plan.md b/docs/plans/external-ai-app-connection-experience-plan.md index 18a77edf6a..9fc57e62bd 100644 --- a/docs/plans/external-ai-app-connection-experience-plan.md +++ b/docs/plans/external-ai-app-connection-experience-plan.md @@ -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 中定义默认连接事实及原因: @@ -403,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/src/apps/cli/src/modes/chat/external_review.rs b/src/apps/cli/src/modes/chat/external_review.rs index 1d9466a042..9e680b9a70 100644 --- a/src/apps/cli/src/modes/chat/external_review.rs +++ b/src/apps/cli/src/modes/chat/external_review.rs @@ -6,7 +6,8 @@ use bitfun_product_domains::external_source_control::{ ExternalApplicationPrimaryActionV2, ExternalApplicationRecoveryActionV2, ExternalApplicationReviewItemRefV2, ExternalApplicationReviewPageRequestV2, ExternalApplicationReviewPageV2, ExternalApplicationReviewSelectionBaselineV2, - ExternalApplicationReviewSelectionOverrideV2, ExternalApplicationSnapshotV2, + ExternalApplicationReviewSelectionOverrideV2, ExternalApplicationRiskLevelV2, + ExternalApplicationSafetyCeilingV2, ExternalApplicationSnapshotV2, ExternalApplicationTargetScopeV2, ExternalSourceDesiredState, ExternalSourceEffectiveStatus, ExternalSourceRecoveryActionV1, ExternalSourceSupportState, EXTERNAL_APPLICATION_REVIEW_PAGE_MAX_ITEMS, EXTERNAL_APPLICATION_SCHEMA_V2, @@ -326,8 +327,10 @@ enum ExternalApplicationUiAction { item_ref: ExternalApplicationReviewItemRefV2, selected: bool, }, - ApplyReview, - DeferReview, + SubmitReview { + baseline: ExternalApplicationReviewSelectionBaselineV2, + immediate_selection: Option<(ExternalApplicationReviewItemRefV2, bool)>, + }, } struct ExternalApplicationReviewUiState { @@ -540,11 +543,12 @@ impl ExternalApplicationUiState { "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.review_id != summary.review_id || 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(), @@ -632,30 +636,58 @@ impl ExternalApplicationUiState { Ok(()) } - fn review_apply_request( + 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_baseline: ExternalApplicationReviewSelectionBaselineV2::Recommended, - selection_overrides: review - .selection_overrides - .iter() - .map( - |(item_ref, selected)| ExternalApplicationReviewSelectionOverrideV2 { - item_ref: item_ref.clone(), - selected: *selected, - }, - ) - .collect(), + selection_overrides, + selection_baseline, }, ) } @@ -770,15 +802,40 @@ fn parse_external_application_action( 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::ApplyReview); + return Ok(ExternalApplicationUiAction::SubmitReview { + baseline: ExternalApplicationReviewSelectionBaselineV2::Recommended, + immediate_selection: None, + }); } - if review_command.eq_ignore_ascii_case("defer") && parts.next().is_none() { - return Ok(ExternalApplicationUiAction::DeferReview); + 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 | apply | defer]]".to_string()) + 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 { @@ -816,14 +873,10 @@ fn external_application_recovery_label( } fn external_application_overview_text(snapshot: &ExternalApplicationSnapshotV2) -> String { - let mut lines = vec![ - "External applications".to_string(), - String::new(), - format!( - "Safe Mode: {}", - if snapshot.safe_mode { "on" } else { "off" } - ), - ]; + 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 @@ -837,20 +890,22 @@ fn external_application_overview_text(snapshot: &ExternalApplicationSnapshotV2) application.display_name, external_application_status_label(application.effective_status) )); - lines.push(format!( - " {} enabled, {} pending review, {} blocked, {} conflicts", - application.enabled_count, - application.pending_review_count, - application.blocked_count, - application.conflict_count - )); - lines.push(format!( - " Health: {}", - external_application_health_label(application.health) - )); + 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() { - lines.push(format!( - " Recovery: {}", + facts.push(format!( + "Recovery: {}", application .recovery_actions .iter() @@ -859,14 +914,15 @@ fn external_application_overview_text(snapshot: &ExternalApplicationSnapshotV2) .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 => { - lines.push(" Next: /extensions review".to_string()) - } + ExternalApplicationPrimaryActionV2::Review => {} ExternalApplicationPrimaryActionV2::Retry => { lines.push(" Next: /extensions refresh".to_string()) } @@ -877,22 +933,23 @@ fn external_application_overview_text(snapshot: &ExternalApplicationSnapshotV2) if application.effective_status == ExternalApplicationEffectiveStatusV2::Connected { lines.push(format!(" Disconnect: /extensions disconnect {number}")); } - if application.effective_status == ExternalApplicationEffectiveStatusV2::NeedsAttention - { - lines.push(format!(" Later: /extensions defer {number}")); - } } } if snapshot.review_summary.is_some() && snapshot.host_capabilities.can_read_review { lines.push(String::new()); lines.push("Review: /extensions review".to_string()); } - if snapshot.host_capabilities.can_refresh { - lines.push("Refresh: /extensions refresh".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 @@ -903,22 +960,29 @@ fn external_application_review_text(state: &ExternalApplicationUiState) -> Resul 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, - item.display_summary - )); - lines.push(format!( - " Baseline: {}", - if item.recommended { - "recommended" - } else { - "optional" - } + external_application_risk_label(item.risk_level) )); } if !review.previous_cursors.is_empty() { @@ -927,13 +991,9 @@ fn external_application_review_text(state: &ExternalApplicationUiState) -> Resul if review.page.next_cursor.is_some() { lines.push("Next: /extensions review next".to_string()); } - let can_mutate = state.can_mutate().is_ok(); if can_mutate { - lines.push("Include: /extensions review include ".to_string()); - lines.push("Exclude: /extensions review exclude ".to_string()); - lines.push("Apply: /extensions review apply".to_string()); + lines.push("Adjust: /extensions review ".to_string()); } - lines.push("Later (no changes): /extensions review defer".to_string()); Ok(lines.join("\n")) } @@ -2582,6 +2642,12 @@ mod external_application_v2_tests { 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(), @@ -2590,7 +2656,8 @@ mod external_application_v2_tests { "/extensions connect", "/extensions disconnect", "/extensions defer", - "/extensions review apply", + "/extensions review allow", + "/extensions review deny", ] { assert!(!read_only.contains(forbidden), "{forbidden}\n{read_only}"); } @@ -2740,7 +2807,13 @@ mod external_application_v2_tests { state.set_review_item_selected(0, false).unwrap(); state.set_review_item_selected(1, true).unwrap(); - let request = state.review_apply_request("operation-1").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, @@ -2755,6 +2828,111 @@ mod external_application_v2_tests { 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] @@ -2801,7 +2979,26 @@ mod external_application_v2_tests { } #[test] - fn review_commands_resolve_current_page_numbers_and_offer_a_non_mutating_defer() { + 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())) @@ -2832,11 +3029,17 @@ mod external_application_v2_tests { ); assert_eq!( parse_external_application_action("review apply", &state).unwrap(), - ExternalApplicationUiAction::ApplyReview + ExternalApplicationUiAction::SubmitReview { + baseline: ExternalApplicationReviewSelectionBaselineV2::Recommended, + immediate_selection: None, + } ); assert_eq!( - parse_external_application_action("review defer", &state).unwrap(), - ExternalApplicationUiAction::DeferReview + parse_external_application_action("review deny", &state).unwrap(), + ExternalApplicationUiAction::SubmitReview { + baseline: ExternalApplicationReviewSelectionBaselineV2::None, + immediate_selection: None, + } ); } } diff --git a/src/apps/cli/src/modes/chat/external_sources.rs b/src/apps/cli/src/modes/chat/external_sources.rs index db343ec763..385f0bb45d 100644 --- a/src/apps/cli/src/modes/chat/external_sources.rs +++ b/src/apps/cli/src/modes/chat/external_sources.rs @@ -438,7 +438,7 @@ impl ChatMode { Ok(text) => { chat_view.show_info_popup(text); chat_view.set_status(Some( - "Review selection updated locally; run /extensions review apply to save." + "Review selection updated; run /extensions review apply to use it." .to_string(), )); } @@ -446,14 +446,6 @@ impl ChatMode { } return; } - if matches!(action, ExternalApplicationUiAction::DeferReview) { - self.external_application_ui.review = None; - chat_view.set_status(Some( - "Review closed with no changes applied; it remains pending.".to_string(), - )); - return; - } - let pending = match &action { ExternalApplicationUiAction::Show => ExternalApplicationPendingRequest::Snapshot { force_refresh: false, @@ -551,11 +543,17 @@ impl ChatMode { } } } - ExternalApplicationUiAction::ApplyReview => { - match self - .external_application_ui - .review_apply_request(&format!("tui-{}", uuid::Uuid::new_v4())) - { + 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)); @@ -563,8 +561,7 @@ impl ChatMode { } } } - ExternalApplicationUiAction::SetReviewItem { .. } - | ExternalApplicationUiAction::DeferReview => unreachable!(), + ExternalApplicationUiAction::SetReviewItem { .. } => unreachable!(), }; let agent = self.agent.clone(); @@ -629,9 +626,10 @@ impl ChatMode { ExternalApplicationUiAction::DeferApplication { .. } => { "Deferring external application decision" } - ExternalApplicationUiAction::ApplyReview => "Applying external application review", - ExternalApplicationUiAction::SetReviewItem { .. } - | ExternalApplicationUiAction::DeferReview => unreachable!(), + 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" @@ -832,7 +830,7 @@ impl ChatMode { ExternalApplicationUiAction::DeferApplication { .. } => { "External application decision deferred" } - ExternalApplicationUiAction::ApplyReview => { + ExternalApplicationUiAction::SubmitReview { .. } => { "External application review applied" } _ => "External application change applied", diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index d60909ab22..72d9ff991f 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -3316,6 +3316,21 @@ impl WorkspaceExternalSourceService { } 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| { @@ -3326,15 +3341,18 @@ impl WorkspaceExternalSourceService { workspace_scope_id.as_deref(), registration, host_capabilities, + &source_ecosystems, + &subagents_by_candidate_id, ) }) .collect::>(); - let review_plan = external_application_review_plan( + let review_summary = external_application_review_summary( &catalog, self.execution_domain_id.as_str(), workspace_scope_id.as_deref(), target_scope, preferences.preference_revision, + &subagents_by_candidate_id, ); let snapshot = ExternalApplicationSnapshotV2 { schema_version: EXTERNAL_APPLICATION_SCHEMA_V2, @@ -3346,7 +3364,7 @@ impl WorkspaceExternalSourceService { safe_mode: self.safe_mode_enabled(), host_capabilities, applications, - review_summary: review_plan.summary(), + review_summary, }; snapshot .validate() @@ -3385,9 +3403,10 @@ impl WorkspaceExternalSourceService { preferences.preference_revision, ); let opening_request = request.cursor.is_none() && request.expected_generations.is_empty(); - if request.review_id != plan.review_id - || request.preference_revision != preferences.preference_revision - || (!opening_request && request.expected_generations != plan.expected_generations) + 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", @@ -4791,31 +4810,42 @@ 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.items.is_empty() { + if self.summary_items.is_empty() { return None; } let mut counts = BTreeMap::new(); - for item in &self.items { + for item in &self.summary_items { *counts.entry(item.item_ref.kind).or_insert(0usize) += 1; } - let recommended_count = self.items.iter().filter(|item| item.recommended).count(); + let recommended_count = self + .summary_items + .iter() + .filter(|item| item.recommended) + .count(); let blocked_count = self - .items + .summary_items .iter() .filter(|item| item.safety_ceiling == ExternalApplicationSafetyCeilingV2::Blocked) .count(); Some(ExternalApplicationReviewSummaryV2 { review_id: self.review_id.clone(), - total_count: self.items.len(), + total_count: self.summary_items.len(), category_counts: counts .into_iter() .map(|(kind, count)| ExternalApplicationReviewCategoryCountV2 { kind, count }) .collect(), - max_selection_count: self.items.len().saturating_sub(blocked_count), + 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()], @@ -4823,13 +4853,13 @@ impl ExternalApplicationReviewPlan { recommendation_summary: ExternalApplicationReviewRecommendationSummaryV2 { recommended_count, optional_count: self - .items + .summary_items .len() .saturating_sub(recommended_count) .saturating_sub(blocked_count), blocked_count, }, - safety_ceiling: if blocked_count == self.items.len() { + safety_ceiling: if blocked_count == self.summary_items.len() { ExternalApplicationSafetyCeilingV2::Blocked } else { ExternalApplicationSafetyCeilingV2::ReviewRequired @@ -4861,131 +4891,270 @@ fn external_application_review_plan( 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 { - items.push(ExternalApplicationReviewItemV2 { - item_ref: ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Tool, - stable_id: request.approval_key.clone(), + 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, }, - 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 { - items.push(ExternalApplicationReviewItemV2 { - item_ref: ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Mcp, - stable_id: request.decision_key.clone(), + 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, }, - 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 approval_key in &catalog.pending_subagent_approvals { - let summary = catalog - .subagents - .iter() - .find(|summary| summary.decision_key == *approval_key); - items.push(ExternalApplicationReviewItemV2 { - item_ref: ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Subagent, - stable_id: approval_key.clone(), + 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, }, - display_name: summary - .map(|summary| summary.display_name.clone()) - .unwrap_or_else(|| "External subagent".to_string()), - 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_key, display_name, display_summary) in catalog + for conflict in catalog .command_conflicts .iter() .filter(|conflict| conflict.selected_candidate_id.is_none()) - .map(|conflict| { - ( - conflict.conflict_key.clone(), - format!("Resolve command conflict: {}", conflict.command_name), - "Choose one compatible command source".to_string(), - ) - }) - .chain( - catalog - .tool_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - .map(|conflict| { - ( - conflict.conflict_key.clone(), - format!("Resolve tool conflict: {}", conflict.tool_name), - "Choose one compatible tool source".to_string(), - ) - }), - ) - .chain( - catalog - .mcp_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - .map(|conflict| { - ( - conflict.conflict_key.clone(), - format!("Resolve MCP conflict: {}", conflict.server_name), - "Choose one compatible MCP server".to_string(), - ) - }), - ) - .chain( - catalog - .subagent_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - .map(|conflict| { - ( - conflict.conflict_key.clone(), - format!("Resolve subagent conflict: {}", conflict.logical_id), - "Choose one compatible subagent source".to_string(), - ) - }), - ) { - items.push(ExternalApplicationReviewItemV2 { - item_ref: ExternalApplicationReviewItemRefV2 { - kind: ExternalApplicationReviewItemKindV2::Conflict, - stable_id: conflict_key, + 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(), + ) }, - display_name, - display_summary, - risk_level: ExternalApplicationRiskLevelV2::High, - risk_reason_codes: vec!["ambiguous_runtime_route".to_string()], - recommended: false, - safety_ceiling: ExternalApplicationSafetyCeilingV2::Blocked, - }); + ); + } + 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, @@ -5018,7 +5187,7 @@ fn external_application_review_plan( hasher.update([generation.owner as u8]); hasher.update(generation.generation.to_le_bytes()); } - for item in &items { + 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]); @@ -5028,6 +5197,7 @@ fn external_application_review_plan( review_id, expected_generations, items, + summary_items, } } @@ -5077,6 +5247,8 @@ fn project_external_application_v2( 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 @@ -5155,7 +5327,12 @@ fn project_external_application_v2( } else { ExternalApplicationConnectionStateV2::Disconnected }; - let counts = external_application_counts(catalog, ecosystem_id); + 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)); @@ -5262,11 +5439,13 @@ fn public_user_decision( 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| { - catalog.sources.iter().any(|source| { - source.record.key == *source_key && source.record.ecosystem_id == *ecosystem_id - }) + source_ecosystems + .get(source_key) + .is_some_and(|source_ecosystem| source_ecosystem == ecosystem_id) }; let mut counts = ExternalApplicationAggregateCounts::default(); for command in &catalog.commands { @@ -5367,10 +5546,8 @@ fn external_application_counts( .filter(|conflict| { conflict.selected_candidate_id.is_none() && conflict.candidates.iter().any(|candidate| { - catalog - .subagents - .iter() - .find(|subagent| subagent.candidate_id == candidate.candidate_id) + subagents_by_candidate_id + .get(candidate.candidate_id.as_str()) .is_some_and(|subagent| subagent.source_keys.iter().any(&source_belongs)) }) }) @@ -10835,6 +11012,7 @@ mod tests { .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(), @@ -10846,6 +11024,7 @@ mod tests { 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 @@ -10855,6 +11034,7 @@ mod tests { 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, @@ -10864,6 +11044,72 @@ mod tests { 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(); 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 7e7b23eba8..ba1e9f0afa 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 @@ -261,6 +261,42 @@ describe('ExternalSourcesAPI', () => { }); }); + 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, 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 ddb20250d0..4641a313e9 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -1949,12 +1949,14 @@ export const externalSourcesAPI = { }, ), ); + const openingRequest = request.cursor === undefined + && request.expectedGenerations.length === 0; if (page.executionDomainId !== request.executionDomainId || page.workspaceScopeId !== request.workspaceScopeId || page.targetScope !== request.targetScope - || page.reviewId !== request.reviewId || page.preferenceRevision !== request.preferenceRevision - || page.cursor !== request.cursor) { + || page.cursor !== request.cursor + || (!openingRequest && page.reviewId !== request.reviewId)) { throw new ExternalSourceApiError( 'invalid_response', 'External application V2 review page did not match its request', 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 fd3120e3db..354f94e237 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.appearance.ts @@ -35,7 +35,7 @@ export const externalSourcesConfigAppearanceDescriptor: AppearanceSurfaceDescrip { id: 'ecosystemState' }, { id: 'attentionSummary' }, { id: 'application' }, - { id: 'appAttention' }, + { id: 'applicationFacts' }, { id: 'applicationToggle' }, { id: 'appCapabilities' }, { id: 'appCapability' }, diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss index e886495dec..998faf596f 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); } } @@ -142,38 +167,54 @@ } &__review { - display: grid; + 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-toolbar, &__review-actions { display: flex; + flex-wrap: wrap; + justify-content: flex-end; align-items: center; - justify-content: space-between; - gap: var(--bf-appearance-token-size-gap-3); + 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; - button { - min-height: 30px; - padding: 5px 10px; - border: 1px solid var(--bf-appearance-token-border-subtle); - border-radius: var(--bf-appearance-token-size-radius-sm); - color: var(--bf-appearance-token-color-text-primary); - background: var(--bf-appearance-token-color-bg-secondary); - font: inherit; + > summary { + width: fit-content; + padding-top: var(--bf-appearance-token-size-gap-3); cursor: pointer; - &:disabled { cursor: not-allowed; opacity: 0.55; } &:focus-visible { outline: 2px solid var(--bf-appearance-token-color-accent-500); outline-offset: 2px; } } - } - &__review-actions { justify-content: flex-end; } + &[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; @@ -185,7 +226,7 @@ &__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); @@ -197,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; @@ -260,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; } } @@ -355,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; } @@ -363,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 { @@ -969,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 5fecc5dd2a..5c52547233 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -494,7 +494,34 @@ describe('ExternalSourcesConfig', () => { }); expect(container.textContent).toContain('applications.status.configuration_available'); - expect(container.textContent).toContain('applications.summary.enabledCount'); + 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"]', ); @@ -519,6 +546,239 @@ describe('ExternalSourcesConfig', () => { 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, @@ -656,7 +916,7 @@ describe('ExternalSourcesConfig', () => { expect(setSafeModeMock).not.toHaveBeenCalled(); }); - it('reviews V2 items inline with a recommended baseline and bounded overrides', async () => { + it('reviews V2 items with a recommended baseline and bounded advanced overrides', async () => { const reviewSnapshot = { ...applicationSnapshotV2, applications: [{ @@ -765,6 +1025,11 @@ describe('ExternalSourcesConfig', () => { 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"]', ); @@ -789,7 +1054,9 @@ describe('ExternalSourcesConfig', () => { ); await act(async () => pagedSelections[1].click()); - const submit = container.querySelector('[data-bf-part="submitReview"]'); + const submit = container.querySelector( + '[data-bf-part="submitReview"][data-review-baseline="recommended"]', + ); await act(async () => { submit?.click(); await Promise.resolve(); @@ -816,6 +1083,84 @@ describe('ExternalSourcesConfig', () => { 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', @@ -1224,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; @@ -1268,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; @@ -1308,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; @@ -2918,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 () => { @@ -2951,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; @@ -3336,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 () => { @@ -3364,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 79695852b7..0cc504ff2c 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, @@ -298,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[]; }; @@ -314,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: [], }; @@ -631,7 +620,7 @@ const ExternalSourcesConfig: React.FC = ({ return { status: 'ignored' }; } setError(null); - void externalSourcesAPI.getSnapshot(workspacePath, forceRefresh) + void externalSourcesAPI.getSnapshot(workspacePath, false) .then((legacySnapshot) => { acceptReadSnapshot(legacySnapshot, scope, sequence); }) @@ -760,23 +749,27 @@ const ExternalSourcesConfig: React.FC = ({ 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?.reviewId === summary.reviewId - ? applicationReviewState.expectedGenerations + ? applicationReviewState?.expectedGenerations ?? [] : []; setApplicationReviewState((previous) => ({ - reviewId: summary.reviewId, - preferenceRevision: current.preferenceRevision, + reviewId, + preferenceRevision, loading: true, - items: append && previous?.reviewId === summary.reviewId ? previous.items : [], + 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 === summary.reviewId ? previous.overrides : {}, - itemResults: append && previous?.reviewId === summary.reviewId ? previous.itemResults : [], - submitted: append && previous?.reviewId === summary.reviewId ? previous.submitted : false, + 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, { @@ -784,39 +777,70 @@ const ExternalSourcesConfig: React.FC = ({ executionDomainId: current.executionDomainId, ...(current.workspaceScopeId ? { workspaceScopeId: current.workspaceScopeId } : {}), targetScope: current.workspaceScopeId ? 'workspace_override' : 'user_default', - reviewId: summary.reviewId, - preferenceRevision: current.preferenceRevision, + 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 !== current.preferenceRevision + || 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 || previous.reviewId !== page.reviewId) return 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) => previous ? { ...previous, loading: false } : previous); + setApplicationReviewState((previous) => ( + append && previous ? { ...previous, loading: false } : null + )); setError({ kind: 'load', ...externalOperationErrorFacts(reviewError) }); } - }, [applicationReviewState?.expectedGenerations, applicationReviewState?.reviewId, + }, [acceptApplicationSnapshot, applicationReviewState?.expectedGenerations, + applicationReviewState?.preferenceRevision, applicationReviewState?.reviewId, applicationSnapshot, requestScope, workspacePath]); useEffect(() => { @@ -907,16 +931,28 @@ const ExternalSourcesConfig: React.FC = ({ return result; }, [applicationSnapshot, canMutateApplicationScope, loadSnapshot, requestScope, t, workspacePath]); - const submitApplicationReview = useCallback(async () => { + 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 selectionOverrides = Object.entries(current.overrides).flatMap(([key, selected]) => { - const item = itemByKey.get(key); - return item ? [{ itemRef: item.itemRef, selected }] : []; - }); + 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); @@ -924,7 +960,7 @@ const ExternalSourcesConfig: React.FC = ({ type: 'submit_application_review', reviewId: current.reviewId, expectedGenerations: current.expectedGenerations, - selectionBaseline: 'recommended', + selectionBaseline, selectionOverrides, }, 'application-review'); if (result) { @@ -1652,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')} +
) : null} -
- {t('common.technicalDetails')} - {error.code ? ( -
{t('operationErrors.errorCode', { code: error.code })}
- ) : null} - {error.stage ? ( -
{t('operationErrors.stage', { stage: error.stage })}
- ) : null} - {error.causationId ? ( -
{t('operationErrors.causationId', { id: error.causationId })}
- ) : null} -
) : null} {(snapshot && hostReadOnly) || applicationHostReadOnly ? ( @@ -1845,36 +1902,7 @@ const ExternalSourcesConfig: React.FC = ({ {operationStatus} ) : null} - {safeModeEnabled !== undefined ? ( - void setSafeMode(event.currentTarget.checked)} - /> - )} - > - {safeModeEnabled ? ( -
- {t('safeMode.activeNotice')} -
- ) : null} -
- ) : null} + {safeModeEnabled ? safeModeSection : null} {snapshot || applicationSnapshot ? ( = ({ items: applicationReviewState.items, selected: applicationReviewState.overrides, selectedCount: selectedApplicationReviewCount, + recommendedCount: applicationReviewState.recommendedCount, totalCount: applicationReviewState.totalCount, maxSelectionCount: applicationReviewState.maxSelectionCount, + applicationNames: applicationSnapshot.applications + .filter((application) => application.pendingReviewCount > 0) + .map((application) => application.displayName), nextCursor: applicationReviewState.nextCursor, itemResults: applicationReviewState.itemResults, completed: applicationReviewState.submitted, @@ -1909,21 +1941,32 @@ const ExternalSourcesConfig: React.FC = ({ void loadApplicationReviewPage(applicationReviewState.nextCursor); } }, - onSubmit: () => void submitApplicationReview(), + onSubmit: (baseline, immediateSelection) => void submitApplicationReview( + baseline, + immediateSelection, + ), } : undefined} /> ) : null} {hookManagement} - {snapshot ? ( + {snapshot || applicationSnapshot ? (
setAdvancedOpen(event.currentTarget.open)} > - + {t('applications.advanced.title')} - {t('applications.advanced.description')} + + {safeModeEnabled === false ? safeModeSection : null} {snapshot && policy ? ( = ({
    {group.diagnostics.map((diagnostic) => ( -
  • - {t(`diagnostics.category.${sourceDiagnosticCategory(diagnostic.code)}`)} -
    - {t('common.technicalDetails')} - {diagnostic.code} -
    -
  • +
  • + {t(`diagnostics.category.${sourceDiagnosticCategory(diagnostic.code)}`)} +
  • ))}
@@ -2436,10 +2475,6 @@ const ExternalSourcesConfig: React.FC = ({ {catalogDiagnostics.map((diagnostic) => (
  • {t(`diagnostics.category.${sourceDiagnosticCategory(diagnostic.code)}`)} -
    - {t('common.technicalDetails')} - {diagnostic.code} -
  • ))} diff --git a/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx index 279eb9d18d..38701cf1eb 100644 --- a/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx @@ -190,6 +190,19 @@ describe('HooksConfig imported Hook management', () => { expect(container.textContent).toContain('activation.title'); }); + it('explains the empty imported Hooks state instead of leaving an empty row', async () => { + getSnapshotMock.mockResolvedValue({ + ...snapshot, + catalog: { ...catalog, sources: [] }, + }); + + await act(async () => root.render()); + await flush(); + + const empty = container.querySelector('[data-hooks-empty="true"]'); + expect(empty?.textContent).toContain('imports.empty'); + }); + it('loads settings and sources together, then exposes exact commands before apply', async () => { await act(async () => root.render()); await flush(); diff --git a/src/web-ui/src/infrastructure/config/components/HooksConfig.tsx b/src/web-ui/src/infrastructure/config/components/HooksConfig.tsx index d30b212fde..730c23edbd 100644 --- a/src/web-ui/src/infrastructure/config/components/HooksConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/HooksConfig.tsx @@ -469,7 +469,11 @@ const HooksConfig: React.FC = ({ embedded = false }) => { {importSnapshot.imports.length === 0 && availableSources.length === 0 && corruptDiagnostics.length === 0 ? ( - + {t('imports.empty')}} + multiline + > {null} ) : null} diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppsOverview.test.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppsOverview.test.tsx index 8e828eafb6..c7a834f9de 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppsOverview.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppsOverview.test.tsx @@ -75,7 +75,7 @@ describe('ExternalAppsOverview', () => { ]); }); - it('renders the V2 Host status and aggregate instead of inventing capability types', async () => { + it('renders only the V2 Host status by default instead of inventing capability details', async () => { await act(async () => { root.render( { }); expect(container.textContent).toContain('applications.status.temporarily_unavailable'); - expect(container.textContent).toContain('applications.summary.enabledCount'); + expect(container.textContent).not.toContain('applications.summary.enabledCount'); expect(container.textContent).not.toContain('applications.summary.noContent'); expect(container.querySelector('button[aria-label^="applications.expand"]')).toBeNull(); }); @@ -163,9 +163,180 @@ describe('ExternalAppsOverview', () => { }); expect(container.textContent).toContain('applications.status.connected'); - expect(container.textContent).toContain('applications.summary.health.degraded'); - expect(container.textContent).toContain('applications.summary.blockedCount'); - expect(container.textContent).toContain('applications.summary.conflictCount'); - expect(container.textContent).toContain('recoveryActions.refresh'); + expect(container.textContent).not.toContain('applications.summary.health.degraded'); + const details = container.querySelector( + '[data-bf-part="applicationFacts"]', + ); + expect(details?.getAttribute('aria-label')).toContain('applications.summary.health.degraded'); + expect(details?.getAttribute('aria-label')).toContain('applications.summary.blockedCount'); + expect(details?.getAttribute('aria-label')).toContain('applications.summary.conflictCount'); + expect(details?.getAttribute('aria-label')).toContain('recoveryActions.refresh'); + }); + + it('explains a single review choice in one standard settings row', async () => { + await act(async () => { + root.render( + key) as never} + totalAttentionCount={1} + busy={false} + canMutate + policiesEnabled + onToggle={vi.fn()} + onOpenAdvanced={vi.fn()} + review={{ + open: true, + loading: false, + items: [{ + itemRef: { kind: 'tool', stableId: 'tool-a' }, + displayName: 'Tool A', + displaySummary: 'Run an untranslated local process', + riskLevel: 'moderate', + riskReasonCodes: ['process_or_resource_access'], + recommended: true, + safetyCeiling: 'review_required', + }], + selected: {}, + selectedCount: 1, + recommendedCount: 1, + totalCount: 1, + maxSelectionCount: 1, + applicationNames: ['OpenCode'], + itemResults: [], + completed: false, + canSubmit: true, + onClose: vi.fn(), + onToggleItem: vi.fn(), + onLoadMore: vi.fn(), + onSubmit: vi.fn(), + }} + />, + ); + }); + + const decision = container.querySelector('.bitfun-external-sources-config__review-decision'); + expect(decision?.classList.contains('bitfun-config-page-row')).toBe(true); + expect(decision?.textContent).toContain('OpenCode'); + expect(decision?.textContent).toContain('Tool A'); + expect(decision?.textContent).toContain('applications.review.category.tool'); + expect(decision?.textContent).toContain('applications.review.risk.moderate'); + expect(decision?.textContent) + .toContain('applications.review.riskReason.processOrResourceAccess'); + expect(decision?.textContent).toContain('applications.review.recommendation.enable'); + expect(decision?.querySelectorAll('br')).toHaveLength(1); + expect(container.textContent).not.toContain('Run an untranslated local process'); + expect(container.querySelector('.bitfun-external-sources-config__review-adjustments')) + .toBeNull(); + expect(container.querySelector( + '[data-bf-part="submitReview"][data-review-baseline="recommended"]', + )?.textContent).toBe('applications.review.enableThisItem'); + expect(container.querySelector( + '[data-bf-part="submitReview"][data-review-baseline="none"]', + )?.textContent).toBe('applications.review.doNotEnable'); + expect(container.querySelector('[data-bf-part="attentionSummary"]')).toBeNull(); + }); + + it('offers both explicit choices for one selectable item that is disabled by default', async () => { + const item = { + itemRef: { kind: 'subagent' as const, stableId: 'agent-a' }, + displayName: 'External agent', + displaySummary: 'Untranslated backend copy', + riskLevel: 'high' as const, + riskReasonCodes: ['delegated_tool_access'], + recommended: false, + safetyCeiling: 'review_required' as const, + }; + const onSubmit = vi.fn(); + + await act(async () => { + root.render( + key) as never} + totalAttentionCount={1} + busy={false} + canMutate + policiesEnabled + onToggle={vi.fn()} + onOpenAdvanced={vi.fn()} + review={{ + open: true, + loading: false, + items: [item], + selected: {}, + selectedCount: 0, + recommendedCount: 0, + totalCount: 1, + maxSelectionCount: 1, + applicationNames: ['OpenCode'], + itemResults: [], + completed: false, + canSubmit: true, + onClose: vi.fn(), + onToggleItem: vi.fn(), + onLoadMore: vi.fn(), + onSubmit, + }} + />, + ); + }); + + expect(container.querySelector('.bitfun-external-sources-config__review-adjustments')) + .toBeNull(); + expect(container.textContent) + .toContain('applications.review.riskReason.delegatedToolAccess'); + expect(container.querySelector( + '[data-bf-part="submitReview"][data-review-baseline="none"]', + )?.textContent).toBe('applications.review.keepDisabled'); + const enable = container.querySelector( + '[data-bf-part="submitReview"][data-review-baseline="recommended"]', + ); + expect(enable?.textContent).toBe('applications.review.enableThisItem'); + await act(async () => enable?.click()); + expect(onSubmit).toHaveBeenCalledWith('recommended', { item, selected: true }); + }); + + it('shows only a compact progress state while the first review page loads', async () => { + await act(async () => { + root.render( + key) as never} + totalAttentionCount={2} + busy={false} + canMutate + policiesEnabled + onToggle={vi.fn()} + onOpenAdvanced={vi.fn()} + review={{ + open: true, + loading: true, + items: [], + selected: {}, + selectedCount: 0, + recommendedCount: 0, + totalCount: 2, + maxSelectionCount: 2, + applicationNames: ['OpenCode'], + itemResults: [], + completed: false, + canSubmit: true, + onClose: vi.fn(), + onToggleItem: vi.fn(), + onLoadMore: vi.fn(), + onSubmit: vi.fn(), + }} + />, + ); + }); + + expect(container.querySelector('[role="status"]')?.textContent) + .toBe('applications.review.loading'); + expect(container.querySelector('[data-bf-part="attentionSummary"]')).toBeNull(); + expect(container.querySelector('[data-bf-part="submitReview"]')).toBeNull(); + expect(container.querySelector('.bitfun-external-sources-config__review-adjustments')) + .toBeNull(); + expect(container.querySelector('.bitfun-external-sources-config__app-list')).toBeNull(); }); }); diff --git a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppsOverview.tsx b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppsOverview.tsx index 47d738bf30..9a09ceddb9 100644 --- a/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppsOverview.tsx +++ b/src/web-ui/src/infrastructure/config/components/external-sources/ExternalAppsOverview.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; -import { Switch } from '@/component-library'; -import { ChevronDown, ChevronRight, Settings2 } from 'lucide-react'; -import { ConfigPageSection } from '../common'; +import { Switch, Tooltip } from '@/component-library'; +import { ArrowLeft, ChevronDown, ChevronRight, CircleAlert, Settings2 } from 'lucide-react'; +import { ConfigPageRow, ConfigPageSection } from '../common'; import type { ExternalApplicationCapabilityPlan, ExternalApplicationView, @@ -19,8 +19,10 @@ export interface ExternalApplicationReviewView { items: ExternalApplicationReviewItemV2[]; selected: Record; selectedCount: number; + recommendedCount: number; totalCount: number; maxSelectionCount: number; + applicationNames: string[]; nextCursor?: string; itemResults: ExternalApplicationReviewItemResultV2[]; completed: boolean; @@ -28,7 +30,10 @@ export interface ExternalApplicationReviewView { onClose: () => void; onToggleItem: (item: ExternalApplicationReviewItemV2, selected: boolean) => void; onLoadMore: () => void; - onSubmit: () => void; + onSubmit: ( + baseline: 'recommended' | 'none', + immediateSelection?: { item: ExternalApplicationReviewItemV2; selected: boolean }, + ) => void; } export interface ExternalAppsOverviewProps { @@ -60,6 +65,21 @@ const CAPABILITY_LABEL: Record = { mcp: 'applications.capabilities.mcps', }; +const REVIEW_CATEGORY_LABEL: Record = { + command: 'applications.review.category.command', + tool: 'applications.review.category.tool', + subagent: 'applications.review.category.subagent', + mcp: 'applications.review.category.mcp', + conflict: 'applications.review.category.conflict', +}; + +const REVIEW_REASON_LABEL: Record = { + process_or_resource_access: 'applications.review.riskReason.processOrResourceAccess', + process_or_network_access: 'applications.review.riskReason.processOrNetworkAccess', + delegated_tool_access: 'applications.review.riskReason.delegatedToolAccess', + ambiguous_runtime_route: 'applications.review.riskReason.ambiguousRuntimeRoute', +}; + function capabilityAccessLabel( capability: ExternalApplicationCapabilityPlan, t: TFunction, @@ -67,11 +87,11 @@ function capabilityAccessLabel( return t(`applications.capabilityAccess.${capability.effectiveAccess}`); } -function v2ApplicationSummary( +function v2ApplicationFacts( application: ExternalApplicationView, t: TFunction, ): string { - const facts = [t('applications.summary.enabledCount', { count: application.enabledCount })]; + const facts: string[] = []; if (application.health && application.health !== 'healthy') { facts.push(t(`applications.summary.health.${application.health}`)); } @@ -106,14 +126,63 @@ export const ExternalAppsOverview: React.FC = ({ review, }) => { const [expanded, setExpanded] = useState>(() => new Set()); + const openingReview = review?.open && review.loading && review.items.length === 0; + const singleReviewItem = review?.totalCount === 1 && review.items.length === 1 + ? review.items[0] + : undefined; + const reviewApplicationLabel = review?.applicationNames.length + ? review.applicationNames.join(', ') + : t('applications.review.unknownApplication'); + const hasSelectableReviewItem = review?.items.some( + (item) => item.safetyCeiling !== 'blocked', + ) ?? false; + const canCustomizeReview = Boolean(review + && hasSelectableReviewItem + && review.totalCount > 1); + const reviewSubmitDisabled = Boolean(!review + || busy + || !review.canSubmit + || review.loading + || review.completed + || review.items.length === 0); + const singleReviewReason = singleReviewItem?.riskReasonCodes + .map((code) => REVIEW_REASON_LABEL[code]) + .find(Boolean); + + const reviewDescription = review && singleReviewItem ? ( + <> + + {t(REVIEW_CATEGORY_LABEL[singleReviewItem.itemRef.kind])} + {' · '} + {singleReviewItem.displayName} + {' · '} + {singleReviewItem.safetyCeiling === 'blocked' + ? t('applications.review.safety.blocked') + : t(`applications.review.risk.${singleReviewItem.riskLevel}`)} + +
    + + {singleReviewReason ? `${t(singleReviewReason)} ` : ''} + {singleReviewItem.safetyCeiling === 'blocked' + ? t('applications.review.recommendation.blocked') + : singleReviewItem.recommended + ? t('applications.review.recommendation.enable') + : t('applications.review.recommendation.keepDisabled')} + + + ) : review ? ( + t('applications.review.recommendation.multiple', { + count: review.totalCount, + recommended: review.recommendedCount, + }) + ) : null; return ( - {totalAttentionCount > 0 ? ( + {totalAttentionCount > 0 && !review?.open ? ( ) : null} {review?.open ? (
    - - - {t('applications.review.selectionCount', { - selected: review.selectedCount, - maximum: review.maxSelectionCount, - })} - -
    -
    - {review.items.map((item) => { - const key = reviewItemKey(item); - const selected = review.selected[key] ?? item.recommended; - const result = review.itemResults.find( - (candidate) => reviewItemRefKey(candidate.itemRef) === key, - ); - return ( - - ); - })} -
    - {review.loading ? ( -
    {t('applications.review.loading')}
    - ) : null} -
    - {review.nextCursor ? ( - - ) : null}
    + {openingReview ? ( + {t('applications.review.loading')}} + multiline + > + {null} + + ) : null} + {!openingReview ? ( + <> + +
    + {singleReviewItem ? ( + <> + + {singleReviewItem.safetyCeiling !== 'blocked' ? ( + + ) : null} + + ) : ( + <> + {review.selectedCount > 0 ? ( + + ) : null} + + + )} +
    +
    + {canCustomizeReview ? ( +
    + {t('applications.review.customize')} +
    + {t('applications.review.selectionCount', { + selected: review.selectedCount, + maximum: review.maxSelectionCount, + })} +
    +
    + {review.items.map((item) => { + const key = reviewItemKey(item); + const selected = review.selected[key] ?? item.recommended; + const result = review.itemResults.find( + (candidate) => reviewItemRefKey(candidate.itemRef) === key, + ); + return ( + + ); + })} +
    + {review.nextCursor ? ( +
    + +
    + ) : null} +
    + ) : null} + + ) : null}
    ) : (
    @@ -223,6 +373,9 @@ export const ExternalAppsOverview: React.FC = ({ const hasCapabilityDetails = application.enabledCount === undefined; const capabilityRows = application.connectPlan .filter((entry) => entry.count > 0); + const applicationFacts = application.enabledCount !== undefined + ? v2ApplicationFacts(application, t) + : ''; return (
    = ({ {application.displayName} - {application.attentionCount > 0 ? ( - - {application.attentionCount} - - ) : null} {t(`applications.status.${application.status}`)} -
    -
    - {application.enabledCount !== undefined - ? v2ApplicationSummary(application, t) - : application.activeCapabilities.length > 0 - ? application.activeCapabilities.map((capability) => ( + {applicationFacts ? ( + - {t(CAPABILITY_LABEL[capability.capabilityId] ?? capability.capabilityId)} - {capability.count} + - )) - : t('applications.summary.noContent')} + + ) : null}
    = ({ {group.diagnostics.map((diagnostic) => (
  • {t(`diagnostics.category.${sourceDiagnosticCategory(diagnostic.code)}`)} -
    - {t('common.technicalDetails')} - {diagnostic.code} -
  • ))} diff --git a/src/web-ui/src/locales/en-US/settings/external-sources.json b/src/web-ui/src/locales/en-US/settings/external-sources.json index a688d38683..65a7b11e2c 100644 --- a/src/web-ui/src/locales/en-US/settings/external-sources.json +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -6,7 +6,6 @@ "checkingNonBlocking": "Checking for updates…", "applications": { "title": "Discovered applications", - "description": "BitFun continuously reads connected application settings and checks changes before they become available.", "status": { "configuration_available": "Configuration available", "temporarily_unavailable": "Temporarily unavailable", @@ -18,8 +17,6 @@ "no_configuration": "No configuration found" }, "summary": { - "enabledCount": "{{count}} item enabled", - "enabledCount_other": "{{count}} items enabled", "blockedCount": "{{count}} item blocked", "blockedCount_other": "{{count}} items blocked", "conflictCount": "{{count}} conflict", @@ -28,8 +25,6 @@ "degraded": "Partially available", "unavailable": "Unavailable" }, - "attention": "{{count}} item needs review", - "attention_other": "{{count}} items need review", "checking": "Looking for configuration", "noContent": "Nothing available yet" }, @@ -56,7 +51,35 @@ "selectionLimit": "Selection limit reached", "loading": "Loading review items…", "loadMore": "Load more", - "apply": "Apply selected", + "useRecommended": "Use recommended", + "doNotEnable": "Don't enable", + "adjustItems": "Adjust individual items", + "enableThisItem": "Enable this item", + "doNotEnableAny": "Don't enable any", + "enableRecommended": "Enable recommended items", + "enableSelected": "Enable selected ({{count}})", + "keepDisabled": "Keep disabled", + "customize": "Choose individually", + "unknownApplication": "External application", + "category": { + "command": "Command", + "tool": "Tool", + "subagent": "Agent", + "mcp": "MCP server", + "conflict": "Conflict" + }, + "recommendation": { + "enable": "BitFun recommends enabling this item.", + "keepDisabled": "BitFun recommends keeping this item disabled. Choose Enable this item only if you need it.", + "blocked": "The current safety policy does not allow this item, so it will remain disabled.", + "multiple": "{{count}} items need confirmation. BitFun recommends enabling {{recommended}} of them; the rest stay disabled. Open individual choices to review or change them." + }, + "riskReason": { + "processOrResourceAccess": "Enabling it lets BitFun run this tool and give it access to local resources.", + "processOrNetworkAccess": "Enabling it may start a local process or connect to an external service.", + "delegatedToolAccess": "Enabling it lets this Agent use the tools configured for it.", + "ambiguousRuntimeRoute": "More than one source has this name. Choose a source in Advanced settings first." + }, "risk": { "low": "Low risk", "moderate": "Moderate risk", @@ -81,8 +104,7 @@ "failed": "Could not apply" }, "title_one": "{{count}} item needs review", - "title_other": "{{count}} items need review", - "description": "BitFun handles the safe defaults automatically. Open to confirm the items that need a decision." + "title_other": "{{count}} items need review" }, "capabilities": { "command": "Commands", @@ -97,8 +119,7 @@ "disabled": "Disabled" }, "advanced": { - "title": "Advanced settings", - "description": "Sources, capability access, diagnostics, and conflicts" + "title": "Advanced settings" }, "detail": { "back": "Back to applications", @@ -123,7 +144,6 @@ }, "hooksManagement": { "title": "Hooks", - "description": "Manage BitFun Hooks and review imports from compatible AI applications.", "loading": "Loading Hook management…" }, "hooks": { @@ -137,7 +157,6 @@ "showMoreSourcesForProvider": "Show {{count}} more sources for {{provider}}", "showMoreEntriesForProvider": "Show {{count}} more Hooks for {{provider}}", "showMoreDiagnosticsForProvider": "Show {{count}} more diagnostics for {{provider}}", - "technicalDetails": "Technical details", "loading": "Checking Hook configuration…", "pending": "Hook discovery is still running. The catalog will update automatically.", "empty": "No Hook configuration was found.", @@ -212,7 +231,6 @@ "hideDetails": "Hide details", "close": "Close", "unknown": "Unknown", - "technicalDetails": "Technical details", "selected": "Currently used", "selectedUnavailable": "Selected, currently unavailable", "notSelected": "Not used", @@ -244,8 +262,7 @@ "safeMode": { "title": "External Safe Mode", "description": "Block new external tool, agent, and MCP calls while keeping discovered commands and diagnostics visible. Calls already in progress are not cancelled.", - "activeDescription": "New executable external calls are blocked. Calls already in progress may finish; commands and diagnostics remain available for recovery.", - "activeNotice": "Safe Mode applies only to this Host process and execution domain. Restarting or switching the execution Host turns it off.", + "activeNotice": "New external calls are blocked for this Host process. Calls already in progress may finish; restarting or switching the Host turns Safe Mode off.", "toggleLabel": "Use External Safe Mode", "entered": "External Safe Mode is active.", "exited": "External Safe Mode is off and eligible integrations were reconciled." @@ -331,11 +348,8 @@ "refreshRequired": "The catalog changed before this action completed. Refresh, review the current state, and try again.", "policyIncompatible": "External settings were written by a newer BitFun version. Back up and reset them before making changes.", "unavailableRetry": "Temporarily unavailable. Try again later.", - "internal": "The action failed. Refresh to retry.", - "referenceId": "Reference: {{id}}", - "errorCode": "Error code: {{code}}", - "stage": "Stage: {{stage}}", - "causationId": "Related operation: {{id}}" + "internal": "The action failed and no settings were changed. Try again below. If it keeps failing, contact support with the reference number.", + "referenceId": "Support reference (include when contacting support): {{id}}" }, "diagnostics": { "summary": "{{count}} items need attention. Review the details to see what is affected.", @@ -351,7 +365,7 @@ "unreadableSource": "BitFun could not read part of a source. Check file access, then refresh.", "notSupported": "This type of external content is not supported yet, so BitFun did not load or run it.", "checkFailed": "BitFun could not check one source. Other sources remain available; refresh to retry.", - "sourceIssue": "BitFun found an issue in one source. The affected content was not enabled; review the technical details." + "sourceIssue": "BitFun found an issue in one source. The affected content was not enabled. Check the source configuration, then refresh." } }, "sources": { diff --git a/src/web-ui/src/locales/en-US/settings/hooks.json b/src/web-ui/src/locales/en-US/settings/hooks.json index 77e6e52917..852ad269e1 100644 --- a/src/web-ui/src/locales/en-US/settings/hooks.json +++ b/src/web-ui/src/locales/en-US/settings/hooks.json @@ -37,7 +37,7 @@ "loading": "Loading imported hooks...", "loadFailed": "Imported hooks could not be loaded.", "refreshFailed": "Imported hooks could not be refreshed.", - "empty": "No compatible Claude Code or Codex command hooks were found.", + "empty": "No imported Hooks yet. Add one in hooks.json, or refresh to check Claude Code and Codex.", "review": "Review", "update": "Review update", "remove": "Remove", diff --git a/src/web-ui/src/locales/zh-CN/settings/external-sources.json b/src/web-ui/src/locales/zh-CN/settings/external-sources.json index e22710d9c1..914720b86d 100644 --- a/src/web-ui/src/locales/zh-CN/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -6,7 +6,6 @@ "checkingNonBlocking": "正在检查更新…", "applications": { "title": "已发现的应用", - "description": "连接后 BitFun 会持续读取来源配置,并在内容可用前检查变更。", "status": { "configuration_available": "有可用配置", "temporarily_unavailable": "暂时不可用", @@ -18,7 +17,6 @@ "no_configuration": "未发现配置" }, "summary": { - "enabledCount": "已启用 {{count}} 项", "blockedCount": "{{count}} 项受阻", "blockedCount_other": "{{count}} 项受阻", "conflictCount": "{{count}} 个冲突", @@ -27,9 +25,6 @@ "degraded": "部分可用", "unavailable": "不可用" }, - "enabledCount_other": "已启用 {{count}} 项", - "attention": "{{count}} 项等待确认", - "attention_other": "{{count}} 项等待确认", "checking": "正在查找配置", "noContent": "暂时没有可用内容" }, @@ -56,7 +51,35 @@ "selectionLimit": "已达到选择上限", "loading": "正在加载审核项…", "loadMore": "加载更多", - "apply": "应用所选项", + "useRecommended": "使用推荐设置", + "doNotEnable": "暂不启用", + "adjustItems": "调整单项", + "enableThisItem": "启用此项", + "doNotEnableAny": "全部不启用", + "enableRecommended": "启用建议项", + "enableSelected": "启用已选项({{count}})", + "keepDisabled": "保持停用", + "customize": "逐项选择", + "unknownApplication": "外部应用", + "category": { + "command": "命令", + "tool": "工具", + "subagent": "Agent", + "mcp": "MCP 服务器", + "conflict": "冲突" + }, + "recommendation": { + "enable": "BitFun 建议启用此项。", + "keepDisabled": "BitFun 建议保持此项停用;确实需要时再选择“启用此项”。", + "blocked": "当前安全策略不允许启用此项,它将保持停用。", + "multiple": "有 {{count}} 项需要确认。BitFun 建议启用其中 {{recommended}} 项,其余保持停用;可展开“逐项选择”查看或修改。" + }, + "riskReason": { + "processOrResourceAccess": "启用后,BitFun 可以运行此工具并让它访问本机资源。", + "processOrNetworkAccess": "启用后,可能会启动本机进程或连接外部服务。", + "delegatedToolAccess": "启用后,此 Agent 可以调用为它配置的工具。", + "ambiguousRuntimeRoute": "存在多个同名来源,请先在高级设置中选择一个来源。" + }, "risk": { "low": "低风险", "moderate": "中等风险", @@ -81,8 +104,7 @@ "failed": "未能应用" }, "title_one": "{{count}} 项需要确认", - "title_other": "{{count}} 项需要确认", - "description": "BitFun 会自动处理安全默认项。打开后确认需要你决定的内容。" + "title_other": "{{count}} 项需要确认" }, "capabilities": { "command": "命令", @@ -97,8 +119,7 @@ "disabled": "已停用" }, "advanced": { - "title": "高级设置", - "description": "配置来源、能力访问、诊断与冲突" + "title": "高级设置" }, "detail": { "back": "返回应用列表", @@ -123,7 +144,6 @@ }, "hooksManagement": { "title": "Hooks", - "description": "管理 BitFun Hook,并检查可从兼容 AI 应用导入的 Hook。", "loading": "正在加载 Hook 管理…" }, "hooks": { @@ -137,7 +157,6 @@ "showMoreSourcesForProvider": "再显示 {{provider}} 的 {{count}} 个来源", "showMoreEntriesForProvider": "再显示 {{provider}} 的 {{count}} 个 Hook", "showMoreDiagnosticsForProvider": "再显示 {{provider}} 的 {{count}} 条诊断", - "technicalDetails": "技术详情", "loading": "正在检查 Hook 配置…", "pending": "Hook 发现仍在进行,目录会自动更新。", "empty": "未发现 Hook 配置。", @@ -212,7 +231,6 @@ "hideDetails": "收起详情", "close": "关闭", "unknown": "未知", - "technicalDetails": "技术详情", "selected": "当前使用", "selectedUnavailable": "已选择,当前不可用", "notSelected": "未使用", @@ -244,8 +262,7 @@ "safeMode": { "title": "外部来源安全模式", "description": "阻止新的外部工具、Agent 和 MCP 调用,同时保留已发现的命令与诊断信息;已在执行的调用不会被取消。", - "activeDescription": "新的外部可执行调用已被阻止;已在执行的调用仍可能完成,命令和诊断信息继续可用于恢复。", - "activeNotice": "安全模式仅对当前 Host 进程和执行域有效;重启或切换执行 Host 后会自动关闭。", + "activeNotice": "当前 Host 进程已阻止新的外部调用;已在执行的调用仍可能完成,重启或切换 Host 后安全模式会自动关闭。", "toggleLabel": "使用外部来源安全模式", "entered": "外部来源安全模式已启用。", "exited": "外部来源安全模式已关闭,符合条件的集成已重新协调。" @@ -331,11 +348,8 @@ "refreshRequired": "操作完成前目录已变化。请刷新并确认当前状态后重试。", "policyIncompatible": "外部设置由较新的 BitFun 版本写入。请先备份并重置,再进行更改。", "unavailableRetry": "暂时不可用,请稍后重试。", - "internal": "操作失败,请刷新重试。", - "referenceId": "参考编号:{{id}}", - "errorCode": "错误编号:{{code}}", - "stage": "处理阶段:{{stage}}", - "causationId": "关联操作:{{id}}" + "internal": "操作失败,当前设置未更改。请使用下方操作重试;若仍失败,请联系支持并提供参考编号。", + "referenceId": "支持参考编号(联系支持时请提供):{{id}}" }, "diagnostics": { "summary": "有 {{count}} 项内容需要注意,请查看详情了解影响范围。", @@ -351,7 +365,7 @@ "unreadableSource": "BitFun 无法读取来源中的部分内容。请检查文件访问权限后刷新。", "notSupported": "BitFun 暂不支持此类外部内容,因此没有加载或运行它。", "checkFailed": "BitFun 未能检查一个来源,其他来源仍可使用。请刷新重试。", - "sourceIssue": "BitFun 在一个来源中发现问题,受影响的内容未启用。请查看技术详情。" + "sourceIssue": "BitFun 在一个来源中发现问题,受影响的内容未启用。请检查来源配置后刷新。" } }, "sources": { diff --git a/src/web-ui/src/locales/zh-CN/settings/hooks.json b/src/web-ui/src/locales/zh-CN/settings/hooks.json index 73243293f8..0da80f9565 100644 --- a/src/web-ui/src/locales/zh-CN/settings/hooks.json +++ b/src/web-ui/src/locales/zh-CN/settings/hooks.json @@ -37,7 +37,7 @@ "loading": "正在加载已导入的 Hooks...", "loadFailed": "无法加载已导入的 Hooks。", "refreshFailed": "无法刷新已导入的 Hooks。", - "empty": "未发现兼容的 Claude Code 或 Codex 命令 Hooks。", + "empty": "当前没有已导入的 Hook。可在 hooks.json 中添加,或刷新检查 Claude Code 和 Codex。", "review": "审阅", "update": "审阅更新", "remove": "移除", diff --git a/src/web-ui/src/locales/zh-TW/settings/external-sources.json b/src/web-ui/src/locales/zh-TW/settings/external-sources.json index 8beff206cf..405ea14508 100644 --- a/src/web-ui/src/locales/zh-TW/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -6,7 +6,6 @@ "checkingNonBlocking": "正在檢查更新…", "applications": { "title": "已發現的應用", - "description": "連線後 BitFun 會持續讀取來源設定,並在內容可用前檢查變更。", "status": { "configuration_available": "有可用設定", "temporarily_unavailable": "暫時無法使用", @@ -18,7 +17,6 @@ "no_configuration": "未發現設定" }, "summary": { - "enabledCount": "已啟用 {{count}} 項", "blockedCount": "{{count}} 項受阻", "blockedCount_other": "{{count}} 項受阻", "conflictCount": "{{count}} 個衝突", @@ -27,9 +25,6 @@ "degraded": "部分可用", "unavailable": "無法使用" }, - "enabledCount_other": "已啟用 {{count}} 項", - "attention": "{{count}} 項等待確認", - "attention_other": "{{count}} 項等待確認", "checking": "正在尋找設定", "noContent": "暫時沒有可用內容" }, @@ -56,7 +51,35 @@ "selectionLimit": "已達到選擇上限", "loading": "正在載入審核項目…", "loadMore": "載入更多", - "apply": "套用所選項目", + "useRecommended": "使用建議設定", + "doNotEnable": "暫不啟用", + "adjustItems": "調整單項", + "enableThisItem": "啟用此項", + "doNotEnableAny": "全部不啟用", + "enableRecommended": "啟用建議項", + "enableSelected": "啟用已選項({{count}})", + "keepDisabled": "保持停用", + "customize": "逐項選擇", + "unknownApplication": "外部應用", + "category": { + "command": "命令", + "tool": "工具", + "subagent": "Agent", + "mcp": "MCP 伺服器", + "conflict": "衝突" + }, + "recommendation": { + "enable": "BitFun 建議啟用此項。", + "keepDisabled": "BitFun 建議保持此項停用;確實需要時再選擇「啟用此項」。", + "blocked": "目前安全策略不允許啟用此項,它將保持停用。", + "multiple": "有 {{count}} 項需要確認。BitFun 建議啟用其中 {{recommended}} 項,其餘保持停用;可展開「逐項選擇」查看或修改。" + }, + "riskReason": { + "processOrResourceAccess": "啟用後,BitFun 可以執行此工具並讓它存取本機資源。", + "processOrNetworkAccess": "啟用後,可能會啟動本機程序或連線至外部服務。", + "delegatedToolAccess": "啟用後,此 Agent 可以呼叫為它設定的工具。", + "ambiguousRuntimeRoute": "存在多個同名來源,請先在進階設定中選擇一個來源。" + }, "risk": { "low": "低風險", "moderate": "中等風險", @@ -81,8 +104,7 @@ "failed": "未能套用" }, "title_one": "{{count}} 項需要確認", - "title_other": "{{count}} 項需要確認", - "description": "BitFun 會自動處理安全預設項。開啟後確認需要你決定的內容。" + "title_other": "{{count}} 項需要確認" }, "capabilities": { "command": "命令", @@ -97,8 +119,7 @@ "disabled": "已停用" }, "advanced": { - "title": "進階設定", - "description": "設定來源、能力存取、診斷與衝突" + "title": "進階設定" }, "detail": { "back": "返回應用清單", @@ -123,7 +144,6 @@ }, "hooksManagement": { "title": "Hooks", - "description": "管理 BitFun Hook,並檢查可從相容 AI 應用匯入的 Hook。", "loading": "正在載入 Hook 管理…" }, "hooks": { @@ -137,7 +157,6 @@ "showMoreSourcesForProvider": "再顯示 {{provider}} 的 {{count}} 個來源", "showMoreEntriesForProvider": "再顯示 {{provider}} 的 {{count}} 個 Hook", "showMoreDiagnosticsForProvider": "再顯示 {{provider}} 的 {{count}} 筆診斷", - "technicalDetails": "技術詳情", "loading": "正在檢查 Hook 設定…", "pending": "Hook 探索仍在進行,目錄會自動更新。", "empty": "未發現 Hook 設定。", @@ -212,7 +231,6 @@ "hideDetails": "收合詳細資料", "close": "關閉", "unknown": "未知", - "technicalDetails": "技術詳細資料", "selected": "目前使用", "selectedUnavailable": "已選擇,目前無法使用", "notSelected": "未使用", @@ -244,8 +262,7 @@ "safeMode": { "title": "外部來源安全模式", "description": "阻止新的外部工具、Agent 與 MCP 呼叫,同時保留已探索的命令與診斷資訊;已在執行的呼叫不會被取消。", - "activeDescription": "新的外部可執行呼叫已被阻止;已在執行的呼叫仍可能完成,命令與診斷資訊可繼續用於復原。", - "activeNotice": "安全模式僅對目前的 Host 程序與執行域有效;重新啟動或切換執行 Host 後會自動關閉。", + "activeNotice": "目前 Host 程序已阻止新的外部呼叫;已在執行的呼叫仍可能完成,重新啟動或切換 Host 後安全模式會自動關閉。", "toggleLabel": "使用外部來源安全模式", "entered": "外部來源安全模式已啟用。", "exited": "外部來源安全模式已關閉,符合條件的整合已重新協調。" @@ -331,11 +348,8 @@ "refreshRequired": "操作完成前目錄已變更。請重新整理並確認目前狀態後重試。", "policyIncompatible": "外部設定由較新的 BitFun 版本寫入。請先備份並重設,再進行變更。", "unavailableRetry": "暫時無法使用,請稍後重試。", - "internal": "操作失敗,請重新整理後重試。", - "referenceId": "參考編號:{{id}}", - "errorCode": "錯誤編號:{{code}}", - "stage": "處理階段:{{stage}}", - "causationId": "關聯操作:{{id}}" + "internal": "操作失敗,目前設定未變更。請使用下方操作重試;若仍失敗,請聯絡支援並提供參考編號。", + "referenceId": "支援參考編號(聯絡支援時請提供):{{id}}" }, "diagnostics": { "summary": "有 {{count}} 項內容需要注意,請查看詳細資料以瞭解影響範圍。", @@ -351,7 +365,7 @@ "unreadableSource": "BitFun 無法讀取來源中的部分內容。請檢查檔案存取權限後重新整理。", "notSupported": "BitFun 暫不支援此類外部內容,因此沒有載入或執行它。", "checkFailed": "BitFun 未能檢查一個來源,其他來源仍可使用。請重新整理後重試。", - "sourceIssue": "BitFun 在一個來源中發現問題,受影響的內容未啟用。請查看技術詳細資料。" + "sourceIssue": "BitFun 在一個來源中發現問題,受影響的內容未啟用。請檢查來源設定後重新整理。" } }, "sources": { diff --git a/src/web-ui/src/locales/zh-TW/settings/hooks.json b/src/web-ui/src/locales/zh-TW/settings/hooks.json index 2dc7c9b693..d07b3c3bb9 100644 --- a/src/web-ui/src/locales/zh-TW/settings/hooks.json +++ b/src/web-ui/src/locales/zh-TW/settings/hooks.json @@ -37,7 +37,7 @@ "loading": "正在載入已匯入的 Hooks...", "loadFailed": "無法載入已匯入的 Hooks。", "refreshFailed": "無法重新整理已匯入的 Hooks。", - "empty": "未發現相容的 Claude Code 或 Codex 命令 Hooks。", + "empty": "目前沒有已匯入的 Hook。可在 hooks.json 中新增,或重新整理以檢查 Claude Code 與 Codex。", "review": "檢閱", "update": "檢閱更新", "remove": "移除",