From c08bffa08692d2a585dea09114d32209593e75b7 Mon Sep 17 00:00:00 2001 From: limityan Date: Thu, 16 Jul 2026 22:01:37 +0800 Subject: [PATCH] feat(extensions): add OpenCode prompt command sources --- AGENTS.md | 2 +- Cargo.toml | 1 + .../external-ai-work-sources-design.md | 73 +- .../opencode-config-assets-adapter-design.md | 47 +- docs/architecture/product-architecture.md | 32 +- .../opencode-extension-compatibility-plan.md | 326 ++--- .../core-boundaries/rules/crate-layout.mjs | 1 + scripts/core-boundaries/rules/crate-rules.mjs | 3 +- .../core-boundaries/rules/feature-rules.mjs | 2 +- .../rules/source/forbidden-rules.mjs | 4 +- .../rules/source/public-api-rules.mjs | 122 ++ scripts/core-boundaries/self-test.mjs | 6 +- src/apps/cli/AGENTS.md | 16 +- src/apps/cli/src/modes/chat.rs | 1131 +++++++++++++- src/apps/cli/src/ui/chat/input.rs | 17 +- src/apps/cli/src/ui/command_menu.rs | 300 +++- src/apps/cli/src/ui/mod.rs | 2 +- .../desktop/src/api/external_sources_api.rs | 174 +++ src/apps/desktop/src/api/mod.rs | 1 + .../src/api/remote_workspace_policy.rs | 12 + src/apps/desktop/src/lib.rs | 4 + .../adapters/opencode-adapter/AGENTS-CN.md | 23 +- .../adapters/opencode-adapter/AGENTS.md | 36 +- .../adapters/opencode-adapter/Cargo.toml | 7 +- .../opencode-adapter/src/command_source.rs | 1300 +++++++++++++++++ .../adapters/opencode-adapter/src/lib.rs | 2 + .../tests/opencode_command_adapter.rs | 750 ++++++++++ src/crates/assembly/AGENTS-CN.md | 1 + src/crates/assembly/AGENTS.md | 1 + src/crates/assembly/core/AGENTS-CN.md | 3 +- src/crates/assembly/core/AGENTS.md | 8 +- src/crates/assembly/core/Cargo.toml | 4 + .../assembly/core/src/external_sources.rs | 1174 +++++++++++++++ src/crates/assembly/core/src/lib.rs | 2 + .../assembly/external-sources/Cargo.toml | 16 + .../assembly/external-sources/src/lib.rs | 772 ++++++++++ .../tests/coordinator_contracts.rs | 524 +++++++ .../contracts/product-domains/AGENTS-CN.md | 4 +- .../contracts/product-domains/AGENTS.md | 6 +- .../contracts/product-domains/Cargo.toml | 8 +- .../product-domains/src/external_sources.rs | 548 +++++++ .../contracts/product-domains/src/lib.rs | 3 + .../tests/external_source_contracts.rs | 217 +++ src/crates/services/services-core/Cargo.toml | 2 + .../services/services-core/src/json_store.rs | 180 ++- .../tests/json_store_contracts.rs | 33 +- .../src/file_watch/service.rs | 138 +- .../tests/file_watch_contracts.rs | 106 ++ .../scenes/settings/SettingsScene.test.tsx | 12 +- .../src/app/scenes/settings/SettingsScene.tsx | 2 + .../src/app/scenes/settings/settingsConfig.ts | 16 + .../settings/settingsTabSearchContent.ts | 9 + .../api/service-api/ExternalSourcesAPI.ts | 94 ++ .../components/ExternalSourcesConfig.scss | 89 ++ .../components/ExternalSourcesConfig.test.tsx | 305 ++++ .../components/ExternalSourcesConfig.tsx | 375 +++++ .../i18n/presets/namespaceRegistry.ts | 1 + src/web-ui/src/locales/en-US/settings.json | 2 + .../en-US/settings/external-sources.json | 50 + src/web-ui/src/locales/zh-CN/settings.json | 2 + .../zh-CN/settings/external-sources.json | 50 + src/web-ui/src/locales/zh-TW/settings.json | 2 + .../zh-TW/settings/external-sources.json | 50 + 63 files changed, 8857 insertions(+), 346 deletions(-) create mode 100644 src/apps/desktop/src/api/external_sources_api.rs create mode 100644 src/crates/adapters/opencode-adapter/src/command_source.rs create mode 100644 src/crates/adapters/opencode-adapter/tests/opencode_command_adapter.rs create mode 100644 src/crates/assembly/core/src/external_sources.rs create mode 100644 src/crates/assembly/external-sources/Cargo.toml create mode 100644 src/crates/assembly/external-sources/src/lib.rs create mode 100644 src/crates/assembly/external-sources/tests/coordinator_contracts.rs create mode 100644 src/crates/contracts/product-domains/src/external_sources.rs create mode 100644 src/crates/contracts/product-domains/tests/external_source_contracts.rs create mode 100644 src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts create mode 100644 src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss create mode 100644 src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx create mode 100644 src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx create mode 100644 src/web-ui/src/locales/en-US/settings/external-sources.json create mode 100644 src/web-ui/src/locales/zh-CN/settings/external-sources.json create mode 100644 src/web-ui/src/locales/zh-TW/settings/external-sources.json diff --git a/AGENTS.md b/AGENTS.md index 96b2229ea8..b3067d7779 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ Keep crate dependencies inside each layer to the smallest set needed. | # | Layer | Path | Owns | Modules / entries | Layer doc | |---|---|---|---|---|---| | 1 | Interfaces and entrypoints | `src/apps/*`, `src/web-ui`, `src/mobile-web`, `BitFun-Installer`, `tests/e2e`, `src/crates/interfaces` | Product hosts, commands, UI entrypoints, protocol interfaces, and cross-surface tests | desktop, CLI, server, relay, Web UI, mobile web, installer, E2E, `acp` | nearest local `AGENTS.md`; [interfaces](src/crates/interfaces/AGENTS.md) | -| 2 | Product assembly | `src/crates/assembly` | Compatibility exports, product capability selection, product-full wiring, and adapter/service registration | `core`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | +| 2 | Product assembly | `src/crates/assembly` | Compatibility exports, product capability selection, product-full wiring, adapter/service registration, and ecosystem-neutral source coordination | `core`, `external-sources`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | | 3 | Adapters | `src/crates/adapters` | AI/transport/WebDriver/OpenCode protocol adapters and external-provider translation | `ai-adapters`, `opencode-adapter`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | | 4 | Services | `src/crates/services` | Reusable OS, filesystem, terminal, MCP, remote, git, watch, process, LSP plugin registry, session persistence primitives, MiniApp runtime IO, and network implementations | `services-core`, `services-integrations`, `relay-service`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | | 5 | Execution primitives | `src/crates/execution` | Portable agent, harness, stream, DeepReview policy/report, plugin host boundary, typed-service, tool-contract, tool-group, and tool-execution building blocks | `agent-runtime`, `agent-stream`, `tool-contracts`, `harness`, `plugin-runtime-host`, `runtime-services`, `tool-provider-groups`, `tool-execution` | [AGENTS.md](src/crates/execution/AGENTS.md) | diff --git a/Cargo.toml b/Cargo.toml index a077e0d22d..30f3d335aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "src/apps/relay-server", "src/crates/interfaces/acp", "src/crates/assembly/core", + "src/crates/assembly/external-sources", "src/crates/adapters/ai-adapters", "src/crates/adapters/opencode-adapter", "src/crates/adapters/webdriver", diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index 944b07f564..804ba909d8 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -4,8 +4,10 @@ 是第一条完整兼容来源;其他生态只在有稳定格式和真实消费方时接入。各生态的解析、加载顺序和运行语义仍由对应 适配器负责,本文不建立跨生态通用配置格式或脚本 SDK。 -本文是产品与目标架构设计。当前 BitFun 只具备 BitFun 原生受管包的来源确认、启停记录和少量 OpenCode custom -tool 静态名称预览,尚未具备本文描述的统一来源视图、OpenCode 完整来源发现、配置实时兼容或插件执行能力。 +本文同时记录当前可用纵向切片与目标架构。当前 BitFun 已具备通用外部来源目录和生命周期协调器,并通过 +OpenCode Prompt Command 适配器接入本地用户全局/项目来源;Desktop 可查看、刷新、抑制和处理跨来源冲突, +CLI/TUI 可列出并执行 prompt-only Command。完整配置映射、Codex/Claude Code 适配器以及 Tool、Subagent、插件 +执行仍属于后续阶段,不能因来源被识别就宣称已经可用。 ## 1. 产品判断与竞品启示 @@ -28,11 +30,13 @@ tool 静态名称预览,尚未具备本文描述的统一来源视图、OpenCo 目标: 1. 自动发现当前执行域中的用户全局、项目和工作区外部来源,不阻塞项目打开、TUI 输入或无关会话。 -2. 当前能够安全消费的低风险内容默认无感应用,并通过可撤销的非阻塞摘要说明来源和影响。 +2. 当前能够安全消费且不存在同名冲突的低风险内容默认无感应用,并通过可撤销的非阻塞摘要说明来源和影响; + 外部能力与产品本地能力、或独立外部 provider 之间发生同名冲突时,不得静默选择胜者。 3. 插件、Hook、Command、MCP 等可执行或有外部副作用的内容先发现,首次启用或能力扩大时再由用户确认。 4. 运行中感知来源修改、升级、删除和重新出现;成功更新安全切换,失败时优雅保留仍合规的上一有效代次。 5. 用户始终能解释“发现了什么、来自哪里、当前是否生效、为何降级、下一步能做什么”。 6. 产品体验可复用于未来生态,但解析、优先级、权限和运行语义不被抽象成最低公分母。 +7. 冲突选择按“能力 + 逻辑名称 + 全部候选身份与内容版本”形成指纹;同一指纹只询问一次,任一候选更新后才重新询问。 非目标: @@ -174,18 +178,22 @@ tool 静态名称预览,尚未具备本文描述的统一来源视图、OpenCo ```mermaid flowchart LR Sources["用户全局 / 项目 / 工作区外部来源"] - Adapter["生态发现与解析适配器"] + Adapters["同级生态适配器:OpenCode / Codex / Claude Code"] + Ports["能力专属 provider 契约"] Catalog["外部来源目录与只读状态"] - Coordinator["生态来源协调器:监听、候选、差异与切换"] + Watch["文件观察与外部变化事实"] + Coordinator["共享生命周期协调器:候选、差异与原子替换"] Policy["激活策略与各能力 owner"] Config["Runtime Configuration Service"] Host["Plugin Runtime Host"] - Owners["Skill / MCP / Tool / Config / TUI 等归属模块"] + Owners["Command / Skill / MCP / Tool / Config / Subagent 等归属模块"] Surface["Desktop / CLI / Web / SDK"] - Sources --> Adapter - Adapter --> Catalog - Adapter --> Coordinator + Sources --> Adapters + Adapters --> Ports + Watch --> Coordinator + Coordinator --> Ports + Ports --> Catalog Catalog --> Surface Coordinator --> Policy Policy --> Config @@ -198,8 +206,12 @@ flowchart LR | 部分 | 负责 | 不能承担 | |---|---|---| | 外部来源目录 | 聚合来源身份、作用域、资产清单、用户处理偏好和可读状态 | 解释所有生态格式、保存凭据、授予脚本权限或管理 worker。 | -| 生态发现与解析适配器 | 发现本生态标准来源,保留真实优先级、格式和诊断 | 写 BitFun 配置、执行第三方代码或创建跨生态最低公分母。 | -| 生态来源协调器 | 监听变化、生成候选、执行 import 前包络比较和 import 后贡献比较、请求准备并决定切换 | 直接提交配置、工具、权限或界面状态。 | +| 生态发现与解析适配器 | 发现本生态标准来源,保留真实优先级、格式、参数展开和诊断,并通过能力专属 provider 输出 | 写 BitFun 配置、依赖兄弟生态 adapter、执行其他生态语义或创建跨生态最低公分母。 | +| 能力专属 provider 契约 | 用来源限定身份交付 Command、Tool、Subagent 等类型化定义与调用/展开结果 | 携带任意 payload 的通用资产对象,或让一种能力的新增字段污染其他能力。 | +| 文件观察服务 | 提供可订阅、去抖的文件变化事实 | 解释生态路径、决定优先级、提交业务状态。 | +| 本地 JSON 存储服务 | 提供跨进程锁、锁内读改写和严格同卷原子替换等通用文件能力;替换失败时保留旧文件 | 定义外部来源偏好 schema、冲突策略或生态语义。 | +| 共享生命周期协调器 | 调用已注册 provider、生成不可变候选、按 provider 原子替换、保留隔离诊断,并请求能力 owner 切换 | 按生态 ID 分支业务行为、解析生态文件、直接提交配置、工具、权限或界面状态。 | +| 冲突解析 | 对独立 provider 或产品本地能力的同名候选建立版本敏感指纹;未选择时不激活,选择后只在指纹不变时复用 | 用 adapter 优先级静默覆盖另一生态或本地能力,或把选择写回外部文件。 | | 激活策略与各能力 owner | 根据风险、用户选择、组织上限和执行域决定自动应用、等待确认或限制 | 修改生态加载顺序或把策略拒绝伪装成解析失败。 | | Runtime Configuration Service | 应用兼容配置视图,执行显式导入、冲突预览、原子写入和撤销 | 读取凭据值或加载插件代码。 | | Plugin Runtime Host / 执行服务 | 准备代次、监督进程、期限、取消、背压、健康和贡献生命周期 | 决定来源优先级、产品提示策略或最终业务状态。 | @@ -209,6 +221,19 @@ flowchart LR MCP、Tool、Permission 和 Plugin Runtime 边界;不得建立同时扫描目录、写配置、下载依赖、执行命令和注册贡献的 “大导入器”。 +`ecosystem_id`、来源类型和执行域 ID 是开放且可校验的标识,不是 core 中持续扩大的枚举分支。只有 Product +Assembly 知道当前构建注册了哪些具体 adapter;产品入口、目录、协调器和能力 owner 不得导入 OpenCode、Codex +或 Claude Code 的私有类型。新增生态通过同级 adapter 与现有能力契约接入,不能修改另一个生态 adapter。 + +provider discovery 必须是可独立调度的 request/result,不在协调器锁内串行扫描。产品组装为每个 provider 设定期限, +超时后只沿用该 provider 的上一有效结果;健康兄弟 provider 继续更新。同步文件适配器超时后底层阻塞任务未必可取消, +因此同一 provider 同时最多保留一个 in-flight discovery,后续刷新复用它,完成后再提交结果,不能无限堆积线程。 +未来网络 provider 还应实现协作式 deadline/cancel,但不改变目录、冲突或产品入口契约。 + +来源降级必须区分粒度:整个配置/目录状态未知时回退对应来源;能确定身份的单个 Command 读取或解析失败时只回退该 +Command;明确缺失且未被标记失败的 Command 是稳定删除。产品调用在刷新后还要校验先前投影的候选 ID 与内容版本, +否则菜单展示旧版本、执行新版本会绕过冲突重新确认。 + ## 7. 状态与提示规则 以下表格是各宿主唯一的一级用户状态集合;Host 的 `ready/restarting/paused` 等内部阶段只能作为详情和原因映射, @@ -237,13 +262,22 @@ MCP、Tool、Permission 和 Plugin Runtime 边界;不得建立同时扫描目 ## 8. 分阶段落地与验收 -第一阶段只建立当前可证明的体验,不借设计提前宣称运行能力: - -1. 发现 OpenCode 当前支持的用户全局和项目来源,建立来源限定身份和聚合清单。 -2. 静态 custom tool 名称继续标为“已发现,静态预览,未执行”;不能因进入来源页变成“已加载”。 -3. 对已经有真实归属模块且不产生外部副作用的 L1 内容,允许无感应用并提供非阻塞摘要;尚未接通的内容只展示。 -4. 先完成来源变化、无效候选、删除、重新出现、去重提示和作用域测试,再接入真实 JS/TS 执行。 -5. standalone tool 闭环必须补齐首次启用、能力摘要、候选代次和删除撤下验证,不能只证明 `execute` 成功。 +第一阶段以 Prompt Command 做第一个可用纵向切片,不借设计提前宣称其他运行能力: + +1. 建立共享来源目录、生命周期协调器、开放生态 ID 和 Prompt Command 专属契约;用第二个 fake adapter 证明 + provider 更新、失败和删除彼此隔离。 +2. 发现 OpenCode 当前支持的用户全局和项目 Command 来源,建立来源限定身份、生态内覆盖关系和聚合清单; + OpenCode 自身定义的项目/用户优先级仍由 adapter 解释,跨 provider 或与 BitFun 本地 Command 的同名冲突进入待选择状态。 +3. 支持 `$ARGUMENTS` 与位置参数的 prompt-only 命令在用户显式选择或输入时展开并提交;发现本身不向会话发送内容。 +4. 含 `!shell`、`@file`、`{env:...}`、`{file:...}`、`agent`、`model`、`variant` 或 `subtask` 等未接通语义的命令标记为“部分受限”,不做 + 静默忽略后的部分执行。静态 custom tool 名称仍只能标为“已发现,静态预览,未执行”。 +5. Desktop 提供统一来源状态、刷新、按执行域抑制/恢复和冲突候选选择;首次 provider 扫描完成前显示中性检查状态, + 不把暂时空目录误报为最终空结果;已经选择且指纹未变化的冲突退出待处理区。CLI/TUI 使用同一目录列出和执行 + Command;跨 provider 候选以来源限定别名供 CLI 用户直接选择,同次选择也解析本地同名冲突。发现或确认不阻塞 + 普通聊天输入;发现未完成时未限定 slash 别名不猜测冲突结果,显式 `/builtin:` 仍可立即执行。执行域全局偏好 + 使用独立偏好文件、跨进程锁和严格原子替换,并在查询、刷新和执行前重新读取,使并行 Desktop/CLI 进程不会继续使用 + 另一进程已停用的来源或丢失并发选择。Desktop IPC 仅返回设置页所需摘要,不携带 Prompt Command 模板正文。 +6. 先完成来源变化、无效候选、稳定删除、重新出现、偏好保持和去重提示,再在后续 PR 接入真实 JS/TS Tool。 验收至少覆盖: @@ -259,6 +293,9 @@ MCP、Tool、Permission 和 Plugin Runtime 边界;不得建立同时扫描目 - 全局来源偏好与项目执行实例策略分开;跨项目只重新求值而不重复提示,跨执行域或新实例扩大执行包络时不会 错误继承确认。 - 持续来源撤销后不会被下一次 watcher 更新重新应用;当前项目与整个执行域的抑制范围可验证。 +- 同名外部候选和产品本地能力在用户选择前均不会被静默覆盖;选择在候选内容版本和参与集合不变时不重复询问, + 任一候选更新、删除或参与集合变化后重新进入待选择,即使变化后只剩一个实现也不静默切换。 +- 冲突偏好按执行域与命令族只保留当前指纹,并以去重候选身份标记曾发生冲突;连续内容更新不会按历史指纹线性膨胀。 - 显式导入的字段级预览、冲突、撤销和凭据脱敏可验证。 - 当前只支持静态预览的资产不会被产品文案误报为已应用或可执行。 diff --git a/docs/architecture/extensions/opencode-config-assets-adapter-design.md b/docs/architecture/extensions/opencode-config-assets-adapter-design.md index c8d000ba5c..37ab4db7c4 100644 --- a/docs/architecture/extensions/opencode-config-assets-adapter-design.md +++ b/docs/architecture/extensions/opencode-config-assets-adapter-design.md @@ -10,8 +10,9 @@ 配置字段与来源以 [OpenCode 配置文档](https://opencode.ai/docs/config/)和稳定提交中的主/TUI 配置实现为准。 -本文是目标设计。当前 BitFun 已有部分原生归属模块和兼容目录发现,但尚未实现本文定义的完整 OpenCode -配置来源图、合并语义和资产映射。 +本文同时记录首个可用切片与后续目标。当前 BitFun 已实现本地用户全局/项目 Prompt Command 的来源发现、 +JSON/JSONC/Markdown 解析、参数展开、运行时刷新和冲突选择,但尚未实现本文定义的完整 OpenCode 配置来源图、 +全部合并语义和其他资产映射。 ## 1. 目标与边界 @@ -78,7 +79,8 @@ OpenCode 当前版本的真实合并/去重语义,不用 BitFun 常规配置 - 远程 `.well-known/opencode` 中的 `config`,以及 `remote_config` 指向的 URL/Headers;本地只先记录远程引用, 主动联网获取前按 L2 处理,组织已批准且有既有连接的执行域可以由对应 owner 自动允许。 -- `~/.config/opencode/opencode.json` 或 `opencode.jsonc`。 +- XDG 用户配置根(默认 `~/.config/opencode`,Windows 也不改用 AppData)中的 `config.json`、`opencode.json` 和 + `opencode.jsonc`。 - `OPENCODE_CONFIG` 指定的配置文件。 - 从工作树根到当前目录按 root-first 顺序发现项目 `opencode.json/jsonc`。 - `.opencode`、`~/.opencode`、全局配置目录和 `OPENCODE_CONFIG_DIR` 中的 `agents/`、`commands/`、`modes/`、 @@ -88,6 +90,11 @@ OpenCode 当前版本的真实合并/去重语义,不用 BitFun 常规配置 所有来源合并后再应用 `OPENCODE_PERMISSION`、旧 `tools` 到 permission 的迁移,以及关闭自动压缩/裁剪的环境覆盖。这些属于冻结版本的后处理,不是新的配置来源。 +PR1 只实现上述本地 Command 子集:XDG 用户配置根、`OPENCODE_CONFIG`、root-first 项目配置、用户/项目 +`command(s)/`、兼容 `~/.opencode` 与 `OPENCODE_CONFIG_DIR`;`OPENCODE_DISABLE_PROJECT_CONFIG` 可整体关闭项目 +扫描。`OPENCODE_CONFIG_CONTENT`、远程、组织、系统管理员与 MDM 来源仍保留在目标来源图中,不得在产品状态中误报 +为已加载。路径按规范化来源身份去重,显式环境路径与默认路径相同时只保留 OpenCode 顺序中的最后一个阶段。 + ### 3.2 TUI 独立来源顺序 `tui.json/jsonc` 不是主配置来源图的附属字段。稳定版使用独立顺序: @@ -165,9 +172,8 @@ OpenCode 来源顺序决定兼容输入如何合并;BitFun 产品能力上限 ## 5. 声明式资产映射 -下表的“默认行为”是对应交付阶段完成后的目标行为。OC-R1 只激活不启动进程、不 import module、不读取凭据且不 -主动联网的结果;远程 Instruction、Skill/Reference/Command/MCP/LSP/Formatter/Plugin/Tool 和其他可执行项到 -OC-R2 前只解析、展示来源与诊断。 +下表的“默认行为”是对应交付阶段完成后的目标行为。首个实现切片只接入本地 prompt-only Command:不启动进程、 +不 import module、不读取凭据、不主动联网;其他远程或可执行资产只解析、展示来源与诊断。 | 资产 | OpenCode 输入 | BitFun 归属模块 / 适配方式 | 默认行为 | 降级条件 | |---|---|---|---|---| @@ -175,7 +181,7 @@ OC-R2 前只解析、展示来源与诊断。 | Agents / Modes | JSON、Markdown、description、mode、prompt、model、variant、temperature、top_p、steps、deprecated `maxSteps`、deprecated `tools`、permission、disable、options、hidden、color | Agent 归属模块创建兼容定义和作用域视图 | 纯声明字段按 OpenCode 顺序应用;扩大工具、权限或外部能力时确认 | BitFun 不支持字段进入诊断,不阻止其他 Agent。 | | Skills | `.opencode/.claude/.agents` 项目与用户根、`SKILL.md`、`skills.paths/urls` | Skill 归属模块复用按需加载并补齐规则顺序 | 说明和索引按需加载;URL、脚本或外部依赖按 L2 确认 | URL 或可执行资源失败只降级对应 Skill。 | | References | `references` / 旧 `reference`,本地 path 或 Git repository/branch/description/hidden | **基础能力缺失**:先补 Workspace Reference 的异步准备与 `@alias` 消费接口 | 本地引用保留相对来源;Git 拉取按 L2 确认并保留缓存/隐藏语义 | 拉取失败不阻止项目,外部目录仍遵守工具权限。 | -| Commands | JSON/Markdown、`$ARGUMENTS`、位置参数、`@file`、`!shell`、agent/model/variant/subtask | **已有行为、边界未抽取**:从现有 CLI/TUI 命令路径增量提取模板与执行接口 | 发现并预览模板;首次执行型启用确认后保留 OpenCode 展开顺序和子任务语义 | 文件或 shell 展开失败只终止本次命令。 | +| Commands | JSON/JSONC、Markdown、`$ARGUMENTS`、位置参数、`@file`、`!shell`、agent/model/variant/subtask | Prompt Command 专属契约;OpenCode adapter 保留发现、覆盖、解析和参数展开语义,CLI/TUI 只消费中立定义与展开结果 | PR1 支持 prompt-only 模板,用户显式选择或输入即确认本次发送;未接通的文件、shell、agent/model/variant/subtask 标为部分受限且不做部分执行 | 已知命令文件无效只回退该命令;稳定删除撤下新调用;目录枚举未知时回退对应目录来源,不能把未知当空目录。 | | MCP | local 的 command/environment/cwd/timeout,remote 的 URL/headers/oauth/timeout,Agent 选择 | MCP 归属模块创建兼容配置视图 | 首次按命令、网络、凭据和作用域确认,再按来源启用状态运行 | 凭据或网络不可用时单个 Server 不可用。 | | LSP | command、extensions、env、initialization | LSP 归属模块注册兼容实例 | 首次确认外部进程和作用域后按文件类型启动 | 自定义 Server 缺少 extensions 或启动失败时只禁用该项。 | | Formatters | command、environment、extensions、`$FILE` | **基础能力缺失**:先补文件写入后的 Formatter 执行消费点,再做格式转换 | 首次确认命令后执行匹配 Formatter | 超时后标记未格式化,文件写入结果保留。 | @@ -202,11 +208,29 @@ OC-R2 前只解析、展示来源与诊断。 ### 5.3 Commands -命令展开按 OpenCode 顺序解析参数、文件引用和 shell 输出。`!shell` 在脚本执行域执行,不另建绕过可靠性 -控制的同步 shell 路径。展开有期限、取消和输出大小限制;大输出保存后只把引用交给命令模板。 +PR1 只展开 `$ARGUMENTS` 与 `$1`、`$2` 等位置参数。OpenCode adapter 负责参数拆分、替换顺序和未使用参数追加, +Prompt Command owner 只接收最终可发送文本;产品 core 不按生态 ID 解释模板。包含 `@file`、`!shell`、 +`{env:...}`、`{file:...}`、agent/model/variant/subtask 的命令仍进入目录,但整体标为“部分受限”,不能解析凭据或 +删除不支持的部分后继续发送。 + +Markdown front matter 的 `description`、`agent`、`model`、`variant`、`subtask` 按当前 OpenCode schema 校验; +已知字段类型错误使该命令不可用,不能当作缺省值继续执行。初次 YAML 解析失败时,adapter 按 OpenCode 当前规则将 +未引用且包含冒号的顶层值改写为 block scalar 后重试,避免拒绝 OpenCode 自身可加载的文件。 + +配置文件限制为 1 MiB,单个 Markdown 命令限制为 256 KiB,单个目录来源最多扫描 2048 个 Markdown 文件,且单个 +provider 的模板正文总量限制为 8 MiB;超过限制进入明确诊断,不能无界占用内核目录或 TUI 刷新。Desktop 设置页只接收命令摘要,模板 +正文不进入 IPC。执行前以来源限定命令 ID 和命令内容版本校验当前投影,若文件在菜单展示后更新,旧投影必须返回 +stale selection 并等待重新选择,不能直接执行刚刷新的新内容。 + +后续阶段接通文件引用和 shell 输出时仍按 OpenCode 顺序展开。`!shell` 必须进入脚本执行域,不另建绕过可靠性控制 +的同步 shell 路径;展开有期限、取消和输出大小限制,大输出保存后只把引用交给命令模板。 -自定义命令可按 OpenCode 规则覆盖同名内置命令,但产品可以声明少量 protected 命令。发生保护冲突时, -兼容视图保留插件命令的可调用别名,并显示行为差异。 +OpenCode 生态内部仍按其规则覆盖同名内置命令,但跨独立 provider 或与 BitFun 本地命令同名时不得静默覆盖。 +发生冲突后,兼容视图展示全部来源;CLI/TUI 对本地/单一外部冲突提供 `/builtin:name` 与 `/external:name`,对跨 +provider 候选提供 `/external::`。一次外部候选选择同时解析同名本地命令冲突。选择按候选身份和 +`content_version` 形成的冲突指纹持久化,同一指纹只询问一次;任一外部候选更新、删除或参与集合变化后指纹变化并 +重新询问,即使变化后只剩一个外部或内建候选也不能静默切换实现。持久化只保留每个执行域/命令族的当前指纹和 +去重后的曾冲突候选身份,不累计每次内容版本的完整历史。 ### 5.4 MCP、LSP 与 Formatter @@ -246,6 +270,7 @@ OpenCode 配置文档还包含下列不属于声明式目录资产、但会改 - OpenCode 配置来源顺序和插件加载顺序分别维护;配置归属模块不重新排列插件。 - 同名配置键按来源覆盖;非冲突键合并。 +- 生态 adapter 只执行本生态规范明确规定的覆盖;独立生态/产品本地能力的同名候选交给通用冲突契约,不按 adapter 注册顺序决胜。 - 同名命令、Theme、Keybind 和插件条目按各自 OpenCode 规则处理,不能使用一个通用“后者覆盖”规则猜测。 - 用户/组织保护项是一层显式策略,不改写来源顺序;兼容报告同时展示“OpenCode 结果”和“策略后结果”。 - 产品定义只给出构建期默认和明确保护的产品能力,不参与项目运行时资产的同名冲突。 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 9afdb2d9af..7867074678 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -33,9 +33,9 @@ BitFun 同时面向桌面 GUI、TUI/CLI、Web、ACP、Server、Remote、SDK 和 6. **入口形态受宿主约束**:TUI、GUI、Web 和 SDK 共享能力服务接口和只读视图,不共享渲染句柄、主题键、键位模型或界面状态;插件界面贡献必须先声明目标入口形态,再由对应宿主适配。 7. **产品定制先解析,运行时扩展后加载**:产品身份、能力上限和 GUI/TUI 布局选择在构建/组装期解析;用户配置和插件只能在该上限内扩展,不能反向改写产品事实。 8. **平台差异留在入口和具体能力实现**:target 只选择 ABI,feature 只控制确实可选的依赖;共享内核不按平台 - 分叉业务语义,也不新增包含所有 OS 方法的总接口。新端口必须有当前调用方,或满足 2.1 节的短期前置接口条件。 -9. **发现无感,生效按风险分级**:外部用户/项目来源后台发现,不阻塞产品入口;低风险声明式内容可自动应用 - 并提供撤销,可执行来源首次启用或能力扩大时形成非阻塞确认。激活后的本地 OpenCode 扩展默认按当前用户能力 + 分叉业务语义,也不新增包含所有 OS 方法的总接口。新端口必须有当前调用方。 +9. **发现无感,生效按风险分级**:外部用户/项目来源后台发现,不阻塞产品入口;无冲突的低风险声明式内容可自动应用并提供撤销; + 与产品本地能力或独立外部 provider 同名时必须由用户选择,且选择只在候选身份与内容版本不变时复用;可执行来源首次启用或能力扩大时形成非阻塞确认。激活后的本地 OpenCode 扩展默认按当前用户能力 运行;经 BitFun 能力接口的调用可细分限制,脚本直接文件/网络/进程能力只在真实操作系统或容器边界存在时可 粗粒度收紧,否则停用相应 target。策略降级必须与待确认、解析错误和插件故障分开显示。 10. **开放权限不降低可靠性**:第三方代码始终位于受监督的独立执行进程,具备期限、取消、背压、崩溃回收、 @@ -58,7 +58,7 @@ BitFun 只保留四个稳定接口切面;工具、事件和权限作为归属 | 前后端能力服务切面 | GUI、TUI/CLI、Web、ACP、Server、Remote、SDK 客户端 | 能力服务接口 | 命令请求、会话/工作区状态、权限提示、诊断、产物引用、能力状态、事件流、类型化错误、插件状态只读视图 | 内核状态机、执行层内部类型、`PluginRuntimeClient`、主机内部状态、生态原始载荷、Tauri/React/TUI 实现、具体服务提供方、未预算的界面贡献接口 | | BitFun 与插件切面 | 插件运行时主机、安全控制面、产品组装、生态适配器 | 扩展贡献接口 | 插件来源、启用状态、能力与副作用、真实工具定义、钩子变换、权限要求、界面贡献、诊断和故障事实 | 最终权限结果、最终工具结果、审计写入、内核权威状态、前后端协议 DTO、界面实现代码 | | 插件通用运行时切面 | 智能体内核、执行层、产品组装、插件运行时主机 | 主机内部 ABI | 类型化调用、请求身份、期限、取消、有界队列、健康状态、响应校验和诊断 | SDK 门面、前后端接口、生态适配器对象、worker/subprocess 句柄、产品入口状态 | -| OpenCode 适配切面 | 插件运行时主机和脚本执行进程内部 | 兼容适配层 | OpenCode plugin/config/tool/hook/event/TUI target 的解析、执行、兼容 Client 和 BitFun 模块映射 | 独立智能体内核、OpenCode 原始类型泄漏到产品接口、外部 OpenCode CLI 前置依赖 | +| 外部生态兼容适配切面 | 来源协调器、能力 owner、插件运行时主机和脚本执行进程内部 | 每生态独立兼容适配层 + 能力专属 provider 契约 | 各生态来源发现、优先级、格式/参数语义、诊断,以及到 Command/Tool/Subagent/Config 等 BitFun 模块的类型化映射 | 跨生态任意 payload、兄弟适配器依赖、生态原始类型泄漏到产品接口、把外部 CLI 作为默认前置依赖 | 这四项是能力必须归入的概念切面,不表示表中每项已有稳定 API。当前接口仍须满足 2.1 节的真实消费方、版本与验证准入。 @@ -67,7 +67,7 @@ BitFun 只保留四个稳定接口切面;工具、事件和权限作为归属 | 子接口 | 归属 | 用法 | |---|---|---| | 工具 ABI | `tool-contracts` / 执行层 | 具备真实执行实现的插件 custom tool、MCP 工具和内置工具进入同一可调用工具集合、权限和陈旧调用保护路径;只有声明或候选项的插件工具不能进入该集合。 | -| 事件清单 | `events` / 智能体内核事件 schema | 对固定 OpenCode 版本分别维护服务插件 v1 和终端插件 v2 事件清单;插件观察兼容事件,BitFun 内部私有字段在适配层转换或脱敏。 | +| 事件清单 | `events` / 智能体内核事件 schema | 对固定生态版本维护各自事件清单;插件观察兼容事件,BitFun 内部私有字段在对应适配层转换或脱敏。 | | 权限与副作用 | 安全控制面 / runtime ports | 来源/target 激活后,默认兼容策略允许 OpenCode `permission.ask` 和直接脚本能力按当前用户权限运行;经 BitFun 接口的调用可细分收紧,直接脚本能力只能由真实 OS/容器环境粗粒度限制,否则停用 target。 | ### 2.1 公开接口准入规则 @@ -174,7 +174,8 @@ flowchart TB Extension["BitFun 与插件切面"] HostAbi["插件通用运行时切面"] PluginHost["插件运行时主机"] - OpenCodeAdapter["OpenCode 适配切面"] + ProviderPorts["Command / Tool / Subagent 等能力专属 provider"] + EcosystemAdapters["同级生态 adapter:OpenCode / Codex / Claude Code"] PluginUnit["插件执行单元"] PlatformPorts["平台端口"] PlatformAdapters["平台和外部系统适配器"] @@ -191,8 +192,11 @@ flowchart TB Events --> Extension Extension --> HostAbi HostAbi --> PluginHost - PluginHost --> OpenCodeAdapter - OpenCodeAdapter --> PluginUnit + Owners --> ProviderPorts + Assembly -.-> EcosystemAdapters + EcosystemAdapters --> ProviderPorts + PluginHost --> EcosystemAdapters + EcosystemAdapters --> PluginUnit Execution --> PlatformPorts PluginHost --> PlatformPorts PlatformPorts --> PlatformAdapters @@ -204,19 +208,25 @@ flowchart TB - 插件只进入扩展贡献接口,不直接写内核状态、工具结果、权限结果或审计事实。 - 插件运行时主机只负责类型化调用、期限、取消、有界队列、逻辑 target 状态、响应校验和故障状态; 物理进程健康、资源预算与进程树回收属于脚本执行服务。 -- OpenCode 适配层负责保留外部格式和调用语义并映射到 BitFun 归属模块;它本身不成为新的业务归属模块。 +- 每个生态适配层独立保留该生态的外部格式、来源顺序和调用语义,并映射到 BitFun 归属模块;它本身不成为新的 + 业务归属模块,也不能依赖或修改兄弟生态 adapter。通用目录、生命周期协调器和能力 owner 只依赖开放生态 ID、 + 来源限定身份与能力专属 provider 契约,不按 OpenCode、Codex 或 Claude Code 分支行为。 - 产品组装是组装根,只在组装期选择能力、服务实现、插件运行时绑定和降级策略。 - 依赖方向保持为产品入口 / interfaces → assembly → adapters / services / execution → contracts。assembly 可以选择下层提供方,但不能依赖 app crate;需要同时被独立应用和嵌入式模式复用的实现必须下沉到可复用 owner, 再由各 app 和 assembly 组合。 -## 4. OpenCode-compatible 当前 P0 基线与目标 +## 4. OpenCode-compatible 当前基线与目标 -当前 P0 只验证了 BitFun 专用插件目录中的来源校验、工作区审核、启停记录、CLI 诊断和 custom tool 名称预览。 +Plugin Runtime P0 只验证了 BitFun 专用插件目录中的来源校验、工作区审核、启停记录、CLI 诊断和 custom tool 名称预览。 它不执行 JS/TS,不注册真实工具,也不运行 OpenCode 钩子、Client 或终端插件。现有能力只能称为“静态预览”, 不能称为“OpenCode 插件运行时”。详细代码事实集中在 [`plugin-runtime-host-design.md#8-当前实现附录`](extensions/plugin-runtime-host-design.md#8-当前实现附录)。 +与 Plugin Runtime 分离的 Prompt Command 基线已经通过能力专属 provider 契约接入:可发现本地用户/项目 OpenCode +Command,处理跨来源冲突,并在 CLI/TUI 中执行受支持的 prompt-only 模板。该能力不执行 JS/TS,也不能推导 Tool、 +Hook、Subagent 或完整配置兼容已经可用。 + 目标路线不要求 OpenCode 插件作者维护 `bitfun.plugin.json` 或复制到 `.bitfun/plugins`。BitFun 直接发现用户和 项目的 OpenCode 配置、插件目录、工具目录和软件包来源;低风险内容按用户偏好自动应用或先询问,可执行来源在 首次启用或能力扩大时非阻塞确认。已准入候选自动记录当前执行版本,在自有脚本进程中真实加载插件,再通过兼容 diff --git a/docs/plans/opencode-extension-compatibility-plan.md b/docs/plans/opencode-extension-compatibility-plan.md index 21c8180ead..631d0d28f3 100644 --- a/docs/plans/opencode-extension-compatibility-plan.md +++ b/docs/plans/opencode-extension-compatibility-plan.md @@ -1,181 +1,165 @@ # OpenCode 扩展兼容执行计划 -本文只定义近期可执行顺序。完整能力差异保留在 -[兼容矩阵](../architecture/extensions/opencode-extension-compatibility.md),运行边界见 -[插件运行时主机](../architecture/extensions/plugin-runtime-host-design.md)和 -[OpenCode 插件适配](../architecture/extensions/opencode-plugin-runtime-adapter-design.md)。兼容矩阵是审计库存,不是默认 -路线图。 -外部来源的统一提示、风险分级、导入和变化体验见 -[外部 AI 工作内容设计](../architecture/extensions/external-ai-work-sources-design.md)。 - -当前基线只有来源确认和静态 custom tool 名称预览:不执行 JS/TS,不注册真实工具,也不运行 Hook、Client 或 TUI -插件。任何计划项都不能被表述成当前能力。 - -## 1. 执行原则 - -1. 先完成一个遵循官方公开契约、但不依赖外部软件包的 standalone custom tool,再考虑依赖物化、package plugin、Hook 或 TUI contribution。 -2. 只实现固定版本文档、源码和真实样例共同需要的语义;未知接口稳定失败,不伪造成功。 -3. 复用现有 Tool Runtime、权限和事件 owner;界面反馈只进入目标宿主已经存在的状态入口,不建立插件专用工具调用状态机。 -4. 外部类型停留在 adapter/worker 内;BitFun 只接收经过校验的工具定义、调用结果、变换或诊断。 -5. 脚本执行实现不进入通用平台抽象。若首个样例确实需要新端口,它只表达该调用方需要的 load/invoke/cancel/ - dispose 和诊断,不暴露 Bun、worker 数量、IPC 或进程句柄。 -6. Desktop、Remote 和 HarmonyOS PC 原生 CLI/TUI 分别资格验证;一个平台可用不能推导其他平台可用。HarmonyOS - 手机 Remote App 不在本计划的平台执行范围内。 -7. 用户全局和项目来源自动发现,但“已发现”“已应用”和“可执行”必须分开;低风险内容可无感应用,可执行来源 - 首次启用和 import 前执行包络扩大时非阻塞确认。动态贡献只能在 import 后确认时,候选不得先注册,且必须说明 - import 直接副作用不可撤销;不能用阻塞项目的迁移向导代替,非交互操作也只在实际依赖待确认资产时返回 - `action-required`。 - -第三方脚本可以直接访问文件、网络、环境和子进程。独立进程、期限、取消和有界队列能限制故障传播,但没有 -OS/container 资源限制时不能称为沙箱,也不能保证阻止 CPU、内存或进程耗尽。来源身份、执行域和现有策略必须在 -module import 前确定;严格策略无法落实时停用该 target。 - -## 2. 阶段总览 - -| 阶段 | 可观察结果 | 明确不包含 | -|---|---|---| -| OC-E0 来源识别基线 | CLI 准确显示用户/项目来源、作用域和“静态预览,未执行”,以一次非阻塞摘要聚合当前可识别内容,并固定稳定版本、官方契约和无外部依赖样例 | JS/TS 执行、完整配置导入、完整来源管理 UI | -| OC-E1 standalone tool | `.opencode/tools/` 中一个无外部依赖的契约样例完成首次确认、真实调用、身份/路径字段、`abort`、候选切换和删除撤下 | `metadata`/`ask`、官方 import 型样例、package plugin、npm 依赖安装、Hook、TUI | -| OC-E2 package plugin | 一个代表性真实插件无需改包即可工作,并覆盖更新失败、能力扩大和上一有效代次 | 全部历史 loader、完整 Client/Server、插件市场 | -| OC-E3 样例驱动扩展 | 一个真实 Hook 或最小 TUI contribution 闭环 | 全量 Hook、原始 renderer、完整 Server、Remote plugin | - -OC-E0 和 OC-E1 是近期范围。OC-E2/OC-E3 只有在前一阶段稳定并选定真实阻塞样例后才启动。 - -## 3. OC-E0:让来源识别与基线可信 - -交付: - -- 固定 OpenCode 稳定版本、custom tool 官方文档和对应源码/测试;记录本次使用的 commit。 -- 冻结一个遵循官方 export/ToolContext 契约、但不引用外部软件包的最小 `.opencode/tools/*.ts|js` 样例,并记录它与官方 import 型示例的差异。 -- 记录 BitFun 当前 source resolver、静态预览、CLI 状态和 Tool Runtime 的真实代码路径。 -- 静态名称只能显示为预览,不得进入模型可调用工具集合。 -- 对当前能够识别的用户全局和项目来源建立来源限定身份、作用域和聚合清单;同一全局来源不按项目重复提示。 -- 首次发现使用非阻塞摘要。当前静态 custom tool 只能进入 L0 清单;如果阶段内接通真实 L1 内容,才允许按 - “自动应用低风险内容 / 先询问”偏好进入归属模块,并必须支持按当前项目或执行域抑制来源/资产;watcher 更新 - 不得绕过撤销偏好重新应用。 -- 覆盖来源变化、原子保存/连续 watcher 事件、无效候选、稳定删除、重新出现和聚合去重;发现、解析或提示不得 - 阻塞 TUI 输入。 -- 官方文档只承诺复数 `tools/` 目录。单数 `tool/` 若由冻结源码和测试证明仍兼容,可作为版本化兼容输入;不能 - 写成长期公开保证,也不能复制第二套 resolver。 +本文定义 OpenCode 兼容能力的近期交付顺序。完整能力差异保留在 +[兼容矩阵](../architecture/extensions/opencode-extension-compatibility.md),跨生态来源体验与生命周期见 +[外部 AI 工作内容设计](../architecture/extensions/external-ai-work-sources-design.md)。兼容矩阵是审计库存,不是默认路线图。 -退出条件:版本和样例可复现;当前状态不包含“ready/available/兼容运行时”等误导文案;产品状态能明确区分 -已发现、已应用和代码可执行;用户/项目作用域和变化结果可解释,首次摘要不重复轰炸用户。 +PR1 已将基线推进到通用外部来源目录、生命周期协调器和 OpenCode Prompt Command 纵向切片;BitFun 原有受管 +插件包来源确认和 custom tool 静态预览继续保留。后续不沿用“先做一个大而全的 OpenCode Plugin Runtime”路线, +而是沿用稳定的跨生态来源契约,按 Tool、Subagent 分别交给真实能力 owner。每个 PR 都必须产生用户可直接验证的 +结果,同时不提前承诺尚未执行的生态能力。 -## 4. OC-E1:standalone tool 纵向闭环 +## 1. 稳定架构基线 -启动条件:OpenCode 路径发现已经归到唯一 adapter/source resolver,来源限定身份、非阻塞待确认状态和 OC-E0 -变化基线通过。 - -最小路径: +### 1.1 接口层与实现层 ```text -OpenCode source resolver - -> script loader +Product surfaces (Desktop / CLI / TUI) + -> External Source Catalog / lifecycle coordinator + -> consumes capability-specific provider contracts + -> Prompt Command provider contract + -> future Tool provider contract + -> future Subagent provider contract + +Same-level ecosystem adapters implement those provider contracts -> OpenCode adapter - -> Plugin Runtime Host - -> existing Tool Runtime - -> model/CLI invocation -``` - -调用返回时沿同一路径回到 Tool Runtime;Host/adapter 不各自维护一套调用或生命周期状态。 - -交付: - -- 从 workspace/user 官方目录发现 tool,不要求 `bitfun.plugin.json`、复制目录或安装 OpenCode CLI。 -- 首次按来源/target/执行域显示代码来源、工作目录和直接文件/网络/进程能力;确认前不 import module 或启动 worker。 -- 真实加载 module exports;只有取得有效 description/args/`execute` 的工具才注册。 -- 保留冻结样例的参数校验和执行行为;不能只把静态扫描得到的名称或 schema 当作执行定义。官方示例使用的 `@opencode-ai/plugin` 解析和依赖等待不属于本阶段,未支持前必须明确报告兼容差异。 -- 冻结版源码的 `ToolContext` 有 `agent`、`sessionID`、`messageID`、`directory`、`worktree`、`abort`、`metadata` - 和 `ask` 八项。OC-E1 必须提供前五项并把 `abort` 接到 Host 取消/期限;首个样例不调用 `metadata`/`ask`,两者在 - 本阶段返回明确 `unsupported`,因此 OC-E1 只能声明该契约子集可用。需要这两项的真实样例出现后,必须先定义其 - 到现有事件/权限 owner 的关联、取消和审计行为,再扩大兼容范围。上述字段只留在版本化 adapter,不提升为 BitFun - 跨生态稳定接口。 -- 覆盖正常结果、参数错误、throw、超时、取消、迟到响应、进程退出、结果过大和不可序列化结果。 -- Tool Runtime 继续负责排队、权限、执行状态和结果;worker/Host 只提供真实执行和诊断。 -- CLI 显示来源、可用/不可用原因、执行错误和恢复建议;module 可解析或进程启动成功不等于工具可用。 -- 同一执行包络和真实贡献摘要下的重启与普通更新,只有在来源身份/完整性和更新策略仍有效时才不重复确认; - import 前包络或 import 后贡献扩大分别进入 `action-required`。文件删除立即撤下新调用,候选失败仅在上一 - 代次仍合规且可验证时继续服务。 - -退出条件:无外部依赖且不调用 `metadata`/`ask` 的契约样例在 Desktop 完成发现、首次确认、真实调用、取消、 -安全更新、删除撤下和失败恢复;作者无需 -改源码或重打包;产品状态明确列出 context 子集;失败不阻塞 TUI -输入、终端恢复和无关工具;静态预览与实际 exports 冲突时以实际加载结果为准。 - -Remote 和 HarmonyOS PC 原生 CLI/TUI 在各自通过同一冻结样例前保持明确不支持,不能调用 Desktop worker 代执行 -工作区代码;手机 Remote App 不作为 HarmonyOS PC 的通过证据。 - -## 5. OC-E2:一个真实 package plugin - -启动前必须选定一个 standalone tool 无法覆盖的真实插件,并在评审中列出: - -- 它需要 package/server loader 的原因; -- 实际调用的 PluginInput、Client、`$` 或 `serverUrl` 方法; -- 依赖安装、版本身份、停用和恢复需求; -- 不支持它时的用户影响。 - -只实现该样例需要的来源、入口解析、依赖物化、最小 Client 和生命周期。未知方法必须稳定失败,写操作不得伪造 -成功。普通更新只有在来源身份/完整性和更新策略仍有效时才可以后台准备;软件包版本/完整性、import 前执行包络、 -凭据范围或执行域发生未获策略覆盖的变化时先确认,隔离 import 后发现动态贡献扩大时在注册前确认。停用、更新 -失败或进程崩溃后, -旧 contribution 只有在来源版本和当前策略仍可验证时才能继续;明确删除、撤销或策略收紧时必须撤下并说明结果。 - -不在本阶段预建所有 npm/Arborist 选项、历史入口 fallback、完整回环 Server、全局插件管理 UI 或完整 Client。 + -> future Codex adapter + -> future Claude Code adapter -退出条件:代表性插件无需改包工作;standalone tool 路径没有回归;依赖或插件失败只影响对应 target;未选择的 -插件形态仍保持未承诺。 - -## 6. OC-E3:按真实样例增加 Hook 或 TUI contribution - -### Hook - -每次只选择一个阻塞真实插件的稳定 Hook。先确定 BitFun 最终 owner、允许变换的字段、执行顺序、最终校验和失败 -范围,再扩展 adapter/host。合法变换由 owner 提交;插件不能直接写会话、权限、工具结果或审计状态。 - -每个 Hook 独立验证正常、链式、非法结果、异常、超时、取消和 owner 终检。一个 Hook 完成不表示其他 Hook 或 -“完整服务插件面”完成。 - -### TUI contribution - -首批只考虑: - -- command / slash alias; -- key binding 候选; -- toast 只在 CLI 已有类型化状态/通知 owner 后另行加入,不能复用 GUI 本地服务来假装跨宿主能力。 - -command/slash/key 进入 CLI action registry。键位冲突、退出/恢复 fallback、焦点和布局由 -宿主决定。插件不能持有 Ratatui Frame 或终端句柄。Route/Dialog/Prompt/slot/theme/state/KV/client/event 继续留在 -兼容矩阵中,只有真实样例阻塞时再单独立项;原始 `CliRenderer`、Solid/OpenTUI 组件树保持不支持。 - -退出条件:冻结样例可发现、启停并清理 contribution;冲突来源可见;异常不会造成输入锁死、空白页面或终端无法 -恢复。 - -## 7. 验证与发布 - -| 证据 | E0 | E1 | E2 | E3 | -|---|---:|---:|---:|---:| -| 固定版本和样例 | 必需 | 必需 | 必需 | 必需 | -| resolver/adapter/Host focused test | 基线 | 必需 | 必需 | 必需 | -| Tool Runtime 端到端 | - | 必需 | 必需 | Hook 涉及时 | -| CLI 状态与诊断 | 必需 | 必需 | 必需 | 必需 | -| 来源作用域、确认与变化生命周期 | 必需 | 必需 | 必需 | 必需 | -| TUI 输入/恢复 | 文案 | 失败路径 | 失败路径 | TUI 项必需 | -| Remote/HarmonyOS PC 原生 CLI/TUI | 明确状态 | 分别资格验证 | 分别资格验证 | 分别资格验证 | - -发布说明只列已通过的阶段、样例和平台。例如应写“Desktop 支持 OpenCode standalone custom tool 样例;package -plugin、Hook 与 TUI plugin 尚未支持”,不能笼统写“已兼容 OpenCode 插件”。 - -## 8. 暂停条件 - -出现以下情况时停止扩面: - -- 为一个样例新建第二个 Tool Runtime、Agent Runtime、会话 owner 或通用生态 API; -- 新内部端口暴露 Bun/QuickJS、worker 数、IPC 或 OS 进程句柄; -- 只有静态解析,没有真实 `execute`,却把工具标记为可用; -- 为“以后可能需要”增加 Client、Hook 或 TUI 方法,无当前样例; -- 一个阶段同时要求全量配置、package manager、Hook、renderer 和权限系统; -- 可执行来源在首次确认前 import、联网、读取凭据或启动 worker,或用阻塞式项目向导等待确认; -- 平台只通过 cross-check,没有同一样例的运行证据。 +Product Assembly registers adapter implementations with the coordinator +``` -延期项:完整配置兼容、所有 Hook、原始 OpenTUI renderer、完整 OpenCode Server/OpenAPI、Remote plugin、IDE/Web/ -attach、GitHub/GitLab/Slack 连接器、实验接口,以及新的沙箱、凭据或组织策略设计。 +- 通用层只认识开放的 `ecosystem_id`、来源限定身份、作用域、执行域、状态、诊断和能力专属贡献,不按 + OpenCode/Codex/Claude Code 分支业务行为。 +- 每个生态适配器独立维护自己的路径发现、优先级、格式、参数展开和版本兼容语义;兄弟适配器之间不得依赖、复用 + 私有类型或借用对方身份。 +- Product Assembly 是唯一选择具体适配器的地方。Desktop、CLI/TUI、来源目录和能力 owner 只依赖稳定契约。 +- 不建立携带任意 payload 的 `ExtensionAsset`、通用脚本 SDK 或跨生态配置对象。Command、Tool、Subagent 分别走 + 类型化贡献接口;新增一种能力不能迫使既有能力改写公共对象。 +- 来源目录按“提供者 + 来源限定身份”协调代次,provider discovery 独立并发且有期限;某个适配器解析失败、升级 + 或删除来源时,只影响其自己的来源和贡献,不能阻塞或清空其他生态。目录型来源进一步记录命令粒度的读取失败, + 避免一个坏文件复活同目录中已稳定删除的命令。 +- 文件观察是 OS 服务事实,候选代次与来源合并是协调器事实,OpenCode 路径和语义是 OpenCode adapter 事实, + 最终调用仍属于 Command/Tool/Subagent owner。 + +### 1.2 产品基线 + +- 用户全局和当前项目来源后台发现,不阻塞项目打开、TUI 输入或普通会话。 +- 设置中提供统一“外部 AI 应用”入口,显示来源、作用域、实际生效状态、受限原因和最近刷新结果;默认聚合,不要求 + 用户理解文件级实现。 +- 当前安全支持的内容可在用户明确调用时直接使用;尚缺运行能力的字段只显示“已识别但当前不可用”,不伪装成功。 +- 来源可按当前执行域抑制、恢复和重新加载;观察器更新不得绕过用户的抑制选择。 +- 更新先生成不可变候选代次,通过解析和校验后原子切换。解析失败保留仍然合规的上一有效代次;稳定删除、显式停用 + 或安全撤销必须撤下新调用,不能借“优雅降级”继续执行已删除内容。 +- 同一全局来源的发现摘要按执行域去重;项目来源按工作区展示。普通变化聚合为非阻塞状态,不用 Modal 轰炸用户。 + +## 2. 渐进 PR 范围 + +| PR | 用户可观察结果 | 新增能力 owner | 明确不包含 | +|---|---|---|---| +| PR1:来源目录 + OpenCode Command | Desktop 可查看、抑制/恢复并刷新全局/项目 OpenCode 来源;CLI/TUI 可列出并执行支持的 `/command`;运行中修改、删除、恢复后自动刷新 | 通用来源目录与生命周期协调器;Prompt Command 契约;OpenCode Command adapter | JS/TS Tool 执行、Hook、MCP、OpenCode Client/Server、Subagent 执行、复制式导入 | +| PR2:OpenCode standalone Tool | 一个真实、无外部依赖的 `.opencode/tools/` 样例经预览和确认后进入现有 Tool Runtime,可调用、取消、更新和撤下 | 现有 Tool Runtime + 独立 Tool 兼容接口 | package plugin、npm 依赖安装、Hook、TUI renderer、完整 `metadata`/`ask` | +| PR3:OpenCode Subagent | 全局/项目 agent 定义进入现有 Subagent owner,可选择、调用、更新和撤下;unsupported 字段有明确诊断 | 现有 Subagent owner + 独立 Subagent 兼容接口 | 原始 OpenCode 会话内核、完整 primary-agent 替换、跨产品通用 agent JSON | + +Tool 与 Subagent 不复用 Command 的贡献对象,只复用来源身份、状态、代次、诊断和观察生命周期。未来接入 Codex 或 +Claude Code 时新增同级 adapter,并在 Product Assembly 注册;不能修改 OpenCode adapter 来容纳其他生态。 + +## 3. PR1:来源目录与 OpenCode Command 纵向闭环 + +### 3.1 支持范围 + +- 发现 OpenCode 当前稳定契约中的用户全局与项目 `command` 配置,以及 `command/`、`commands/` Markdown 目录。 +- 用户全局根遵循 OpenCode 的 XDG 语义(默认 `~/.config/opencode`,Windows 不改用 AppData),读取 `config.json`、 + `opencode.json`、`opencode.jsonc`;同时支持 `OPENCODE_CONFIG`、`OPENCODE_CONFIG_DIR` 和 + `OPENCODE_DISABLE_PROJECT_CONFIG`。`OPENCODE_CONFIG_CONTENT` 与远程配置在 PR1 明确不接入。 +- 支持 Markdown YAML front matter 中的 `description` 和正文模板,以及 JSON/JSONC `command` 中的 + `template`、`description`。Markdown 已知字段按当前 OpenCode schema 校验,类型错误不得静默丢弃;同时保留 + OpenCode 对未引用冒号值的兼容重试。 +- 保留 OpenCode 生态内部的名称和覆盖顺序;独立 provider 之间或与 BitFun 本地能力同名时不得按适配器优先级静默决胜, + 必须生成版本敏感的冲突指纹并等待用户选择。候选版本不变时只询问一次,更新后重新询问。CLI/TUI 将跨 provider + 候选投影为 `/external::` 明确选择项;一次显式选择同时解决同名 BitFun 本地命令,不连续确认。 +- 支持 `$ARGUMENTS` 与 `$1`、`$2` 等位置参数展开。显式选择或输入 `/command ...` 本身就是本次 prompt-only + 命令的用户确认;发现阶段不自动向会话发送内容。 +- `!shell`、`@file`、`{env:...}`、`{file:...}`、`agent`、`model`、`variant`、`subtask` 等尚未接通真实 owner 的语义继续被识别,但命令标记为 + “当前受限”并给出原因,不做部分执行或静默忽略。 +- 外部文件始终只读;不要求安装 OpenCode CLI,不复制到 BitFun 配置,也不写回或升级来源。 + +### 3.2 分层归属 + +| 位置 | PR1 责任 | 不得承担 | +|---|---|---| +| `contracts/product-domains` | 开放生态 ID、来源限定身份、作用域、状态/诊断、来源快照、Prompt Command 定义和 provider 端口 | OpenCode 路径、文件 IO、UI、具体适配器选择 | +| `adapters/opencode-adapter` | OpenCode 全局/项目来源图、优先级、JSON/JSONC/Markdown 解析、参数展开、受限字段诊断 | 产品提示、用户偏好、文件观察服务、其他生态逻辑 | +| `services/services-core` | 通用 JSON 严格原子写入、跨进程锁和锁内读改写原语;替换失败保留旧文件 | 外部来源偏好 schema、生态语义、冲突策略 | +| `services/services-integrations` | 可订阅、去抖的文件变化事实 | 来源合并、OpenCode 语义、能力注册 | +| `assembly/external-sources` | provider-neutral 的原子代次、隔离降级、同名冲突目录和版本敏感选择 | 注册具体 adapter、按生态分支或解释生态文件 | +| `assembly/core` | 注册 adapter、按工作区协调刷新、定义偏好 schema/路径并通过服务原语持久化、连接 watcher 与产品入口 | 实现文件锁/原子写、复制 OpenCode parser、按生态分支能力行为 | +| `apps/cli` | 将可用外部 Command 投影到 TUI 菜单和输入分发;本地冲突使用 `/builtin:name` 与 `/external:name` 明确选择 | 解析 OpenCode 文件或注册假工具 | +| `apps/desktop` / `web-ui` | 统一来源摘要、刷新、抑制/恢复和非阻塞反馈 | 持有 adapter、直接读取用户目录、通过 IPC 传输模板正文 | + +### 3.3 生命周期与失败语义 + +1. 初次查询立即返回已知快照;所有 provider 首次返回前保留 `discovery_pending`,产品只显示中性“正在检查”,不能把 + 暂时空目录误报成“未识别来源”。后台刷新失败只把对应 provider 标记为降级,不阻塞宿主。 +2. 各 provider discovery 由 Core 独立调度,当前期限为 5 秒;超时只回退该 provider。每个 provider 同时最多一个 + in-flight discovery,后续刷新复用该任务,防止不可取消的阻塞扫描耗尽线程池。 +3. 文件事件按稳定窗口聚合后重扫完整有效来源图,不把编辑器原子保存误判成永久删除。来源路径按规范化身份去重, + watcher 的重复注册不能把递归观察降级为非递归。 +4. 新候选通过契约校验后切换。配置文件整体不可读时回退对应来源;Markdown 已知命令读写/解析失败时只回退该命令, + 同目录其他稳定删除仍撤下;目录枚举状态未知时保守标记整个目录来源不可用,不能把“未知”当作空目录。 +5. 外部命令执行前刷新,并以候选 ID + 命令内容版本校验菜单/冲突选择;投影后发生更新时拒绝旧选择,不得执行新版本。 +6. 稳定删除或用户抑制立即使该来源不再参与新命令解析;当前已发送的会话消息不回滚。用户抑制和冲突选择属于本地 + 执行域全局偏好,保存在外部来源专属偏好文件中;读写使用跨进程锁、锁内合并和严格同卷原子替换,失败时不得先 + 删除旧文件。缓存服务在查询、刷新和执行前重新读取,不能因 Desktop/CLI 分属不同进程而继续沿用旧选择,或用旧 + 整份全局配置覆盖新值。 +7. 来源重新出现时重新进入发现目录,但保持原有抑制偏好,直到用户恢复。 +8. 两个 provider 具有同名命令时,目录生成版本敏感的待选择项;用户选择前不激活任何候选。刷新、更新或移除 + 其中一个时只重算该冲突,不撤下无关 provider 的来源限定贡献;曾被选择的候选集合发生变化后,即使只剩一个 + 候选也重新进入待确认状态,不能静默切换实现。偏好按执行域/命令族保存单个当前指纹和去重的曾冲突候选身份, + 不按内容版本累计无界历史。 +9. Desktop 首屏使用非强制快照并后台刷新;首次发现完成前短轮询并保持“正在检查”,已完成选择的冲突不再停留在 + “需要你的选择”区块。工作区切换、轮询和设置写响应按请求作用域、独立 mutation 栅栏与单调 generation 校验; + 同工作区慢写期间的轮询不能覆盖写结果,旧工作区慢响应也不得覆盖当前页面。Remote 工作区在取得同执行域实现前 + 明确显示不支持,不回退读取本机来源。Desktop IPC 只返回设置页所需的来源、冲突和命令摘要,不传输命令模板正文。 + +### 3.4 PR1 验证门槛 + +- 契约测试使用两个独立 fake adapter,证明一个适配器失败、更新、删除不会污染另一个。 +- OpenCode fixture 固定 XDG 全局/项目、`config.json`、单复数目录、JSON/JSONC、Markdown、路径去重、覆盖、参数展开、 + 大小/数量上限和受限字段。 +- watcher 覆盖创建、连续写入、原子替换、稳定删除与重新出现;目录切换不阻塞 TUI 输入。 +- CLI/TUI 覆盖列表、跨 provider 候选选择与直接输入、本地同名冲突只询问一次、版本变化后重新询问、受限命令提示 + 和刷新后撤下;首次发现期间未限定别名不误路由,候选删除后不静默切换到剩余外部或内建实现。 +- Desktop 覆盖空状态、部分失败、刷新、抑制/恢复、敏感路径缩略显示及 IPC 摘要不包含模板正文。 +- 通过相关 crate tests、Web focused tests、`type-check:web`、仓库 hygiene 与 core boundary 检查。 + +## 4. PR2:OpenCode standalone Tool + +PR2 只在 PR1 的来源限定身份、生命周期和产品状态稳定后启动。它从 workspace/user 官方目录发现 tool,不要求 +`bitfun.plugin.json`,并把一个真实、无外部依赖且不调用 `metadata`/`ask` 的契约样例接入现有 Tool Runtime。 + +- 首次按来源/target/执行域说明代码来源、工作目录和直接文件/网络/进程能力;确认前不 import 或启动 worker。 +- worker 只提供真实 load/invoke/cancel/dispose 和诊断;Tool Runtime 继续负责 schema、权限、排队、审计与结果。 +- 更新只有在来源身份、完整性、执行包络和更新策略仍有效时自动准备;能力扩大进入非阻塞 `action-required`。 +- 删除或撤销立即撤下新调用;失败只影响对应 target,不影响 Command、Subagent 或其他生态。 + +## 5. PR3:OpenCode Subagent + +PR3 为现有 Subagent owner 增加独立兼容端口,由 OpenCode adapter 映射 agent Markdown/JSON 定义。它不复用 Tool +运行时,也不把 OpenCode agent 类型提升为跨生态 DTO。 + +- 支持的 prompt、description、模式和工具选择进入现有 Subagent 定义;未支持字段显示明确诊断。 +- 全局/项目覆盖、来源抑制、更新和删除复用通用来源生命周期。 +- 选择与执行仍由现有会话/Subagent owner 决定;adapter 不能替换 BitFun Agent Kernel。 + +## 6. 暂停条件 + +出现以下情况时停止扩面并先修复架构: + +- 新生态 adapter 依赖现有生态 adapter,或 core/UI 开始按生态 ID 分支业务行为; +- 为未来可能需求新增任意 payload 资产、通用脚本 SDK、第二套 Tool Runtime/Agent Runtime; +- 只有静态解析却把 Tool、Hook、Subagent 或受限 Command 标为可用; +- watcher 更新能绕过用户抑制,或一个 provider 的失败清空其他 provider; +- 同名候选仍由固定优先级静默选中,或候选内容版本变化后继续沿用旧冲突选择; +- 为完整兼容一次性引入 package manager、Hook、renderer、Server 和权限系统; +- 本地可用被直接推导为 Remote/HarmonyOS PC 可用,缺少同一 fixture 的真实运行证据。 diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index 1abad6a90a..baa057c1c2 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -17,6 +17,7 @@ export const crateLayoutRules = [ { crateName: 'tool-runtime', layer: 'execution', path: 'src/crates/execution/tool-execution' }, { crateName: 'product-capabilities', layer: 'assembly', path: 'src/crates/assembly/product-capabilities' }, + { crateName: 'external-sources', layer: 'assembly', path: 'src/crates/assembly/external-sources' }, { crateName: 'services-core', layer: 'services', path: 'src/crates/services/services-core' }, { crateName: 'services-integrations', layer: 'services', path: 'src/crates/services/services-integrations' }, diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index de8fc6dd9d..119aa73e1c 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -17,6 +17,7 @@ export const noCoreDependencyCrates = [ 'tool-packs', 'product-domains', 'opencode-adapter', + 'external-sources', 'terminal', 'tool-runtime', 'transport', @@ -35,7 +36,7 @@ export const forbiddenManifestDependencyRules = [ reason: 'OpenCode adapter production dependencies are limited to the reviewed product composition root', message: - 'only bitfun-core product-full assembly may inject bitfun-opencode-adapter through the Plugin Runtime Host boundary', + 'only bitfun-core product-full assembly may register bitfun-opencode-adapter through reviewed capability composition roots', }, ]; diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 37424bd91c..0a114a2a83 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -196,6 +196,6 @@ export const ownerCrateFeatureAssemblyRules = [ { manifestPath: 'src/crates/contracts/product-domains/Cargo.toml', reason: 'product-domains must keep product domain feature groups explicit and default-light', - requiredProductFullFeatures: ['plugin-source', 'miniapp', 'function-agents'], + requiredProductFullFeatures: ['plugin-source', 'miniapp', 'function-agents', 'external-sources'], }, ]; diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index e73ebc94c1..6660cbba1f 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -4115,10 +4115,12 @@ export const forbiddenContentUnderRules = [ /\b(?:use\s+bitfun_opencode_adapter\b|extern\s+crate\s+bitfun_opencode_adapter\b|bitfun_opencode_adapter::)/, allowPaths: [ 'src/crates/adapters/opencode-adapter/tests/opencode_source_adapter.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_command_adapter.rs', 'src/crates/assembly/core/src/plugin_runtime.rs', + 'src/crates/assembly/core/src/external_sources.rs', ], message: - 'only a reviewed product composition root may import bitfun-opencode-adapter and inject it into Plugin Runtime Host', + 'only a reviewed product composition root may import bitfun-opencode-adapter through a capability-specific provider boundary', }, ], }, diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index ae927f0903..1d3015a2a9 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -5,6 +5,7 @@ export const publicApiContractSlices = [ 'bitfun-plugin-extension-contract', 'plugin-runtime-internal-abi', 'opencode-adapter-boundary', + 'external-source-command-contract', ]; const contractSlices = { @@ -12,6 +13,7 @@ const contractSlices = { bitfunPluginExtension: 'bitfun-plugin-extension-contract', pluginRuntimeInternalAbi: 'plugin-runtime-internal-abi', opencodeAdapterBoundary: 'opencode-adapter-boundary', + externalSourceCommandContract: 'external-source-command-contract', }; function pluginRuntimeEntry(symbol, p0, consumer, verification, contractSlice, wireImpact = true) { @@ -172,6 +174,108 @@ export const opencodeAdapterPublicApiEntries = [ 'load_opencode_package_adapter', 'bitfun-core managed plugin composition root and PluginRuntimeHost integration tests', ), + opencodeAdapterEntry( + 'OpenCodeCommandProvider', + 'bitfun-core external source composition root and OpenCode command adapter tests', + ), + opencodeAdapterEntry( + 'OpenCodeCommandProviderOptions', + 'OpenCode command adapter fixture tests and explicit environment injection', + ), +]; + +function externalSourceEntry(symbol, owner, consumer, wireImpact = false) { + return { + symbol, + owner, + consumer, + verification: + 'external source contract tests, fake-provider coordinator tests, OpenCode command fixtures, and CLI/Desktop product tests', + p0: 'PR1 ecosystem-neutral source catalog and OpenCode prompt-command vertical slice', + contractSlice: contractSlices.externalSourceCommandContract, + wireImpact, + rationale: + 'PR1 needs typed capability contracts and provider-neutral lifecycle coordination without ecosystem payload leakage', + exit: 'remove only through a reviewed capability-contract migration with equivalent isolation and product tests', + }; +} + +export const externalSourceContractPublicApiEntries = [ + 'ExternalSourceContractError', + 'SourceKey', + 'SourceQualifiedCommandId', + 'ExternalSourceScope', + 'ExternalSourceHealth', + 'ExternalSourceDiagnosticSeverity', + 'ExternalSourceDiagnostic', + 'ExternalSourceRecord', + 'PromptCommandAvailability', + 'PromptCommandDefinition', + 'ExpandedPromptCommand', + 'PromptCommandProviderIdentity', + 'PromptCommandProviderSnapshot', + 'ExternalSourceContext', + 'ExternalWatchRoot', + 'ExternalSourceProviderError', + 'PromptCommandSourceProvider', + 'ExternalSourceLifecycleState', + 'ExternalSourceCatalogEntry', + 'PromptCommandCatalogEntry', + 'PromptCommandConflictCandidate', + 'PromptCommandConflict', + 'prompt_command_conflict_key', + 'ExternalSourceCatalogSnapshot', +].map((symbol) => + externalSourceEntry( + symbol, + 'product-domains external source contract owner', + 'ecosystem command providers, external-source coordinator, product composition, and neutral product surfaces', + true, + ), +); + +export const externalSourceCoordinatorPublicApiEntries = [ + externalSourceEntry( + 'ExternalSourceCoordinator', + 'external-sources assembly owner', + 'bitfun-core product composition root', + ), + ...['ExternalSourceDiscoveryRequest', 'ExternalSourceDiscoveryResult'].map((symbol) => + externalSourceEntry( + symbol, + 'external-sources assembly owner', + 'bitfun-core bounded concurrent provider scheduler', + ), + ), +]; + +export const externalSourceCorePublicApiEntries = [ + ...[ + 'ExpandedPromptCommand', + 'ExternalSourceCatalogEntry', + 'ExternalSourceCatalogSnapshot', + 'ExternalSourceDiagnostic', + 'ExternalSourceLifecycleState', + 'PromptCommandAvailability', + 'PromptCommandCatalogEntry', + 'PromptCommandDefinition', + 'SourceKey', + 'prompt_command_conflict_key', + 'external_source_conflict_choices', + 'remember_external_source_conflict_choice', + 'set_external_prompt_command_conflict_choice', + 'external_source_snapshot', + 'set_external_source_enabled', + 'expand_external_prompt_command', + 'subscribe_external_source_updates', + 'ExternalSourceSubscription', + ].map((symbol) => + externalSourceEntry( + symbol, + 'bitfun-core external source composition facade', + 'bitfun-cli and desktop host APIs', + ), + ), ]; function pluginSourceEntry(symbol, owner, consumer, verification, wireImpact) { @@ -295,6 +399,24 @@ export const publicApiAllowlistRules = [ 'managed plugin package and trust contracts must stay explicitly budgeted and ecosystem-neutral', allowedSymbolEntries: pluginSourceContractPublicApiEntries, }, + { + path: 'src/crates/contracts/product-domains/src/external_sources.rs', + reason: + 'external source contracts must stay capability-specific, ecosystem-neutral, and explicitly consumer-backed', + allowedSymbolEntries: externalSourceContractPublicApiEntries, + }, + { + path: 'src/crates/assembly/external-sources/src/lib.rs', + reason: + 'external source assembly API must expose only the provider-neutral coordinator', + allowedSymbolEntries: externalSourceCoordinatorPublicApiEntries, + }, + { + path: 'src/crates/assembly/core/src/external_sources.rs', + reason: + 'core external source facade must stay limited to neutral product operations and read models', + allowedSymbolEntries: externalSourceCorePublicApiEntries, + }, { path: 'src/crates/services/services-integrations/src/plugin_source.rs', reason: diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 1a980005e8..b1b0085413 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -955,9 +955,11 @@ export function runManifestParserSelfTest({ ).map((entry) => entry.symbol); if ( opencodeAdapterPublicApiSymbols.join(',') !== - 'load_opencode_package_adapter' + 'load_opencode_package_adapter,OpenCodeCommandProvider,OpenCodeCommandProviderOptions' ) { - throw new Error('OpenCode adapter public API budget must stay limited to one reviewed factory'); + throw new Error( + 'OpenCode adapter public API budget must stay limited to the reviewed package factory and command provider surface', + ); } for (const entry of opencodeAdapterPublicApiRule.allowedSymbolEntries) { for (const field of ['owner', 'consumer', 'verification', 'p0', 'contractSlice', 'rationale', 'exit']) { diff --git a/src/apps/cli/AGENTS.md b/src/apps/cli/AGENTS.md index 740371143f..8be84fc1e7 100644 --- a/src/apps/cli/AGENTS.md +++ b/src/apps/cli/AGENTS.md @@ -37,14 +37,14 @@ before product-definition, TUI layout, branding, packaging, runtime, or plugin a - Product assembly may expose only the immutable protection IDs allowed by the customization design. CLI must not turn them into user/source plugin policy or store plugin activation, update, permission, or health state in the assembly result. -- Current OpenCode adapter code is a managed-package/static-preview path only. After the matching - OC-R phases are implemented, OpenCode standard config and plugin sources become - read-only live sources without requiring a BitFun import. Low-risk declarative - results follow the user's auto-apply/ask preference; executable sources require - one source/target activation before import and another decision only when the - pre-import execution envelope or post-import contribution set expands. Codex - and Claude remain import/reference sources unless their own design explicitly - changes. Never copy credentials or silently ignore unsupported fields. +- OpenCode Prompt Commands from standard user and project configuration are + read-only live sources. CLI may execute only the expanded prompt through the + existing agent owner; it must re-confirm changed conflict participants and + must not execute OpenCode plugin code, tools, hooks, or subagents. +- The managed-package OpenCode adapter remains a static-preview path. Other + OpenCode plugin capabilities, Codex, and Claude remain import/reference sources + unless their own reviewed adapter design explicitly changes. Never copy + credentials or silently ignore unsupported fields. - Keep native instruction references, explicit import records, executable plugin sources, and credentials as separate asset classes. Importing non-executable config must not establish executable-source policy. CLI consumes the external diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index b8e8ab61ce..c00efbff01 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -7,7 +7,7 @@ use arboard::Clipboard; use crossterm::event::{ Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, }; -use std::collections::HashMap; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -16,8 +16,8 @@ use tokio::sync::broadcast::error::TryRecvError; use bitfun_events::{AgenticEvent, ToolEventData}; use crate::actions::{ - action_by_id, action_for_alias, ActionContext, ActionHandler, ActionSpec, ActionState, - ResolvedKeymap, + action_by_id, action_for_alias, slash_actions, ActionContext, ActionHandler, ActionSpec, + ActionState, ResolvedKeymap, }; use crate::agent::{core_adapter::CoreAgentAdapter, Agent}; use crate::chat_state::ChatState; @@ -25,6 +25,7 @@ use crate::config::CliConfig; use crate::runtime::CliRuntimeContext; use crate::ui::agent_selector::AgentItem; use crate::ui::chat::{ChatView, MouseGestureOutcome}; +use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; use crate::ui::command_palette::PaletteAction; use crate::ui::login_form::LoginFormAction; use crate::ui::mcp_add_dialog::McpAddAction; @@ -54,6 +55,12 @@ use bitfun_core::agentic::tools::implementations::skills::{ registry::SkillRegistry, ModeSkillInfo, SkillInfo, }; +use bitfun_core::external_sources::{ + expand_external_prompt_command, external_source_conflict_choices, external_source_snapshot, + prompt_command_conflict_key, remember_external_source_conflict_choice, + set_external_prompt_command_conflict_choice, subscribe_external_source_updates, + ExternalSourceCatalogSnapshot, PromptCommandAvailability, +}; use bitfun_core::service::config::GlobalConfigManager; use bitfun_core::service::session_usage::{ render_usage_report_markdown, SessionUsageReportRequest, @@ -64,6 +71,317 @@ const SPINNER_REDRAW_INTERVAL_MS: u64 = 100; /// Coalesce rapid resize bursts to reduce flicker during window drag. const RESIZE_REDRAW_DEBOUNCE_MS: u64 = 75; +fn native_command_conflict_key<'a>( + execution_domain_id: &str, + command_name: &str, + candidates: impl IntoIterator, +) -> String { + format!( + "native:{}", + prompt_command_conflict_key(execution_domain_id, command_name, candidates) + ) +} + +fn external_command_projections( + snapshot: &ExternalSourceCatalogSnapshot, + conflict_choices: &BTreeMap, +) -> Vec { + let built_in_actions = slash_actions(ActionState::chat(false, false)); + let mut projections = snapshot + .commands + .iter() + .map(|entry| { + let ecosystem = snapshot + .sources + .iter() + .find(|source| source.record.key == entry.definition.id.source) + .map(|source| source.record.ecosystem_id.as_str()) + .unwrap_or("external"); + let restricted = !matches!( + entry.definition.availability, + PromptCommandAvailability::Available + ); + let native_collision = built_in_actions.iter().find_map(|action| { + if !action + .name + .trim_start_matches('/') + .eq_ignore_ascii_case(&entry.definition.name) + { + return None; + } + let source = snapshot + .sources + .iter() + .find(|source| source.record.key == entry.definition.id.source)?; + let native_candidate_id = format!("bitfun.cli:{}", action.id); + let external_candidate_id = entry.definition.id.stable_key(); + let conflict_key = native_command_conflict_key( + source.record.execution_domain_id.as_str(), + &entry.definition.name, + [ + (native_candidate_id.as_str(), env!("CARGO_PKG_VERSION")), + ( + external_candidate_id.as_str(), + entry.definition.content_version.as_str(), + ), + ], + ); + Some(NativeCommandCollisionProjection { + native_action_id: action.id.to_string(), + native_candidate_id, + external_candidate_id, + selected_candidate_id: conflict_choices.get(&conflict_key).cloned(), + conflict_key, + }) + }); + ExternalCommandProjection { + action_id: format!("external-command:{}", entry.definition.name), + command_name: entry.definition.name.clone(), + invocation_alias: format!("/{}", entry.definition.name), + candidate_id: entry.definition.id.stable_key(), + content_version: entry.definition.content_version.clone(), + description: format!("{} · {}", entry.definition.description, ecosystem), + restricted, + provider_conflict_key: None, + native_collision, + } + }) + .collect::>(); + + for conflict in snapshot + .command_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + { + let built_in = built_in_actions.iter().find(|action| { + action + .name + .trim_start_matches('/') + .eq_ignore_ascii_case(&conflict.command_name) + }); + let native_group = built_in.and_then(|action| { + let execution_domain = conflict.candidates.iter().find_map(|candidate| { + snapshot + .sources + .iter() + .find(|source| source.record.key == candidate.source) + .map(|source| source.record.execution_domain_id.as_str()) + })?; + let native_candidate_id = format!("bitfun.cli:{}", action.id); + let mut candidates = conflict + .candidates + .iter() + .map(|candidate| { + ( + candidate.candidate_id.as_str(), + candidate.content_version.as_str(), + ) + }) + .collect::>(); + candidates.push((native_candidate_id.as_str(), env!("CARGO_PKG_VERSION"))); + let conflict_key = + native_command_conflict_key(execution_domain, &conflict.command_name, candidates); + Some((action.id.to_string(), native_candidate_id, conflict_key)) + }); + projections.extend(conflict.candidates.iter().map(|candidate| { + let native_collision = native_group.as_ref().map( + |(native_action_id, native_candidate_id, conflict_key)| { + NativeCommandCollisionProjection { + native_action_id: native_action_id.clone(), + native_candidate_id: native_candidate_id.clone(), + external_candidate_id: candidate.candidate_id.clone(), + selected_candidate_id: conflict_choices.get(conflict_key).cloned(), + conflict_key: conflict_key.clone(), + } + }, + ); + ExternalCommandProjection { + action_id: format!("external-command-candidate:{}", candidate.candidate_id), + command_name: conflict.command_name.clone(), + invocation_alias: format!( + "/external:{}:{}", + candidate.source.provider_id, conflict.command_name + ), + candidate_id: candidate.candidate_id.clone(), + content_version: candidate.content_version.clone(), + description: format!( + "{} · {} · {}", + candidate.command_description, + candidate.source_display_name, + candidate.ecosystem_id + ), + restricted: !matches!(candidate.availability, PromptCommandAvailability::Available), + provider_conflict_key: Some(conflict.conflict_key.clone()), + native_collision, + } + })); + } + projections +} + +fn external_command_counts(snapshot: &ExternalSourceCatalogSnapshot) -> (usize, usize) { + snapshot + .commands + .iter() + .fold((0, 0), |(available, restricted), entry| { + if matches!( + entry.definition.availability, + PromptCommandAvailability::Available + ) { + (available + 1, restricted) + } else { + (available, restricted + 1) + } + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct BuiltinCommandReconfirmation { + conflict_key: String, + candidate_id: String, + confirmed: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ExternalSourceConflictPreferences { + choices: BTreeMap, + lineage_current_keys: BTreeMap, + conflicted_candidate_ids: BTreeSet, +} + +impl + From<( + BTreeMap, + BTreeMap, + BTreeSet, + )> for ExternalSourceConflictPreferences +{ + fn from( + (choices, lineage_current_keys, conflicted_candidate_ids): ( + BTreeMap, + BTreeMap, + BTreeSet, + ), + ) -> Self { + Self { + choices, + lineage_current_keys, + conflicted_candidate_ids, + } + } +} + +fn builtin_command_reconfirmation( + action_id: &str, + action_name: &str, + preferences: &ExternalSourceConflictPreferences, +) -> Option { + let candidate_id = format!("bitfun.cli:{action_id}"); + let participated_in_conflict = preferences.conflicted_candidate_ids.contains(&candidate_id); + if !participated_in_conflict { + return None; + } + let command_name = action_name.trim_start_matches('/'); + let conflict_key = native_command_conflict_key( + "local-user", + command_name, + [(candidate_id.as_str(), env!("CARGO_PKG_VERSION"))], + ); + let confirmed = preferences.choices.get(&conflict_key) == Some(&candidate_id); + Some(BuiltinCommandReconfirmation { + conflict_key, + candidate_id, + confirmed, + }) +} + +fn builtin_reconfirmation_names( + preferences: &ExternalSourceConflictPreferences, +) -> BTreeSet { + slash_actions(ActionState::chat(false, false)) + .into_iter() + .filter(|action| { + builtin_command_reconfirmation(action.id, action.name, preferences) + .is_some_and(|reconfirmation| !reconfirmation.confirmed) + }) + .map(|action| action.name.trim_start_matches('/').to_ascii_lowercase()) + .collect() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CommandQualifier { + Unqualified, + Builtin, + External, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CommandRoute { + Builtin, + External, + AskForCollisionChoice, + WaitForDiscovery, + UnknownBuiltin, +} + +fn parse_command_token(token: &str) -> (CommandQualifier, &str) { + let requested_name = token.trim_start_matches('/'); + let Some((qualifier, command_name)) = requested_name.split_once(':') else { + return (CommandQualifier::Unqualified, requested_name); + }; + if qualifier.eq_ignore_ascii_case("builtin") { + (CommandQualifier::Builtin, command_name) + } else if qualifier.eq_ignore_ascii_case("external") { + (CommandQualifier::External, command_name) + } else { + (CommandQualifier::Unqualified, requested_name) + } +} + +fn command_route( + qualifier: CommandQualifier, + has_builtin: bool, + external: Option<&ExternalCommandProjection>, + discovery_pending: bool, + builtin_reconfirmation_required: bool, +) -> CommandRoute { + match qualifier { + CommandQualifier::Builtin => { + if has_builtin { + CommandRoute::Builtin + } else { + CommandRoute::UnknownBuiltin + } + } + CommandQualifier::External => CommandRoute::External, + CommandQualifier::Unqualified => { + if builtin_reconfirmation_required { + return CommandRoute::AskForCollisionChoice; + } + if discovery_pending { + return CommandRoute::WaitForDiscovery; + } + if let Some(collision) = external.and_then(|command| command.native_collision.as_ref()) + { + return match collision.selected_candidate_id.as_deref() { + Some(selected) if selected == collision.external_candidate_id => { + CommandRoute::External + } + Some(selected) if selected == collision.native_candidate_id => { + CommandRoute::Builtin + } + _ => CommandRoute::AskForCollisionChoice, + }; + } + if has_builtin { + CommandRoute::Builtin + } else { + CommandRoute::External + } + } + } +} + fn agent_event_stream_failure(error: TryRecvError) -> Option { match error { TryRecvError::Empty => None, @@ -150,6 +468,10 @@ pub(crate) struct ChatMode { pending_mcp_op: Option, /// Running MCP tasks (non-blocking, polled in main loop) pending_mcp_tasks: Vec, + external_source_snapshot: Option, + external_source_conflict_choices: BTreeMap, + external_source_conflict_lineage_current_keys: BTreeMap, + external_source_conflicted_candidate_ids: BTreeSet, } /// Map agent_type to a display name for status messages @@ -184,6 +506,10 @@ impl ChatMode { initial_prompt: None, pending_mcp_op: None, pending_mcp_tasks: Vec::new(), + external_source_snapshot: None, + external_source_conflict_choices: BTreeMap::new(), + external_source_conflict_lineage_current_keys: BTreeMap::new(), + external_source_conflicted_candidate_ids: BTreeSet::new(), } } @@ -199,6 +525,36 @@ impl ChatMode { self } + fn external_conflict_preferences(&self) -> ExternalSourceConflictPreferences { + ExternalSourceConflictPreferences { + choices: self.external_source_conflict_choices.clone(), + lineage_current_keys: self.external_source_conflict_lineage_current_keys.clone(), + conflicted_candidate_ids: self.external_source_conflicted_candidate_ids.clone(), + } + } + + fn update_external_source_view( + &self, + chat_view: &mut ChatView, + snapshot: &ExternalSourceCatalogSnapshot, + ) { + let preferences = self.external_conflict_preferences(); + chat_view.set_external_source_state( + external_command_projections(snapshot, &preferences.choices), + snapshot.discovery_pending, + builtin_reconfirmation_names(&preferences), + ); + } + + fn replace_external_conflict_preferences( + &mut self, + preferences: ExternalSourceConflictPreferences, + ) { + self.external_source_conflict_choices = preferences.choices; + self.external_source_conflict_lineage_current_keys = preferences.lineage_current_keys; + self.external_source_conflicted_candidate_ids = preferences.conflicted_candidate_ids; + } + fn workspace_path_for_sync(&self, chat_state: &ChatState) -> std::path::PathBuf { chat_state .workspace @@ -534,6 +890,46 @@ impl ChatMode { self.agent_type = chat_state.agent_type.clone(); self.workspace = chat_state.workspace.clone(); + let external_workspace = self.agent.workspace_path_buf(); + let (initial_external_sources, mut external_source_rx, conflict_preferences) = + tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let updates = + subscribe_external_source_updates(Some(&external_workspace)).await; + let snapshot = external_source_snapshot(Some(&external_workspace), false).await; + let preferences = external_source_conflict_choices().await.map(Into::into); + (snapshot, updates.ok(), preferences) + }) + }); + match conflict_preferences { + Ok(preferences) => self.replace_external_conflict_preferences(preferences), + Err(error) => tracing::warn!("External source preferences are unavailable: {}", error), + } + match initial_external_sources { + Ok(snapshot) => { + let (available, restricted) = external_command_counts(&snapshot); + let pending_conflicts = snapshot + .command_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .count(); + self.update_external_source_view(&mut chat_view, &snapshot); + self.external_source_snapshot = Some(snapshot.clone()); + if snapshot.discovery_pending { + chat_view.set_status(Some( + "Checking compatible commands from external AI applications".to_string(), + )); + } else if available + restricted > 0 || pending_conflicts > 0 { + chat_view.set_status(Some(format!( + "External sources: {available} commands available, {restricted} restricted, {pending_conflicts} need a choice" + ))); + } + } + Err(error) => { + tracing::warn!("External source discovery is unavailable: {}", error); + } + } + // Load current model name for display self.load_current_model_name(&mut chat_state, &rt_handle); @@ -622,6 +1018,59 @@ impl ChatMode { needs_redraw = true; } + let mut external_source_closed = false; + if let Some(receiver) = external_source_rx.as_mut() { + let mut latest = None; + for _ in 0..4 { + match receiver.try_recv() { + Ok(snapshot) => latest = Some(snapshot), + Err(TryRecvError::Lagged(_)) => continue, + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Closed) => { + external_source_closed = true; + break; + } + } + } + if let Some(snapshot) = latest { + let discovery_just_finished = self + .external_source_snapshot + .as_ref() + .is_some_and(|previous| previous.discovery_pending) + && !snapshot.discovery_pending; + let preferences = tokio::task::block_in_place(|| { + rt_handle + .block_on(external_source_conflict_choices()) + .map(Into::into) + }); + if let Ok(preferences) = preferences { + self.replace_external_conflict_preferences(preferences); + } + self.update_external_source_view(&mut chat_view, &snapshot); + if snapshot.discovery_pending { + chat_view.set_status(Some( + "Checking compatible commands from external AI applications" + .to_string(), + )); + } else if discovery_just_finished { + let (available, restricted) = external_command_counts(&snapshot); + let pending_conflicts = snapshot + .command_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .count(); + chat_view.set_status(Some(format!( + "External sources ready: {available} commands available, {restricted} restricted, {pending_conflicts} need a choice" + ))); + } + self.external_source_snapshot = Some(snapshot); + needs_redraw = true; + } + } + if external_source_closed { + external_source_rx = None; + } + if chat_view.login_form_visible() { self.refresh_account_panel_live(&mut chat_view); if crate::account_sync::sync_in_flight() { @@ -1687,10 +2136,37 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) -> Result> { + if let Some(external) = self.external_command_projection_for_action(action_id) { + return self.select_and_handle_external_command( + &external, "", chat_view, chat_state, rt_handle, + ); + } let Some(action) = action_by_id(action_id, ActionContext::Chat) else { chat_view.set_status(Some(format!("Unknown action: {action_id}"))); return Ok(None); }; + if let Some(collision) = self.native_command_collision_for_action(action.id) { + self.remember_native_command_choice( + &collision, + &collision.native_candidate_id, + chat_view, + rt_handle, + ); + } else if let Some(reconfirmation) = builtin_command_reconfirmation( + action.id, + action.name, + &self.external_conflict_preferences(), + ) + .filter(|reconfirmation| !reconfirmation.confirmed) + { + self.remember_command_choice( + &reconfirmation.conflict_key, + &reconfirmation.candidate_id, + vec![reconfirmation.candidate_id.clone()], + chat_view, + rt_handle, + ); + } self.dispatch_action( action, ActionState::chat(chat_state.is_processing, false), @@ -1713,22 +2189,441 @@ impl ChatMode { return Ok(None); } - let Some(action) = action_for_alias(parts[0], ActionContext::Chat) else { + let token = parts[0]; + let (qualifier, command_name) = parse_command_token(token); + let arguments = command + .get(token.len()..) + .map(str::trim_start) + .unwrap_or(""); + if let Some(candidate) = self.external_conflict_projection_for_alias(token) { + return self.select_and_handle_external_command( + &candidate, arguments, chat_view, chat_state, rt_handle, + ); + } + let builtin_alias = format!("/{command_name}"); + let builtin_action = action_for_alias(&builtin_alias, ActionContext::Chat); + let mut external = self.external_command_projection(command_name); + let authoritative_preferences = tokio::task::block_in_place(|| { + rt_handle + .block_on(external_source_conflict_choices()) + .map(Into::into) + }); + if let Ok(authoritative_preferences) = authoritative_preferences { + if authoritative_preferences != self.external_conflict_preferences() { + self.replace_external_conflict_preferences(authoritative_preferences); + external = self.external_command_projection(command_name); + if let Some(snapshot) = &self.external_source_snapshot { + self.update_external_source_view(chat_view, snapshot); + } + } + } + let builtin_reconfirmation = builtin_action.and_then(|action| { + builtin_command_reconfirmation( + action.id, + action.name, + &self.external_conflict_preferences(), + ) + }); + if let Some(collision) = external + .as_ref() + .and_then(|command| command.native_collision.as_ref()) + .cloned() + { + if qualifier == CommandQualifier::External { + self.remember_native_command_choice( + &collision, + &collision.external_candidate_id, + chat_view, + rt_handle, + ); + } else if qualifier == CommandQualifier::Builtin { + self.remember_native_command_choice( + &collision, + &collision.native_candidate_id, + chat_view, + rt_handle, + ); + } + } else if qualifier == CommandQualifier::Builtin { + if let Some(reconfirmation) = builtin_reconfirmation + .as_ref() + .filter(|reconfirmation| !reconfirmation.confirmed) + { + self.remember_command_choice( + &reconfirmation.conflict_key, + &reconfirmation.candidate_id, + vec![reconfirmation.candidate_id.clone()], + chat_view, + rt_handle, + ); + } else if let Some(collision) = builtin_action + .and_then(|action| self.native_command_collision_for_action(action.id)) + { + let native_candidate_id = collision.native_candidate_id.clone(); + self.remember_native_command_choice( + &collision, + &native_candidate_id, + chat_view, + rt_handle, + ); + } + } + let unresolved_candidates = self.external_conflict_projections(command_name); + let native_choice_is_active = unresolved_candidates.iter().any(|candidate| { + candidate + .native_collision + .as_ref() + .is_some_and(|collision| { + collision.selected_candidate_id.as_deref() + == Some(collision.native_candidate_id.as_str()) + }) + }); + if external.is_none() + && qualifier != CommandQualifier::Builtin + && !unresolved_candidates.is_empty() + && !native_choice_is_active + { + let mut choices = unresolved_candidates + .iter() + .map(|candidate| { + if candidate.restricted { + format!("{} (restricted)", candidate.invocation_alias) + } else { + candidate.invocation_alias.clone() + } + }) + .collect::>(); + if builtin_action.is_some() { + choices.insert(0, format!("/builtin:{command_name}")); + } chat_state.add_system_message(format!( - "Unknown command: {}\nUse /help to see available commands", - parts[0] + "Command /{command_name} is provided by multiple sources. Choose one once: {}. The choice is remembered until a participant changes.", + choices.join(", ") )); return Ok(None); - }; - self.dispatch_action( - action, - ActionState::chat(chat_state.is_processing, false), + } + let discovery_pending = self + .external_source_snapshot + .as_ref() + .is_some_and(|snapshot| snapshot.discovery_pending); + let builtin_reconfirmation_required = external.is_none() + && builtin_reconfirmation + .as_ref() + .is_some_and(|reconfirmation| !reconfirmation.confirmed); + match command_route( + qualifier, + builtin_action.is_some(), + external.as_ref(), + discovery_pending, + builtin_reconfirmation_required, + ) { + CommandRoute::Builtin => { + let action = builtin_action.expect("route requires an available built-in action"); + self.dispatch_action( + action, + ActionState::chat(chat_state.is_processing, false), + chat_view, + chat_state, + rt_handle, + ) + } + CommandRoute::External => match self.handle_external_command( + command_name, + arguments, + external.as_ref(), + chat_view, + chat_state, + rt_handle, + ) { + Ok(result) => Ok(result), + Err(error) if error.to_string().contains("command not found") => { + chat_state.add_system_message(format!( + "Unknown command: {}\nUse /help or type / to see available commands", + parts[0] + )); + Ok(None) + } + Err(error) => Err(error), + }, + CommandRoute::AskForCollisionChoice => { + if builtin_reconfirmation_required { + chat_state.add_system_message(format!( + "The previous external candidate for /{command_name} changed or was removed. Use /builtin:{command_name} once to confirm the remaining BitFun command." + )); + } else { + chat_state.add_system_message(format!( + "Command /{command_name} is provided by BitFun and an external source. Choose /builtin:{command_name} or /external:{command_name}; the choice is remembered until the external command changes." + )); + } + Ok(None) + } + CommandRoute::WaitForDiscovery => { + let explicit = if builtin_action.is_some() { + format!(" Use /builtin:{command_name} to run the BitFun command now.") + } else { + String::new() + }; + chat_state.add_system_message(format!( + "BitFun is still checking compatible external commands.{explicit}" + )); + Ok(None) + } + CommandRoute::UnknownBuiltin => { + chat_state.add_system_message(format!( + "Unknown built-in command: /builtin:{command_name}\nUse /help or type / to see available commands" + )); + Ok(None) + } + } + } + + fn external_command_projection(&self, command_name: &str) -> Option { + external_command_projections( + self.external_source_snapshot.as_ref()?, + &self.external_source_conflict_choices, + ) + .into_iter() + .find(|command| { + command.provider_conflict_key.is_none() + && command.command_name.eq_ignore_ascii_case(command_name) + }) + } + + fn external_command_projection_for_action( + &self, + action_id: &str, + ) -> Option { + external_command_projections( + self.external_source_snapshot.as_ref()?, + &self.external_source_conflict_choices, + ) + .into_iter() + .find(|command| command.action_id == action_id) + } + + fn external_conflict_projection_for_alias( + &self, + token: &str, + ) -> Option { + external_command_projections( + self.external_source_snapshot.as_ref()?, + &self.external_source_conflict_choices, + ) + .into_iter() + .find(|command| { + command.provider_conflict_key.is_some() + && command.invocation_alias.eq_ignore_ascii_case(token) + }) + } + + fn external_conflict_projections(&self, command_name: &str) -> Vec { + self.external_source_snapshot + .as_ref() + .map(|snapshot| { + external_command_projections(snapshot, &self.external_source_conflict_choices) + .into_iter() + .filter(|command| { + command.provider_conflict_key.is_some() + && command.command_name.eq_ignore_ascii_case(command_name) + }) + .collect() + }) + .unwrap_or_default() + } + + fn native_command_collision_for_action( + &self, + action_id: &str, + ) -> Option { + external_command_projections( + self.external_source_snapshot.as_ref()?, + &self.external_source_conflict_choices, + ) + .into_iter() + .filter_map(|command| command.native_collision) + .find(|collision| collision.native_action_id == action_id) + } + + fn remember_native_command_choice( + &mut self, + collision: &NativeCommandCollisionProjection, + candidate_id: &str, + chat_view: &mut ChatView, + rt_handle: &tokio::runtime::Handle, + ) { + self.remember_command_choice( + &collision.conflict_key, + candidate_id, + vec![ + collision.native_candidate_id.clone(), + collision.external_candidate_id.clone(), + ], + chat_view, + rt_handle, + ); + } + + fn remember_command_choice( + &mut self, + conflict_key: &str, + candidate_id: &str, + participants: Vec, + chat_view: &mut ChatView, + rt_handle: &tokio::runtime::Handle, + ) { + let persisted = tokio::task::block_in_place(|| { + rt_handle.block_on(remember_external_source_conflict_choice( + conflict_key, + candidate_id, + participants.clone(), + )) + }); + match persisted { + Ok(preferences) => self.replace_external_conflict_preferences(preferences.into()), + Err(error) => { + tracing::warn!( + "Failed to persist external command conflict choice: {}", + error + ); + chat_view.set_status(Some( + "The command choice could not be saved; this explicit command will run once" + .to_string(), + )); + } + } + if let Some(snapshot) = &self.external_source_snapshot { + self.update_external_source_view(chat_view, snapshot); + } + } + + fn select_and_handle_external_command( + &mut self, + projection: &ExternalCommandProjection, + arguments: &str, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + if projection.restricted { + chat_state.add_system_message(format!( + "External command {} is currently restricted and cannot be selected.", + projection.invocation_alias + )); + return Ok(None); + } + if let Some(provider_conflict_key) = &projection.provider_conflict_key { + let workspace = self.agent.workspace_path_buf(); + let snapshot = tokio::task::block_in_place(|| { + rt_handle.block_on(set_external_prompt_command_conflict_choice( + Some(&workspace), + provider_conflict_key, + &projection.candidate_id, + )) + }); + let snapshot = match snapshot { + Ok(snapshot) => snapshot, + Err(error) => { + chat_state.add_system_message(format!( + "Could not select {}: {error}", + projection.invocation_alias + )); + return Ok(None); + } + }; + if let Some(collision) = &projection.native_collision { + self.remember_native_command_choice( + collision, + &projection.candidate_id, + chat_view, + rt_handle, + ); + } + self.external_source_snapshot = Some(snapshot); + let Some(active) = self.external_command_projection(&projection.command_name) else { + chat_state.add_system_message(format!( + "Selected external command /{} is no longer available; refresh and choose again.", + projection.command_name + )); + return Ok(None); + }; + if let Some(collision) = &active.native_collision { + self.remember_native_command_choice( + collision, + &active.candidate_id, + chat_view, + rt_handle, + ); + } + if let Some(snapshot) = &self.external_source_snapshot { + self.update_external_source_view(chat_view, snapshot); + } + return self.handle_external_command( + &projection.command_name, + arguments, + Some(&active), + chat_view, + chat_state, + rt_handle, + ); + } + if let Some(collision) = &projection.native_collision { + self.remember_native_command_choice( + collision, + &projection.candidate_id, + chat_view, + rt_handle, + ); + } + self.handle_external_command( + &projection.command_name, + arguments, + Some(projection), chat_view, chat_state, rt_handle, ) } + fn handle_external_command( + &mut self, + command_name: &str, + arguments: &str, + expected: Option<&ExternalCommandProjection>, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + if chat_state.is_processing { + chat_view.set_status(Some( + "External prompt commands are unavailable while a turn is processing".to_string(), + )); + return Ok(None); + } + let workspace = self.agent.workspace_path_buf(); + let expanded = tokio::task::block_in_place(|| { + rt_handle.block_on(expand_external_prompt_command( + Some(&workspace), + command_name, + arguments, + expected.map(|command| command.candidate_id.as_str()), + expected.map(|command| command.content_version.as_str()), + )) + }); + match expanded { + Ok(expanded) => { + self.send_message_to_agent(expanded.content, chat_view, chat_state, rt_handle); + Ok(None) + } + Err(error) if error.contains("command not found") => Err(anyhow!(error)), + Err(error) => { + chat_state.add_system_message(format!( + "External command /{command_name} is unavailable: {error}" + )); + Ok(None) + } + } + } + fn dispatch_action( &mut self, action: &'static ActionSpec, @@ -3794,10 +4689,224 @@ impl ChatMode { mod tests { use tokio::sync::broadcast::error::TryRecvError; - use super::{agent_event_stream_failure, mark_active_turn_failed}; + use super::{ + agent_event_stream_failure, builtin_command_reconfirmation, command_route, + external_command_projections, mark_active_turn_failed, parse_command_token, + CommandQualifier, CommandRoute, ExternalSourceConflictPreferences, + }; use crate::actions::{ActionState, ResolvedKeymap}; use crate::chat_state::ChatState; use crate::config::ShortcutsConfig; + use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; + use bitfun_core::external_sources::ExternalSourceCatalogSnapshot; + use std::collections::{BTreeMap, BTreeSet}; + + fn external_command( + name: &str, + selected_candidate_id: Option<&str>, + ) -> ExternalCommandProjection { + ExternalCommandProjection { + action_id: format!("external-command:{name}"), + command_name: name.to_string(), + invocation_alias: format!("/{name}"), + candidate_id: format!("external:{name}"), + content_version: "v1".to_string(), + description: "External command".to_string(), + restricted: false, + provider_conflict_key: None, + native_collision: Some(NativeCommandCollisionProjection { + native_action_id: name.to_string(), + native_candidate_id: format!("bitfun.cli:{name}"), + external_candidate_id: format!("external:{name}"), + conflict_key: "conflict-v1".to_string(), + selected_candidate_id: selected_candidate_id.map(str::to_string), + }), + } + } + + #[test] + fn explicit_builtin_never_falls_through_to_an_external_command() { + let external = external_command("review", None); + assert_eq!( + command_route( + CommandQualifier::Builtin, + false, + Some(&external), + false, + false, + ), + CommandRoute::UnknownBuiltin + ); + } + + #[test] + fn command_qualifiers_are_ascii_case_insensitive() { + assert_eq!( + parse_command_token("/BUILTIN:help"), + (CommandQualifier::Builtin, "help") + ); + assert_eq!( + parse_command_token("/External:review"), + (CommandQualifier::External, "review") + ); + } + + #[test] + fn unresolved_provider_conflicts_expose_explicit_cli_choices() { + let snapshot: ExternalSourceCatalogSnapshot = serde_json::from_value(serde_json::json!({ + "generation": 1, + "discoveryPending": false, + "sources": [ + { + "stableKey": "first", + "record": { + "key": { "providerId": "first.commands", "sourceId": "global" }, + "ecosystemId": "first", + "displayName": "First commands", + "sourceKind": "prompt_commands", + "scope": "user_global", + "location": "/first", + "executionDomainId": "local-user", + "health": "available", + "contentVersion": "source-v1" + }, + "lifecycle": "available" + }, + { + "stableKey": "second", + "record": { + "key": { "providerId": "second.commands", "sourceId": "global" }, + "ecosystemId": "second", + "displayName": "Second commands", + "sourceKind": "prompt_commands", + "scope": "user_global", + "location": "/second", + "executionDomainId": "local-user", + "health": "available", + "contentVersion": "source-v1" + }, + "lifecycle": "available" + } + ], + "commands": [], + "commandConflicts": [{ + "conflictKey": "provider-conflict-v1", + "commandName": "review", + "candidates": [ + { + "candidateId": "first-candidate", + "source": { "providerId": "first.commands", "sourceId": "global" }, + "sourceDisplayName": "First commands", + "ecosystemId": "first", + "contentVersion": "command-v1", + "commandDescription": "First review", + "sourceScope": "user_global", + "sourceLocation": "/first", + "availability": { "state": "available" } + }, + { + "candidateId": "second-candidate", + "source": { "providerId": "second.commands", "sourceId": "global" }, + "sourceDisplayName": "Second commands", + "ecosystemId": "second", + "contentVersion": "command-v1", + "commandDescription": "Second review", + "sourceScope": "user_global", + "sourceLocation": "/second", + "availability": { "state": "available" } + } + ] + }] + })) + .unwrap(); + + let projections = external_command_projections(&snapshot, &BTreeMap::new()); + + assert_eq!(projections.len(), 2); + assert!(projections.iter().all(|projection| { + projection.provider_conflict_key.as_deref() == Some("provider-conflict-v1") + })); + assert!(projections + .iter() + .any(|projection| projection.invocation_alias == "/external:first.commands:review")); + assert!(projections + .iter() + .any(|projection| projection.invocation_alias == "/external:second.commands:review")); + } + + #[test] + fn native_collision_requires_one_choice_and_then_reuses_it() { + let unresolved = external_command("help", None); + assert_eq!( + command_route( + CommandQualifier::Unqualified, + true, + Some(&unresolved), + false, + false, + ), + CommandRoute::AskForCollisionChoice + ); + let selected = external_command("help", Some("external:help")); + assert_eq!( + command_route( + CommandQualifier::Unqualified, + true, + Some(&selected), + false, + false, + ), + CommandRoute::External + ); + } + + #[test] + fn discovery_pending_requires_an_explicit_command_qualifier() { + assert_eq!( + command_route(CommandQualifier::Unqualified, true, None, true, false,), + CommandRoute::WaitForDiscovery + ); + assert_eq!( + command_route(CommandQualifier::Builtin, true, None, true, false), + CommandRoute::Builtin + ); + } + + #[test] + fn removed_external_candidate_requires_builtin_reconfirmation() { + assert_eq!( + command_route(CommandQualifier::Unqualified, true, None, false, true,), + CommandRoute::AskForCollisionChoice + ); + assert_eq!( + command_route(CommandQualifier::Builtin, true, None, false, true), + CommandRoute::Builtin + ); + } + + #[test] + fn persisted_collision_history_detects_a_removed_external_candidate() { + let action = + crate::actions::action_for_alias("/help", crate::actions::ActionContext::Chat).unwrap(); + let mut preferences = ExternalSourceConflictPreferences { + choices: BTreeMap::new(), + lineage_current_keys: BTreeMap::new(), + conflicted_candidate_ids: BTreeSet::from([ + "bitfun.cli:help".to_string(), + "external:help".to_string(), + ]), + }; + + let pending = builtin_command_reconfirmation(action.id, action.name, &preferences).unwrap(); + assert!(!pending.confirmed); + + preferences + .choices + .insert(pending.conflict_key.clone(), pending.candidate_id.clone()); + let confirmed = + builtin_command_reconfirmation(action.id, action.name, &preferences).unwrap(); + assert!(confirmed.confirmed); + } #[test] fn agent_event_stream_failure_ignores_empty_queue() { diff --git a/src/apps/cli/src/ui/chat/input.rs b/src/apps/cli/src/ui/chat/input.rs index 1ebb902f50..a25226bdb9 100644 --- a/src/apps/cli/src/ui/chat/input.rs +++ b/src/apps/cli/src/ui/chat/input.rs @@ -6,7 +6,22 @@ impl ChatView { } fn refresh_command_menu(&mut self) { - self.command_menu.update(&self.text_input.input, self.text_input.cursor); + self.command_menu + .update(&self.text_input.input, self.text_input.cursor); + } + + pub(crate) fn set_external_source_state( + &mut self, + commands: Vec, + discovery_pending: bool, + builtin_reconfirmations: std::collections::BTreeSet, + ) { + self.command_menu.set_external_source_state( + commands, + discovery_pending, + builtin_reconfirmations, + ); + self.refresh_command_menu(); } /// Send user input, returns the input text if non-empty diff --git a/src/apps/cli/src/ui/command_menu.rs b/src/apps/cli/src/ui/command_menu.rs index d4883de17c..845e62412d 100644 --- a/src/apps/cli/src/ui/command_menu.rs +++ b/src/apps/cli/src/ui/command_menu.rs @@ -8,12 +8,45 @@ use ratatui::{ Frame, }; -use crate::actions::{slash_actions, ActionProjection, ActionState}; +use crate::actions::{slash_actions, ActionState}; use crate::ui::theme::{StyleKind, Theme}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExternalCommandProjection { + pub action_id: String, + pub command_name: String, + pub invocation_alias: String, + pub candidate_id: String, + pub content_version: String, + pub description: String, + pub restricted: bool, + pub provider_conflict_key: Option, + pub native_collision: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct NativeCommandCollisionProjection { + pub native_action_id: String, + pub native_candidate_id: String, + pub external_candidate_id: String, + pub conflict_key: String, + pub selected_candidate_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CommandMenuItem { + id: String, + name: String, + description: String, +} pub(super) struct CommandMenuState { action_state: ActionState, - items: Vec, + items: Vec, + external_commands: Vec, + external_discovery_pending: bool, + builtin_reconfirmations: BTreeSet, list_state: ListState, visible: bool, suppressed: bool, @@ -26,6 +59,9 @@ impl CommandMenuState { Self { action_state, items: Vec::new(), + external_commands: Vec::new(), + external_discovery_pending: false, + builtin_reconfirmations: BTreeSet::new(), list_state: ListState::default(), visible: false, suppressed: false, @@ -52,7 +88,88 @@ impl CommandMenuState { } let query = input.split_whitespace().next().unwrap_or(""); - let mut commands = slash_actions(self.action_state); + let built_in = slash_actions(self.action_state); + let built_in_names = built_in + .iter() + .map(|action| action.name.to_ascii_lowercase()) + .collect::>(); + let mut commands = built_in + .into_iter() + .map(|action| { + let collision = self.external_commands.iter().find_map(|command| { + let collision = command.native_collision.as_ref()?; + (collision.native_action_id == action.id).then_some(collision) + }); + let selected_external = collision.is_some_and(|collision| { + collision.selected_candidate_id.as_deref() + == Some(collision.external_candidate_id.as_str()) + }); + let unresolved = + collision.is_some_and(|collision| collision.selected_candidate_id.is_none()); + let command_name = action.name.trim_start_matches('/').to_ascii_lowercase(); + let reconfirmation_required = self.builtin_reconfirmations.contains(&command_name); + let discovery_pending = self.external_discovery_pending + && self.action_state.context == crate::actions::ActionContext::Chat; + let name = if selected_external + || unresolved + || reconfirmation_required + || discovery_pending + { + format!("/builtin:{}", action.name.trim_start_matches('/')) + } else { + action.name.to_string() + }; + CommandMenuItem { + id: action.id.to_string(), + name, + description: if unresolved || reconfirmation_required { + format!("{} (choose once)", action.description) + } else if discovery_pending { + format!("{} (checking external sources)", action.description) + } else { + action.description.to_string() + }, + } + }) + .collect::>(); + if self.action_state.context == crate::actions::ActionContext::Chat + && !self.action_state.is_processing + { + commands.extend(self.external_commands.iter().map(|command| { + let requested_name = format!("/{}", command.command_name); + let selected_external = + command.native_collision.as_ref().is_some_and(|collision| { + collision.selected_candidate_id.as_deref() + == Some(collision.external_candidate_id.as_str()) + }); + let collides = built_in_names.contains(&requested_name.to_ascii_lowercase()); + let name = if command.provider_conflict_key.is_some() { + command.invocation_alias.clone() + } else if collides && !selected_external { + format!("/external:{}", command.command_name) + } else { + requested_name + }; + let unresolved = command + .native_collision + .as_ref() + .is_some_and(|collision| collision.selected_candidate_id.is_none()); + let description = if command.restricted { + format!("{} (currently restricted)", command.description) + } else if unresolved { + format!("{} (choose once)", command.description) + } else if command.provider_conflict_key.is_some() { + format!("{} (choose this source)", command.description) + } else { + command.description.clone() + }; + CommandMenuItem { + id: command.action_id.clone(), + name, + description, + } + })); + } if query == "/" { self.items = commands; } else { @@ -63,13 +180,13 @@ impl CommandMenuState { commands.retain(|spec| { spec.name .strip_prefix('/') - .unwrap_or(spec.name) + .unwrap_or(&spec.name) .to_ascii_lowercase() .contains(&normalized) }); self.items = commands; } - self.items.sort_by_key(|spec| spec.name); + self.items.sort_by(|left, right| left.name.cmp(&right.name)); self.visible = !self.items.is_empty(); if self.visible { @@ -117,7 +234,7 @@ impl CommandMenuState { } let selected = self.selected_item()?; - let command = selected.id.to_string(); + let command = selected.id.clone(); self.suppress(); Some(command) } @@ -135,9 +252,9 @@ impl CommandMenuState { let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); let desc_style = theme.style(StyleKind::Muted); let line = Line::from(vec![ - Span::styled(spec.name, name_style), + Span::styled(spec.name.clone(), name_style), Span::raw(" - "), - Span::styled(spec.description, desc_style), + Span::styled(spec.description.clone(), desc_style), ]); ListItem::new(line) }) @@ -231,7 +348,7 @@ impl CommandMenuState { && mouse.row < area.y.saturating_add(area.height) } - fn selected_item(&self) -> Option<&ActionProjection> { + fn selected_item(&self) -> Option<&CommandMenuItem> { let idx = self.list_state.selected().unwrap_or(0); self.items.get(idx) } @@ -293,6 +410,24 @@ impl CommandMenuState { self.action_state = action_state; true } + + #[cfg(test)] + pub(super) fn set_external_commands(&mut self, commands: Vec) { + self.external_commands = commands; + self.update(&self.last_input.clone(), self.last_input.chars().count()); + } + + pub(super) fn set_external_source_state( + &mut self, + commands: Vec, + discovery_pending: bool, + builtin_reconfirmations: BTreeSet, + ) { + self.external_commands = commands; + self.external_discovery_pending = discovery_pending; + self.builtin_reconfirmations = builtin_reconfirmations; + self.update(&self.last_input.clone(), self.last_input.chars().count()); + } } #[cfg(test)] @@ -302,7 +437,7 @@ mod tests { use super::*; fn names(menu: &CommandMenuState) -> Vec<&str> { - menu.items.iter().map(|item| item.name).collect() + menu.items.iter().map(|item| item.name.as_str()).collect() } #[test] @@ -375,6 +510,149 @@ mod tests { assert!(menu.set_action_state(ActionState::chat(false, false))); menu.update("/", 1); - assert_eq!(menu.selected_item().map(|item| item.id), Some("logout")); + assert_eq!( + menu.selected_item().map(|item| item.id.as_str()), + Some("logout") + ); + } + + #[test] + fn external_commands_join_chat_menu_without_entering_the_host_action_registry() { + let mut menu = CommandMenuState::new(ActionState::chat(false, false)); + menu.set_external_commands(vec![ExternalCommandProjection { + action_id: "external-command:review".to_string(), + command_name: "review".to_string(), + invocation_alias: "/review".to_string(), + candidate_id: "external:review".to_string(), + content_version: "v1".to_string(), + description: "Review from OpenCode".to_string(), + restricted: false, + provider_conflict_key: None, + native_collision: None, + }]); + menu.update("/rev", 4); + + assert_eq!(names(&menu), ["/review"]); + assert_eq!( + menu.apply_selection().as_deref(), + Some("external-command:review") + ); + } + + #[test] + fn built_in_aliases_are_protected_with_an_explicit_external_fallback_alias() { + let mut menu = CommandMenuState::new(ActionState::chat(false, false)); + menu.set_external_commands(vec![ExternalCommandProjection { + action_id: "external-command:help".to_string(), + command_name: "help".to_string(), + invocation_alias: "/help".to_string(), + candidate_id: "external:help".to_string(), + content_version: "v1".to_string(), + description: "External help".to_string(), + restricted: false, + provider_conflict_key: None, + native_collision: Some(NativeCommandCollisionProjection { + native_action_id: "help".to_string(), + native_candidate_id: "bitfun.cli:help".to_string(), + external_candidate_id: "external:help".to_string(), + conflict_key: "conflict-v1".to_string(), + selected_candidate_id: None, + }), + }]); + menu.update("/", 1); + + assert!(names(&menu).contains(&"/builtin:help")); + assert!(names(&menu).contains(&"/external:help")); + } + + #[test] + fn remembered_external_choice_routes_the_plain_alias_until_content_changes() { + let mut menu = CommandMenuState::new(ActionState::chat(false, false)); + menu.set_external_commands(vec![ExternalCommandProjection { + action_id: "external-command:help".to_string(), + command_name: "help".to_string(), + invocation_alias: "/help".to_string(), + candidate_id: "external:help".to_string(), + content_version: "v1".to_string(), + description: "External help".to_string(), + restricted: false, + provider_conflict_key: None, + native_collision: Some(NativeCommandCollisionProjection { + native_action_id: "help".to_string(), + native_candidate_id: "bitfun.cli:help".to_string(), + external_candidate_id: "external:help".to_string(), + conflict_key: "conflict-v1".to_string(), + selected_candidate_id: Some("external:help".to_string()), + }), + }]); + menu.update("/", 1); + + assert!(names(&menu).contains(&"/builtin:help")); + assert!(names(&menu).contains(&"/help")); + assert!(!names(&menu).contains(&"/external:help")); + } + + #[test] + fn discovery_pending_keeps_builtin_commands_available_but_explicit() { + let mut menu = CommandMenuState::new(ActionState::chat(false, false)); + menu.set_external_source_state(Vec::new(), true, BTreeSet::new()); + menu.update("/help", 5); + + assert!(names(&menu).contains(&"/builtin:help")); + assert!(!names(&menu).contains(&"/help")); + assert!(menu.items.iter().any(|item| { + item.name == "/builtin:help" && item.description.contains("checking external sources") + })); + } + + #[test] + fn removed_external_collision_keeps_builtin_alias_explicit_until_reconfirmed() { + let mut menu = CommandMenuState::new(ActionState::chat(false, false)); + menu.set_external_source_state(Vec::new(), false, BTreeSet::from(["help".to_string()])); + menu.update("/help", 5); + + assert!(names(&menu).contains(&"/builtin:help")); + assert!(!names(&menu).contains(&"/help")); + assert_eq!(menu.apply_selection().as_deref(), Some("help")); + } + + #[test] + fn removed_external_collision_mouse_selection_returns_builtin_action_for_confirmation() { + let mut menu = CommandMenuState::new(ActionState::chat(false, false)); + menu.set_external_source_state(Vec::new(), false, BTreeSet::from(["help".to_string()])); + menu.update("/help", 5); + menu.last_area = Some(Rect::new(5, 5, 40, 3)); + + let selected = menu.handle_mouse_event(&MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 6, + row: 6, + modifiers: KeyModifiers::NONE, + }); + + assert_eq!(selected.as_deref(), Some("help")); + } + + #[test] + fn unresolved_provider_candidates_have_explicit_selectable_aliases() { + let mut menu = CommandMenuState::new(ActionState::chat(false, false)); + menu.set_external_commands(vec![ExternalCommandProjection { + action_id: "external-command-candidate:opencode-review".to_string(), + command_name: "review".to_string(), + invocation_alias: "/external:opencode.commands:review".to_string(), + candidate_id: "opencode-review".to_string(), + content_version: "v1".to_string(), + description: "OpenCode project · opencode".to_string(), + restricted: false, + provider_conflict_key: Some("provider-conflict-v1".to_string()), + native_collision: None, + }]); + menu.update("/external:opencode", 18); + + assert_eq!(names(&menu), ["/external:opencode.commands:review"]); + assert_eq!( + menu.apply_selection().as_deref(), + Some("external-command-candidate:opencode-review") + ); } } diff --git a/src/apps/cli/src/ui/mod.rs b/src/apps/cli/src/ui/mod.rs index caf4b4f9a4..983c2ca857 100644 --- a/src/apps/cli/src/ui/mod.rs +++ b/src/apps/cli/src/ui/mod.rs @@ -3,7 +3,7 @@ /// Build terminal user interface using ratatui pub(crate) mod agent_selector; pub(crate) mod chat; -mod command_menu; +pub(crate) mod command_menu; pub(crate) mod command_palette; mod diff_render; pub(crate) mod login_form; diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs new file mode 100644 index 0000000000..e846442c4e --- /dev/null +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -0,0 +1,174 @@ +//! Desktop host API for ecosystem-neutral external AI application sources. + +use bitfun_core::external_sources::{ + external_source_snapshot, set_external_prompt_command_conflict_choice, + set_external_source_enabled, ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, + ExternalSourceDiagnostic, PromptCommandAvailability, +}; +use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; +use bitfun_product_domains::external_sources::{PromptCommandConflict, SourceQualifiedCommandId}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSourceSnapshotRequest { + pub workspace_path: Option, + #[serde(default)] + pub force_refresh: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetExternalSourceEnabledRequest { + pub workspace_path: Option, + pub source_key: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetExternalSourceConflictChoiceRequest { + pub workspace_path: Option, + pub conflict_key: String, + pub candidate_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSourceSnapshotResponse { + pub generation: u64, + pub discovery_pending: bool, + pub sources: Vec, + pub commands: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub command_conflicts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalPromptCommandSummary { + pub definition: ExternalPromptCommandDefinitionSummary, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalPromptCommandDefinitionSummary { + pub id: SourceQualifiedCommandId, + pub name: String, + pub description: String, + pub availability: PromptCommandAvailability, + pub content_version: String, +} + +impl From for ExternalSourceSnapshotResponse { + fn from(snapshot: ExternalSourceCatalogSnapshot) -> Self { + Self { + generation: snapshot.generation, + discovery_pending: snapshot.discovery_pending, + sources: snapshot.sources, + commands: snapshot + .commands + .into_iter() + .map(|entry| ExternalPromptCommandSummary { + definition: ExternalPromptCommandDefinitionSummary { + id: entry.definition.id, + name: entry.definition.name, + description: entry.definition.description, + availability: entry.definition.availability, + content_version: entry.definition.content_version, + }, + }) + .collect(), + command_conflicts: snapshot.command_conflicts, + diagnostics: snapshot.diagnostics, + } + } +} + +async fn require_local_workspace(workspace_path: Option<&str>) -> Result, String> { + let Some(workspace_path) = workspace_path else { + return Ok(None); + }; + let path = Path::new(workspace_path); + if !path.is_absolute() { + return Err("External AI application sources require an absolute workspace path".into()); + } + if is_remote_path(workspace_path).await { + return Err( + "External AI application sources are not available for remote workspaces yet".into(), + ); + } + Ok(Some(path)) +} + +#[tauri::command] +pub async fn get_external_source_snapshot( + request: ExternalSourceSnapshotRequest, +) -> Result { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + external_source_snapshot(workspace, request.force_refresh) + .await + .map(Into::into) +} + +#[tauri::command] +pub async fn set_external_source_enabled_command( + request: SetExternalSourceEnabledRequest, +) -> Result { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + set_external_source_enabled(workspace, &request.source_key, request.enabled) + .await + .map(Into::into) +} + +#[tauri::command] +pub async fn set_external_source_conflict_choice_command( + request: SetExternalSourceConflictChoiceRequest, +) -> Result { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + set_external_prompt_command_conflict_choice( + workspace, + &request.conflict_key, + &request.candidate_id, + ) + .await + .map(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desktop_snapshot_never_serializes_prompt_templates() { + let snapshot: ExternalSourceCatalogSnapshot = serde_json::from_value(serde_json::json!({ + "generation": 1, + "discoveryPending": false, + "sources": [], + "commands": [{ + "definition": { + "id": { + "source": { "providerId": "opencode.commands", "sourceId": "global" }, + "localId": "review" + }, + "name": "review", + "description": "Review changes", + "template": "sensitive prompt body", + "availability": { "state": "available" }, + "contentVersion": "v1" + } + }], + "commandConflicts": [], + "diagnostics": [] + })) + .unwrap(); + + let value = serde_json::to_value(ExternalSourceSnapshotResponse::from(snapshot)).unwrap(); + + assert_eq!(value["commands"][0]["definition"]["name"], "review"); + assert!(value["commands"][0]["definition"].get("template").is_none()); + } +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index e07ae113b0..3852d511b6 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -19,6 +19,7 @@ pub mod debug_api; pub mod diff_api; pub mod dto; pub mod editor_ai_api; +pub mod external_sources_api; pub mod git_agent_api; pub mod git_api; pub mod i18n_api; diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 40ee1e0e6b..8b49ccba6e 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -443,6 +443,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "get_directory_children_paginated", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "get_external_source_snapshot", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "get_file_change_history", RemoteWorkspacePolicy::LegacyUnaudited, @@ -1349,6 +1353,14 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::LegacyUnaudited, ), ("set_config", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "set_external_source_conflict_choice_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "set_external_source_enabled_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ("set_macos_edit_menu_mode", RemoteWorkspacePolicy::LocalOnly), ( "set_miniapp_draft_storage", diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 6c0fbee07c..749d16d25a 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -41,6 +41,7 @@ use api::custom_agent_api::{ update_custom_agent, }; use api::diff_api::*; +use api::external_sources_api::*; use api::git_agent_api::*; use api::git_api::*; use api::i18n_api::*; @@ -909,6 +910,9 @@ pub async fn run() { api::btw_api::btw_cancel, api::editor_ai_api::editor_ai_stream, api::editor_ai_api::editor_ai_cancel, + get_external_source_snapshot, + set_external_source_enabled_command, + set_external_source_conflict_choice_command, api::context_upload_api::upload_image_contexts, get_all_tools_info, get_readonly_tools_info, diff --git a/src/crates/adapters/opencode-adapter/AGENTS-CN.md b/src/crates/adapters/opencode-adapter/AGENTS-CN.md index 73b9137286..d0256a0654 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS-CN.md +++ b/src/crates/adapters/opencode-adapter/AGENTS-CN.md @@ -2,9 +2,9 @@ # OpenCode Adapter -当前 crate 负责现有受管包路径使用的 P0 OpenCode 静态来源预览。目标设计中,本 crate 负责 OpenCode -生态适配和来源协调:保留来源顺序与生态语义,生成版本化来源/候选事实,并构造注入 Plugin Runtime Host -的适配器。它不得拥有产品策略、worker 监督、界面实现、凭据或最终结果写入。 +当前 crate 负责现有受管包路径使用的 P0 OpenCode 静态来源预览,以及能力专属 provider 契约的 OpenCode +实现。它保留 OpenCode 来源发现、优先级、格式、参数展开和版本化兼容语义。共享来源目录、生命周期协调、 +文件观察实现、产品策略、界面、凭据、worker 监督和最终结果写入均由其他 owner 负责。 ## 产品来源边界 @@ -17,9 +17,9 @@ - 全局来源偏好按来源/target/执行域去重,但每个项目/工作区执行实例必须重新计算有效来源图、工作目录/环境、 凭据和策略。原始解析与精确物化缓存可以共享,候选 worker 和健康状态不能被当成一个全局结果。跨项目本身 不重复询问,只有执行包络、凭据或能力扩大时确认。 -- OpenCode 来源协调器拥有来源身份/顺序、来源监听、候选代次,以及请求准备或切换代次的决定;配置归属模块 - 提供规范化配置快照,脚本执行服务拥有依赖、worker、进程树和物理健康,Plugin Runtime Host 拥有逻辑 target - 状态和贡献注册。 +- 共享来源协调器拥有候选代次和 provider 原子替换;本 adapter 通过窄 provider 契约提供 OpenCode 限定的来源 + 身份/顺序和观察根,可复用文件观察服务只提供变化事实。配置归属模块提供规范化配置快照,脚本执行服务拥有 + 依赖、worker、进程树和物理健康,Plugin Runtime Host 拥有逻辑 target 状态和贡献注册。 - 第三方模块 import 前必须依据来源、target、实际执行域/用户、产品/组织策略上限、凭据范围和环境范围重新计算 当前有效策略与安全启动模式。来源发现或配置导入批准不等于执行决策;产品来源体验和既有能力 owner 提供 来源/target 决策,本适配器只消费该结果,不拥有提示或信任状态。激活后的本地运行时默认使用兼容模式。 @@ -31,16 +31,19 @@ - 依赖 `bitfun-runtime-ports` 等稳定接口和 `PluginHostAdapter` 边界 trait,不依赖 `bitfun-core`、app crate、Tauri API、产品界面或具体服务管理器。 -- OpenCode 配置 JSON、来源顺序、加载器兼容和来源协调保留在本 crate 内。跨 crate 输出使用类型化来源快照、 +- OpenCode 配置 JSON、来源顺序、加载器兼容和参数展开保留在本 crate 内。跨 crate 输出使用类型化来源快照、 adapter binding 和 Plugin Runtime Host DTO,不得把 OpenCode 原始 JSON 或源码语法暴露为产品接口。 - 当前源码探测只识别测试覆盖的声明式语法子集,不是通用 JS/TS 解析器;没有可识别入口的包和已识别但不支持的 hook 必须返回诊断,其他语法不属于当前兼容范围。 - 未支持的 OpenCode 能力必须显式返回类型化诊断或不支持状态,不得静默忽略。 -- 当前公开接口预算只允许 `load_opencode_package_adapter`。OC-R 实现只有在同步当前消费方、明确的来源协调器/Host - 窄接口、边界更新和聚焦测试后才能替换或增加入口;目标设计本身不表示新 API 已可用。 +- 公开接口必须同步当前 Product Assembly 消费方、能力专属 provider 契约、边界更新和聚焦测试;不得暴露通用 + OpenCode JSON 访问,也不得只为目标设计完整性增加 API。 - 经评审的产品组装根只选择并构造已编译的 OpenCode adapter/provider,再注入 Plugin Runtime Host;它不发现 动态来源、不准备依赖,也不 import 插件模块。 -- 生产组装仅允许位于 `bitfun-core/plugin_runtime`;增加其他消费方时必须同步边界脚本和聚焦主机路径测试。 +- Product Assembly 只允许从经过评审的组装模块(如 `bitfun-core/plugin_runtime` 或 + `bitfun-core/external_sources`)消费本 crate;增加其他消费方时必须同步边界脚本和聚焦组装路径测试。 +- 本 crate 不得依赖 Codex、Claude Code 或其他生态 adapter。新生态是由 Product Assembly 注册的同级 adapter, + 不是本 adapter 的模式。 - 生产 crate 不得直接依赖 `bitfun_opencode_adapter` 内部类型。未支持能力必须诊断化, 不得因外部插件内容导致运行时崩溃。 diff --git a/src/crates/adapters/opencode-adapter/AGENTS.md b/src/crates/adapters/opencode-adapter/AGENTS.md index be36f815bf..aa07f18f50 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS.md +++ b/src/crates/adapters/opencode-adapter/AGENTS.md @@ -3,11 +3,11 @@ # OpenCode Adapter The current crate owns the P0 static OpenCode source preview used by the existing -managed-package path. The target design makes this crate the OpenCode-specific -adapter and source coordinator: it preserves source order and ecosystem semantics, -produces versioned source/candidate facts, and creates the adapter injected into -Plugin Runtime Host. It must not own product policy, worker supervision, UI -implementation, credentials, or final effect writes. +managed-package path and the OpenCode-specific implementations of capability +provider contracts. It preserves OpenCode source discovery, precedence, formats, +argument expansion, and versioned compatibility semantics. Shared source catalog, +lifecycle coordination, file-watch implementation, product policy, UI, credentials, +worker supervision, and final effect writes belong elsewhere. Product-source boundary: @@ -29,9 +29,10 @@ Product-source boundary: materialization caches may be shared; candidate workers and health may not be treated as one global result. Crossing projects alone does not prompt again; only an expanded execution envelope, credential scope, or capability does. -- The OpenCode source coordinator owns source identity/order, source watches, - candidate generations, and the decision to request preparation or switch a - generation. Config owners provide normalized config snapshots; the script +- The shared source coordinator owns candidate generations and atomic provider + replacement. This adapter supplies OpenCode-qualified source identity/order and + watch roots through narrow provider contracts; the reusable file-watch service + supplies change facts. Config owners provide normalized config snapshots; the script execution service owns dependencies, workers, process trees, and physical health; Plugin Runtime Host owns logical target state and contribution registration. - Effective policy and safe-start mode must be recomputed before third-party @@ -52,8 +53,8 @@ Product-source boundary: - Depend on stable contracts such as `bitfun-runtime-ports` and the `PluginHostAdapter` boundary trait, not `bitfun-core`, app crates, Tauri APIs, product UI, or concrete service managers. -- Keep OpenCode config JSON, source ordering, loader compatibility, and source - coordination inside this crate. Cross-crate outputs use typed source snapshots, +- Keep OpenCode config JSON, source ordering, loader compatibility, and argument + expansion inside this crate. Cross-crate outputs use typed source snapshots, adapter bindings, and Plugin Runtime Host DTOs; do not expose raw OpenCode JSON or source syntax as product contracts. - Current source inspection recognizes only the tested declarative subset. It is @@ -62,15 +63,18 @@ Product-source boundary: is outside the current compatibility claim. - Unsupported OpenCode capabilities must be explicit diagnostics or typed unsupported candidates. Do not silently ignore them. -- The current public API budget is limited to `load_opencode_package_adapter`. - OC-R implementation may replace or supplement it only together with a current - consumer, explicit source-coordinator/Host ports, boundary updates, and focused - tests. Target design text alone does not make a new API available. +- Public APIs require a current Product Assembly consumer, a capability-specific + provider contract, boundary updates, and focused tests. Do not expose generic + OpenCode JSON access or add APIs only for target-design completeness. - The reviewed product composition root selects and constructs the compiled OpenCode adapter/provider and injects it into Plugin Runtime Host. It does not discover dynamic sources, prepare dependencies, or import plugin modules. -- Production assembly is limited to `bitfun-core/plugin_runtime`; boundary - guards and focused host-path tests must change with any additional consumer. +- Product Assembly may consume this crate only from reviewed composition modules + such as `bitfun-core/plugin_runtime` or `bitfun-core/external_sources`; boundary + guards and focused assembly-path tests must change with any additional consumer. +- This crate must not depend on Codex, Claude Code, or another ecosystem adapter. + New ecosystems are sibling adapters registered by Product Assembly, not modes of + this adapter. - Production crates must not depend on `bitfun_opencode_adapter` internals. Unsupported capabilities must return diagnostics or typed unsupported states instead of failing at runtime on external plugin content. diff --git a/src/crates/adapters/opencode-adapter/Cargo.toml b/src/crates/adapters/opencode-adapter/Cargo.toml index aadb927ad3..4be76092be 100644 --- a/src/crates/adapters/opencode-adapter/Cargo.toml +++ b/src/crates/adapters/opencode-adapter/Cargo.toml @@ -12,9 +12,13 @@ crate-type = ["rlib"] [dependencies] async-trait = { workspace = true } bitfun-plugin-runtime-host = { path = "../../execution/plugin-runtime-host" } -bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false, features = ["plugin-source"] } +bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false, features = ["external-sources", "plugin-source"] } bitfun-runtime-ports = { path = "../../contracts/runtime-ports" } +bitfun-services-core = { path = "../../services/services-core", default-features = false, features = ["markdown"] } +dirs = { workspace = true } +dunce = { workspace = true } hex = { workspace = true } +regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } @@ -24,6 +28,7 @@ urlencoding = { workspace = true } [dev-dependencies] bitfun-services-integrations = { path = "../../services/services-integrations", default-features = false, features = ["plugin-source"] } tokio = { workspace = true } +tempfile = { workspace = true } [lints] workspace = true diff --git a/src/crates/adapters/opencode-adapter/src/command_source.rs b/src/crates/adapters/opencode-adapter/src/command_source.rs new file mode 100644 index 0000000000..d5087a6439 --- /dev/null +++ b/src/crates/adapters/opencode-adapter/src/command_source.rs @@ -0,0 +1,1300 @@ +use bitfun_product_domains::external_sources::{ + EcosystemId, ExpandedPromptCommand, ExternalSourceContext, ExternalSourceDiagnostic, + ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, + ExternalWatchRoot, PromptCommandAvailability, PromptCommandDefinition, + PromptCommandProviderIdentity, PromptCommandProviderSnapshot, PromptCommandSourceProvider, + SourceKey, SourceQualifiedCommandId, +}; +use bitfun_services_core::markdown::FrontMatterMarkdown; +use regex::Regex; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +const PROVIDER_ID: &str = "opencode.commands"; +const ECOSYSTEM_ID: &str = "opencode"; +const MAX_COMMAND_FILES: usize = 2048; +const MAX_COMMAND_FILE_BYTES: u64 = 256 * 1024; +const MAX_COMMAND_TEMPLATE_BYTES: usize = 8 * 1024 * 1024; +const MAX_CONFIG_FILE_BYTES: u64 = 1024 * 1024; + +#[derive(Debug, Clone)] +pub struct OpenCodeCommandProviderOptions { + pub user_config_dir: PathBuf, + pub legacy_user_config_dir: Option, + pub explicit_config_file: Option, + pub explicit_config_dir: Option, + pub project_config_enabled: bool, +} + +impl OpenCodeCommandProviderOptions { + pub fn from_environment() -> Self { + let home = dirs::home_dir(); + let user_config_dir = opencode_user_config_dir( + std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), + home.clone(), + ); + let legacy_user_config_dir = home.map(|home| home.join(".opencode")); + Self { + user_config_dir, + legacy_user_config_dir, + explicit_config_file: std::env::var_os("OPENCODE_CONFIG").map(PathBuf::from), + explicit_config_dir: std::env::var_os("OPENCODE_CONFIG_DIR").map(PathBuf::from), + project_config_enabled: !environment_truthy("OPENCODE_DISABLE_PROJECT_CONFIG"), + } + } +} + +impl Default for OpenCodeCommandProviderOptions { + fn default() -> Self { + Self::from_environment() + } +} + +pub struct OpenCodeCommandProvider { + options: OpenCodeCommandProviderOptions, +} + +impl OpenCodeCommandProvider { + pub fn new(options: OpenCodeCommandProviderOptions) -> Self { + Self { options } + } + + fn discover_layers(&self, context: &ExternalSourceContext) -> Vec { + let mut layers = Vec::new(); + // Phase 1: global JSON configuration. + push_config_file_layer( + &mut layers, + &self.options.user_config_dir.join("config.json"), + ExternalSourceScope::UserGlobal, + "OpenCode user configuration", + ); + push_config_directory_layers( + &mut layers, + &self.options.user_config_dir, + ExternalSourceScope::UserGlobal, + "OpenCode user configuration", + ); + // Phase 2: OPENCODE_CONFIG. + if let Some(path) = &self.options.explicit_config_file { + push_config_file_layer( + &mut layers, + path, + ExternalSourceScope::UserGlobal, + "OpenCode OPENCODE_CONFIG", + ); + } + // Phase 3: project JSON configuration, root first. + if self.options.project_config_enabled { + if let Some(workspace_root) = &context.workspace_root { + let project_root = find_project_root(workspace_root); + for directory in directories_between(&project_root, workspace_root) { + push_config_directory_layers( + &mut layers, + &directory, + ExternalSourceScope::Project, + "OpenCode project configuration", + ); + } + } + } + // Phase 4: global command directories. + push_command_directory_layer( + &mut layers, + &self.options.user_config_dir, + ExternalSourceScope::UserGlobal, + "OpenCode user command directory", + ); + // Phase 5: project .opencode directories, nearest first. Since later + // values win, the outer project directory wins a same-name tie. + if self.options.project_config_enabled { + if let Some(workspace_root) = &context.workspace_root { + let project_root = find_project_root(workspace_root); + for directory in directories_between(&project_root, workspace_root) + .into_iter() + .rev() + { + push_directory_layers( + &mut layers, + &directory.join(".opencode"), + ExternalSourceScope::Project, + "OpenCode project command directory", + ); + } + } + } + // Phase 6: ~/.opencode compatibility directory. + if let Some(legacy) = &self.options.legacy_user_config_dir { + if legacy != &self.options.user_config_dir { + push_directory_layers( + &mut layers, + legacy, + ExternalSourceScope::UserGlobal, + "OpenCode legacy user configuration", + ); + } + } + // Phase 7: OPENCODE_CONFIG_DIR. + if let Some(directory) = &self.options.explicit_config_dir { + push_directory_layers( + &mut layers, + directory, + ExternalSourceScope::WorkspaceLocal, + "OpenCode OPENCODE_CONFIG_DIR", + ); + } + deduplicate_layers_keep_last(layers) + } +} + +impl Default for OpenCodeCommandProvider { + fn default() -> Self { + Self::new(OpenCodeCommandProviderOptions::default()) + } +} + +impl PromptCommandSourceProvider for OpenCodeCommandProvider { + fn identity(&self) -> PromptCommandProviderIdentity { + PromptCommandProviderIdentity::new(PROVIDER_ID, ECOSYSTEM_ID, "OpenCode") + .expect("static OpenCode provider identity must be valid") + } + + fn discover( + &self, + context: &ExternalSourceContext, + ) -> Result { + if context + .workspace_root + .as_ref() + .is_some_and(|workspace_root| !workspace_root.is_absolute()) + { + return Err(ExternalSourceProviderError::new( + "opencode.command.workspace_invalid", + "workspace root must be absolute", + false, + )); + } + + let mut sources = Vec::new(); + let mut diagnostics = Vec::new(); + let mut command_candidates = Vec::new(); + let mut unavailable_command_ids = Vec::new(); + let mut provider_template_bytes = 0usize; + + for layer in self.discover_layers(context) { + let parsed = match &layer.kind { + SourceLayerKind::ConfigFile(path) => parse_config_file(path), + SourceLayerKind::CommandDirectory(path) => parse_command_directory(path), + }; + let source_key = source_key(&layer); + let ParsedLayer { + commands, + unavailable_command_names, + diagnostics: parsed_diagnostics, + content_version, + mut fatal, + } = parsed; + let mut layer_diagnostics = parsed_diagnostics + .into_iter() + .map(|diagnostic| ExternalSourceDiagnostic { + source: Some(source_key.clone()), + ..diagnostic + }) + .collect::>(); + let layer_template_bytes = commands + .values() + .map(|command| command.template.len()) + .sum::(); + if !fatal + && provider_template_bytes.saturating_add(layer_template_bytes) + > MAX_COMMAND_TEMPLATE_BYTES + { + fatal = true; + layer_diagnostics.push(ExternalSourceDiagnostic::warning( + "opencode.command.provider_template_bytes_limit", + "OpenCode command templates exceed the 8 MiB provider limit", + Some(source_key.clone()), + )); + } else if !fatal { + provider_template_bytes += layer_template_bytes; + } + let mut has_restricted_commands = false; + if !fatal { + unavailable_command_ids.extend(unavailable_command_names.into_iter().filter_map( + |name| SourceQualifiedCommandId::new(source_key.clone(), name).ok(), + )); + for (name, input) in commands { + match command_definition(source_key.clone(), name.clone(), input) { + Ok(definition) => { + has_restricted_commands |= !matches!( + definition.availability, + PromptCommandAvailability::Available + ); + command_candidates.push(definition); + } + Err(error) => { + if let Ok(command_id) = + SourceQualifiedCommandId::new(source_key.clone(), name) + { + unavailable_command_ids.push(command_id); + } + layer_diagnostics.push(ExternalSourceDiagnostic::warning( + error.code, + error.message, + Some(source_key.clone()), + )); + } + } + } + } + let source_health = if fatal { + ExternalSourceHealth::Unavailable + } else if !layer_diagnostics.is_empty() { + ExternalSourceHealth::Degraded + } else if has_restricted_commands { + ExternalSourceHealth::Partial + } else { + ExternalSourceHealth::Available + }; + diagnostics.extend(layer_diagnostics.clone()); + sources.push(ExternalSourceRecord { + key: source_key.clone(), + ecosystem_id: EcosystemId::new(ECOSYSTEM_ID) + .expect("static ecosystem id must be valid"), + display_name: layer.display_name, + source_kind: layer.source_kind.to_string(), + scope: layer.scope, + location: layer.location.to_string_lossy().to_string(), + execution_domain_id: context.execution_domain_id.clone(), + health: source_health, + content_version, + diagnostics: layer_diagnostics, + }); + } + + Ok(PromptCommandProviderSnapshot { + provider: self.identity(), + sources, + commands: command_candidates, + unavailable_command_ids, + diagnostics, + }) + } + + fn expand( + &self, + command: &PromptCommandDefinition, + arguments: &str, + ) -> Result { + if command.id.source.provider_id.as_str() != PROVIDER_ID { + return Err(ExternalSourceProviderError::new( + "opencode.command.identity_mismatch", + "command is not owned by the OpenCode command provider", + false, + )); + } + match &command.availability { + PromptCommandAvailability::Available => Ok(ExpandedPromptCommand { + content: expand_template(&command.template, arguments), + }), + PromptCommandAvailability::Restricted { reason, .. } + | PromptCommandAvailability::Invalid { reason } => { + Err(ExternalSourceProviderError::new( + "opencode.command.restricted", + reason.clone(), + false, + )) + } + _ => Err(ExternalSourceProviderError::new( + "opencode.command.availability_unknown", + "command availability is not supported by this adapter version", + false, + )), + } + } + + fn resolve_commands( + &self, + commands: &[PromptCommandDefinition], + enabled_sources: &BTreeSet, + ) -> Result, ExternalSourceProviderError> { + let mut effective = BTreeMap::new(); + for command in commands + .iter() + .filter(|command| enabled_sources.contains(&command.id.source)) + { + // Discovery preserves OpenCode's low-to-high source order. A later + // candidate replaces an earlier same-name definition. + effective.insert(command.name.to_ascii_lowercase(), command.clone()); + } + Ok(effective.into_values().collect()) + } + + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { + let mut roots = BTreeMap::new(); + add_directory_watch_roots(&mut roots, &self.options.user_config_dir); + if let Some(path) = &self.options.legacy_user_config_dir { + add_directory_watch_roots(&mut roots, path); + } + if let Some(path) = &self.options.explicit_config_file { + if let Some(parent) = path.parent() { + add_nearest_existing_watch_root(&mut roots, parent); + } + } + if let Some(path) = &self.options.explicit_config_dir { + add_directory_watch_roots(&mut roots, path); + } + if self.options.project_config_enabled { + if let Some(workspace_root) = &context.workspace_root { + let project_root = find_project_root(workspace_root); + for directory in directories_between(&project_root, workspace_root) { + add_watch_root(&mut roots, directory.clone(), false); + add_directory_watch_roots(&mut roots, &directory.join(".opencode")); + } + } + } + roots + .into_iter() + .map(|(path, recursive)| ExternalWatchRoot { path, recursive }) + .collect() + } +} + +#[derive(Debug)] +struct SourceLayer { + kind: SourceLayerKind, + location: PathBuf, + scope: ExternalSourceScope, + display_name: String, + source_kind: &'static str, +} + +#[derive(Debug)] +enum SourceLayerKind { + ConfigFile(PathBuf), + CommandDirectory(PathBuf), +} + +fn push_directory_layers( + layers: &mut Vec, + directory: &Path, + scope: ExternalSourceScope, + display_name: &str, +) { + push_config_directory_layers(layers, directory, scope, display_name); + push_command_directory_layer(layers, directory, scope, display_name); +} + +fn push_config_directory_layers( + layers: &mut Vec, + directory: &Path, + scope: ExternalSourceScope, + display_name: &str, +) { + for name in ["opencode.json", "opencode.jsonc"] { + push_config_file_layer(layers, &directory.join(name), scope, display_name); + } +} + +fn push_command_directory_layer( + layers: &mut Vec, + directory: &Path, + scope: ExternalSourceScope, + display_name: &str, +) { + let command_roots = [directory.join("command"), directory.join("commands")]; + if command_roots + .iter() + .any(|path| match fs::symlink_metadata(path) { + Ok(_) => true, + Err(error) => error.kind() != std::io::ErrorKind::NotFound, + }) + { + layers.push(SourceLayer { + kind: SourceLayerKind::CommandDirectory(directory.to_path_buf()), + location: directory.to_path_buf(), + scope, + display_name: display_name.to_string(), + source_kind: "opencode_command_directory", + }); + } +} + +fn push_config_file_layer( + layers: &mut Vec, + path: &Path, + scope: ExternalSourceScope, + display_name: &str, +) { + if path.is_file() { + layers.push(SourceLayer { + kind: SourceLayerKind::ConfigFile(path.to_path_buf()), + location: path.to_path_buf(), + scope, + display_name: display_name.to_string(), + source_kind: "opencode_config", + }); + } +} + +fn source_key(layer: &SourceLayer) -> SourceKey { + let mut hasher = Sha256::new(); + hasher.update(layer.source_kind.as_bytes()); + hasher.update([0]); + let identity_path = dunce::canonicalize(&layer.location) + .unwrap_or_else(|_| normalize_path_lexically(&layer.location)); + hasher.update(identity_path.to_string_lossy().as_bytes()); + let digest = hex::encode(hasher.finalize()); + SourceKey::new( + PROVIDER_ID, + format!("{}-{}", layer.source_kind, &digest[..24]), + ) + .expect("hashed OpenCode source id must be valid") +} + +fn deduplicate_layers_keep_last(layers: Vec) -> Vec { + let mut seen = BTreeSet::new(); + let mut unique = layers + .into_iter() + .rev() + .filter(|layer| seen.insert(source_key(layer))) + .collect::>(); + unique.reverse(); + unique +} + +fn normalize_path_lexically(path: &Path) -> PathBuf { + use std::path::Component; + + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + component => normalized.push(component.as_os_str()), + } + } + normalized +} + +#[derive(Debug, Default, Deserialize)] +struct OpenCodeConfigDocument { + #[serde(default, rename = "command")] + commands: BTreeMap, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct OpenCodeCommandInput { + template: String, + #[serde(default)] + description: Option, + #[serde(default)] + agent: Option, + #[serde(default)] + model: Option, + #[serde(default)] + variant: Option, + #[serde(default)] + subtask: Option, +} + +struct ParsedLayer { + commands: BTreeMap, + unavailable_command_names: BTreeSet, + diagnostics: Vec, + content_version: String, + fatal: bool, +} + +fn parse_config_file(path: &Path) -> ParsedLayer { + match fs::metadata(path) { + Ok(metadata) if metadata.len() > MAX_CONFIG_FILE_BYTES => { + return ParsedLayer { + commands: BTreeMap::new(), + unavailable_command_names: BTreeSet::new(), + diagnostics: vec![ExternalSourceDiagnostic::error( + "opencode.command.config_too_large", + "OpenCode config exceeds the 1 MiB compatibility limit", + None, + )], + content_version: format!("too-large:{}", metadata.len()), + fatal: true, + }; + } + Ok(_) => {} + Err(error) => { + return ParsedLayer { + commands: BTreeMap::new(), + unavailable_command_names: BTreeSet::new(), + diagnostics: vec![ExternalSourceDiagnostic::error( + "opencode.command.config_unreadable", + format!("Failed to inspect OpenCode command config: {error}"), + None, + )], + content_version: "unreadable".to_string(), + fatal: true, + }; + } + } + match fs::read_to_string(path) { + Ok(content) => { + let content_version = content_version([(path, content.as_bytes())]); + match parse_config_document(&content) { + Ok(document) => ParsedLayer { + commands: document.commands, + unavailable_command_names: BTreeSet::new(), + diagnostics: Vec::new(), + content_version, + fatal: false, + }, + Err(error) => ParsedLayer { + commands: BTreeMap::new(), + unavailable_command_names: BTreeSet::new(), + diagnostics: vec![ExternalSourceDiagnostic::error( + "opencode.command.config_invalid", + format!("Failed to parse OpenCode command config: {error}"), + None, + )], + content_version, + fatal: true, + }, + } + } + Err(error) => ParsedLayer { + commands: BTreeMap::new(), + unavailable_command_names: BTreeSet::new(), + diagnostics: vec![ExternalSourceDiagnostic::error( + "opencode.command.config_unreadable", + format!("Failed to read OpenCode command config: {error}"), + None, + )], + content_version: "unreadable".to_string(), + fatal: true, + }, + } +} + +fn parse_command_directory(directory: &Path) -> ParsedLayer { + let mut files = Vec::new(); + let mut visited = BTreeSet::new(); + let mut scan_diagnostics = Vec::new(); + let mut scan_failed = false; + for name in ["command", "commands"] { + scan_failed |= collect_markdown_files( + &directory.join(name), + &mut files, + &mut visited, + &mut scan_diagnostics, + ); + } + files.sort(); + let truncated_files = if files.len() > MAX_COMMAND_FILES { + files.split_off(MAX_COMMAND_FILES) + } else { + Vec::new() + }; + let truncated = !truncated_files.is_empty(); + + let mut commands = BTreeMap::new(); + let mut unavailable_command_names = truncated_files + .iter() + .filter_map(|path| command_name(directory, path)) + .collect::>(); + let mut diagnostics = scan_diagnostics; + if truncated { + diagnostics.push(ExternalSourceDiagnostic::warning( + "opencode.command.file_limit", + format!("OpenCode command directory exceeds the {MAX_COMMAND_FILES} file limit"), + None, + )); + } + let mut version_hasher = Sha256::new(); + let mut total_template_bytes = 0usize; + let mut template_budget_exhausted = false; + for path in &files { + let Some(name) = command_name(directory, path) else { + continue; + }; + if template_budget_exhausted { + unavailable_command_names.insert(name); + continue; + } + let metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) => { + commands.remove(&name); + unavailable_command_names.insert(name); + diagnostics.push(ExternalSourceDiagnostic::warning( + "opencode.command.file_unreadable", + format!("Failed to inspect command file: {error}"), + None, + )); + continue; + } + }; + if metadata.len() > MAX_COMMAND_FILE_BYTES { + commands.remove(&name); + unavailable_command_names.insert(name); + diagnostics.push(ExternalSourceDiagnostic::warning( + "opencode.command.file_too_large", + "OpenCode command file exceeds the 256 KiB compatibility limit", + None, + )); + continue; + } + let content = match fs::read_to_string(path) { + Ok(content) => content, + Err(error) => { + commands.remove(&name); + unavailable_command_names.insert(name); + diagnostics.push(ExternalSourceDiagnostic::warning( + "opencode.command.file_unreadable", + format!("Failed to read command file: {error}"), + None, + )); + continue; + } + }; + version_hasher.update(path.to_string_lossy().as_bytes()); + version_hasher.update([0]); + version_hasher.update(content.as_bytes()); + version_hasher.update([0]); + if total_template_bytes.saturating_add(content.len()) > MAX_COMMAND_TEMPLATE_BYTES { + commands.remove(&name); + unavailable_command_names.insert(name); + template_budget_exhausted = true; + diagnostics.push(ExternalSourceDiagnostic::warning( + "opencode.command.total_template_bytes_limit", + "OpenCode command templates exceed the 8 MiB collection limit", + None, + )); + continue; + } + total_template_bytes += content.len(); + match parse_markdown_command(&content) { + Ok(input) => { + unavailable_command_names.remove(&name); + commands.insert(name, input); + } + Err(error) => { + commands.remove(&name); + unavailable_command_names.insert(name); + diagnostics.push(ExternalSourceDiagnostic::warning( + "opencode.command.markdown_invalid", + format!("Failed to parse OpenCode command Markdown: {error}"), + None, + )); + } + } + } + ParsedLayer { + commands, + unavailable_command_names, + diagnostics, + content_version: format!("sha256:{}", hex::encode(version_hasher.finalize())), + fatal: scan_failed || truncated || template_budget_exhausted, + } +} + +fn collect_markdown_files( + directory: &Path, + files: &mut Vec, + visited: &mut BTreeSet, + diagnostics: &mut Vec, +) -> bool { + if files.len() > MAX_COMMAND_FILES { + return false; + } + match fs::metadata(directory) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false, + Err(error) => { + diagnostics.push(ExternalSourceDiagnostic::error( + "opencode.command.directory_unreadable", + format!( + "Failed to inspect OpenCode command directory '{}': {error}", + directory.display() + ), + None, + )); + return true; + } + Ok(metadata) if !metadata.is_dir() => { + diagnostics.push(ExternalSourceDiagnostic::error( + "opencode.command.directory_invalid", + format!( + "OpenCode command directory path '{}' is not a directory", + directory.display() + ), + None, + )); + return true; + } + Ok(_) => {} + } + let canonical = match dunce::canonicalize(directory) { + Ok(canonical) => canonical, + Err(error) => { + diagnostics.push(ExternalSourceDiagnostic::error( + "opencode.command.directory_unreadable", + format!( + "Failed to resolve OpenCode command directory '{}': {error}", + directory.display() + ), + None, + )); + return true; + } + }; + if !visited.insert(canonical) { + return false; + } + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) => { + diagnostics.push(ExternalSourceDiagnostic::error( + "opencode.command.directory_unreadable", + format!( + "Failed to read OpenCode command directory '{}': {error}", + directory.display() + ), + None, + )); + return true; + } + }; + let mut failed = false; + let mut paths = Vec::new(); + for entry in entries { + match entry { + Ok(entry) => paths.push(entry.path()), + Err(error) => { + failed = true; + diagnostics.push(ExternalSourceDiagnostic::error( + "opencode.command.directory_unreadable", + format!( + "Failed to enumerate OpenCode command directory '{}': {error}", + directory.display() + ), + None, + )); + } + } + } + paths.sort(); + for path in paths { + if files.len() > MAX_COMMAND_FILES { + break; + } + if path.is_dir() { + failed |= collect_markdown_files(&path, files, visited, diagnostics); + } else if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) + { + files.push(path); + } + } + failed +} + +fn command_name(directory: &Path, path: &Path) -> Option { + let relative = path.strip_prefix(directory).ok()?; + let mut components = relative.components(); + let first = components.next()?.as_os_str().to_str()?; + if first != "command" && first != "commands" { + return None; + } + let tail = components.collect::(); + let mut name = tail.to_string_lossy().replace('\\', "/"); + if name.to_ascii_lowercase().ends_with(".md") { + name.truncate(name.len() - 3); + } + (!name.is_empty()).then_some(name) +} + +fn parse_markdown_command(content: &str) -> Result { + let (metadata, body) = if content.starts_with("---\n") || content.starts_with("---\r\n") { + let (metadata, body) = FrontMatterMarkdown::load_str(content).or_else(|first_error| { + let sanitized = sanitize_opencode_frontmatter(content); + if sanitized == content { + return Err(first_error); + } + FrontMatterMarkdown::load_str(&sanitized).map_err(|retry_error| { + format!( + "{first_error}; OpenCode-compatible front matter retry failed: {retry_error}" + ) + }) + })?; + (Some(metadata), body) + } else { + (None, content.to_string()) + }; + let mut input = OpenCodeCommandInput { + template: body.trim().to_string(), + ..OpenCodeCommandInput::default() + }; + if let Some(metadata) = metadata { + let optional_string = |key: &str| -> Result, String> { + match metadata.get(key) { + None => Ok(None), + Some(value) => value.as_str().map(str::to_string).map(Some).ok_or_else(|| { + format!("OpenCode command front matter field '{key}' must be a string") + }), + } + }; + input.description = optional_string("description")?; + input.agent = optional_string("agent")?; + input.model = optional_string("model")?; + input.variant = optional_string("variant")?; + input.subtask = match metadata.get("subtask") { + None => None, + Some(value) => Some(value.as_bool().ok_or_else(|| { + "OpenCode command front matter field 'subtask' must be a boolean".to_string() + })?), + }; + } + if input.template.is_empty() { + return Err("command template is empty".to_string()); + } + Ok(input) +} + +fn sanitize_opencode_frontmatter(content: &str) -> String { + let Some(captures) = markdown_frontmatter_regex().captures(content) else { + return content.to_string(); + }; + let Some(frontmatter) = captures.get(1) else { + return content.to_string(); + }; + let mut changed = false; + let sanitized = frontmatter + .as_str() + .lines() + .flat_map(|line| { + if line.trim().starts_with('#') + || line.trim().is_empty() + || line.chars().next().is_some_and(char::is_whitespace) + { + return vec![line.to_string()]; + } + let Some(entry) = markdown_frontmatter_entry_regex().captures(line) else { + return vec![line.to_string()]; + }; + let key = entry.get(1).map(|value| value.as_str()).unwrap_or_default(); + let value = entry + .get(2) + .map(|value| value.as_str().trim()) + .unwrap_or_default(); + if value.is_empty() + || value == ">" + || value == "|" + || value.starts_with('"') + || value.starts_with('\'') + || !value.contains(':') + { + return vec![line.to_string()]; + } + changed = true; + vec![format!("{key}: |-"), format!(" {value}")] + }) + .collect::>() + .join("\n"); + if !changed { + return content.to_string(); + } + let mut result = String::with_capacity(content.len() + sanitized.len()); + result.push_str(&content[..frontmatter.start()]); + result.push_str(&sanitized); + result.push_str(&content[frontmatter.end()..]); + result +} + +fn command_definition( + source: SourceKey, + name: String, + input: OpenCodeCommandInput, +) -> Result { + let content_version = command_content_version(&name, &input); + let mut required_capabilities = Vec::new(); + if shell_regex().is_match(&input.template) { + required_capabilities.push("command.shell".to_string()); + } + if file_regex().is_match(&input.template) { + required_capabilities.push("command.file_reference".to_string()); + } + if input.agent.is_some() { + required_capabilities.push("command.agent".to_string()); + } + if input.model.is_some() { + required_capabilities.push("command.model".to_string()); + } + if input.variant.is_some() { + required_capabilities.push("command.variant".to_string()); + } + if input.subtask.is_some() { + required_capabilities.push("command.subtask".to_string()); + } + if config_variable_regex().is_match(&input.template) { + required_capabilities.push("command.config_variable".to_string()); + } + let availability = if required_capabilities.is_empty() { + PromptCommandAvailability::Available + } else { + PromptCommandAvailability::Restricted { + reason: format!( + "OpenCode command requires capabilities not available in this release: {}", + required_capabilities.join(", ") + ), + required_capabilities, + } + }; + let definition = PromptCommandDefinition { + id: SourceQualifiedCommandId::new(source, name.clone()).map_err(|error| { + ExternalSourceProviderError::new( + "opencode.command.name_invalid", + error.to_string(), + false, + ) + })?, + name: name.clone(), + description: input + .description + .unwrap_or_else(|| format!("OpenCode command /{name}")), + template: input.template, + availability, + content_version, + }; + definition.validate().map_err(|error| { + ExternalSourceProviderError::new( + "opencode.command.definition_invalid", + error.to_string(), + false, + ) + })?; + Ok(definition) +} + +fn parse_config_document(input: &str) -> Result { + let value = serde_json::from_str::(&strip_jsonc(input)) + .map_err(|error| error.to_string())?; + if value.get("commands").is_some() && value.get("command").is_none() { + return Err("unsupported top-level 'commands'; OpenCode uses 'command'".to_string()); + } + serde_json::from_value(value).map_err(|error| error.to_string()) +} + +fn command_content_version(name: &str, input: &OpenCodeCommandInput) -> String { + let mut hasher = Sha256::new(); + for value in [ + Some(name), + Some(input.template.as_str()), + input.description.as_deref(), + input.agent.as_deref(), + input.model.as_deref(), + input.variant.as_deref(), + ] { + match value { + Some(value) => { + hasher.update(value.len().to_le_bytes()); + hasher.update(value.as_bytes()); + } + None => hasher.update(usize::MAX.to_le_bytes()), + } + } + hasher.update([u8::from(input.subtask.unwrap_or(false))]); + hasher.update([u8::from(input.subtask.is_some())]); + format!("sha256:{}", hex::encode(hasher.finalize())) +} + +fn opencode_user_config_dir(xdg_config_home: Option, home: Option) -> PathBuf { + xdg_config_home + .or_else(|| home.map(|home| home.join(".config"))) + .unwrap_or_else(|| PathBuf::from(".config")) + .join("opencode") +} + +fn environment_truthy(key: &str) -> bool { + std::env::var(key) + .ok() + .is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "true" | "1")) +} + +fn strip_jsonc(input: &str) -> String { + let mut without_comments = String::with_capacity(input.len()); + let chars = input.chars().collect::>(); + let mut index = 0; + let mut in_string = false; + let mut escaped = false; + while index < chars.len() { + let current = chars[index]; + if in_string { + without_comments.push(current); + if escaped { + escaped = false; + } else if current == '\\' { + escaped = true; + } else if current == '"' { + in_string = false; + } + index += 1; + continue; + } + if current == '"' { + in_string = true; + without_comments.push(current); + index += 1; + continue; + } + if current == '/' && chars.get(index + 1) == Some(&'/') { + index += 2; + while index < chars.len() && chars[index] != '\n' { + index += 1; + } + without_comments.push('\n'); + index += usize::from(index < chars.len()); + continue; + } + if current == '/' && chars.get(index + 1) == Some(&'*') { + index += 2; + while index + 1 < chars.len() && !(chars[index] == '*' && chars[index + 1] == '/') { + if chars[index] == '\n' { + without_comments.push('\n'); + } + index += 1; + } + index = (index + 2).min(chars.len()); + continue; + } + without_comments.push(current); + index += 1; + } + + let chars = without_comments.chars().collect::>(); + let mut output = String::with_capacity(chars.len()); + let mut index = 0; + let mut in_string = false; + let mut escaped = false; + while index < chars.len() { + let current = chars[index]; + if in_string { + output.push(current); + if escaped { + escaped = false; + } else if current == '\\' { + escaped = true; + } else if current == '"' { + in_string = false; + } + index += 1; + continue; + } + if current == '"' { + in_string = true; + output.push(current); + index += 1; + continue; + } + if current == ',' { + let mut lookahead = index + 1; + while lookahead < chars.len() && chars[lookahead].is_whitespace() { + lookahead += 1; + } + if matches!(chars.get(lookahead), Some('}') | Some(']')) { + index += 1; + continue; + } + } + output.push(current); + index += 1; + } + output +} + +fn expand_template(template: &str, arguments: &str) -> String { + let args = argument_regex() + .find_iter(arguments) + .map(|item| { + let value = item.as_str(); + if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + value[1..value.len() - 1].to_string() + } else { + value.to_string() + } + }) + .collect::>(); + let placeholders = placeholder_regex() + .captures_iter(template) + .filter_map(|capture| capture[1].parse::().ok()) + .collect::>(); + let last = placeholders.iter().copied().max().unwrap_or(0); + let with_positions = + placeholder_regex().replace_all(template, |capture: ®ex::Captures<'_>| { + let position = capture[1].parse::().unwrap_or(0); + let argument_index = position.saturating_sub(1); + if argument_index >= args.len() { + String::new() + } else if position == last { + args[argument_index..].join(" ") + } else { + args[argument_index].clone() + } + }); + let uses_arguments = template.contains("$ARGUMENTS"); + let mut expanded = with_positions.replace("$ARGUMENTS", arguments); + if placeholders.is_empty() && !uses_arguments && !arguments.trim().is_empty() { + expanded.push_str("\n\n"); + expanded.push_str(arguments); + } + expanded.trim().to_string() +} + +fn argument_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r#"(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)"#) + .expect("static OpenCode argument regex must compile") + }) +} + +fn placeholder_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| Regex::new(r"\$(\d+)").expect("static placeholder regex must compile")) +} + +fn shell_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| Regex::new(r"!`[^`]+`").expect("static shell regex must compile")) +} + +fn file_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"(?:^|[^\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)") + .expect("static file reference regex must compile") + }) +} + +fn config_variable_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX + .get_or_init(|| Regex::new(r"\{(?:env|file):[^}]+\}").expect("valid config variable regex")) +} + +fn markdown_frontmatter_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"(?s)^---\r?\n(.*?)\r?\n---") + .expect("static Markdown front matter regex must compile") + }) +} + +fn markdown_frontmatter_entry_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$") + .expect("static Markdown front matter entry regex must compile") + }) +} + +fn content_version<'a>(entries: impl IntoIterator) -> String { + let mut hasher = Sha256::new(); + for (path, content) in entries { + hasher.update(path.to_string_lossy().as_bytes()); + hasher.update([0]); + hasher.update(content); + hasher.update([0]); + } + format!("sha256:{}", hex::encode(hasher.finalize())) +} + +fn find_project_root(start: &Path) -> PathBuf { + let start = if start.is_file() { + start.parent().unwrap_or(start) + } else { + start + }; + start + .ancestors() + .find(|path| path.join(".git").exists()) + .unwrap_or(start) + .to_path_buf() +} + +fn directories_between(root: &Path, opened: &Path) -> Vec { + let opened = if opened.is_file() { + opened.parent().unwrap_or(opened) + } else { + opened + }; + let mut directories = opened + .ancestors() + .take_while(|path| path.starts_with(root)) + .map(Path::to_path_buf) + .collect::>(); + directories.reverse(); + directories +} + +fn nearest_existing_path(mut path: PathBuf) -> Option { + loop { + if path.exists() { + return Some(path); + } + if !path.pop() { + return None; + } + } +} + +#[cfg(test)] +mod tests { + use super::opencode_user_config_dir; + use std::path::PathBuf; + + #[test] + fn default_config_root_uses_xdg_semantics_on_every_platform() { + assert_eq!( + opencode_user_config_dir(None, Some(PathBuf::from("home"))), + PathBuf::from("home/.config/opencode") + ); + assert_eq!( + opencode_user_config_dir( + Some(PathBuf::from("custom-config")), + Some(PathBuf::from("home")) + ), + PathBuf::from("custom-config/opencode") + ); + } +} + +fn add_watch_root(roots: &mut BTreeMap, path: PathBuf, recursive: bool) { + roots + .entry(path) + .and_modify(|existing| *existing |= recursive) + .or_insert(recursive); +} + +fn add_nearest_existing_watch_root(roots: &mut BTreeMap, path: &Path) { + if let Some(path) = nearest_existing_path(path.to_path_buf()) { + add_watch_root(roots, path, false); + } +} + +fn add_directory_watch_roots(roots: &mut BTreeMap, directory: &Path) { + if let Some(parent) = directory.parent() { + add_nearest_existing_watch_root(roots, parent); + } + // Keep the desired root even before it exists. The host watches its nearest + // existing parent non-recursively, then promotes this root to a recursive + // watch after a creation event and a successful rescan. + add_watch_root(roots, directory.to_path_buf(), true); +} diff --git a/src/crates/adapters/opencode-adapter/src/lib.rs b/src/crates/adapters/opencode-adapter/src/lib.rs index 95f247f0ba..a42e67f7c7 100644 --- a/src/crates/adapters/opencode-adapter/src/lib.rs +++ b/src/crates/adapters/opencode-adapter/src/lib.rs @@ -5,6 +5,8 @@ //! Host adapter plus typed dispatch targets. The adapter does not execute //! JavaScript, install npm packages, or depend on a user-local `opencode` CLI. +mod command_source; mod source_adapter; +pub use command_source::{OpenCodeCommandProvider, OpenCodeCommandProviderOptions}; pub use source_adapter::load_opencode_package_adapter; diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_command_adapter.rs b/src/crates/adapters/opencode-adapter/tests/opencode_command_adapter.rs new file mode 100644 index 0000000000..22384fe915 --- /dev/null +++ b/src/crates/adapters/opencode-adapter/tests/opencode_command_adapter.rs @@ -0,0 +1,750 @@ +use bitfun_opencode_adapter::{OpenCodeCommandProvider, OpenCodeCommandProviderOptions}; +use bitfun_product_domains::external_sources::{ + ExecutionDomainId, ExternalSourceContext, ExternalSourceHealth, PromptCommandAvailability, + PromptCommandDefinition, PromptCommandProviderSnapshot, PromptCommandSourceProvider, +}; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +struct Fixture { + _temp: TempDir, + user_config: PathBuf, + legacy_user_config: PathBuf, + project: PathBuf, + opened_directory: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let temp = tempfile::tempdir().expect("tempdir"); + let user_config = temp.path().join("xdg/opencode"); + let legacy_user_config = temp.path().join("home/.opencode"); + let project = temp.path().join("project"); + let opened_directory = project.join("packages/app"); + fs::create_dir_all(&user_config).unwrap(); + fs::create_dir_all(&legacy_user_config).unwrap(); + fs::create_dir_all(project.join(".git")).unwrap(); + fs::create_dir_all(&opened_directory).unwrap(); + Self { + _temp: temp, + user_config, + legacy_user_config, + project, + opened_directory, + } + } + + fn provider(&self) -> OpenCodeCommandProvider { + self.provider_with_project_config(true) + } + + fn provider_with_project_config( + &self, + project_config_enabled: bool, + ) -> OpenCodeCommandProvider { + OpenCodeCommandProvider::new(OpenCodeCommandProviderOptions { + user_config_dir: self.user_config.clone(), + legacy_user_config_dir: Some(self.legacy_user_config.clone()), + explicit_config_file: None, + explicit_config_dir: None, + project_config_enabled, + }) + } + + fn context(&self) -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(self.opened_directory.clone()), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } + } +} + +fn write(path: impl AsRef, contents: &str) { + let path = path.as_ref(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); +} + +fn markdown(description: &str, template: &str) -> String { + format!("---\ndescription: {description}\n---\n{template}\n") +} + +fn resolve_all( + provider: &OpenCodeCommandProvider, + snapshot: &PromptCommandProviderSnapshot, +) -> Vec { + provider + .resolve_commands( + &snapshot.commands, + &snapshot + .sources + .iter() + .map(|source| source.key.clone()) + .collect::>(), + ) + .unwrap() +} + +#[test] +fn discovers_global_project_and_nested_command_directories_in_opencode_order() { + let fixture = Fixture::new(); + write( + fixture.user_config.join("opencode.jsonc"), + r#"{ + // OpenCode accepts JSONC and trailing commas. + "command": { + "review": { "template": "global config $ARGUMENTS", "description": "global config" }, + "from-json": { "template": "json $ARGUMENTS", }, + }, + }"#, + ); + write( + fixture.user_config.join("command/review.md"), + &markdown("global markdown", "global markdown $ARGUMENTS"), + ); + write( + fixture.legacy_user_config.join("commands/nested/legacy.md"), + &markdown("legacy nested", "legacy $ARGUMENTS"), + ); + write( + fixture.project.join("opencode.json"), + r#"{"command":{"review":{"template":"project config $ARGUMENTS","description":"project config"}}}"#, + ); + write( + fixture.project.join(".opencode/command/review.md"), + &markdown("project directory", "project directory $ARGUMENTS"), + ); + write( + fixture.opened_directory.join("opencode.jsonc"), + r#"{"command":{"review":{"template":"closer config $ARGUMENTS","description":"closer config"}}}"#, + ); + write( + fixture + .opened_directory + .join(".opencode/commands/review.md"), + &markdown("closest directory", "closest directory $ARGUMENTS"), + ); + + let provider = fixture.provider(); + let snapshot = provider + .discover(&fixture.context()) + .expect("discover OpenCode commands"); + let resolved = resolve_all(&provider, &snapshot); + let review = resolved + .iter() + .find(|command| command.name == "review") + .unwrap(); + + assert_eq!(review.description, "project directory"); + assert_eq!(review.template, "project directory $ARGUMENTS"); + assert!(resolved.iter().any(|command| command.name == "from-json")); + assert!(resolved + .iter() + .any(|command| command.name == "nested/legacy")); + assert!(snapshot.sources.len() >= 6); + assert!(snapshot + .sources + .iter() + .all(|source| source.ecosystem_id.as_str() == "opencode")); +} + +#[test] +fn mirrors_current_opencode_command_precedence_phases() { + let fixture = Fixture::new(); + let explicit = fixture._temp.path().join("explicit"); + let provider = OpenCodeCommandProvider::new(OpenCodeCommandProviderOptions { + user_config_dir: fixture.user_config.clone(), + legacy_user_config_dir: Some(fixture.legacy_user_config.clone()), + explicit_config_file: None, + explicit_config_dir: Some(explicit.clone()), + project_config_enabled: true, + }); + let winner = || { + let snapshot = provider.discover(&fixture.context()).unwrap(); + resolve_all(&provider, &snapshot) + .into_iter() + .find(|command| command.name == "review") + .unwrap() + .template + }; + + write( + fixture.user_config.join("opencode.json"), + r#"{"command":{"review":{"template":"global json"}}}"#, + ); + write( + fixture.project.join("opencode.json"), + r#"{"command":{"review":{"template":"project json"}}}"#, + ); + assert_eq!(winner(), "project json"); + + write( + fixture.user_config.join("commands/review.md"), + &markdown("global directory", "global directory"), + ); + assert_eq!(winner(), "global directory"); + + write( + fixture + .opened_directory + .join(".opencode/commands/review.md"), + &markdown("closest project directory", "closest project directory"), + ); + assert_eq!(winner(), "closest project directory"); + + write( + fixture.project.join(".opencode/commands/review.md"), + &markdown("outer project directory", "outer project directory"), + ); + assert_eq!(winner(), "outer project directory"); + + write( + fixture.legacy_user_config.join("commands/review.md"), + &markdown("legacy directory", "legacy directory"), + ); + assert_eq!(winner(), "legacy directory"); + + write( + explicit.join("commands/review.md"), + &markdown("explicit directory", "explicit directory"), + ); + assert_eq!(winner(), "explicit directory"); +} + +#[test] +fn discovers_user_global_commands_without_an_open_workspace() { + let fixture = Fixture::new(); + write( + fixture.user_config.join("command/global.md"), + &markdown("global command", "global $ARGUMENTS"), + ); + write( + fixture.user_config.join("config.json"), + r#"{"command":{"from-config-json":{"template":"legacy global"}}}"#, + ); + write( + fixture.project.join(".opencode/command/project.md"), + &markdown("project command", "project $ARGUMENTS"), + ); + let context = ExternalSourceContext { + workspace_root: None, + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + }; + + let snapshot = fixture.provider().discover(&context).unwrap(); + + assert!(snapshot + .commands + .iter() + .any(|command| command.name == "global")); + assert!(snapshot + .commands + .iter() + .any(|command| command.name == "from-config-json")); + assert!(!snapshot + .commands + .iter() + .any(|command| command.name == "project")); +} + +#[test] +fn suppressing_an_opencode_winner_reveals_the_next_ecosystem_source() { + let fixture = Fixture::new(); + write( + fixture.user_config.join("commands/review.md"), + &markdown("global review", "global"), + ); + write( + fixture.project.join(".opencode/commands/review.md"), + &markdown("project review", "project"), + ); + let provider = fixture.provider(); + let snapshot = provider.discover(&fixture.context()).unwrap(); + let mut enabled = snapshot + .sources + .iter() + .map(|source| source.key.clone()) + .collect::>(); + + let resolved = provider + .resolve_commands(&snapshot.commands, &enabled) + .unwrap(); + assert_eq!(resolved[0].template, "project"); + let project_source = snapshot + .commands + .iter() + .find(|command| command.template == "project") + .unwrap() + .id + .source + .clone(); + enabled.remove(&project_source); + + let fallback = provider + .resolve_commands(&snapshot.commands, &enabled) + .unwrap(); + assert_eq!(fallback[0].template, "global"); + enabled.insert(project_source); + assert_eq!( + provider + .resolve_commands(&snapshot.commands, &enabled) + .unwrap()[0] + .template, + "project" + ); +} + +#[test] +fn disabled_project_config_excludes_project_files_directories_and_watch_roots() { + let fixture = Fixture::new(); + write( + fixture.user_config.join("commands/global.md"), + &markdown("global", "global"), + ); + write( + fixture.project.join("opencode.json"), + r#"{"command":{"project-json":{"template":"project"}}}"#, + ); + write( + fixture.project.join(".opencode/commands/project-dir.md"), + &markdown("project", "project"), + ); + let provider = fixture.provider_with_project_config(false); + + let snapshot = provider.discover(&fixture.context()).unwrap(); + + assert_eq!(snapshot.commands.len(), 1); + assert_eq!(snapshot.commands[0].name, "global"); + assert!(!provider + .watch_roots(&fixture.context()) + .iter() + .any(|root| root.path.starts_with(&fixture.project))); +} + +#[test] +fn invalid_source_is_diagnostic_and_does_not_remove_other_valid_sources() { + let fixture = Fixture::new(); + write( + fixture.user_config.join("commands/global.md"), + &markdown("valid global", "global prompt"), + ); + write( + fixture.project.join("opencode.jsonc"), + "{ this is invalid jsonc", + ); + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + + assert!(snapshot + .commands + .iter() + .any(|command| command.name == "global")); + assert!(snapshot + .sources + .iter() + .any(|source| source.health == ExternalSourceHealth::Unavailable)); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "opencode.command.config_invalid")); +} + +#[test] +fn unsupported_expansion_features_restrict_the_whole_command() { + let fixture = Fixture::new(); + write( + fixture.user_config.join("opencode.json"), + r#"{ + "command": { + "shell": {"template":"Run !`git status`"}, + "file": {"template":"Review @src/main.rs"}, + "config-var": {"template":"Review {env:HOME}"}, + "agent": {"template":"Delegate this", "agent":"explore"}, + "subtask": {"template":"Delegate this", "subtask":true} + } + }"#, + ); + + let provider = fixture.provider(); + let snapshot = provider.discover(&fixture.context()).unwrap(); + assert!(snapshot + .sources + .iter() + .any(|source| source.health == ExternalSourceHealth::Partial)); + for name in ["shell", "file", "config-var", "agent", "subtask"] { + let command = snapshot + .commands + .iter() + .find(|command| command.name == name) + .unwrap(); + assert!(matches!( + command.availability, + PromptCommandAvailability::Restricted { .. } + )); + assert!(provider.expand(command, "").is_err()); + } +} + +#[test] +fn plural_command_config_is_diagnostic_instead_of_silently_empty() { + let fixture = Fixture::new(); + write( + fixture.user_config.join("opencode.json"), + r#"{"commands":{"review":{"template":"wrong field"}}}"#, + ); + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + + assert!(snapshot.commands.is_empty()); + assert!(snapshot + .sources + .iter() + .any(|source| source.health == ExternalSourceHealth::Unavailable)); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| { diagnostic.message.contains("OpenCode uses 'command'") })); +} + +#[test] +fn one_invalid_definition_does_not_fail_the_provider() { + let fixture = Fixture::new(); + write( + fixture.user_config.join("commands/global.md"), + &markdown("valid global", "global prompt"), + ); + let invalid_name = "x".repeat(200); + write( + fixture.project.join("opencode.json"), + &format!(r#"{{"command":{{"{invalid_name}":{{"template":"invalid"}}}}}}"#), + ); + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + + assert!(snapshot + .commands + .iter() + .any(|command| command.name == "global")); + assert!(snapshot + .sources + .iter() + .any(|source| source.health == ExternalSourceHealth::Degraded)); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "opencode.command.name_invalid")); +} + +#[test] +fn invalid_known_markdown_metadata_is_not_silently_dropped() { + let fixture = Fixture::new(); + write( + fixture.user_config.join("commands/review.md"), + "---\ndescription:\n - not-a-string\n---\nReview this change\n", + ); + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + + assert!(snapshot.commands.is_empty()); + assert_eq!(snapshot.unavailable_command_ids.len(), 1); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "opencode.command.markdown_invalid")); +} + +#[test] +fn markdown_frontmatter_retries_opencode_unquoted_colon_compatibility() { + let fixture = Fixture::new(); + write( + fixture.user_config.join("commands/review.md"), + "---\ndescription: Review: focused changes\n---\nReview this change\n", + ); + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + let command = snapshot + .commands + .iter() + .find(|command| command.name == "review") + .unwrap(); + + assert_eq!(command.description, "Review: focused changes"); + assert!(matches!( + command.availability, + PromptCommandAvailability::Available + )); +} + +#[test] +fn expands_arguments_and_positions_using_the_frozen_opencode_semantics() { + let fixture = Fixture::new(); + let provider = fixture.provider(); + write( + fixture.user_config.join("opencode.json"), + r#"{ + "command": { + "all": {"template":"Review $ARGUMENTS"}, + "positions": {"template":"First=$1 Rest=$2"}, + "append": {"template":"Review this change"} + } + }"#, + ); + let snapshot = provider.discover(&fixture.context()).unwrap(); + + let expand = |name: &str, arguments: &str| { + let command = snapshot + .commands + .iter() + .find(|item| item.name == name) + .unwrap(); + provider.expand(command, arguments).unwrap().content + }; + assert_eq!( + expand("all", "src/lib.rs carefully"), + "Review src/lib.rs carefully" + ); + assert_eq!( + expand("positions", "\"hello world\" second third"), + "First=hello world Rest=second third" + ); + assert_eq!( + expand("append", "with tests"), + "Review this change\n\nwith tests" + ); +} + +#[test] +fn deleting_the_winning_file_reveals_the_next_opencode_source() { + let fixture = Fixture::new(); + let global = fixture.user_config.join("commands/review.md"); + let project = fixture.project.join(".opencode/commands/review.md"); + write(&global, &markdown("global", "global")); + write(&project, &markdown("project", "project")); + let provider = fixture.provider(); + + let initial = provider.discover(&fixture.context()).unwrap(); + let initial = resolve_all(&provider, &initial); + assert_eq!( + initial + .iter() + .find(|item| item.name == "review") + .unwrap() + .template, + "project" + ); + fs::remove_file(project).unwrap(); + let refreshed = provider.discover(&fixture.context()).unwrap(); + let refreshed = resolve_all(&provider, &refreshed); + assert_eq!( + refreshed + .iter() + .find(|item| item.name == "review") + .unwrap() + .template, + "global" + ); +} + +#[test] +fn invalid_higher_priority_directory_candidate_does_not_expose_a_lower_duplicate() { + let fixture = Fixture::new(); + write( + fixture.user_config.join("command/review.md"), + &markdown("lower", "lower"), + ); + write( + fixture.user_config.join("commands/review.md"), + "---\ndescription: invalid\n", + ); + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + + snapshot + .validate() + .expect("snapshot identities remain unique"); + assert!(!snapshot + .commands + .iter() + .any(|command| command.name == "review")); + assert!(snapshot + .unavailable_command_ids + .iter() + .any(|command_id| command_id.local_id.as_str() == "review")); +} + +#[test] +fn semantically_invalid_known_command_is_marked_unavailable() { + let fixture = Fixture::new(); + write( + fixture.user_config.join("opencode.json"), + r#"{"command":{"review":{"template":""}}}"#, + ); + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + + assert!(snapshot.commands.is_empty()); + assert!(snapshot + .unavailable_command_ids + .iter() + .any(|command_id| command_id.local_id.as_str() == "review")); +} + +#[test] +fn invalid_command_directory_shape_is_unavailable_not_a_stable_empty_source() { + let fixture = Fixture::new(); + write(fixture.user_config.join("command"), "not a directory"); + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + + assert!(snapshot + .sources + .iter() + .any(|source| source.health == ExternalSourceHealth::Unavailable)); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "opencode.command.directory_invalid")); +} + +#[test] +fn explicit_paths_aliasing_default_paths_do_not_duplicate_sources_or_commands() { + let fixture = Fixture::new(); + let config = fixture.user_config.join("opencode.json"); + write(&config, r#"{"command":{"review":{"template":"review"}}}"#); + write( + fixture.user_config.join("commands/other.md"), + &markdown("other", "other"), + ); + let provider = OpenCodeCommandProvider::new(OpenCodeCommandProviderOptions { + user_config_dir: fixture.user_config.clone(), + legacy_user_config_dir: Some(fixture.legacy_user_config.clone()), + explicit_config_file: Some(config), + explicit_config_dir: Some(fixture.user_config.clone()), + project_config_enabled: true, + }); + + let snapshot = provider.discover(&fixture.context()).unwrap(); + + snapshot.validate().expect("snapshot must be unique"); + let source_keys = snapshot + .sources + .iter() + .map(|source| source.key.clone()) + .collect::>(); + assert_eq!(source_keys.len(), snapshot.sources.len()); + let command_ids = snapshot + .commands + .iter() + .map(|command| command.id.clone()) + .collect::>(); + assert_eq!(command_ids.len(), snapshot.commands.len()); +} + +#[test] +fn oversized_config_is_bounded_and_reported() { + let fixture = Fixture::new(); + fs::write( + fixture.user_config.join("opencode.json"), + vec![b' '; 1024 * 1024 + 1], + ) + .unwrap(); + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "opencode.command.config_too_large")); +} + +#[test] +fn oversized_command_directory_is_not_partially_published() { + let fixture = Fixture::new(); + let directory = fixture.user_config.join("commands"); + fs::create_dir_all(&directory).unwrap(); + for index in 0..=2048 { + fs::write(directory.join(format!("command-{index:04}.md")), "prompt").unwrap(); + } + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + + assert!(snapshot.commands.is_empty()); + assert!(snapshot + .sources + .iter() + .any(|source| source.health == ExternalSourceHealth::Unavailable)); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "opencode.command.file_limit")); +} + +#[test] +fn command_directory_template_bytes_are_bounded_as_a_collection() { + let fixture = Fixture::new(); + let directory = fixture.user_config.join("commands"); + fs::create_dir_all(&directory).unwrap(); + let body = "x".repeat(220 * 1024); + for index in 0..40 { + fs::write(directory.join(format!("large-{index:02}.md")), &body).unwrap(); + } + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + let published_bytes = snapshot + .commands + .iter() + .map(|command| command.template.len()) + .sum::(); + + assert!(published_bytes <= 8 * 1024 * 1024); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| { diagnostic.code == "opencode.command.total_template_bytes_limit" })); +} + +#[test] +fn template_bytes_are_bounded_across_all_discovered_layers() { + let fixture = Fixture::new(); + let body = "x".repeat(220 * 1024); + for (root, prefix) in [ + (fixture.user_config.join("commands"), "global"), + (fixture.project.join(".opencode/commands"), "project"), + ] { + fs::create_dir_all(&root).unwrap(); + for index in 0..24 { + fs::write(root.join(format!("{prefix}-{index:02}.md")), &body).unwrap(); + } + } + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + let published_bytes = snapshot + .commands + .iter() + .map(|command| command.template.len()) + .sum::(); + + assert!(published_bytes <= 8 * 1024 * 1024); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| { diagnostic.code == "opencode.command.provider_template_bytes_limit" })); +} + +#[test] +fn watch_roots_cover_global_and_project_creation_paths() { + let fixture = Fixture::new(); + let roots = fixture.provider().watch_roots(&fixture.context()); + + assert!(roots + .iter() + .any(|root| root.path == fixture.user_config && root.recursive)); + assert!(roots + .iter() + .any(|root| root.path == fixture.project && !root.recursive)); + assert!(roots + .iter() + .any(|root| { root.path == fixture.project.join(".opencode") && root.recursive })); +} diff --git a/src/crates/assembly/AGENTS-CN.md b/src/crates/assembly/AGENTS-CN.md index 44c9de4d08..9f153d553f 100644 --- a/src/crates/assembly/AGENTS-CN.md +++ b/src/crates/assembly/AGENTS-CN.md @@ -9,6 +9,7 @@ | Crate | 职责 | 本地文档 | |---|---|---| | `core` | `bitfun-core` 兼容门面与 product-full 组装 | [AGENTS.md](core/AGENTS.md) | +| `external-sources` | 基于能力专属 provider 契约的生态中立来源生命周期协调 | 继承本指南 | | `product-capabilities` | 产品能力 profile、tool group facts、service requirements 与 harness selection | [AGENTS.md](product-capabilities/AGENTS.md) | ## 放置规则 diff --git a/src/crates/assembly/AGENTS.md b/src/crates/assembly/AGENTS.md index b0d59a10c3..ce0d2397b3 100644 --- a/src/crates/assembly/AGENTS.md +++ b/src/crates/assembly/AGENTS.md @@ -12,6 +12,7 @@ integration, or stable product-domain contracts. | Crate | Responsibility | Local doc | |---|---|---| | `core` | `bitfun-core` compatibility facade and product-full assembly | [AGENTS.md](core/AGENTS.md) | +| `external-sources` | Ecosystem-neutral source lifecycle coordination over capability-specific provider contracts | inherited | | `product-capabilities` | Product capability profiles, tool group facts, service requirements, and harness selections | [AGENTS.md](product-capabilities/AGENTS.md) | ## Placement Rules diff --git a/src/crates/assembly/core/AGENTS-CN.md b/src/crates/assembly/core/AGENTS-CN.md index c89e9eb146..d59e82752e 100644 --- a/src/crates/assembly/core/AGENTS-CN.md +++ b/src/crates/assembly/core/AGENTS-CN.md @@ -45,7 +45,8 @@ SessionManager -> Session -> DialogTurn -> ModelRound - Product-domain 改动可以在有等价保护时迁移纯产品领域计划;filesystem writes、worker/host side effect、 Git/AI concrete calls、marker IO 和 path-manager integration 仍留在 core,除非有经过评审的 owner 设计。 - `plugin_source` 只注入产品目录并保留兼容接口;受管插件包发现与信任持久化归 `services-integrations`,生态适配解析与 Plugin Runtime Host 行为分别归对应的适配器层和执行层。 -- `plugin_runtime` 是 `product-full` 唯一允许选择生态适配器并注入 Plugin Runtime Host 的组装文件。产品入口只消费其产品级激活视图,不得导入适配器或 Host ABI 类型。 +- `plugin_runtime` 与 `external_sources` 是经过评审、可分别为对应能力契约选择生态适配器的 `product-full` 组装文件。 + 产品入口只消费产品级视图,不得导入适配器或 Host ABI 类型。 - Remote/service 改动必须保持 external protocol lifecycle、workspace projection、scheduler/session restore、 terminal pre-warm 和 product execution 边界清晰。 - Feature 改动必须保持 `product-full` 作为兼容产品组装边界;默认能力选择只有在单独的 product matrix review 后才能变化。 diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index 5e11223bc5..b35f117306 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -65,10 +65,10 @@ SessionManager -> Session -> DialogTurn -> ModelRound concrete managed-package discovery and trust persistence stay in `services-integrations`, while ecosystem parsing and Plugin Runtime Host behavior remain in their adapter and execution owners. -- `plugin_runtime` is the only product-full composition file allowed to select - an ecosystem adapter and inject it into Plugin Runtime Host. Product surfaces - consume its product-level activation view and must not import adapter or Host - ABI types. +- `plugin_runtime` and `external_sources` are the reviewed product-full + composition files allowed to select ecosystem adapters for their respective + capability contracts. Product surfaces consume product-level views and must + not import adapter or Host ABI types. - Remote/service changes must keep external protocol lifecycle, workspace projection, scheduler/session restore, terminal pre-warm, and product execution boundaries explicit. diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index a9309a753c..bec0e5b7d5 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -85,6 +85,9 @@ bitfun-harness = { path = "../../execution/harness" } # Product capability pack contracts bitfun-product-capabilities = { path = "../product-capabilities", default-features = false, optional = true } +# Ecosystem-neutral external source lifecycle coordinator +bitfun-external-sources = { path = "../external-sources", optional = true } + # Agent tool contracts bitfun-agent-tools = { path = "../../execution/tool-contracts" } @@ -176,6 +179,7 @@ product-full = [ "dep:globset", "dep:include_dir", "dep:bitfun-opencode-adapter", + "dep:bitfun-external-sources", "dep:bitfun-plugin-runtime-host", "dep:indexmap", "dep:md5", diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs new file mode 100644 index 0000000000..81a4f958cb --- /dev/null +++ b/src/crates/assembly/core/src/external_sources.rs @@ -0,0 +1,1174 @@ +//! Product composition and lifecycle service for external AI application sources. +//! +//! Concrete ecosystem providers are selected only in this assembly module. The +//! catalog and product surfaces remain provider- and ecosystem-neutral. + +pub use bitfun_product_domains::external_sources::{ + prompt_command_conflict_key, ExpandedPromptCommand, ExternalSourceCatalogEntry, + ExternalSourceCatalogSnapshot, ExternalSourceDiagnostic, ExternalSourceLifecycleState, + PromptCommandAvailability, PromptCommandCatalogEntry, PromptCommandDefinition, SourceKey, +}; + +use bitfun_external_sources::{ + ExternalSourceCoordinator, ExternalSourceDiscoveryRequest, ExternalSourceDiscoveryResult, +}; +use bitfun_opencode_adapter::OpenCodeCommandProvider; +use bitfun_product_domains::external_sources::{ + ExecutionDomainId, ExternalSourceContext, PromptCommandSourceProvider, +}; +use bitfun_services_core::json_store::JsonFileStore; +use bitfun_services_integrations::file_watch::{FileWatchService, FileWatcherConfig}; +use dashmap::{mapref::entry::Entry, DashMap}; +use futures::future::{join_all, BoxFuture, Shared}; +use futures::FutureExt; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex as StdMutex, MutexGuard, OnceLock, Weak}; +use tokio::sync::broadcast; + +const PROVIDER_DISCOVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const EXTERNAL_SOURCE_PREFERENCES_FILE: &str = "external-sources.json"; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct ExternalSourcesConfig { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + suppressed_source_keys: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + conflict_choices: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + conflict_lineage_current_keys: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + conflicted_candidate_ids: BTreeSet, +} + +#[derive(Debug, Clone)] +struct ExternalSourcePreferenceStore { + path: PathBuf, +} + +impl ExternalSourcePreferenceStore { + fn new(path: PathBuf) -> Self { + Self { path } + } + + fn global() -> Result { + let path_manager = + crate::infrastructure::try_get_path_manager_arc().map_err(|error| error.to_string())?; + Ok(Self::new( + path_manager + .user_config_dir() + .join(EXTERNAL_SOURCE_PREFERENCES_FILE), + )) + } + + async fn read(&self) -> Result { + JsonFileStore + .read_locked_optional(&self.path) + .await + .map(|config| config.unwrap_or_default()) + .map_err(|error| error.to_string()) + } + + async fn update( + &self, + update: impl FnOnce(&mut ExternalSourcesConfig) -> R, + ) -> Result<(R, ExternalSourcesConfig), String> { + JsonFileStore + .update_locked(&self.path, ExternalSourcesConfig::default(), update) + .await + .map_err(|error| error.to_string()) + } +} + +type SharedDiscoveryTask = Shared>; + +struct InFlightDiscovery { + task: SharedDiscoveryTask, + wake_scheduled: bool, +} + +struct WorkspaceExternalSourceService { + workspace_root: Option, + coordinator: Arc>, + updates: broadcast::Sender, + watch_states: tokio::sync::Mutex>, + refresh_gate: tokio::sync::Mutex<()>, + discovery_tasks: tokio::sync::Mutex< + BTreeMap, + >, + initial_refresh_started: AtomicBool, + keepalive_started: AtomicBool, + last_access_epoch_seconds: AtomicU64, + watcher: Arc, +} + +impl WorkspaceExternalSourceService { + async fn create(workspace_root: Option) -> Result, String> { + let context = ExternalSourceContext { + workspace_root: workspace_root.clone(), + execution_domain_id: ExecutionDomainId::new("local-user") + .map_err(|error| error.to_string())?, + }; + let providers: Vec> = + vec![Arc::new(OpenCodeCommandProvider::default())]; + let mut coordinator = ExternalSourceCoordinator::new(context, providers)?; + let preferences = read_external_sources_config().await?; + coordinator.replace_suppressed_sources( + preferences.suppressed_source_keys.iter().cloned().collect(), + ); + coordinator.replace_conflict_choices(preferences.conflict_choices); + coordinator + .replace_conflict_lineage_current_keys(preferences.conflict_lineage_current_keys); + coordinator.replace_conflicted_candidate_ids(preferences.conflicted_candidate_ids); + let (updates, _) = broadcast::channel(32); + let service = Arc::new(Self { + workspace_root, + coordinator: Arc::new(StdMutex::new(coordinator)), + updates, + watch_states: tokio::sync::Mutex::new(BTreeMap::new()), + refresh_gate: tokio::sync::Mutex::new(()), + discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), + initial_refresh_started: AtomicBool::new(false), + keepalive_started: AtomicBool::new(false), + last_access_epoch_seconds: AtomicU64::new(epoch_seconds()), + watcher: Arc::new(FileWatchService::new(FileWatcherConfig::default())), + }); + service.start_watching().await; + Ok(service) + } + + async fn refresh(self: &Arc) -> Result { + self.initial_refresh_started.store(true, Ordering::Release); + // Preferences are global to the local execution domain and may be + // changed by another BitFun process. Synchronize before every refresh + // so a cached CLI/Desktop service cannot keep an externally disabled + // source active. + sync_service_preferences(self).await?; + let _refresh_guard = self.refresh_gate.lock().await; + let requests = lock_coordinator(&self.coordinator).discovery_requests(); + let scheduled = self.prepare_discovery_tasks(requests).await; + let polled = poll_discovery_tasks(scheduled, PROVIDER_DISCOVERY_TIMEOUT).await; + let results = self.finish_discovery_poll(polled).await; + let snapshot = lock_coordinator(&self.coordinator).apply_discovery_results(results); + self.ensure_watch_roots().await; + let _ = self.updates.send(snapshot.clone()); + Ok(snapshot) + } + + async fn prepare_discovery_tasks( + &self, + requests: Vec, + ) -> Vec<( + bitfun_product_domains::external_sources::ProviderId, + SharedDiscoveryTask, + bool, + )> { + let mut tasks = self.discovery_tasks.lock().await; + requests + .into_iter() + .map(|request| { + let provider_id = request.provider_id().clone(); + if let Some(in_flight) = tasks.get(&provider_id) { + return (provider_id, in_flight.task.clone(), false); + } + let task = spawn_discovery_task(request); + tasks.insert( + provider_id.clone(), + InFlightDiscovery { + task: task.clone(), + wake_scheduled: false, + }, + ); + (provider_id, task, true) + }) + .collect() + } + + async fn finish_discovery_poll( + self: &Arc, + polled: Vec, + ) -> Vec { + let mut results = Vec::with_capacity(polled.len()); + let mut wake_tasks = Vec::new(); + let mut tasks = self.discovery_tasks.lock().await; + for poll in polled { + match poll { + DiscoveryPoll::Complete(result) => { + tasks.remove(&result.provider_id().clone()); + results.push(result); + } + DiscoveryPoll::InFlight(provider_id) => { + results.push(discovery_failure( + provider_id, + "external_source.discovery_in_progress", + "provider discovery is still running; using its last valid version", + )); + } + DiscoveryPoll::TimedOut(provider_id) => { + if let Some(in_flight) = tasks.get_mut(&provider_id) { + if !in_flight.wake_scheduled { + in_flight.wake_scheduled = true; + wake_tasks.push((provider_id.clone(), in_flight.task.clone())); + } + } + results.push(discovery_failure( + provider_id, + "external_source.discovery_timeout", + "provider discovery exceeded the 5 second deadline", + )); + } + } + } + drop(tasks); + for (provider_id, task) in wake_tasks { + let weak = Arc::downgrade(self); + tokio::spawn(async move { + let result = task.await; + let Some(service) = weak.upgrade() else { + return; + }; + service + .complete_deferred_discovery(provider_id, result) + .await; + }); + } + results + } + + async fn complete_deferred_discovery( + &self, + provider_id: bitfun_product_domains::external_sources::ProviderId, + result: ExternalSourceDiscoveryResult, + ) { + let _refresh_guard = self.refresh_gate.lock().await; + if self + .discovery_tasks + .lock() + .await + .remove(&provider_id) + .is_none() + { + return; + } + let snapshot = lock_coordinator(&self.coordinator).apply_discovery_result(result); + self.ensure_watch_roots().await; + let _ = self.updates.send(snapshot); + } + + fn ensure_background_refresh(self: &Arc) { + if self + .initial_refresh_started + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + let weak = Arc::downgrade(self); + tokio::spawn(async move { + let Some(service) = weak.upgrade() else { + return; + }; + if let Err(error) = service.refresh().await { + log::warn!("Initial external source refresh failed: {}", error); + } + }); + } + + fn touch(&self) { + self.last_access_epoch_seconds + .store(epoch_seconds(), Ordering::Release); + } + + fn ensure_idle_keepalive(self: &Arc) { + if self + .keepalive_started + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + let service = Arc::clone(self); + tokio::spawn(async move { + const IDLE_SECONDS: u64 = 300; + loop { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + let idle_for = epoch_seconds() + .saturating_sub(service.last_access_epoch_seconds.load(Ordering::Acquire)); + // The keepalive itself and this task account for one strong + // service reference. A subscription or in-flight operation + // keeps the service alive independently of idle time. + if idle_for < IDLE_SECONDS || Arc::strong_count(&service) > 1 { + continue; + } + let key = service.workspace_root.clone(); + if let Some(entry) = workspace_services().get(&key) { + let should_remove = entry + .value() + .upgrade() + .is_some_and(|cached| Arc::ptr_eq(&cached, &service)); + drop(entry); + if should_remove { + workspace_services().remove(&key); + } + } + break; + } + }); + } + + fn snapshot(&self) -> ExternalSourceCatalogSnapshot { + lock_coordinator(&self.coordinator).snapshot() + } + + async fn set_source_enabled( + &self, + stable_key: &str, + enabled: bool, + ) -> Result { + let previous = { + let mut coordinator = lock_coordinator(&self.coordinator); + let previous = coordinator.suppressed_sources().clone(); + coordinator.set_source_enabled(stable_key, enabled)?; + previous + }; + let updated = lock_coordinator(&self.coordinator) + .suppressed_sources() + .clone(); + let authoritative = match persist_source_enabled_change(stable_key, enabled).await { + Ok(authoritative) => authoritative, + Err(error) => { + lock_coordinator(&self.coordinator).replace_suppressed_sources(previous); + return Err(error); + } + }; + if authoritative != updated { + log::debug!("External source suppression preferences changed in another workspace"); + } + propagate_suppressed_sources(&authoritative); + Ok(self.snapshot()) + } + + async fn set_conflict_choice( + &self, + conflict_key: &str, + candidate_id: &str, + ) -> Result { + let (previous_choices, previous_lineage_keys, previous_conflicted_ids, participants) = { + let mut coordinator = lock_coordinator(&self.coordinator); + let participants = coordinator + .snapshot() + .command_conflicts + .into_iter() + .find(|conflict| conflict.conflict_key == conflict_key) + .map(|conflict| { + conflict + .candidates + .into_iter() + .map(|candidate| candidate.candidate_id) + .collect::>() + }) + .ok_or_else(|| format!("unknown external source conflict: {conflict_key}"))?; + let previous_choices = coordinator.conflict_choices().clone(); + let previous_lineage_keys = coordinator.conflict_lineage_current_keys().clone(); + let previous_conflicted_ids = coordinator.conflicted_candidate_ids().clone(); + coordinator.set_conflict_choice(conflict_key, candidate_id)?; + ( + previous_choices, + previous_lineage_keys, + previous_conflicted_ids, + participants, + ) + }; + let (updated_choices, updated_lineage_keys, updated_conflicted_ids) = { + let coordinator = lock_coordinator(&self.coordinator); + ( + coordinator.conflict_choices().clone(), + coordinator.conflict_lineage_current_keys().clone(), + coordinator.conflicted_candidate_ids().clone(), + ) + }; + let authoritative = + match persist_conflict_choice(conflict_key, candidate_id, participants).await { + Ok(authoritative) => authoritative, + Err(error) => { + let mut coordinator = lock_coordinator(&self.coordinator); + coordinator.replace_conflict_choices(previous_choices); + coordinator.replace_conflict_lineage_current_keys(previous_lineage_keys); + coordinator.replace_conflicted_candidate_ids(previous_conflicted_ids); + return Err(error); + } + }; + if authoritative.conflict_choices != updated_choices + || authoritative.conflict_lineage_current_keys != updated_lineage_keys + || authoritative.conflicted_candidate_ids != updated_conflicted_ids + { + log::debug!("External source conflict preferences changed in another workspace"); + } + propagate_conflict_preferences(&authoritative); + Ok(self.snapshot()) + } + + async fn expand_command( + self: &Arc, + name: &str, + arguments: &str, + expected_candidate_id: Option<&str>, + expected_content_version: Option<&str>, + ) -> Result { + // Explicit invocation refreshes first, so a stable deletion cannot be + // bypassed by an old menu projection. + self.refresh().await?; + let coordinator = Arc::clone(&self.coordinator); + let name = name.to_string(); + let arguments = arguments.to_string(); + let expected_candidate_id = expected_candidate_id.map(str::to_string); + let expected_content_version = expected_content_version.map(str::to_string); + tokio::task::spawn_blocking(move || { + lock_coordinator(&coordinator) + .expand_command_guarded( + &name, + &arguments, + expected_candidate_id.as_deref(), + expected_content_version.as_deref(), + ) + .map_err(|error| error.to_string()) + }) + .await + .map_err(|error| format!("external command expansion task failed: {error}"))? + } + + async fn start_watching(self: &Arc) { + let watch_roots = lock_coordinator(&self.coordinator).watch_roots(); + if watch_roots.is_empty() { + return; + } + self.ensure_watch_roots().await; + let mut receiver = self.watcher.subscribe(); + let weak: Weak = Arc::downgrade(self); + tokio::spawn(async move { + loop { + let events = match receiver.recv().await { + Ok(events) => events, + Err(broadcast::error::RecvError::Lagged(_)) => { + if let Some(service) = weak.upgrade() { + let _ = service.refresh().await; + continue; + } + break; + } + Err(broadcast::error::RecvError::Closed) => break, + }; + let Some(service) = weak.upgrade() else { + break; + }; + let watch_roots = lock_coordinator(&service.coordinator).watch_roots(); + let relevant = events.iter().any(|event| { + let path = Path::new(&event.path); + watch_roots.iter().any(|root| path.starts_with(&root.path)) + }); + if !relevant { + continue; + } + if let Err(error) = service.refresh().await { + log::warn!( + "External source background refresh failed for '{}': {}", + service + .workspace_root + .as_deref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "user-global".to_string()), + error + ); + } + } + }); + } + + async fn ensure_watch_roots(&self) { + let watch_roots = lock_coordinator(&self.coordinator).watch_roots(); + let watcher = Arc::clone(&self.watcher); + let mut states = self.watch_states.lock().await; + for root in watch_roots { + let key = (root.path.clone(), root.recursive); + let exists = root.path.exists(); + let was_available = states.get(&key).copied().unwrap_or(false); + if !exists { + states.insert(key, false); + continue; + } + if was_available { + continue; + } + let mut config = FileWatcherConfig::default(); + config.watch_recursively = root.recursive; + config.ignore_hidden_files = false; + config.debounce_interval_ms = 350; + let path = root.path.to_string_lossy().to_string(); + match watcher.watch_path(&path, Some(config)).await { + Ok(()) => { + states.insert(key, true); + } + Err(error) => { + states.insert(key, false); + log::warn!("Failed to watch external source root '{}': {}", path, error); + } + } + } + } +} + +enum DiscoveryPoll { + Complete(ExternalSourceDiscoveryResult), + InFlight(bitfun_product_domains::external_sources::ProviderId), + TimedOut(bitfun_product_domains::external_sources::ProviderId), +} + +async fn poll_discovery_tasks( + scheduled: Vec<( + bitfun_product_domains::external_sources::ProviderId, + SharedDiscoveryTask, + bool, + )>, + timeout: std::time::Duration, +) -> Vec { + join_all( + scheduled + .into_iter() + .map(|(provider_id, task, is_new)| async move { + if !is_new { + return match task.clone().now_or_never() { + Some(result) => DiscoveryPoll::Complete(result), + None => DiscoveryPoll::InFlight(provider_id), + }; + } + match tokio::time::timeout(timeout, task).await { + Ok(result) => DiscoveryPoll::Complete(result), + Err(_) => DiscoveryPoll::TimedOut(provider_id), + } + }), + ) + .await +} + +fn spawn_discovery_task(request: ExternalSourceDiscoveryRequest) -> SharedDiscoveryTask { + let provider_id = request.provider_id().clone(); + async move { + match tokio::task::spawn_blocking(move || request.execute()).await { + Ok(result) => result, + Err(error) => discovery_failure( + provider_id, + "external_source.discovery_task_failed", + &format!("provider discovery task failed: {error}"), + ), + } + } + .boxed() + .shared() +} + +fn discovery_failure( + provider_id: bitfun_product_domains::external_sources::ProviderId, + code: &str, + message: &str, +) -> ExternalSourceDiscoveryResult { + ExternalSourceDiscoveryResult::failed( + provider_id, + bitfun_product_domains::external_sources::ExternalSourceProviderError::new( + code, message, true, + ), + ) +} + +fn lock_coordinator( + coordinator: &StdMutex, +) -> MutexGuard<'_, ExternalSourceCoordinator> { + match coordinator.lock() { + Ok(guard) => guard, + Err(poisoned) => { + log::error!("External source coordinator mutex was poisoned, recovering lock"); + poisoned.into_inner() + } + } +} + +static WORKSPACE_SERVICES: OnceLock< + DashMap, Weak>, +> = OnceLock::new(); + +fn workspace_services() -> &'static DashMap, Weak> { + WORKSPACE_SERVICES.get_or_init(DashMap::new) +} + +fn normalize_workspace_root(workspace_root: Option<&Path>) -> Result, String> { + let Some(workspace_root) = workspace_root else { + return Ok(None); + }; + if !workspace_root.is_absolute() { + return Err("external source workspace root must be absolute".to_string()); + } + Ok(Some( + dunce::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf()), + )) +} + +async fn service_for( + workspace_root: Option<&Path>, +) -> Result, String> { + let workspace_root = normalize_workspace_root(workspace_root)?; + if let Some(service) = workspace_services() + .get(&workspace_root) + .and_then(|service| service.value().upgrade()) + { + service.touch(); + sync_service_preferences(&service).await?; + return Ok(service); + } + let created = WorkspaceExternalSourceService::create(workspace_root.clone()).await?; + let service = match workspace_services().entry(workspace_root) { + Entry::Occupied(mut entry) => match entry.get().upgrade() { + Some(existing) => existing, + None => { + entry.insert(Arc::downgrade(&created)); + created + } + }, + Entry::Vacant(entry) => { + entry.insert(Arc::downgrade(&created)); + created + } + }; + service.touch(); + service.ensure_idle_keepalive(); + sync_service_preferences(&service).await?; + Ok(service) +} + +fn epoch_seconds() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +async fn read_external_sources_config() -> Result { + ExternalSourcePreferenceStore::global()?.read().await +} + +async fn persist_source_enabled_change( + stable_key: &str, + enabled: bool, +) -> Result, String> { + let stable_key = stable_key.to_string(); + ExternalSourcePreferenceStore::global()? + .update(move |config| { + let mut sources = config + .suppressed_source_keys + .iter() + .cloned() + .collect::>(); + if enabled { + sources.remove(&stable_key); + } else { + sources.insert(stable_key); + } + config.suppressed_source_keys = sources.iter().cloned().collect(); + sources + }) + .await + .map(|(sources, _)| sources) +} + +async fn persist_conflict_choice( + conflict_key: &str, + candidate_id: &str, + participants: Vec, +) -> Result { + let conflict_key = conflict_key.to_string(); + let candidate_id = candidate_id.to_string(); + ExternalSourcePreferenceStore::global()? + .update(move |config| { + ExternalSourceCoordinator::reconcile_conflict_preferences( + &mut config.conflict_choices, + &mut config.conflict_lineage_current_keys, + &mut config.conflicted_candidate_ids, + &conflict_key, + &candidate_id, + &participants, + ); + }) + .await + .map(|(_, config)| config) +} + +fn propagate_suppressed_sources(sources: &BTreeSet) { + for service in workspace_services().iter() { + let Some(service) = service.value().upgrade() else { + continue; + }; + let snapshot = { + let mut coordinator = lock_coordinator(&service.coordinator); + coordinator.replace_suppressed_sources(sources.clone()); + coordinator.snapshot() + }; + let _ = service.updates.send(snapshot); + } +} + +fn propagate_conflict_preferences(preferences: &ExternalSourcesConfig) { + for service in workspace_services().iter() { + let Some(service) = service.value().upgrade() else { + continue; + }; + let snapshot = { + let mut coordinator = lock_coordinator(&service.coordinator); + coordinator.replace_conflict_choices(preferences.conflict_choices.clone()); + coordinator.replace_conflict_lineage_current_keys( + preferences.conflict_lineage_current_keys.clone(), + ); + coordinator + .replace_conflicted_candidate_ids(preferences.conflicted_candidate_ids.clone()); + coordinator.snapshot() + }; + let _ = service.updates.send(snapshot); + } +} + +async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> Result<(), String> { + let preferences = read_external_sources_config().await?; + let suppressed_sources = preferences + .suppressed_source_keys + .iter() + .cloned() + .collect::>(); + let (changed, snapshot) = { + let mut coordinator = lock_coordinator(&service.coordinator); + let mut changed = false; + if coordinator.suppressed_sources() != &suppressed_sources { + coordinator.replace_suppressed_sources(suppressed_sources); + changed = true; + } + if coordinator.conflict_choices() != &preferences.conflict_choices { + coordinator.replace_conflict_choices(preferences.conflict_choices.clone()); + changed = true; + } + if coordinator.conflict_lineage_current_keys() != &preferences.conflict_lineage_current_keys + { + coordinator + .replace_conflict_lineage_current_keys(preferences.conflict_lineage_current_keys); + changed = true; + } + if coordinator.conflicted_candidate_ids() != &preferences.conflicted_candidate_ids { + coordinator.replace_conflicted_candidate_ids(preferences.conflicted_candidate_ids); + changed = true; + } + (changed, coordinator.snapshot()) + }; + if changed { + let _ = service.updates.send(snapshot); + } + Ok(()) +} + +fn validate_conflict_preference(conflict_key: &str, candidate_id: &str) -> Result<(), String> { + if conflict_key.is_empty() || conflict_key.len() > 512 { + return Err("external source conflict key is invalid".to_string()); + } + if candidate_id.is_empty() || candidate_id.len() > 512 { + return Err("external source conflict candidate is invalid".to_string()); + } + Ok(()) +} + +pub async fn external_source_conflict_choices() -> Result< + ( + BTreeMap, + BTreeMap, + BTreeSet, + ), + String, +> { + let preferences = read_external_sources_config().await?; + Ok(( + preferences.conflict_choices, + preferences.conflict_lineage_current_keys, + preferences.conflicted_candidate_ids, + )) +} + +pub async fn remember_external_source_conflict_choice( + conflict_key: &str, + candidate_id: &str, + participants: Vec, +) -> Result< + ( + BTreeMap, + BTreeMap, + BTreeSet, + ), + String, +> { + validate_conflict_preference(conflict_key, candidate_id)?; + if participants.is_empty() + || !participants + .iter() + .any(|candidate| candidate == candidate_id) + || participants + .iter() + .any(|candidate| validate_conflict_preference(conflict_key, candidate).is_err()) + { + return Err("external source conflict participants are invalid".to_string()); + } + let preferences = persist_conflict_choice(conflict_key, candidate_id, participants).await?; + propagate_conflict_preferences(&preferences); + Ok(( + preferences.conflict_choices, + preferences.conflict_lineage_current_keys, + preferences.conflicted_candidate_ids, + )) +} + +pub async fn set_external_prompt_command_conflict_choice( + workspace_root: Option<&Path>, + conflict_key: &str, + candidate_id: &str, +) -> Result { + validate_conflict_preference(conflict_key, candidate_id)?; + service_for(workspace_root) + .await? + .set_conflict_choice(conflict_key, candidate_id) + .await +} + +pub async fn external_source_snapshot( + workspace_root: Option<&Path>, + force_refresh: bool, +) -> Result { + let service = service_for(workspace_root).await?; + if force_refresh { + service.refresh().await + } else { + service.ensure_background_refresh(); + Ok(service.snapshot()) + } +} + +pub async fn set_external_source_enabled( + workspace_root: Option<&Path>, + source_key: &str, + enabled: bool, +) -> Result { + service_for(workspace_root) + .await? + .set_source_enabled(source_key, enabled) + .await +} + +pub async fn expand_external_prompt_command( + workspace_root: Option<&Path>, + name: &str, + arguments: &str, + expected_candidate_id: Option<&str>, + expected_content_version: Option<&str>, +) -> Result { + service_for(workspace_root) + .await? + .expand_command( + name, + arguments, + expected_candidate_id, + expected_content_version, + ) + .await +} + +pub async fn subscribe_external_source_updates( + workspace_root: Option<&Path>, +) -> Result { + let service = service_for(workspace_root).await?; + let receiver = service.updates.subscribe(); + service.ensure_background_refresh(); + Ok(ExternalSourceSubscription { + _service: service, + receiver, + }) +} + +pub struct ExternalSourceSubscription { + _service: Arc, + receiver: broadcast::Receiver, +} + +impl ExternalSourceSubscription { + pub fn try_recv( + &mut self, + ) -> Result { + self.receiver.try_recv() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_product_domains::external_sources::{ + ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceRecord, + ExternalSourceScope, PromptCommandAvailability, PromptCommandDefinition, + PromptCommandProviderIdentity, PromptCommandProviderSnapshot, SourceQualifiedCommandId, + }; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct DelayedProvider { + identity: PromptCommandProviderIdentity, + source: SourceKey, + command_name: String, + delay: std::time::Duration, + calls: Arc, + } + + impl PromptCommandSourceProvider for DelayedProvider { + fn identity(&self) -> PromptCommandProviderIdentity { + self.identity.clone() + } + + fn discover( + &self, + context: &ExternalSourceContext, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(self.delay); + let record = ExternalSourceRecord { + key: self.source.clone(), + ecosystem_id: self.identity.ecosystem_id.clone(), + display_name: self.identity.display_name.clone(), + source_kind: "prompt_commands".to_string(), + scope: ExternalSourceScope::UserGlobal, + location: format!("/{}", self.command_name), + execution_domain_id: context.execution_domain_id.clone(), + health: ExternalSourceHealth::Available, + content_version: "source-v1".to_string(), + diagnostics: Vec::new(), + }; + Ok(PromptCommandProviderSnapshot { + provider: self.identity.clone(), + sources: vec![record], + commands: vec![PromptCommandDefinition { + id: SourceQualifiedCommandId::new( + self.source.clone(), + self.command_name.clone(), + ) + .unwrap(), + name: self.command_name.clone(), + description: self.command_name.clone(), + template: self.command_name.clone(), + availability: PromptCommandAvailability::Available, + content_version: "command-v1".to_string(), + }], + unavailable_command_ids: Vec::new(), + diagnostics: Vec::new(), + }) + } + + fn expand( + &self, + command: &PromptCommandDefinition, + _arguments: &str, + ) -> Result { + Ok(ExpandedPromptCommand { + content: command.template.clone(), + }) + } + + fn watch_roots( + &self, + _context: &ExternalSourceContext, + ) -> Vec { + Vec::new() + } + } + + fn delayed_provider( + id: &str, + delay: std::time::Duration, + calls: Arc, + ) -> Arc { + Arc::new(DelayedProvider { + identity: PromptCommandProviderIdentity::new(id, id, id).unwrap(), + source: SourceKey::new(id, "global").unwrap(), + command_name: id.to_string(), + delay, + calls, + }) + } + + fn test_service( + providers: Vec>, + ) -> Arc { + let context = ExternalSourceContext { + workspace_root: None, + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + }; + let (updates, _) = broadcast::channel(8); + Arc::new(WorkspaceExternalSourceService { + workspace_root: None, + coordinator: Arc::new(StdMutex::new( + ExternalSourceCoordinator::new(context, providers).unwrap(), + )), + updates, + watch_states: tokio::sync::Mutex::new(BTreeMap::new()), + refresh_gate: tokio::sync::Mutex::new(()), + discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), + initial_refresh_started: AtomicBool::new(false), + keepalive_started: AtomicBool::new(false), + last_access_epoch_seconds: AtomicU64::new(epoch_seconds()), + watcher: Arc::new(FileWatchService::new(FileWatcherConfig::default())), + }) + } + + #[tokio::test] + async fn preference_store_merges_updates_from_independent_instances() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("external-sources.json"); + let first = ExternalSourcePreferenceStore::new(path.clone()); + let second = ExternalSourcePreferenceStore::new(path); + + let disable = first.update(|config| { + config + .suppressed_source_keys + .push("opencode:global".to_string()); + }); + let choose = second.update(|config| { + ExternalSourceCoordinator::reconcile_conflict_preferences( + &mut config.conflict_choices, + &mut config.conflict_lineage_current_keys, + &mut config.conflicted_candidate_ids, + "prompt_command:local-user:review:v1", + "candidate-a", + &["candidate-a".to_string(), "candidate-b".to_string()], + ); + }); + let (disabled, chosen) = tokio::join!(disable, choose); + disabled.unwrap(); + chosen.unwrap(); + + let persisted = first.read().await.unwrap(); + assert_eq!(persisted.suppressed_source_keys, ["opencode:global"]); + assert_eq!( + persisted + .conflict_choices + .get("prompt_command:local-user:review:v1") + .map(String::as_str), + Some("candidate-a") + ); + assert_eq!( + persisted.conflict_lineage_current_keys["prompt_command:local-user:review"], + "prompt_command:local-user:review:v1" + ); + assert_eq!( + persisted.conflicted_candidate_ids, + BTreeSet::from(["candidate-a".to_string(), "candidate-b".to_string()]) + ); + } + + #[tokio::test] + async fn invalid_preference_file_is_an_error_instead_of_resetting_choices() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("external-sources.json"); + tokio::fs::write(&path, "{ invalid json").await.unwrap(); + + let error = ExternalSourcePreferenceStore::new(path) + .read() + .await + .expect_err("invalid preferences must fail closed"); + + assert!(error.contains("deserialize")); + } + + #[test] + fn conflict_lineages_are_compact_and_independent() { + let mut choices = BTreeMap::from([ + ( + "prompt_command:local-user:review:old".to_string(), + "external-a".to_string(), + ), + ( + "native:prompt_command:local-user:help:old".to_string(), + "bitfun.cli:help".to_string(), + ), + ]); + let mut lineage_keys = BTreeMap::from([ + ( + "prompt_command:local-user:review".to_string(), + "prompt_command:local-user:review:old".to_string(), + ), + ( + "native:prompt_command:local-user:help".to_string(), + "native:prompt_command:local-user:help:old".to_string(), + ), + ]); + let mut conflicted_ids = BTreeSet::from([ + "external-a".to_string(), + "external-b".to_string(), + "bitfun.cli:help".to_string(), + ]); + + ExternalSourceCoordinator::reconcile_conflict_preferences( + &mut choices, + &mut lineage_keys, + &mut conflicted_ids, + "native:prompt_command:local-user:help:new", + "bitfun.cli:help", + &["bitfun.cli:help".to_string()], + ); + + assert!(choices.contains_key("prompt_command:local-user:review:old")); + assert!(!choices.contains_key("native:prompt_command:local-user:help:old")); + assert_eq!(choices.len(), 2); + assert_eq!(lineage_keys.len(), 2); + } + + #[tokio::test] + async fn slow_provider_is_not_respawned_while_healthy_sibling_updates() { + let slow_calls = Arc::new(AtomicUsize::new(0)); + let healthy_calls = Arc::new(AtomicUsize::new(0)); + let service = test_service(vec![ + delayed_provider( + "slow", + std::time::Duration::from_millis(250), + Arc::clone(&slow_calls), + ), + delayed_provider( + "healthy", + std::time::Duration::ZERO, + Arc::clone(&healthy_calls), + ), + ]); + + let requests = lock_coordinator(&service.coordinator).discovery_requests(); + let scheduled = service.prepare_discovery_tasks(requests).await; + let polled = poll_discovery_tasks(scheduled, std::time::Duration::from_millis(25)).await; + let results = service.finish_discovery_poll(polled).await; + let snapshot = lock_coordinator(&service.coordinator).apply_discovery_results(results); + assert!(snapshot + .commands + .iter() + .any(|command| command.definition.name == "healthy")); + + let requests = lock_coordinator(&service.coordinator).discovery_requests(); + let scheduled = service.prepare_discovery_tasks(requests).await; + assert!(scheduled + .iter() + .any(|(provider_id, _, is_new)| { provider_id.as_str() == "slow" && !is_new })); + let polled = poll_discovery_tasks(scheduled, std::time::Duration::from_millis(25)).await; + let results = service.finish_discovery_poll(polled).await; + let snapshot = lock_coordinator(&service.coordinator).apply_discovery_results(results); + + assert_eq!(slow_calls.load(Ordering::SeqCst), 1); + assert!(healthy_calls.load(Ordering::SeqCst) >= 2); + assert!(snapshot + .commands + .iter() + .any(|command| command.definition.name == "healthy")); + } +} diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 7623466e0a..929dc37d57 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -7,6 +7,8 @@ #[cfg(feature = "product-full")] pub mod agentic; // Agent system, tool system, and product runtime orchestration +#[cfg(feature = "product-full")] +pub mod external_sources; #[cfg(feature = "product-domains")] pub mod function_agents; // Function-based agents pub mod infrastructure; // AI clients, storage, logging, events diff --git a/src/crates/assembly/external-sources/Cargo.toml b/src/crates/assembly/external-sources/Cargo.toml new file mode 100644 index 0000000000..b8b6a82284 --- /dev/null +++ b/src/crates/assembly/external-sources/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "bitfun-external-sources" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "Ecosystem-neutral external source lifecycle coordination" + +[lib] +name = "bitfun_external_sources" +crate-type = ["rlib"] + +[dependencies] +bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false, features = ["external-sources"] } + +[lints] +workspace = true diff --git a/src/crates/assembly/external-sources/src/lib.rs b/src/crates/assembly/external-sources/src/lib.rs new file mode 100644 index 0000000000..155657e244 --- /dev/null +++ b/src/crates/assembly/external-sources/src/lib.rs @@ -0,0 +1,772 @@ +//! Ecosystem-neutral external source lifecycle coordination. +//! +//! The coordinator consumes capability-specific provider contracts and never +//! branches on ecosystem identity. Concrete provider selection remains in the +//! product composition root. + +use bitfun_product_domains::external_sources::{ + prompt_command_conflict_key, ExpandedPromptCommand, ExternalSourceCatalogEntry, + ExternalSourceCatalogSnapshot, ExternalSourceContext, ExternalSourceDiagnostic, + ExternalSourceHealth, ExternalSourceLifecycleState, ExternalSourceProviderError, + ExternalSourceRecord, ExternalWatchRoot, PromptCommandAvailability, PromptCommandCatalogEntry, + PromptCommandConflict, PromptCommandConflictCandidate, PromptCommandDefinition, + PromptCommandProviderIdentity, PromptCommandProviderSnapshot, PromptCommandSourceProvider, + ProviderId, SourceKey, +}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::sync::Arc; + +struct ProviderGeneration { + provider: Arc, + identity: PromptCommandProviderIdentity, + initial_result_received: bool, + last_success: Option, + last_usable_sources: BTreeMap, + using_last_valid_sources: BTreeSet, + last_error: Option, +} + +#[derive(Clone)] +struct SourceGeneration { + record: ExternalSourceRecord, + commands: Vec, +} + +/// A provider-neutral discovery unit that product assembly may schedule with +/// its own concurrency and timeout policy. +pub struct ExternalSourceDiscoveryRequest { + provider_id: ProviderId, + provider: Arc, + context: ExternalSourceContext, +} + +impl ExternalSourceDiscoveryRequest { + pub fn provider_id(&self) -> &ProviderId { + &self.provider_id + } + + pub fn execute(self) -> ExternalSourceDiscoveryResult { + let candidate = self.provider.discover(&self.context); + ExternalSourceDiscoveryResult { + provider_id: self.provider_id, + candidate, + } + } +} + +/// Result of one independently scheduled provider discovery. +#[derive(Clone)] +pub struct ExternalSourceDiscoveryResult { + provider_id: ProviderId, + candidate: Result, +} + +impl ExternalSourceDiscoveryResult { + pub fn provider_id(&self) -> &ProviderId { + &self.provider_id + } + + pub fn failed(provider_id: ProviderId, error: ExternalSourceProviderError) -> Self { + Self { + provider_id, + candidate: Err(error), + } + } +} + +/// Coordinates provider generations, suppression, degradation, and selection. +pub struct ExternalSourceCoordinator { + context: ExternalSourceContext, + providers: Vec, + suppressed_sources: BTreeSet, + conflict_choices: BTreeMap, + conflict_lineage_current_keys: BTreeMap, + conflicted_candidate_ids: BTreeSet, + removed_sources: BTreeMap, + generation: u64, + snapshot: ExternalSourceCatalogSnapshot, +} + +impl fmt::Debug for ExternalSourceCoordinator { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ExternalSourceCoordinator") + .field("context", &self.context) + .field("providers", &self.providers.len()) + .field("suppressed_sources", &self.suppressed_sources) + .field("conflict_choices", &self.conflict_choices.len()) + .field("generation", &self.generation) + .finish() + } +} + +impl ExternalSourceCoordinator { + pub fn new( + context: ExternalSourceContext, + providers: Vec>, + ) -> Result { + let mut provider_ids = BTreeSet::new(); + let mut generations = Vec::with_capacity(providers.len()); + for provider in providers { + let identity = provider.identity(); + let provider_id = identity.provider_id.as_str().to_string(); + if !provider_ids.insert(provider_id.clone()) { + return Err(format!( + "duplicate external source provider id: {provider_id}" + )); + } + generations.push(ProviderGeneration { + provider, + identity, + initial_result_received: false, + last_success: None, + last_usable_sources: BTreeMap::new(), + using_last_valid_sources: BTreeSet::new(), + last_error: None, + }); + } + let discovery_pending = !generations.is_empty(); + Ok(Self { + context, + providers: generations, + suppressed_sources: BTreeSet::new(), + conflict_choices: BTreeMap::new(), + conflict_lineage_current_keys: BTreeMap::new(), + conflicted_candidate_ids: BTreeSet::new(), + removed_sources: BTreeMap::new(), + generation: 0, + snapshot: ExternalSourceCatalogSnapshot { + generation: 0, + discovery_pending, + sources: Vec::new(), + commands: Vec::new(), + command_conflicts: Vec::new(), + diagnostics: Vec::new(), + }, + }) + } + + pub fn refresh(&mut self) -> ExternalSourceCatalogSnapshot { + let results = self + .discovery_requests() + .into_iter() + .map(ExternalSourceDiscoveryRequest::execute) + .collect(); + self.apply_discovery_results(results) + } + + pub fn discovery_requests(&self) -> Vec { + self.providers + .iter() + .map(|generation| ExternalSourceDiscoveryRequest { + provider_id: generation.identity.provider_id.clone(), + provider: Arc::clone(&generation.provider), + context: self.context.clone(), + }) + .collect() + } + + pub fn apply_discovery_results( + &mut self, + results: Vec, + ) -> ExternalSourceCatalogSnapshot { + let mut results = results + .into_iter() + .map(|result| (result.provider_id, result.candidate)) + .collect::>(); + for generation in &mut self.providers { + let candidate = results + .remove(&generation.identity.provider_id) + .unwrap_or_else(|| { + Err(ExternalSourceProviderError::new( + "external_source.discovery_result_missing", + "provider discovery did not return a result", + true, + )) + }); + apply_provider_candidate(generation, candidate); + } + self.rebuild_snapshot() + } + + pub fn apply_discovery_result( + &mut self, + result: ExternalSourceDiscoveryResult, + ) -> ExternalSourceCatalogSnapshot { + if let Some(generation) = self + .providers + .iter_mut() + .find(|generation| generation.identity.provider_id == result.provider_id) + { + apply_provider_candidate(generation, result.candidate); + } + self.rebuild_snapshot() + } + + pub fn snapshot(&self) -> ExternalSourceCatalogSnapshot { + self.snapshot.clone() + } + + pub fn set_source_enabled(&mut self, stable_key: &str, enabled: bool) -> Result<(), String> { + let known = self.providers.iter().any(|provider| { + provider.last_success.as_ref().is_some_and(|snapshot| { + snapshot + .sources + .iter() + .any(|source| source.preference_key() == stable_key) + }) + }); + if !known { + return Err(format!("unknown external source: {stable_key}")); + } + if enabled { + self.suppressed_sources.remove(stable_key); + } else { + self.suppressed_sources.insert(stable_key.to_string()); + } + self.rebuild_snapshot(); + Ok(()) + } + + pub fn replace_suppressed_sources(&mut self, stable_keys: BTreeSet) { + self.suppressed_sources = stable_keys; + self.rebuild_snapshot(); + } + + pub fn suppressed_sources(&self) -> &BTreeSet { + &self.suppressed_sources + } + + pub fn replace_conflict_choices(&mut self, choices: BTreeMap) { + self.conflict_choices = choices; + self.rebuild_snapshot(); + } + + pub fn conflict_choices(&self) -> &BTreeMap { + &self.conflict_choices + } + + pub fn replace_conflict_lineage_current_keys(&mut self, keys: BTreeMap) { + self.conflict_lineage_current_keys = keys; + self.rebuild_snapshot(); + } + + pub fn conflict_lineage_current_keys(&self) -> &BTreeMap { + &self.conflict_lineage_current_keys + } + + pub fn replace_conflicted_candidate_ids(&mut self, candidate_ids: BTreeSet) { + self.conflicted_candidate_ids = candidate_ids; + self.rebuild_snapshot(); + } + + pub fn conflicted_candidate_ids(&self) -> &BTreeSet { + &self.conflicted_candidate_ids + } + + /// Applies the compact, provider-neutral conflict preference lineage rule. + /// One current fingerprint is retained per execution-domain/command family, + /// while candidate identities that have participated in a real conflict + /// remain marked so a later singleton update still requires confirmation. + pub fn reconcile_conflict_preferences( + choices: &mut BTreeMap, + lineage_current_keys: &mut BTreeMap, + conflicted_candidate_ids: &mut BTreeSet, + conflict_key: &str, + candidate_id: &str, + participants: &[String], + ) { + let lineage_key = Self::conflict_lineage_key(conflict_key); + if let Some(previous_key) = + lineage_current_keys.insert(lineage_key, conflict_key.to_string()) + { + if previous_key != conflict_key { + choices.remove(&previous_key); + } + } + if participants.len() > 1 { + conflicted_candidate_ids.extend(participants.iter().cloned()); + } + choices.insert(conflict_key.to_string(), candidate_id.to_string()); + } + + pub fn set_conflict_choice( + &mut self, + conflict_key: &str, + candidate_id: &str, + ) -> Result<(), String> { + let conflict = self + .snapshot + .command_conflicts + .iter() + .find(|conflict| conflict.conflict_key == conflict_key) + .ok_or_else(|| format!("unknown external source conflict: {conflict_key}"))?; + let candidate = conflict + .candidates + .iter() + .find(|candidate| candidate.candidate_id == candidate_id) + .ok_or_else(|| format!("unknown conflict candidate: {candidate_id}"))?; + if !matches!(candidate.availability, PromptCommandAvailability::Available) { + return Err(format!( + "external source conflict candidate is not available: {candidate_id}" + )); + } + let participants = conflict + .candidates + .iter() + .map(|candidate| candidate.candidate_id.clone()) + .collect::>(); + Self::reconcile_conflict_preferences( + &mut self.conflict_choices, + &mut self.conflict_lineage_current_keys, + &mut self.conflicted_candidate_ids, + conflict_key, + candidate_id, + &participants, + ); + self.rebuild_snapshot(); + Ok(()) + } + + fn conflict_lineage_key(conflict_key: &str) -> String { + conflict_key + .rsplit_once(':') + .map_or(conflict_key, |(lineage, _)| lineage) + .to_string() + } + + pub fn watch_roots(&self) -> Vec { + let mut roots = self + .providers + .iter() + .flat_map(|provider| provider.provider.watch_roots(&self.context)) + .collect::>(); + roots.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.recursive.cmp(&right.recursive)) + }); + roots.dedup_by(|left, right| left.path == right.path && left.recursive == right.recursive); + roots + } + + pub fn expand_command( + &self, + name: &str, + arguments: &str, + ) -> Result { + self.expand_command_guarded(name, arguments, None, None) + } + + pub fn expand_command_guarded( + &self, + name: &str, + arguments: &str, + expected_candidate_id: Option<&str>, + expected_content_version: Option<&str>, + ) -> Result { + let command = self + .snapshot + .commands + .iter() + .find(|entry| entry.definition.name.eq_ignore_ascii_case(name)) + .map(|entry| &entry.definition) + .ok_or_else(|| { + ExternalSourceProviderError::new( + "external_source.command_not_found", + format!("external prompt command not found: {name}"), + false, + ) + })?; + if expected_candidate_id.is_some() != expected_content_version.is_some() { + return Err(ExternalSourceProviderError::new( + "external_source.invalid_invocation_guard", + "external command invocation guard is incomplete", + false, + )); + } + if let (Some(expected_candidate_id), Some(expected_content_version)) = + (expected_candidate_id, expected_content_version) + { + if command.id.stable_key() != expected_candidate_id + || command.content_version != expected_content_version + { + return Err(ExternalSourceProviderError::new( + "external_source.stale_command_selection", + "external command changed after it was selected; review the updated command and try again", + true, + )); + } + } + match &command.availability { + PromptCommandAvailability::Available => {} + PromptCommandAvailability::Restricted { reason, .. } + | PromptCommandAvailability::Invalid { reason } => { + return Err(ExternalSourceProviderError::new( + "external_source.command_unavailable", + reason.clone(), + false, + )); + } + _ => { + return Err(ExternalSourceProviderError::new( + "external_source.command_unavailable", + "this command availability state is not supported by this runtime", + false, + )); + } + } + let provider = self + .providers + .iter() + .find(|provider| provider.identity.provider_id == command.id.source.provider_id) + .ok_or_else(|| { + ExternalSourceProviderError::new( + "external_source.provider_not_found", + "provider for the selected command is no longer registered", + false, + ) + })?; + provider.provider.expand(command, arguments) + } + + fn rebuild_snapshot(&mut self) -> ExternalSourceCatalogSnapshot { + self.generation = self.generation.saturating_add(1); + let mut sources = Vec::new(); + let mut diagnostics = Vec::new(); + let mut command_candidates_by_name: BTreeMap> = + BTreeMap::new(); + + for provider in &self.providers { + let Some(provider_snapshot) = &provider.last_success else { + if let Some(error) = &provider.last_error { + diagnostics.push(ExternalSourceDiagnostic::error( + error.code.clone(), + error.message.clone(), + None, + )); + } + continue; + }; + + diagnostics.extend(provider_snapshot.diagnostics.clone()); + if let Some(error) = &provider.last_error { + diagnostics.push(ExternalSourceDiagnostic::warning( + error.code.clone(), + error.message.clone(), + None, + )); + } + + let mut enabled_source_keys = BTreeSet::new(); + for record in &provider_snapshot.sources { + let stable_key = record.preference_key(); + let lifecycle = if self.suppressed_sources.contains(&stable_key) { + ExternalSourceLifecycleState::Suppressed + } else if provider.using_last_valid_sources.contains(&record.key) { + ExternalSourceLifecycleState::UsingLastValidVersion + } else { + match record.health { + ExternalSourceHealth::Available => ExternalSourceLifecycleState::Available, + ExternalSourceHealth::Partial => ExternalSourceLifecycleState::Restricted, + ExternalSourceHealth::Degraded => ExternalSourceLifecycleState::Degraded, + ExternalSourceHealth::Unavailable => { + ExternalSourceLifecycleState::Unavailable + } + _ => ExternalSourceLifecycleState::Unavailable, + } + }; + if lifecycle != ExternalSourceLifecycleState::Suppressed { + enabled_source_keys.insert(record.key.clone()); + } + sources.push(ExternalSourceCatalogEntry { + stable_key, + record: record.clone(), + lifecycle, + }); + } + + match provider + .provider + .resolve_commands(&provider_snapshot.commands, &enabled_source_keys) + { + Ok(commands) => { + for command in commands { + if !enabled_source_keys.contains(&command.id.source) + || command.id.source.provider_id != provider.identity.provider_id + || command.validate().is_err() + { + diagnostics.push(ExternalSourceDiagnostic::error( + "external_source.invalid_resolved_command", + "provider returned an invalid resolved command", + Some(command.id.source), + )); + continue; + } + command_candidates_by_name + .entry(command.name.to_ascii_lowercase()) + .or_default() + .push(command); + } + } + Err(error) => diagnostics.push(ExternalSourceDiagnostic::error( + error.code, + error.message, + None, + )), + } + } + + sources.sort_by(|left, right| left.record.key.cmp(&right.record.key)); + let current_source_keys = sources + .iter() + .map(|source| source.stable_key.clone()) + .collect::>(); + for previous in &self.snapshot.sources { + if previous.lifecycle != ExternalSourceLifecycleState::Removed + && !current_source_keys.contains(&previous.stable_key) + { + self.removed_sources + .insert(previous.stable_key.clone(), previous.record.clone()); + } + } + for current in ¤t_source_keys { + self.removed_sources.remove(current); + } + sources.extend(self.removed_sources.iter().map(|(stable_key, record)| { + ExternalSourceCatalogEntry { + stable_key: stable_key.clone(), + record: record.clone(), + lifecycle: ExternalSourceLifecycleState::Removed, + } + })); + sources.sort_by(|left, right| left.record.key.cmp(&right.record.key)); + let mut commands = Vec::new(); + let mut command_conflicts = Vec::new(); + for (command_name, mut candidates) in command_candidates_by_name { + candidates.sort_by(|left, right| left.id.stable_key().cmp(&right.id.stable_key())); + let requires_reconfirmation = candidates.len() == 1 + && self + .conflicted_candidate_ids + .contains(&candidates[0].id.stable_key()); + if candidates.len() == 1 && !requires_reconfirmation { + commands.push(PromptCommandCatalogEntry { + definition: candidates.remove(0), + }); + continue; + } + + let conflict_candidates = candidates + .iter() + .filter_map(|command| { + let source = sources + .iter() + .find(|source| source.record.key == command.id.source)?; + Some(PromptCommandConflictCandidate { + candidate_id: command.id.stable_key(), + source: command.id.source.clone(), + source_display_name: source.record.display_name.clone(), + ecosystem_id: source.record.ecosystem_id.clone(), + content_version: command.content_version.clone(), + command_description: command.description.clone(), + source_scope: source.record.scope, + source_location: source.record.location.clone(), + availability: command.availability.clone(), + }) + }) + .collect::>(); + let conflict_key = prompt_command_conflict_key( + self.context.execution_domain_id.as_str(), + &command_name, + conflict_candidates.iter().map(|candidate| { + ( + candidate.candidate_id.as_str(), + candidate.content_version.as_str(), + ) + }), + ); + let lineage_key = Self::conflict_lineage_key(&conflict_key); + if let Some(previous_key) = self.conflict_lineage_current_keys.get(&lineage_key) { + if previous_key != &conflict_key { + self.conflict_choices.remove(previous_key); + } + } + let selected_candidate_id = self + .conflict_choices + .get(&conflict_key) + .filter(|selected| { + conflict_candidates + .iter() + .any(|candidate| &candidate.candidate_id == *selected) + }) + .cloned(); + if let Some(selected) = &selected_candidate_id { + if let Some(definition) = candidates + .iter() + .find(|candidate| candidate.id.stable_key() == *selected) + { + commands.push(PromptCommandCatalogEntry { + definition: definition.clone(), + }); + } + } + command_conflicts.push(PromptCommandConflict { + conflict_key, + command_name, + candidates: conflict_candidates, + selected_candidate_id, + }); + } + commands.sort_by(|left, right| left.definition.name.cmp(&right.definition.name)); + self.snapshot = ExternalSourceCatalogSnapshot { + generation: self.generation, + discovery_pending: self + .providers + .iter() + .any(|provider| !provider.initial_result_received), + sources, + commands, + command_conflicts, + diagnostics, + }; + self.snapshot.clone() + } +} + +fn mark_provider_last_valid(generation: &mut ProviderGeneration) { + generation.using_last_valid_sources = generation.last_usable_sources.keys().cloned().collect(); +} + +fn apply_provider_candidate( + generation: &mut ProviderGeneration, + candidate: Result, +) { + generation.initial_result_received = true; + match candidate { + Ok(mut snapshot) => match snapshot.validate() { + Ok(()) if snapshot.provider == generation.identity => { + reconcile_source_generations(generation, &mut snapshot); + generation.last_success = Some(snapshot); + generation.last_error = None; + } + Ok(()) => { + mark_provider_last_valid(generation); + generation.last_error = Some(ExternalSourceProviderError::new( + "external_source.provider_identity_changed", + "provider returned a snapshot for a different identity", + false, + )); + } + Err(error) => { + mark_provider_last_valid(generation); + generation.last_error = Some(ExternalSourceProviderError::new( + "external_source.invalid_candidate", + error.to_string(), + false, + )); + } + }, + Err(error) => { + mark_provider_last_valid(generation); + generation.last_error = Some(error); + } + } +} + +fn reconcile_source_generations( + generation: &mut ProviderGeneration, + snapshot: &mut PromptCommandProviderSnapshot, +) { + generation.using_last_valid_sources.clear(); + let present_sources = snapshot + .sources + .iter() + .map(|source| source.key.clone()) + .collect::>(); + generation + .last_usable_sources + .retain(|source, _| present_sources.contains(source)); + let unavailable_command_ids = snapshot + .unavailable_command_ids + .iter() + .cloned() + .collect::>(); + + for record in &mut snapshot.sources { + let mut current_commands = snapshot + .commands + .iter() + .filter(|command| command.id.source == record.key) + .cloned() + .collect::>(); + if matches!( + record.health, + ExternalSourceHealth::Available | ExternalSourceHealth::Partial + ) { + generation.last_usable_sources.insert( + record.key.clone(), + SourceGeneration { + record: record.clone(), + commands: current_commands, + }, + ); + continue; + } + let Some(previous) = generation.last_usable_sources.get(&record.key).cloned() else { + if record.health == ExternalSourceHealth::Degraded && !current_commands.is_empty() { + generation.last_usable_sources.insert( + record.key.clone(), + SourceGeneration { + record: record.clone(), + commands: current_commands, + }, + ); + } + continue; + }; + if record.health == ExternalSourceHealth::Degraded { + let current_ids = current_commands + .iter() + .map(|command| command.id.clone()) + .collect::>(); + let recovered = previous + .commands + .iter() + .filter(|command| { + unavailable_command_ids.contains(&command.id) + && !current_ids.contains(&command.id) + }) + .cloned() + .collect::>(); + if !recovered.is_empty() { + current_commands.extend(recovered); + generation + .using_last_valid_sources + .insert(record.key.clone()); + } + snapshot + .commands + .retain(|command| command.id.source != record.key); + snapshot.commands.extend(current_commands.clone()); + generation.last_usable_sources.insert( + record.key.clone(), + SourceGeneration { + record: previous.record, + commands: current_commands, + }, + ); + continue; + } + let current_diagnostics = record.diagnostics.clone(); + *record = previous.record; + record.diagnostics = current_diagnostics; + snapshot + .commands + .retain(|command| command.id.source != record.key); + snapshot.commands.extend(previous.commands); + generation + .using_last_valid_sources + .insert(record.key.clone()); + } +} diff --git a/src/crates/assembly/external-sources/tests/coordinator_contracts.rs b/src/crates/assembly/external-sources/tests/coordinator_contracts.rs new file mode 100644 index 0000000000..5a2b8b25fd --- /dev/null +++ b/src/crates/assembly/external-sources/tests/coordinator_contracts.rs @@ -0,0 +1,524 @@ +use bitfun_external_sources::ExternalSourceCoordinator; +use bitfun_product_domains::external_sources::{ + EcosystemId, ExecutionDomainId, ExpandedPromptCommand, ExternalSourceContext, + ExternalSourceHealth, ExternalSourceLifecycleState, ExternalSourceProviderError, + ExternalSourceRecord, ExternalSourceScope, ExternalWatchRoot, PromptCommandAvailability, + PromptCommandDefinition, PromptCommandProviderIdentity, PromptCommandProviderSnapshot, + PromptCommandSourceProvider, SourceKey, SourceQualifiedCommandId, +}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +fn source(provider_id: &str, ecosystem_id: &str, source_id: &str) -> ExternalSourceRecord { + ExternalSourceRecord { + key: SourceKey::new(provider_id, source_id).expect("valid source key"), + ecosystem_id: EcosystemId::new(ecosystem_id).expect("valid ecosystem id"), + display_name: format!("{provider_id} commands"), + source_kind: "prompt_commands".to_string(), + scope: ExternalSourceScope::Project, + location: format!("/workspace/{provider_id}"), + execution_domain_id: ExecutionDomainId::new("local-user").expect("valid domain"), + health: ExternalSourceHealth::Available, + content_version: format!("{provider_id}-v1"), + diagnostics: Vec::new(), + } +} + +fn command(provider_id: &str, source_id: &str, precedence: i32) -> PromptCommandDefinition { + command_named(provider_id, source_id, "review", precedence) +} + +fn command_named( + provider_id: &str, + source_id: &str, + name: &str, + version: i32, +) -> PromptCommandDefinition { + PromptCommandDefinition { + id: SourceQualifiedCommandId::new(SourceKey::new(provider_id, source_id).unwrap(), name) + .unwrap(), + name: name.to_string(), + description: format!("Review from {provider_id}"), + template: format!("{provider_id}: $ARGUMENTS"), + availability: PromptCommandAvailability::Available, + content_version: format!("command-v{version}"), + } +} + +fn context() -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(PathBuf::from("/workspace")), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } +} + +#[derive(Clone)] +enum ProviderState { + Snapshot(PromptCommandProviderSnapshot), + Failed(&'static str), +} + +struct FakeProvider { + identity: PromptCommandProviderIdentity, + state: Arc>, +} + +impl FakeProvider { + fn new(provider_id: &str, ecosystem_id: &str, source_id: &str, precedence: i32) -> Self { + let identity = PromptCommandProviderIdentity::new( + provider_id, + ecosystem_id, + format!("{provider_id} display"), + ) + .unwrap(); + Self { + identity: identity.clone(), + state: Arc::new(Mutex::new(ProviderState::Snapshot( + PromptCommandProviderSnapshot { + provider: identity, + sources: vec![source(provider_id, ecosystem_id, source_id)], + commands: vec![command(provider_id, source_id, precedence)], + unavailable_command_ids: Vec::new(), + diagnostics: Vec::new(), + }, + ))), + } + } + + fn state_handle(&self) -> Arc> { + Arc::clone(&self.state) + } +} + +impl PromptCommandSourceProvider for FakeProvider { + fn identity(&self) -> PromptCommandProviderIdentity { + self.identity.clone() + } + + fn discover( + &self, + _context: &ExternalSourceContext, + ) -> Result { + match self.state.lock().unwrap().clone() { + ProviderState::Snapshot(snapshot) => Ok(snapshot), + ProviderState::Failed(message) => Err(ExternalSourceProviderError::new( + "fake.failed", + message, + true, + )), + } + } + + fn expand( + &self, + command: &PromptCommandDefinition, + arguments: &str, + ) -> Result { + Ok(ExpandedPromptCommand { + content: command.template.replace("$ARGUMENTS", arguments), + }) + } + + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { + vec![ExternalWatchRoot { + path: context.workspace_root.clone().unwrap(), + recursive: true, + }] + } +} + +#[test] +fn provider_failure_isolated_and_successful_deletion_withdraws_only_its_generation() { + let first = FakeProvider::new("first", "ecosystem.first", "project", 10); + let first_state = first.state_handle(); + let second = FakeProvider::new("second", "ecosystem.second", "project", 20); + let second_state = second.state_handle(); + let second_snapshot = match second_state.lock().unwrap().clone() { + ProviderState::Snapshot(snapshot) => snapshot, + ProviderState::Failed(_) => unreachable!(), + }; + let mut coordinator = + ExternalSourceCoordinator::new(context(), vec![Arc::new(first), Arc::new(second)]) + .expect("construct coordinator"); + + let initial = coordinator.refresh(); + assert!(initial.commands.is_empty()); + assert_eq!(initial.command_conflicts.len(), 1); + assert_eq!(initial.sources.len(), 2); + let conflict_key = initial.command_conflicts[0].conflict_key.clone(); + let second_candidate = initial.command_conflicts[0] + .candidates + .iter() + .find(|candidate| candidate.source.provider_id.as_str() == "second") + .unwrap() + .candidate_id + .clone(); + coordinator + .set_conflict_choice(&conflict_key, &second_candidate) + .expect("select second provider once"); + + *second_state.lock().unwrap() = ProviderState::Failed("temporary parse failure"); + let degraded = coordinator.refresh(); + assert_eq!( + degraded.commands[0].definition.description, + "Review from second" + ); + assert_eq!( + degraded + .sources + .iter() + .find(|source| source.record.key.provider_id.as_str() == "second") + .unwrap() + .lifecycle, + ExternalSourceLifecycleState::UsingLastValidVersion + ); + + *second_state.lock().unwrap() = ProviderState::Snapshot(PromptCommandProviderSnapshot { + provider: PromptCommandProviderIdentity::new( + "second", + "ecosystem.second", + "second display", + ) + .unwrap(), + sources: Vec::new(), + commands: Vec::new(), + unavailable_command_ids: Vec::new(), + diagnostics: Vec::new(), + }); + let removed = coordinator.refresh(); + assert_eq!(removed.sources.len(), 2); + assert_eq!( + removed + .sources + .iter() + .find(|source| source.record.key.provider_id.as_str() == "second") + .unwrap() + .lifecycle, + ExternalSourceLifecycleState::Removed + ); + assert!(removed.commands.is_empty()); + assert_eq!(removed.command_conflicts.len(), 1); + assert_eq!(removed.command_conflicts[0].candidates.len(), 1); + assert_eq!( + removed.command_conflicts[0].candidates[0] + .source + .provider_id + .as_str(), + "first" + ); + assert_eq!(removed.command_conflicts[0].selected_candidate_id, None); + + let remaining_conflict = &removed.command_conflicts[0]; + coordinator + .set_conflict_choice( + &remaining_conflict.conflict_key, + &remaining_conflict.candidates[0].candidate_id, + ) + .expect("confirm the remaining provider after the candidate set changed"); + let confirmed = coordinator.snapshot(); + assert_eq!( + confirmed.commands[0].definition.description, + "Review from first" + ); + + let mut updated_first = match first_state.lock().unwrap().clone() { + ProviderState::Snapshot(snapshot) => snapshot, + ProviderState::Failed(_) => unreachable!(), + }; + updated_first.commands[0].content_version = "first-command-v2".to_string(); + *first_state.lock().unwrap() = ProviderState::Snapshot(updated_first); + let changed_singleton = coordinator.refresh(); + assert!(changed_singleton.commands.is_empty()); + assert_eq!(changed_singleton.command_conflicts.len(), 1); + assert_eq!( + changed_singleton.command_conflicts[0].selected_candidate_id, + None + ); + coordinator + .set_conflict_choice( + &changed_singleton.command_conflicts[0].conflict_key, + &changed_singleton.command_conflicts[0].candidates[0].candidate_id, + ) + .expect("confirm the changed singleton once"); + + *second_state.lock().unwrap() = ProviderState::Snapshot(second_snapshot); + let returned = coordinator.refresh(); + assert!(returned.commands.is_empty()); + assert_eq!(returned.command_conflicts.len(), 1); + assert_eq!(returned.command_conflicts[0].selected_candidate_id, None); +} + +#[test] +fn failed_command_uses_last_valid_without_reviving_a_deleted_sibling() { + let provider = FakeProvider::new("first", "ecosystem.first", "project", 1); + let state = provider.state_handle(); + let mut initial = match state.lock().unwrap().clone() { + ProviderState::Snapshot(snapshot) => snapshot, + ProviderState::Failed(_) => unreachable!(), + }; + initial.commands = vec![ + command_named("first", "project", "deleted", 1), + command_named("first", "project", "temporarily-broken", 1), + ]; + *state.lock().unwrap() = ProviderState::Snapshot(initial.clone()); + let mut coordinator = ExternalSourceCoordinator::new(context(), vec![Arc::new(provider)]) + .expect("construct coordinator"); + + let first = coordinator.refresh(); + assert_eq!(first.commands.len(), 2); + + let mut degraded = initial; + degraded.sources[0].health = ExternalSourceHealth::Degraded; + degraded.sources[0].content_version = "first-v2".to_string(); + degraded.commands.clear(); + degraded.unavailable_command_ids = vec![SourceQualifiedCommandId::new( + SourceKey::new("first", "project").unwrap(), + "temporarily-broken", + ) + .unwrap()]; + *state.lock().unwrap() = ProviderState::Snapshot(degraded); + + let refreshed = coordinator.refresh(); + assert_eq!(refreshed.commands.len(), 1); + assert_eq!(refreshed.commands[0].definition.name, "temporarily-broken"); + assert!(refreshed + .commands + .iter() + .all(|command| command.definition.name != "deleted")); + assert_eq!( + refreshed.sources[0].lifecycle, + ExternalSourceLifecycleState::UsingLastValidVersion + ); + + let mut deleted = match state.lock().unwrap().clone() { + ProviderState::Snapshot(snapshot) => snapshot, + ProviderState::Failed(_) => unreachable!(), + }; + deleted.sources[0].health = ExternalSourceHealth::Available; + deleted.sources[0].content_version = "first-v3".to_string(); + deleted.unavailable_command_ids.clear(); + *state.lock().unwrap() = ProviderState::Snapshot(deleted); + + let withdrawn = coordinator.refresh(); + assert!(withdrawn.commands.is_empty()); + assert_eq!( + withdrawn.sources[0].lifecycle, + ExternalSourceLifecycleState::Available + ); +} + +#[test] +fn suppression_survives_refresh_and_expansion_dispatches_by_provider_identity() { + let first = FakeProvider::new("first", "ecosystem.first", "project", 10); + let second = FakeProvider::new("second", "ecosystem.second", "project", 20); + let mut coordinator = + ExternalSourceCoordinator::new(context(), vec![Arc::new(first), Arc::new(second)]) + .expect("construct coordinator"); + + coordinator.refresh(); + let conflict = coordinator.snapshot().command_conflicts[0].clone(); + let second_candidate = conflict + .candidates + .iter() + .find(|candidate| candidate.source.provider_id.as_str() == "second") + .unwrap() + .candidate_id + .clone(); + coordinator + .set_conflict_choice(&conflict.conflict_key, &second_candidate) + .expect("select second provider once"); + let second_key = coordinator + .snapshot() + .sources + .iter() + .find(|source| source.record.key.provider_id.as_str() == "second") + .unwrap() + .stable_key + .clone(); + coordinator + .set_source_enabled(&second_key, false) + .expect("suppress known source"); + let suppressed = coordinator.refresh(); + assert!(suppressed.commands.is_empty()); + let remaining_conflict = suppressed.command_conflicts[0].clone(); + let first_candidate = remaining_conflict.candidates[0].candidate_id.clone(); + coordinator + .set_conflict_choice(&remaining_conflict.conflict_key, &first_candidate) + .expect("confirm remaining provider after changing the candidate set"); + let confirmed = coordinator.snapshot(); + assert_eq!( + confirmed.commands[0].definition.description, + "Review from first" + ); + assert_eq!( + suppressed + .sources + .iter() + .find(|source| source.record.key.provider_id.as_str() == "second") + .unwrap() + .lifecycle, + ExternalSourceLifecycleState::Suppressed + ); + + let expanded = coordinator + .expand_command("review", "this change") + .expect("expand active command"); + assert_eq!(expanded.content, "first: this change"); + + coordinator + .set_source_enabled(&second_key, true) + .expect("restore known source"); + let restored = coordinator.refresh(); + assert!(restored.commands.is_empty()); + assert_eq!(restored.command_conflicts.len(), 1); + assert_eq!(restored.command_conflicts[0].selected_candidate_id, None); +} + +#[test] +fn updated_candidate_content_requires_a_new_conflict_choice() { + let first = FakeProvider::new("first", "ecosystem.first", "project", 10); + let second = FakeProvider::new("second", "ecosystem.second", "project", 20); + let second_state = second.state_handle(); + let mut coordinator = + ExternalSourceCoordinator::new(context(), vec![Arc::new(first), Arc::new(second)]) + .expect("construct coordinator"); + + let initial = coordinator.refresh(); + let initial_conflict = initial.command_conflicts[0].clone(); + let selected = initial_conflict.candidates[1].candidate_id.clone(); + coordinator + .set_conflict_choice(&initial_conflict.conflict_key, &selected) + .unwrap(); + assert_eq!(coordinator.snapshot().commands.len(), 1); + + let mut updated = match second_state.lock().unwrap().clone() { + ProviderState::Snapshot(snapshot) => snapshot, + ProviderState::Failed(_) => unreachable!(), + }; + updated.sources[0].content_version = "second-v2".to_string(); + *second_state.lock().unwrap() = ProviderState::Snapshot(updated); + + let refreshed = coordinator.refresh(); + assert_eq!(refreshed.commands.len(), 1); + assert_eq!( + refreshed.command_conflicts[0].conflict_key, + initial_conflict.conflict_key + ); + assert_eq!( + refreshed.command_conflicts[0].selected_candidate_id, + Some(selected.clone()) + ); + + let mut updated = match second_state.lock().unwrap().clone() { + ProviderState::Snapshot(snapshot) => snapshot, + ProviderState::Failed(_) => unreachable!(), + }; + updated.commands[0].content_version = "second-command-v2".to_string(); + *second_state.lock().unwrap() = ProviderState::Snapshot(updated); + + let refreshed = coordinator.refresh(); + assert!(refreshed.commands.is_empty()); + assert_ne!( + refreshed.command_conflicts[0].conflict_key, + initial_conflict.conflict_key + ); + assert!(refreshed.command_conflicts[0] + .selected_candidate_id + .is_none()); + + for version in 3..=10 { + let conflict = coordinator.snapshot().command_conflicts[0].clone(); + let selected = conflict + .candidates + .iter() + .find(|candidate| candidate.source.provider_id.as_str() == "second") + .unwrap() + .candidate_id + .clone(); + coordinator + .set_conflict_choice(&conflict.conflict_key, &selected) + .unwrap(); + assert_eq!(coordinator.conflict_choices().len(), 1); + assert_eq!(coordinator.conflict_lineage_current_keys().len(), 1); + assert_eq!(coordinator.conflicted_candidate_ids().len(), 2); + + let mut updated = match second_state.lock().unwrap().clone() { + ProviderState::Snapshot(snapshot) => snapshot, + ProviderState::Failed(_) => unreachable!(), + }; + updated.commands[0].content_version = format!("second-command-v{version}"); + *second_state.lock().unwrap() = ProviderState::Snapshot(updated); + + let refreshed = coordinator.refresh(); + assert!(refreshed.commands.is_empty()); + assert!(refreshed.command_conflicts[0] + .selected_candidate_id + .is_none()); + assert!(coordinator.conflict_choices().len() <= 1); + assert_eq!(coordinator.conflict_lineage_current_keys().len(), 1); + assert_eq!(coordinator.conflicted_candidate_ids().len(), 2); + } +} + +#[test] +fn duplicate_provider_registration_is_rejected_without_ecosystem_branching() { + let first = Arc::new(FakeProvider::new("same", "ecosystem.first", "one", 1)); + let duplicate = Arc::new(FakeProvider::new("same", "ecosystem.other", "two", 2)); + + let error = ExternalSourceCoordinator::new(context(), vec![first, duplicate]) + .expect_err("provider id collision must be rejected"); + assert!(error.contains("same")); +} + +#[test] +fn catalog_stays_pending_until_every_provider_has_an_initial_result() { + let first = Arc::new(FakeProvider::new("first", "ecosystem.first", "global", 1)); + let second = Arc::new(FakeProvider::new("second", "ecosystem.second", "global", 1)); + let mut coordinator = ExternalSourceCoordinator::new(context(), vec![first, second]).unwrap(); + + assert!(coordinator.snapshot().discovery_pending); + + let first = coordinator + .discovery_requests() + .into_iter() + .find(|request| request.provider_id().as_str() == "first") + .unwrap() + .execute(); + assert!(coordinator.apply_discovery_result(first).discovery_pending); + + let second = coordinator + .discovery_requests() + .into_iter() + .find(|request| request.provider_id().as_str() == "second") + .unwrap() + .execute(); + assert!(!coordinator.apply_discovery_result(second).discovery_pending); +} + +#[test] +fn invocation_guard_rejects_a_command_changed_after_projection() { + let provider = FakeProvider::new("first", "ecosystem.first", "project", 1); + let state = provider.state_handle(); + let mut coordinator = ExternalSourceCoordinator::new(context(), vec![Arc::new(provider)]) + .expect("construct coordinator"); + let projected = coordinator.refresh().commands[0].definition.clone(); + + let mut updated = match state.lock().unwrap().clone() { + ProviderState::Snapshot(snapshot) => snapshot, + ProviderState::Failed(_) => unreachable!(), + }; + updated.commands[0].template = "updated: $ARGUMENTS".to_string(); + updated.commands[0].content_version = "command-v2".to_string(); + *state.lock().unwrap() = ProviderState::Snapshot(updated); + coordinator.refresh(); + + let error = coordinator + .expand_command_guarded( + "review", + "change", + Some(&projected.id.stable_key()), + Some(&projected.content_version), + ) + .expect_err("stale projection must not execute changed content"); + assert_eq!(error.code, "external_source.stale_command_selection"); +} diff --git a/src/crates/contracts/product-domains/AGENTS-CN.md b/src/crates/contracts/product-domains/AGENTS-CN.md index e5f80285cf..30ae0f346b 100644 --- a/src/crates/contracts/product-domains/AGENTS-CN.md +++ b/src/crates/contracts/product-domains/AGENTS-CN.md @@ -14,7 +14,7 @@ ports;具体 runtime 行为不属于本 crate。 - 本 crate 可以承载纯 DTO、枚举、序列化契约、搜索计划、命令选择决策、storage-shape parser、领域策略和产品领域 port trait。 - 真正执行 IO、进程、AI 调用、Git service 调用、平台集成、tool exposure 或 desktop/Tauri 工作的 concrete adapter 属于本 crate 外部。 - 在下游调用点被有意迁移前,用 re-export 或 wrapper facade 保持既有 core import path。 -- 新增 feature-gated 内容必须保持窄边界。`plugin-source`、`miniapp`、`function-agents` 和 `product-full` 只应启用已声明的产品领域 feature 组。 +- 新增 feature-gated 内容必须保持窄边界。`plugin-source`、`miniapp`、`function-agents`、`external-sources` 和 `product-full` 只应启用已声明的产品领域 feature 组。 ## 归属边界 @@ -23,6 +23,8 @@ ports;具体 runtime 行为不属于本 crate。 - `function-agents` 可以拥有 function-agent DTO、prompt/domain policy、response parsing/repair rule、file-shape analysis 和 Git/AI port trait。 - `plugin-source` 可以拥有 BitFun 插件包清单数据结构、来源标识、工作区信任记录和纯信任版本变更规则。 +- `external-sources` 可以拥有开放生态/来源标识、类型化能力 provider 端口、目录 DTO 与版本敏感冲突指纹; + provider 刷新、文件观察、偏好持久化和生命周期协调属于 assembly、services 或 adapters。 - 具体 filesystem writes、marker IO、host dispatch、worker side effect、compile orchestration、`PathManager` integration、 concrete Git/AI service、provider acquisition 和 transport error mapping 均属于本 crate 外部。 diff --git a/src/crates/contracts/product-domains/AGENTS.md b/src/crates/contracts/product-domains/AGENTS.md index 9d32c8c660..8ed605e3c3 100644 --- a/src/crates/contracts/product-domains/AGENTS.md +++ b/src/crates/contracts/product-domains/AGENTS.md @@ -22,7 +22,7 @@ policies, and narrow ports; concrete runtime behavior belongs outside this crate - Preserve existing core import paths with re-export or wrapper facades until downstream call sites are intentionally migrated. - Feature-gated additions must remain narrow. `plugin-source`, `miniapp`, - `function-agents`, and `product-full` should only enable their declared + `function-agents`, `external-sources`, and `product-full` should only enable their declared product-domain feature groups. ## Ownership Boundary @@ -35,6 +35,10 @@ policies, and narrow ports; concrete runtime behavior belongs outside this crate - `plugin-source` may own BitFun package manifest shapes, source identity, fixed package input data, workspace trust records, and pure trust epoch transitions. +- `external-sources` may own open ecosystem/source identifiers, typed + capability-provider ports, catalog DTOs, and version-sensitive conflict + fingerprints. Provider refresh, filesystem watching, persistence, and + lifecycle coordination belong to assembly, services, or adapters. - Concrete filesystem writes, marker IO, host dispatch, worker side effects, compile orchestration, `PathManager` integration, concrete Git/AI services, provider acquisition, and transport error mapping must stay outside diff --git a/src/crates/contracts/product-domains/Cargo.toml b/src/crates/contracts/product-domains/Cargo.toml index 122c45e3d5..f91183e945 100644 --- a/src/crates/contracts/product-domains/Cargo.toml +++ b/src/crates/contracts/product-domains/Cargo.toml @@ -14,6 +14,11 @@ name = "plugin_source_contracts" path = "tests/plugin_source_contracts.rs" required-features = ["plugin-source"] +[[test]] +name = "external_source_contracts" +path = "tests/external_source_contracts.rs" +required-features = ["external-sources"] + [dependencies] serde = { workspace = true } serde_json = { workspace = true } @@ -28,7 +33,8 @@ default = [] plugin-source = ["hex", "sha2"] miniapp = ["dirs", "sha2", "which"] function-agents = ["log"] -product-full = ["plugin-source", "miniapp", "function-agents"] +external-sources = [] +product-full = ["plugin-source", "miniapp", "function-agents", "external-sources"] [dev-dependencies] tokio = { workspace = true } diff --git a/src/crates/contracts/product-domains/src/external_sources.rs b/src/crates/contracts/product-domains/src/external_sources.rs new file mode 100644 index 0000000000..aa9a5313bd --- /dev/null +++ b/src/crates/contracts/product-domains/src/external_sources.rs @@ -0,0 +1,548 @@ +//! Ecosystem-neutral contracts for external AI application sources. +//! +//! Ecosystem adapters implement capability-specific provider traits. Product +//! surfaces and lifecycle coordination consume these types without branching on +//! a concrete ecosystem or carrying arbitrary extension payloads. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::error::Error; +use std::fmt; +use std::path::PathBuf; + +const MAX_ID_LENGTH: usize = 160; +const MAX_TEXT_LENGTH: usize = 4096; + +fn validate_id(value: &str, label: &'static str) -> Result<(), ExternalSourceContractError> { + if value.is_empty() + || value.len() > MAX_ID_LENGTH + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(ExternalSourceContractError::InvalidIdentifier(label)); + } + Ok(()) +} + +fn validate_text(value: &str, label: &'static str) -> Result<(), ExternalSourceContractError> { + if value.is_empty() || value.len() > MAX_TEXT_LENGTH || value.chars().any(char::is_control) { + return Err(ExternalSourceContractError::InvalidText(label)); + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExternalSourceContractError { + InvalidIdentifier(&'static str), + InvalidText(&'static str), +} + +impl fmt::Display for ExternalSourceContractError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidIdentifier(label) => write!(formatter, "invalid {label} identifier"), + Self::InvalidText(label) => write!(formatter, "invalid {label} text"), + } + } +} + +impl Error for ExternalSourceContractError {} + +macro_rules! open_id { + ($name:ident, $label:literal) => { + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(String); + + impl $name { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_id(&value, $label)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } + } + }; +} + +open_id!(EcosystemId, "ecosystem"); +open_id!(ExecutionDomainId, "execution domain"); +open_id!(ProviderId, "provider"); +open_id!(SourceId, "source"); +open_id!(CommandLocalId, "command"); + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SourceKey { + pub provider_id: ProviderId, + pub source_id: SourceId, +} + +impl SourceKey { + pub fn new( + provider_id: impl Into, + source_id: impl Into, + ) -> Result { + Ok(Self { + provider_id: ProviderId::new(provider_id)?, + source_id: SourceId::new(source_id)?, + }) + } + + pub fn stable_key(&self) -> String { + format!( + "{}:{}{}:{}", + self.provider_id.as_str().len(), + self.provider_id, + self.source_id.as_str().len(), + self.source_id + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SourceQualifiedCommandId { + pub source: SourceKey, + pub local_id: CommandLocalId, +} + +impl SourceQualifiedCommandId { + pub fn new( + source: SourceKey, + local_id: impl Into, + ) -> Result { + Ok(Self { + source, + local_id: CommandLocalId::new(local_id)?, + }) + } + + pub fn stable_key(&self) -> String { + format!( + "{}{}:{}", + self.source.stable_key(), + self.local_id.as_str().len(), + self.local_id + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalSourceScope { + UserGlobal, + Project, + WorkspaceLocal, + RemoteUser, + RemoteProject, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalSourceHealth { + Available, + Partial, + Degraded, + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalSourceDiagnosticSeverity { + Info, + Warning, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSourceDiagnostic { + pub severity: ExternalSourceDiagnosticSeverity, + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +impl ExternalSourceDiagnostic { + pub fn warning( + code: impl Into, + message: impl Into, + source: Option, + ) -> Self { + Self { + severity: ExternalSourceDiagnosticSeverity::Warning, + code: code.into(), + message: message.into(), + source, + } + } + + pub fn error( + code: impl Into, + message: impl Into, + source: Option, + ) -> Self { + Self { + severity: ExternalSourceDiagnosticSeverity::Error, + code: code.into(), + message: message.into(), + source, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSourceRecord { + pub key: SourceKey, + pub ecosystem_id: EcosystemId, + pub display_name: String, + pub source_kind: String, + pub scope: ExternalSourceScope, + pub location: String, + pub execution_domain_id: ExecutionDomainId, + pub health: ExternalSourceHealth, + pub content_version: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +impl ExternalSourceRecord { + pub fn preference_key(&self) -> String { + format!( + "{}:{}{}", + self.execution_domain_id.as_str().len(), + self.execution_domain_id, + self.key.stable_key() + ) + } + + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + validate_id(&self.source_kind, "source kind")?; + validate_text(&self.display_name, "source display name")?; + validate_text(&self.location, "source location")?; + validate_id(&self.content_version, "content version") + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +#[non_exhaustive] +pub enum PromptCommandAvailability { + Available, + Restricted { + reason: String, + required_capabilities: Vec, + }, + Invalid { + reason: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PromptCommandDefinition { + pub id: SourceQualifiedCommandId, + pub name: String, + pub description: String, + pub template: String, + pub availability: PromptCommandAvailability, + /// Version of this command only. Unrelated edits in the same source must + /// not invalidate a remembered conflict choice. + pub content_version: String, +} + +impl PromptCommandDefinition { + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + validate_id(&self.name, "command name")?; + if !self.description.is_empty() { + validate_text(&self.description, "command description")?; + } + if self.template.is_empty() || self.template.len() > 256 * 1024 { + return Err(ExternalSourceContractError::InvalidText("command template")); + } + validate_id(&self.content_version, "command content version") + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExpandedPromptCommand { + pub content: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PromptCommandProviderIdentity { + pub provider_id: ProviderId, + pub ecosystem_id: EcosystemId, + pub display_name: String, +} + +impl PromptCommandProviderIdentity { + pub fn new( + provider_id: impl Into, + ecosystem_id: impl Into, + display_name: impl Into, + ) -> Result { + let display_name = display_name.into(); + validate_text(&display_name, "provider display name")?; + Ok(Self { + provider_id: ProviderId::new(provider_id)?, + ecosystem_id: EcosystemId::new(ecosystem_id)?, + display_name, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PromptCommandProviderSnapshot { + pub provider: PromptCommandProviderIdentity, + pub sources: Vec, + pub commands: Vec, + /// Commands that were discovered by identity but could not be read or + /// parsed in this generation. The coordinator may retain only these + /// commands from the previous valid generation; commands absent from both + /// lists are stable deletions and must be withdrawn. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unavailable_command_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +impl PromptCommandProviderSnapshot { + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + let mut source_keys = BTreeSet::new(); + for source in &self.sources { + source.validate()?; + if source.key.provider_id != self.provider.provider_id + || source.ecosystem_id != self.provider.ecosystem_id + || !source_keys.insert(source.key.clone()) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "provider-qualified source", + )); + } + } + let mut command_ids = BTreeSet::new(); + for command in &self.commands { + command.validate()?; + if command.id.source.provider_id != self.provider.provider_id + || !source_keys.contains(&command.id.source) + || !command_ids.insert(command.id.clone()) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "provider-qualified command", + )); + } + } + let mut unavailable_ids = BTreeSet::new(); + for command_id in &self.unavailable_command_ids { + if command_id.source.provider_id != self.provider.provider_id + || !source_keys.contains(&command_id.source) + || command_ids.contains(command_id) + || !unavailable_ids.insert(command_id.clone()) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "unavailable provider-qualified command", + )); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalSourceContext { + pub workspace_root: Option, + pub execution_domain_id: ExecutionDomainId, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalWatchRoot { + pub path: PathBuf, + pub recursive: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSourceProviderError { + pub code: String, + pub message: String, + pub transient: bool, +} + +impl ExternalSourceProviderError { + pub fn new(code: impl Into, message: impl Into, transient: bool) -> Self { + Self { + code: code.into(), + message: message.into(), + transient, + } + } +} + +impl fmt::Display for ExternalSourceProviderError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +impl Error for ExternalSourceProviderError {} + +/// Capability-specific provider implemented independently by each ecosystem adapter. +pub trait PromptCommandSourceProvider: Send + Sync { + fn identity(&self) -> PromptCommandProviderIdentity; + + fn discover( + &self, + context: &ExternalSourceContext, + ) -> Result; + + fn expand( + &self, + command: &PromptCommandDefinition, + arguments: &str, + ) -> Result; + + /// Resolves same-ecosystem overlays after product suppression is applied. + /// Providers with no internal duplicate names may use this default. + fn resolve_commands( + &self, + commands: &[PromptCommandDefinition], + enabled_sources: &BTreeSet, + ) -> Result, ExternalSourceProviderError> { + let mut names = BTreeSet::new(); + let mut resolved = Vec::new(); + for command in commands + .iter() + .filter(|command| enabled_sources.contains(&command.id.source)) + { + if !names.insert(command.name.to_ascii_lowercase()) { + return Err(ExternalSourceProviderError::new( + "external_source.provider_resolution_required", + "provider returned same-name commands without resolving its ecosystem overlays", + false, + )); + } + resolved.push(command.clone()); + } + Ok(resolved) + } + + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalSourceLifecycleState { + Available, + Restricted, + Degraded, + Unavailable, + Removed, + Suppressed, + UsingLastValidVersion, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSourceCatalogEntry { + pub stable_key: String, + pub record: ExternalSourceRecord, + pub lifecycle: ExternalSourceLifecycleState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PromptCommandCatalogEntry { + pub definition: PromptCommandDefinition, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PromptCommandConflictCandidate { + pub candidate_id: String, + pub source: SourceKey, + pub source_display_name: String, + pub ecosystem_id: EcosystemId, + pub content_version: String, + pub command_description: String, + pub source_scope: ExternalSourceScope, + pub source_location: String, + pub availability: PromptCommandAvailability, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PromptCommandConflict { + pub conflict_key: String, + pub command_name: String, + pub candidates: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_candidate_id: Option, +} + +/// Builds a stable conflict fingerprint that changes when a participant or its +/// content version changes. Candidate ordering does not affect the result. +pub fn prompt_command_conflict_key<'a>( + execution_domain_id: &str, + command_name: &str, + candidates: impl IntoIterator, +) -> String { + let mut candidates = candidates.into_iter().collect::>(); + candidates.sort_unstable(); + let mut first = 0xcbf29ce484222325_u64; + let mut second = 0x84222325cbf29ce4_u64; + for byte in execution_domain_id + .bytes() + .chain([0]) + .chain(command_name.to_ascii_lowercase().bytes()) + .chain(candidates.into_iter().flat_map(|(id, version)| { + format!("{}:{id}{}:{version}", id.len(), version.len()).into_bytes() + })) + { + first ^= u64::from(byte); + first = first.wrapping_mul(0x100000001b3); + second ^= u64::from(byte); + second = second.wrapping_mul(0x9e3779b185ebca87); + } + format!( + "prompt_command:{}:{}:{first:016x}{second:016x}", + execution_domain_id, + command_name.to_ascii_lowercase() + ) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSourceCatalogSnapshot { + pub generation: u64, + /// True until every registered provider has produced its first result. + /// Product surfaces must present this as a neutral discovery state rather + /// than treating the current empty catalog as a confirmed empty result. + #[serde(default)] + pub discovery_pending: bool, + pub sources: Vec, + pub commands: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub command_conflicts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} diff --git a/src/crates/contracts/product-domains/src/lib.rs b/src/crates/contracts/product-domains/src/lib.rs index 423bed6d77..c3e985eb9a 100644 --- a/src/crates/contracts/product-domains/src/lib.rs +++ b/src/crates/contracts/product-domains/src/lib.rs @@ -5,6 +5,9 @@ pub mod canvas; +#[cfg(feature = "external-sources")] +pub mod external_sources; + #[cfg(feature = "plugin-source")] pub mod plugin_source; diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts.rs new file mode 100644 index 0000000000..36fd83dfb3 --- /dev/null +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -0,0 +1,217 @@ +use bitfun_product_domains::external_sources::{ + prompt_command_conflict_key, EcosystemId, ExecutionDomainId, ExpandedPromptCommand, + ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, + ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalWatchRoot, + PromptCommandAvailability, PromptCommandDefinition, PromptCommandProviderIdentity, + PromptCommandProviderSnapshot, PromptCommandSourceProvider, SourceKey, + SourceQualifiedCommandId, +}; +use std::path::PathBuf; + +fn source(provider_id: &str, ecosystem_id: &str, source_id: &str) -> ExternalSourceRecord { + ExternalSourceRecord { + key: SourceKey::new(provider_id, source_id).expect("valid source key"), + ecosystem_id: EcosystemId::new(ecosystem_id).expect("valid ecosystem id"), + display_name: format!("{provider_id} commands"), + source_kind: "prompt_commands".to_string(), + scope: ExternalSourceScope::Project, + location: format!("/workspace/{provider_id}"), + execution_domain_id: ExecutionDomainId::new("local-user").expect("valid domain"), + health: ExternalSourceHealth::Available, + content_version: format!("{provider_id}-v1"), + diagnostics: Vec::new(), + } +} + +fn command(provider_id: &str, source_id: &str, precedence: i32) -> PromptCommandDefinition { + PromptCommandDefinition { + id: SourceQualifiedCommandId::new( + SourceKey::new(provider_id, source_id).unwrap(), + "review", + ) + .unwrap(), + name: "review".to_string(), + description: format!("Review from {provider_id}"), + template: format!("{provider_id}: $ARGUMENTS"), + availability: PromptCommandAvailability::Available, + content_version: format!("command-v{precedence}"), + } +} + +fn context() -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(PathBuf::from("/workspace")), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } +} + +#[test] +fn opaque_ids_are_validated_without_closing_the_ecosystem_set() { + assert_eq!( + EcosystemId::new("future.product/v2") + .expect("future ecosystem ids remain open") + .as_str(), + "future.product/v2" + ); + assert!(EcosystemId::new(" ").is_err()); + assert!(ExecutionDomainId::new("domain\nwith-control").is_err()); +} + +#[test] +fn source_and_command_identity_remain_provider_qualified() { + let left = SourceQualifiedCommandId::new( + SourceKey::new("adapter-a", "project-commands").unwrap(), + "review", + ) + .unwrap(); + let right = SourceQualifiedCommandId::new( + SourceKey::new("adapter-b", "project-commands").unwrap(), + "review", + ) + .unwrap(); + + assert_ne!(left, right); + assert_ne!(left.stable_key(), right.stable_key()); +} + +#[test] +fn conflict_fingerprint_is_order_independent_and_changes_with_content() { + let first = prompt_command_conflict_key("local-user", "review", [("a", "v1"), ("b", "v2")]); + let reordered = prompt_command_conflict_key("local-user", "REVIEW", [("b", "v2"), ("a", "v1")]); + let updated = prompt_command_conflict_key("local-user", "review", [("a", "v1"), ("b", "v3")]); + let remote = prompt_command_conflict_key("remote-user", "review", [("a", "v1"), ("b", "v2")]); + + assert_eq!(first, reordered); + assert_ne!(first, updated); + assert_ne!(first, remote); +} + +#[test] +fn prompt_commands_use_a_typed_contract_instead_of_an_arbitrary_asset_payload() { + let command = PromptCommandDefinition { + id: SourceQualifiedCommandId::new( + SourceKey::new("example-provider", "project-commands").unwrap(), + "review", + ) + .unwrap(), + name: "review".to_string(), + description: "Review the current change".to_string(), + template: "Review $ARGUMENTS".to_string(), + availability: PromptCommandAvailability::Restricted { + reason: "Shell expansion is not supported yet".to_string(), + required_capabilities: vec!["command.shell".to_string()], + }, + content_version: "sha256:command-v1".to_string(), + }; + + let encoded = serde_json::to_value(&command).expect("serialize command contract"); + assert_eq!(encoded["name"], "review"); + assert_eq!(encoded["availability"]["state"], "restricted"); + assert!(encoded.get("payload").is_none()); +} + +struct FakeProvider { + identity: PromptCommandProviderIdentity, + snapshot: PromptCommandProviderSnapshot, +} + +impl FakeProvider { + fn new(provider_id: &str, ecosystem_id: &str, source_id: &str, precedence: i32) -> Self { + let identity = PromptCommandProviderIdentity::new( + provider_id, + ecosystem_id, + format!("{provider_id} display"), + ) + .unwrap(); + Self { + identity: identity.clone(), + snapshot: PromptCommandProviderSnapshot { + provider: identity, + sources: vec![source(provider_id, ecosystem_id, source_id)], + commands: vec![command(provider_id, source_id, precedence)], + unavailable_command_ids: Vec::new(), + diagnostics: Vec::new(), + }, + } + } +} + +impl PromptCommandSourceProvider for FakeProvider { + fn identity(&self) -> PromptCommandProviderIdentity { + self.identity.clone() + } + + fn discover( + &self, + _context: &ExternalSourceContext, + ) -> Result { + Ok(self.snapshot.clone()) + } + + fn expand( + &self, + command: &PromptCommandDefinition, + arguments: &str, + ) -> Result { + Ok(ExpandedPromptCommand { + content: command.template.replace("$ARGUMENTS", arguments), + }) + } + + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { + vec![ExternalWatchRoot { + path: context.workspace_root.clone().unwrap(), + recursive: true, + }] + } +} + +#[test] +fn capability_provider_contract_does_not_require_core_or_an_ecosystem_enum() { + let provider: Box = Box::new(FakeProvider::new( + "fake-provider", + "fake.ecosystem", + "project-commands", + 1, + )); + + let snapshot = provider.discover(&context()).expect("discover fake source"); + assert_eq!(snapshot.provider.ecosystem_id.as_str(), "fake.ecosystem"); + assert_eq!(provider.watch_roots(&context()).len(), 1); +} + +#[test] +fn diagnostics_remain_source_qualified() { + let diagnostic = ExternalSourceDiagnostic::warning( + "fake.warning", + "A non-blocking fake diagnostic", + Some(SourceKey::new("fake", "source").unwrap()), + ); + assert_eq!(diagnostic.source.unwrap().provider_id.as_str(), "fake"); +} + +#[test] +fn provider_snapshot_rejects_duplicate_sources_and_commands() { + let provider = FakeProvider::new("fake", "fake.ecosystem", "project", 1); + let mut duplicate_source = provider.snapshot.clone(); + duplicate_source + .sources + .push(duplicate_source.sources[0].clone()); + assert!(duplicate_source.validate().is_err()); + + let mut duplicate_command = provider.snapshot; + duplicate_command + .commands + .push(duplicate_command.commands[0].clone()); + assert!(duplicate_command.validate().is_err()); +} + +#[test] +fn unavailable_command_must_be_unique_absent_and_source_qualified() { + let provider = FakeProvider::new("fake", "fake.ecosystem", "project", 1); + let mut invalid = provider.snapshot; + invalid + .unavailable_command_ids + .push(invalid.commands[0].id.clone()); + assert!(invalid.validate().is_err()); +} diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index fae3051b5d..7214f70b1e 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -23,6 +23,7 @@ chrono = { workspace = true } zip = { workspace = true, optional = true } thiserror = { workspace = true } log = { workspace = true } +fs2 = { workspace = true } notify = { workspace = true, optional = true } ignore = { workspace = true } sha2 = { workspace = true } @@ -32,6 +33,7 @@ regex = { workspace = true } [target.'cfg(windows)'.dependencies] win32job = { workspace = true } +windows = { workspace = true, features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } [features] default = ["lsp"] diff --git a/src/crates/services/services-core/src/json_store.rs b/src/crates/services/services-core/src/json_store.rs index d5ef295689..c8544d0753 100644 --- a/src/crates/services/services-core/src/json_store.rs +++ b/src/crates/services/services-core/src/json_store.rs @@ -4,9 +4,11 @@ //! keep schema decisions outside this module and use it only for file-level //! read/write behavior. +use fs2::FileExt; use log::{debug, warn}; use serde::{de::DeserializeOwned, Serialize}; use std::collections::HashMap; +use std::fs::OpenOptions; use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; @@ -69,6 +71,17 @@ pub enum JsonFileStoreError { }, #[error("Failed to replace JSON file {path}: unknown error")] ReplaceUnknown { path: PathBuf }, + #[error("Failed to lock JSON file {path}: {source}")] + CrossProcessLock { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("JSON file lock task failed: {source}")] + CrossProcessLockTask { + #[source] + source: tokio::task::JoinError, + }, } impl JsonFileStoreError { @@ -84,7 +97,39 @@ impl JsonFileStoreError { #[derive(Debug, Default, Clone, Copy)] pub struct JsonFileStore; +struct JsonFileCrossProcessLock(std::fs::File); + +impl Drop for JsonFileCrossProcessLock { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.0); + } +} + impl JsonFileStore { + pub async fn read_locked_optional( + &self, + path: &Path, + ) -> Result, JsonFileStoreError> { + let _lock = self.acquire_cross_process_lock(path).await?; + self.read_optional(path).await + } + + pub async fn update_locked( + &self, + path: &Path, + default: T, + update: impl FnOnce(&mut T) -> R, + ) -> Result<(R, T), JsonFileStoreError> + where + T: DeserializeOwned + Serialize, + { + let _lock = self.acquire_cross_process_lock(path).await?; + let mut value = self.read_optional(path).await?.unwrap_or(default); + let result = update(&mut value); + self.write_atomic_strict(path, &value).await?; + Ok((result, value)) + } + pub async fn read_optional( &self, path: &Path, @@ -143,6 +188,25 @@ impl JsonFileStore { &self, path: &Path, value: &T, + ) -> Result<(), JsonFileStoreError> { + self.write_atomic_with_policy(path, value, false).await + } + + /// Writes JSON using a same-volume atomic replacement and never deletes or + /// directly overwrites an existing target as a fallback. + pub async fn write_atomic_strict( + &self, + path: &Path, + value: &T, + ) -> Result<(), JsonFileStoreError> { + self.write_atomic_with_policy(path, value, true).await + } + + async fn write_atomic_with_policy( + &self, + path: &Path, + value: &T, + strict: bool, ) -> Result<(), JsonFileStoreError> { let parent = path .parent() @@ -168,7 +232,12 @@ impl JsonFileStore { return Err(JsonFileStoreError::WriteTemp { source }); } - match Self::replace_file_from_temp(path, &tmp_path).await { + let replacement = if strict { + Self::replace_file_from_temp_strict(path, &tmp_path).await + } else { + Self::replace_file_from_temp(path, &tmp_path).await + }; + match replacement { Ok(()) => return Ok(()), Err(error) => { let should_retry = @@ -191,7 +260,7 @@ impl JsonFileStore { // non-shareable handle, making delete/rename fail with // PermissionDenied. Fallback to direct write to avoid losing session // persistence while keeping best-effort atomic behavior. - if error.kind() == ErrorKind::PermissionDenied { + if !strict && error.kind() == ErrorKind::PermissionDenied { warn!( "Atomic JSON replace permission denied for {}, fallback to direct overwrite", path.display() @@ -222,6 +291,44 @@ impl JsonFileStore { .clone() } + async fn acquire_cross_process_lock( + &self, + path: &Path, + ) -> Result { + let parent = path + .parent() + .ok_or_else(|| JsonFileStoreError::NoParentDirectory { + path: path.to_path_buf(), + })?; + fs::create_dir_all(parent) + .await + .map_err(|source| JsonFileStoreError::CreateParent { source })?; + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "data.json".to_string()); + let lock_path = path.with_file_name(format!("{file_name}.lock")); + tokio::task::spawn_blocking(move || { + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(&lock_path) + .map_err(|source| JsonFileStoreError::CrossProcessLock { + path: lock_path.clone(), + source, + })?; + file.lock_exclusive() + .map_err(|source| JsonFileStoreError::CrossProcessLock { + path: lock_path, + source, + })?; + Ok(JsonFileCrossProcessLock(file)) + }) + .await + .map_err(|source| JsonFileStoreError::CrossProcessLockTask { source })? + } + fn build_temp_json_path(path: &Path, attempt: usize) -> Result { let parent = path .parent() @@ -263,6 +370,56 @@ impl JsonFileStore { fs::rename(tmp_path, target_path).await } + #[cfg(windows)] + async fn replace_file_from_temp_strict( + target_path: &Path, + tmp_path: &Path, + ) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::{ + MoveFileExW, ReplaceFileW, MOVEFILE_WRITE_THROUGH, REPLACEFILE_WRITE_THROUGH, + }; + + let temp = tmp_path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let target = target_path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let result = unsafe { + if target_path.exists() { + ReplaceFileW( + PCWSTR(target.as_ptr()), + PCWSTR(temp.as_ptr()), + PCWSTR::null(), + REPLACEFILE_WRITE_THROUGH, + None, + None, + ) + } else { + MoveFileExW( + PCWSTR(temp.as_ptr()), + PCWSTR(target.as_ptr()), + MOVEFILE_WRITE_THROUGH, + ) + } + }; + result.map_err(|error| std::io::Error::other(error.to_string())) + } + + #[cfg(not(windows))] + async fn replace_file_from_temp_strict( + target_path: &Path, + tmp_path: &Path, + ) -> std::io::Result<()> { + fs::rename(tmp_path, target_path).await + } + fn is_retryable_write_error(error: &std::io::Error) -> bool { matches!( error.kind(), @@ -280,3 +437,22 @@ impl JsonFileStore { Duration::from_millis(JSON_WRITE_RETRY_BASE_DELAY_MS * (1u64 << exp)) } } + +#[cfg(test)] +mod tests { + use super::JsonFileStore; + + #[tokio::test] + async fn strict_replace_failure_preserves_the_existing_target() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("preferences.json"); + let missing_replacement = root.path().join("missing.tmp"); + tokio::fs::write(&target, b"old preferences").await.unwrap(); + + JsonFileStore::replace_file_from_temp_strict(&target, &missing_replacement) + .await + .expect_err("missing replacement must fail"); + + assert_eq!(tokio::fs::read(&target).await.unwrap(), b"old preferences"); + } +} diff --git a/src/crates/services/services-core/tests/json_store_contracts.rs b/src/crates/services/services-core/tests/json_store_contracts.rs index b6748e169e..6bf45fdc82 100644 --- a/src/crates/services/services-core/tests/json_store_contracts.rs +++ b/src/crates/services/services-core/tests/json_store_contracts.rs @@ -3,12 +3,43 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] struct TestPayload { label: String, count: u32, } +#[tokio::test] +async fn locked_updates_merge_independent_read_modify_write_operations() { + let root = TestTempDir::new("locked-update"); + let path = root.path().join("preferences.json"); + let first = JsonFileStore; + let second = JsonFileStore; + + let first_update = first.update_locked(&path, TestPayload::default(), |payload| { + payload.label = "preserved".to_string(); + }); + let second_update = second.update_locked(&path, TestPayload::default(), |payload| { + payload.count = 7; + }); + let (first_result, second_result) = tokio::join!(first_update, second_update); + first_result.expect("first update"); + second_result.expect("second update"); + + let loaded = JsonFileStore + .read_locked_optional::(&path) + .await + .expect("locked read") + .expect("persisted payload"); + assert_eq!( + loaded, + TestPayload { + label: "preserved".to_string(), + count: 7, + } + ); +} + struct TestTempDir { path: PathBuf, } diff --git a/src/crates/services/services-integrations/src/file_watch/service.rs b/src/crates/services/services-integrations/src/file_watch/service.rs index 19693b629b..1b081197ba 100644 --- a/src/crates/services/services-integrations/src/file_watch/service.rs +++ b/src/crates/services/services-integrations/src/file_watch/service.rs @@ -4,7 +4,7 @@ use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watche use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex as StdMutex}; -use tokio::sync::{Mutex, RwLock}; +use tokio::sync::{broadcast, Mutex, RwLock}; use super::types::{FileWatchEvent, FileWatchEventKind, FileWatcherConfig}; @@ -25,6 +25,7 @@ pub struct FileWatchService { watcher: Arc>>, watched_paths: Arc>>, event_buffer: Arc>>, + event_sender: broadcast::Sender>, config: FileWatcherConfig, } @@ -42,15 +43,22 @@ fn lock_event_buffer( impl FileWatchService { pub fn new(config: FileWatcherConfig) -> Self { + let (event_sender, _) = broadcast::channel(64); Self { emitter: Arc::new(Mutex::new(None)), watcher: Arc::new(Mutex::new(None)), watched_paths: Arc::new(RwLock::new(HashMap::new())), event_buffer: Arc::new(StdMutex::new(Vec::new())), + event_sender, config, } } + /// Subscribe to the same debounced event batches emitted to product surfaces. + pub fn subscribe(&self) -> broadcast::Receiver> { + self.event_sender.subscribe() + } + pub async fn set_emitter(&self, emitter: Arc) { let mut e = self.emitter.lock().await; *e = Some(emitter); @@ -69,10 +77,23 @@ impl FileWatchService { { let mut watched_paths = self.watched_paths.write().await; - watched_paths.insert( - path_buf.clone(), - config.unwrap_or_else(|| self.config.clone()), - ); + let config = config.unwrap_or_else(|| self.config.clone()); + watched_paths + .entry(path_buf.clone()) + .and_modify(|existing| { + // Multiple product services may share a root. Registering a + // narrower observer must not silently downgrade an existing + // recursive or hidden-file-aware watch. + existing.watch_recursively |= config.watch_recursively; + existing.ignore_hidden_files &= config.ignore_hidden_files; + existing.debounce_interval_ms = existing + .debounce_interval_ms + .min(config.debounce_interval_ms); + existing.max_events_per_interval = existing + .max_events_per_interval + .max(config.max_events_per_interval); + }) + .or_insert(config); } self.create_watcher().await?; @@ -107,6 +128,12 @@ impl FileWatchService { .map_err(|e| format!("Failed to create watcher: {}", e))?; for (path, config) in watched_paths.iter() { + // A watched source directory may be removed between events. Its + // stable parent watch remains active and the missing path will be + // re-registered when it reappears. + if !path.exists() { + continue; + } let mode = if config.watch_recursively { RecursiveMode::Recursive } else { @@ -125,34 +152,41 @@ impl FileWatchService { let event_buffer = self.event_buffer.clone(); let emitter_arc = self.emitter.clone(); - let config = self.config.clone(); + let debounce_interval_ms = watched_paths + .values() + .map(|config| config.debounce_interval_ms) + .min() + .unwrap_or(self.config.debounce_interval_ms); let watched_paths = self.watched_paths.clone(); + let event_sender = self.event_sender.clone(); tokio::task::spawn_blocking(move || { let rt = tokio::runtime::Handle::current(); - let debounce = std::time::Duration::from_millis(config.debounce_interval_ms); + let debounce = std::time::Duration::from_millis(debounce_interval_ms); let poll = std::time::Duration::from_millis(50); let mut last_event_time: Option = None; loop { match rx.recv_timeout(poll) { Ok(Ok(event)) => { - let ignore = rt.block_on(Self::should_ignore_event(&event, &watched_paths)); - if !ignore { - if let Some(file_event) = Self::convert_event(&event) { - lock_event_buffer(&event_buffer).push(file_event); - last_event_time = Some(std::time::Instant::now()); - } + let file_events = rt.block_on(Self::convert_events(&event, &watched_paths)); + if !file_events.is_empty() { + lock_event_buffer(&event_buffer).extend(file_events); + last_event_time = Some(std::time::Instant::now()); } } - Ok(Err(e)) => eprintln!("Watch error: {:?}", e), + Ok(Err(e)) => error!("File watch error: {}", e), Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, } if let Some(t) = last_event_time { if t.elapsed() >= debounce { - rt.block_on(Self::flush_events_static(&event_buffer, &emitter_arc)); + rt.block_on(Self::flush_events_static( + &event_buffer, + &emitter_arc, + &event_sender, + )); last_event_time = None; } } @@ -162,49 +196,33 @@ impl FileWatchService { Ok(()) } - async fn should_ignore_event( + async fn convert_events( event: &Event, watched_paths: &Arc>>, - ) -> bool { + ) -> Vec { let paths = watched_paths.read().await; - - let event_path = match event.paths.first() { - Some(path) => path, - None => return true, - }; - - let mut matching_config = None; - for (watch_path, config) in paths.iter() { - if event_path.starts_with(watch_path) { - matching_config = Some(config); - break; - } - } - - let config = match matching_config { - Some(config) => config, - None => return true, - }; - - if Self::is_in_excluded_directory(event_path) { - return true; - } - - if Self::is_temporary_file(event_path) { - return true; - } - - if config.ignore_hidden_files { - if let Some(file_name) = event_path.file_name() { - if let Some(name_str) = file_name.to_str() { - if name_str.starts_with('.') { - return true; - } + event + .paths + .iter() + .filter_map(|event_path| { + let config = paths + .iter() + .filter(|(watch_path, _)| event_path.starts_with(watch_path)) + .max_by_key(|(watch_path, _)| watch_path.components().count()) + .map(|(_, config)| config)?; + if Self::is_in_excluded_directory(event_path) + || Self::is_temporary_file(event_path) + || config.ignore_hidden_files + && event_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with('.')) + { + return None; } - } - } - - false + Self::convert_event(&event.kind, event_path) + }) + .collect() } fn is_in_excluded_directory(path: &Path) -> bool { @@ -274,9 +292,8 @@ impl FileWatchService { false } - fn convert_event(event: &Event) -> Option { - let path = event.paths.first()?.to_string_lossy().to_string(); - let kind = match &event.kind { + fn convert_event(kind: &EventKind, path: &Path) -> Option { + let kind = match kind { EventKind::Create(_) => FileWatchEventKind::Create, EventKind::Modify(_) => FileWatchEventKind::Modify, EventKind::Remove(_) => FileWatchEventKind::Remove, @@ -285,7 +302,7 @@ impl FileWatchService { }; Some(FileWatchEvent { - path, + path: path.to_string_lossy().to_string(), kind, timestamp: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -297,6 +314,7 @@ impl FileWatchService { async fn flush_events_static( event_buffer: &Arc>>, emitter_arc: &Arc>>>, + event_sender: &broadcast::Sender>, ) { let events = { let mut buffer = lock_event_buffer(event_buffer); @@ -306,6 +324,10 @@ impl FileWatchService { buffer.drain(..).collect::>() }; + // No active backend subscriber is a normal state; the frontend emitter + // may still consume this batch. + let _ = event_sender.send(events.clone()); + let emitter_guard = emitter_arc.lock().await; if let Some(emitter) = emitter_guard.as_ref() { let mut event_array = Vec::new(); diff --git a/src/crates/services/services-integrations/tests/file_watch_contracts.rs b/src/crates/services/services-integrations/tests/file_watch_contracts.rs index f131d9edf2..22c055393b 100644 --- a/src/crates/services/services-integrations/tests/file_watch_contracts.rs +++ b/src/crates/services/services-integrations/tests/file_watch_contracts.rs @@ -3,6 +3,8 @@ use bitfun_services_integrations::file_watch::{ FileWatchEventKind, FileWatchService, FileWatcherConfig, }; +use std::fs; +use std::time::Duration; #[tokio::test] async fn file_watch_preserves_missing_path_error() { @@ -25,3 +27,107 @@ fn file_watch_event_kind_serializes_snake_case() { assert_eq!(value, "modify"); } + +#[tokio::test] +async fn file_watch_publishes_debounced_batches_to_backend_subscribers() { + let temp = tempfile::tempdir().expect("tempdir"); + let mut config = FileWatcherConfig::default(); + config.debounce_interval_ms = 40; + config.ignore_hidden_files = false; + let service = FileWatchService::new(config.clone()); + let mut events = service.subscribe(); + service + .watch_path(temp.path().to_str().unwrap(), Some(config)) + .await + .expect("watch temp directory"); + + let file = temp.path().join("command.md"); + fs::write(&file, "first").expect("create watched file"); + fs::write(&file, "second").expect("modify watched file"); + + let batch = tokio::time::timeout(Duration::from_secs(5), events.recv()) + .await + .expect("watch batch timeout") + .expect("watch broadcast remains open"); + assert!(batch + .iter() + .any(|event| event.path == file.to_string_lossy())); +} + +#[tokio::test] +async fn a_narrow_duplicate_registration_does_not_downgrade_recursive_watch() { + let temp = tempfile::tempdir().expect("tempdir"); + let nested = temp.path().join("nested"); + fs::create_dir_all(&nested).expect("nested directory"); + let mut recursive = FileWatcherConfig::default(); + recursive.debounce_interval_ms = 40; + recursive.ignore_hidden_files = false; + let service = FileWatchService::new(recursive.clone()); + let mut events = service.subscribe(); + service + .watch_path(temp.path().to_str().unwrap(), Some(recursive.clone())) + .await + .expect("recursive watch"); + recursive.watch_recursively = false; + service + .watch_path(temp.path().to_str().unwrap(), Some(recursive)) + .await + .expect("shared narrow watch"); + + let file = nested.join("command.md"); + fs::write(&file, "created").expect("nested file"); + let batch = tokio::time::timeout(Duration::from_secs(5), events.recv()) + .await + .expect("watch batch timeout") + .expect("watch broadcast remains open"); + assert!(batch + .iter() + .any(|event| event.path == file.to_string_lossy())); +} + +#[tokio::test] +async fn a_removed_root_does_not_block_registering_another_root() { + let temp = tempfile::tempdir().expect("tempdir"); + let removed = temp.path().join("removed"); + let replacement = temp.path().join("replacement"); + fs::create_dir_all(&removed).expect("removed root"); + fs::create_dir_all(&replacement).expect("replacement root"); + let service = FileWatchService::new(FileWatcherConfig::default()); + service + .watch_path(removed.to_str().unwrap(), None) + .await + .expect("first root"); + fs::remove_dir_all(&removed).expect("remove first root"); + + service + .watch_path(replacement.to_str().unwrap(), None) + .await + .expect("missing stale roots should be skipped during reconfiguration"); +} + +#[tokio::test] +async fn atomic_rename_keeps_the_non_temporary_destination_path() { + let temp = tempfile::tempdir().expect("tempdir"); + let mut config = FileWatcherConfig::default(); + config.debounce_interval_ms = 40; + config.ignore_hidden_files = false; + let service = FileWatchService::new(config.clone()); + let mut events = service.subscribe(); + service + .watch_path(temp.path().to_str().unwrap(), Some(config)) + .await + .expect("watch temp directory"); + + let temporary = temp.path().join("command.md.tmp"); + let destination = temp.path().join("command.md"); + fs::write(&temporary, "complete").expect("temporary file"); + fs::rename(&temporary, &destination).expect("atomic rename"); + + let batch = tokio::time::timeout(Duration::from_secs(5), events.recv()) + .await + .expect("watch batch timeout") + .expect("watch broadcast remains open"); + assert!(batch + .iter() + .any(|event| event.path == destination.to_string_lossy())); +} diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx index 0641ad2b97..4780337149 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx @@ -18,6 +18,10 @@ vi.mock('../../../infrastructure/config/components/AcpAgentsConfig', () => ({ default: () =>
, })); +vi.mock('../../../infrastructure/config/components/ExternalSourcesConfig', () => ({ + default: () =>
, +})); + vi.mock('../../../infrastructure/config/components/EditorConfig', () => ({ default: () =>
, })); @@ -69,7 +73,7 @@ describe('SettingsScene lazy tab routing', () => { container.remove(); }); - async function renderActiveTab(tab: 'mcp-tools' | 'acp-agents') { + async function renderActiveTab(tab: 'mcp-tools' | 'acp-agents' | 'external-sources') { useSettingsStore.setState({ activeTab: tab }); await act(async () => { root.render(); @@ -87,4 +91,10 @@ describe('SettingsScene lazy tab routing', () => { expect(container.querySelector('[data-testid="acp-agents-config"]')).not.toBeNull(); }); + + it('renders the lazy external sources config tab', async () => { + await renderActiveTab('external-sources'); + + expect(container.querySelector('[data-testid="external-sources-config"]')).not.toBeNull(); + }); }); diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx index 67614c3bfe..5baf451f15 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx @@ -13,6 +13,7 @@ import './SettingsScene.scss'; const AIModelConfig = lazy(() => import('../../../infrastructure/config/components/AIModelConfig')); const McpToolsConfig = lazy(() => import('../../../infrastructure/config/components/McpToolsConfig')); const AcpAgentsConfig = lazy(() => import('../../../infrastructure/config/components/AcpAgentsConfig')); +const ExternalSourcesConfig = lazy(() => import('../../../infrastructure/config/components/ExternalSourcesConfig')); const EditorConfig = lazy(() => import('../../../infrastructure/config/components/EditorConfig')); const BasicsConfig = lazy(() => import('../../../infrastructure/config/components/BasicsConfig')); const AppearanceConfig = lazy(() => import('../../../infrastructure/config/components/AppearanceConfig')); @@ -70,6 +71,7 @@ const SettingsScene: React.FC = () => { case 'review': Content = ReviewConfig; break; case 'memories': Content = MemoriesConfig; break; case 'mcp-tools': Content = McpToolsConfig; break; + case 'external-sources': Content = ExternalSourcesConfig; break; case 'acp-agents': Content = AcpAgentsConfig; break; case 'editor': Content = EditorConfig; break; case 'keyboard': Content = KeyboardShortcutsTab; break; diff --git a/src/web-ui/src/app/scenes/settings/settingsConfig.ts b/src/web-ui/src/app/scenes/settings/settingsConfig.ts index dc3c6eac57..8e6c347d77 100644 --- a/src/web-ui/src/app/scenes/settings/settingsConfig.ts +++ b/src/web-ui/src/app/scenes/settings/settingsConfig.ts @@ -16,6 +16,7 @@ export type ConfigTab = | 'review' | 'memories' | 'mcp-tools' + | 'external-sources' | 'acp-agents' // | 'lsp' // temporarily hidden from config center | 'editor' @@ -211,6 +212,21 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [ 'knowledge', ], }, + { + id: 'external-sources', + labelKey: 'configCenter.tabs.externalSources', + descriptionKey: 'configCenter.tabDescriptions.externalSources', + keywords: [ + 'external ai applications', + 'import work', + 'extensions', + 'commands', + 'opencode', + 'claude code', + 'codex', + 'compatibility', + ], + }, { id: 'mcp-tools', labelKey: 'configCenter.tabs.mcpTools', diff --git a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts index 2786a85e7b..fdb77bda8d 100644 --- a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts +++ b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts @@ -127,6 +127,15 @@ export const SETTINGS_TAB_SEARCH_CONTENT: Record; +} + +export interface ExternalSourceCatalogSnapshot { + generation: number; + discoveryPending: boolean; + sources: Array<{ + stableKey: string; + record: ExternalSourceRecord; + lifecycle: ExternalSourceLifecycle; + }>; + commands: Array<{ + definition: { + id: { + source: { providerId: string; sourceId: string }; + localId: string; + }; + name: string; + description: string; + availability: PromptCommandAvailability; + contentVersion: string; + }; + }>; + commandConflicts?: Array<{ + conflictKey: string; + commandName: string; + selectedCandidateId?: string; + candidates: Array<{ + candidateId: string; + source: { providerId: string; sourceId: string }; + sourceDisplayName: string; + ecosystemId: string; + contentVersion: string; + commandDescription: string; + sourceScope: ExternalSourceScope; + sourceLocation: string; + availability: PromptCommandAvailability; + }>; + }>; + diagnostics?: Array<{ severity: string; code: string; message: string }>; +} + +export const externalSourcesAPI = { + getSnapshot(workspacePath?: string, forceRefresh = false) { + return api.invoke('get_external_source_snapshot', { + request: { workspacePath, forceRefresh }, + }); + }, + + setSourceEnabled(workspacePath: string | undefined, sourceKey: string, enabled: boolean) { + return api.invoke('set_external_source_enabled_command', { + request: { workspacePath, sourceKey, enabled }, + }); + }, + + setConflictChoice(workspacePath: string | undefined, conflictKey: string, candidateId: string) { + return api.invoke('set_external_source_conflict_choice_command', { + request: { workspacePath, conflictKey, candidateId }, + }); + }, +}; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss new file mode 100644 index 0000000000..fed433dca4 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss @@ -0,0 +1,89 @@ +.bitfun-external-sources-config { + &__notice, + &__empty, + &__conflict-hint { + color: var(--color-text-secondary); + font-size: 12px; + } + + &__notice { + margin-bottom: 12px; + padding: 10px 12px; + border: 1px solid var(--border-subtle); + border-radius: 8px; + } + + &__notice summary { + cursor: pointer; + } + + &__diagnostics { + margin: 8px 0 0; + padding-left: 20px; + } + + &__source-control, + &__conflict-options { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + } + + &__state { + color: var(--color-text-secondary); + font-size: 11px; + + &.is-using_last_valid_version, + &.is-restricted, + &.is-degraded, + &.is-unavailable, + &.is-removed { + color: var(--color-warning); + } + } + + &__conflict { + padding: 12px 0; + border-bottom: 1px solid var(--border-subtle); + + &:last-child { + border-bottom: 0; + } + } + + &__conflict-title { + margin-bottom: 8px; + color: var(--color-text-primary); + font-size: 13px; + font-weight: 600; + } + + &__conflict-options { + align-items: flex-start; + justify-content: flex-start; + } + + &__candidate { + display: flex; + max-width: 360px; + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + + &__candidate-detail { + color: var(--color-text-secondary); + font-size: 11px; + } + + &__ecosystem { + margin-left: 4px; + opacity: 0.7; + } + + &__conflict-hint { + margin-top: 8px; + } +} diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx new file mode 100644 index 0000000000..cbfec899da --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -0,0 +1,305 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import ExternalSourcesConfig from './ExternalSourcesConfig'; + +const getSnapshotMock = vi.hoisted(() => vi.fn()); +const setSourceEnabledMock = vi.hoisted(() => vi.fn()); +const setConflictChoiceMock = vi.hoisted(() => vi.fn()); +const workspaceState = vi.hoisted(() => ({ path: 'D:/workspace/project' })); + +vi.mock('react-i18next', () => ({ + initReactI18next: { + type: '3rdParty', + init: vi.fn(), + }, + useTranslation: () => ({ + t: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key, + }), +})); + +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ + useCurrentWorkspace: () => ({ + workspace: { rootPath: workspaceState.path }, + workspacePath: workspaceState.path, + }), +})); + +vi.mock('@/infrastructure/runtime', () => ({ isTauriRuntime: () => true })); +vi.mock('@/shared/types', () => ({ isRemoteWorkspace: () => false })); +vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ + externalSourcesAPI: { + getSnapshot: getSnapshotMock, + setSourceEnabled: setSourceEnabledMock, + setConflictChoice: setConflictChoiceMock, + }, +})); + +const snapshot = { + generation: 1, + discoveryPending: false, + sources: [{ + stableKey: 'source-key', + record: { + key: { providerId: 'opencode.commands', sourceId: 'project' }, + ecosystemId: 'opencode', + displayName: 'OpenCode project commands', + sourceKind: 'prompt_commands', + scope: 'project', + location: 'D:/workspace/project/.opencode/commands', + health: 'available', + contentVersion: 'v1', + }, + lifecycle: 'available', + }], + commands: [], + diagnostics: [{ + severity: 'warning', + code: 'opencode.command.parse_failed', + message: 'One command file could not be parsed.', + }], + commandConflicts: [{ + conflictKey: 'conflict-v1', + commandName: 'review', + candidates: [{ + candidateId: 'candidate-opencode', + source: { providerId: 'opencode.commands', sourceId: 'project' }, + sourceDisplayName: 'OpenCode project commands', + ecosystemId: 'opencode', + contentVersion: 'v1', + commandDescription: 'Review with OpenCode', + sourceScope: 'project', + sourceLocation: 'D:/workspace/project/.opencode/commands', + availability: { state: 'available' }, + }, { + candidateId: 'candidate-other', + source: { providerId: 'other.commands', sourceId: 'project' }, + sourceDisplayName: 'Other project commands', + ecosystemId: 'other', + contentVersion: 'v1', + commandDescription: 'Review with another source', + sourceScope: 'project', + sourceLocation: 'D:/workspace/project/.other/commands', + availability: { state: 'available' }, + }], + }], +}; + +describe('ExternalSourcesConfig', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.useFakeTimers(); + workspaceState.path = 'D:/workspace/project'; + getSnapshotMock.mockResolvedValue(snapshot); + setSourceEnabledMock.mockResolvedValue(snapshot); + setConflictChoiceMock.mockResolvedValue({ + ...snapshot, + commandConflicts: [{ + ...snapshot.commandConflicts[0], + selectedCandidateId: 'candidate-opencode', + }], + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('requires one explicit conflict choice and persists source toggles', async () => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + expect(getSnapshotMock).toHaveBeenCalledWith('D:/workspace/project', false); + + const candidateButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('OpenCode project commands')); + expect(container.textContent).toContain('diagnostics.summary'); + expect(candidateButton).toBeDefined(); + await act(async () => candidateButton?.click()); + expect(setConflictChoiceMock).toHaveBeenCalledWith( + 'D:/workspace/project', + 'conflict-v1', + 'candidate-opencode', + ); + expect(container.textContent).not.toContain('conflicts.commandName'); + + const sourceToggle = container.querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(sourceToggle.checked).toBe(true); + await act(async () => sourceToggle.click()); + expect(setSourceEnabledMock).toHaveBeenCalledWith( + 'D:/workspace/project', + 'source-key', + false, + ); + }); + + it('keeps a neutral checking state until initial discovery completes', async () => { + getSnapshotMock + .mockResolvedValueOnce({ + ...snapshot, + discoveryPending: true, + sources: [], + diagnostics: [], + commandConflicts: [], + }) + .mockResolvedValue(snapshot); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + expect(container.textContent).toContain('loading'); + expect(container.textContent).not.toContain('sources.empty'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(750); + }); + expect(container.textContent).toContain('OpenCode project commands'); + expect(container.textContent).not.toContain('loading'); + }); + + it('renders a removed source as disabled and off', async () => { + getSnapshotMock.mockResolvedValue({ + ...snapshot, + sources: [{ ...snapshot.sources[0], lifecycle: 'removed' }], + commandConflicts: [], + }); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + const sourceToggle = container.querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(sourceToggle.disabled).toBe(true); + expect(sourceToggle.checked).toBe(false); + }); + + it('ignores an older workspace response after switching workspaces', async () => { + let resolveProject: ((value: typeof snapshot) => void) | undefined; + const projectRequest = new Promise((resolve) => { + resolveProject = resolve; + }); + const otherSnapshot = { + ...snapshot, + generation: 2, + sources: [{ + ...snapshot.sources[0], + stableKey: 'other-source', + record: { + ...snapshot.sources[0].record, + displayName: 'Other workspace commands', + location: 'D:/workspace/other/.opencode/commands', + }, + }], + diagnostics: [], + commandConflicts: [], + }; + getSnapshotMock.mockImplementation((workspacePath: string) => ( + workspacePath === 'D:/workspace/project' + ? projectRequest + : Promise.resolve(otherSnapshot) + )); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + workspaceState.path = 'D:/workspace/other'; + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + resolveProject?.(snapshot); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('Other workspace commands'); + expect(container.textContent).not.toContain('OpenCode project commands'); + }); + + it('ignores a source mutation response from the previous workspace', async () => { + let resolveMutation: ((value: typeof snapshot) => void) | undefined; + const pendingMutation = new Promise((resolve) => { + resolveMutation = resolve; + }); + setSourceEnabledMock.mockReturnValue(pendingMutation); + const otherSnapshot = { + ...snapshot, + generation: 2, + sources: [{ + ...snapshot.sources[0], + stableKey: 'other-source', + record: { + ...snapshot.sources[0].record, + displayName: 'Other workspace commands', + }, + }], + diagnostics: [], + commandConflicts: [], + }; + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + const sourceToggle = container.querySelector('input[type="checkbox"]') as HTMLInputElement; + await act(async () => sourceToggle.click()); + + workspaceState.path = 'D:/workspace/other'; + getSnapshotMock.mockResolvedValue(otherSnapshot); + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + resolveMutation?.(snapshot); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('Other workspace commands'); + expect(container.textContent).not.toContain('OpenCode project commands'); + }); + + it('keeps the latest mutation authoritative over an intervening poll', async () => { + let resolveMutation: ((value: typeof snapshot) => void) | undefined; + setSourceEnabledMock.mockReturnValue(new Promise((resolve) => { + resolveMutation = resolve; + })); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + const sourceToggle = container.querySelector('input[type="checkbox"]') as HTMLInputElement; + await act(async () => sourceToggle.click()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + await act(async () => { + resolveMutation?.({ + ...snapshot, + generation: 2, + sources: [{ ...snapshot.sources[0], lifecycle: 'suppressed' }], + }); + await Promise.resolve(); + }); + + const updatedToggle = container.querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(updatedToggle.checked).toBe(false); + expect(container.textContent).toContain('lifecycle.suppressed'); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx new file mode 100644 index 0000000000..50ebbe4b9e --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -0,0 +1,375 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshCw } from 'lucide-react'; +import { Button, ConfigPageLoading, Switch } from '@/component-library'; +import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { isTauriRuntime } from '@/infrastructure/runtime'; +import { isRemoteWorkspace } from '@/shared/types'; +import { + externalSourcesAPI, + type ExternalSourceCatalogSnapshot, +} from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import { + ConfigPageContent, + ConfigPageHeader, + ConfigPageLayout, + ConfigPageRow, + ConfigPageSection, +} from './common'; +import './ExternalSourcesConfig.scss'; + +function abbreviatedLocation(location: string): string { + const normalized = location.replace(/\\/g, '/'); + const segments = normalized.split('/').filter(Boolean); + return segments.length <= 3 ? normalized : `…/${segments.slice(-3).join('/')}`; +} + +const ExternalSourcesConfig: React.FC = () => { + const { t } = useTranslation('settings/external-sources'); + const { workspace, workspacePath } = useCurrentWorkspace(); + const desktopRuntime = isTauriRuntime(); + const remoteWorkspace = isRemoteWorkspace(workspace); + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(desktopRuntime && !remoteWorkspace); + const [refreshing, setRefreshing] = useState(false); + const [busyKey, setBusyKey] = useState(null); + const [error, setError] = useState(null); + const requestSequence = useRef(0); + const acceptedSequence = useRef(0); + const pendingMutations = useRef(new Map()); + const latestMutationByScope = useRef(new Map()); + const foregroundSequence = useRef(null); + const requestScope = `${desktopRuntime}:${remoteWorkspace}:${workspacePath ?? ''}`; + const requestScopeRef = useRef(requestScope); + if (requestScopeRef.current !== requestScope) { + requestScopeRef.current = requestScope; + requestSequence.current += 1; + acceptedSequence.current = requestSequence.current; + } + + const applySnapshot = useCallback((next: ExternalSourceCatalogSnapshot) => { + setSnapshot((current) => ( + current && next.generation < current.generation ? current : next + )); + }, []); + + const acceptReadSnapshot = useCallback(( + next: ExternalSourceCatalogSnapshot, + scope: string, + sequence: number, + ): boolean => { + if (requestScopeRef.current !== scope || sequence < acceptedSequence.current) return false; + if (Array.from(pendingMutations.current.values()).includes(scope)) return false; + acceptedSequence.current = sequence; + applySnapshot(next); + return true; + }, [applySnapshot]); + + const acceptMutationSnapshot = useCallback(( + next: ExternalSourceCatalogSnapshot, + scope: string, + sequence: number, + ): boolean => { + if (requestScopeRef.current !== scope) return false; + if ((latestMutationByScope.current.get(scope) ?? sequence) > sequence) return false; + acceptedSequence.current = Math.max(acceptedSequence.current, sequence); + applySnapshot(next); + return true; + }, [applySnapshot]); + + const loadSnapshot = useCallback(async (forceRefresh: boolean, foreground: boolean) => { + if (!desktopRuntime || remoteWorkspace) return; + const scope = requestScope; + const sequence = ++requestSequence.current; + if (foreground) { + foregroundSequence.current = sequence; + setRefreshing(true); + } + try { + const next = await externalSourcesAPI.getSnapshot(workspacePath, forceRefresh); + if (!acceptReadSnapshot(next, scope, sequence)) return; + setError(null); + } catch (loadError) { + if (requestScopeRef.current !== scope || sequence < acceptedSequence.current) return; + acceptedSequence.current = sequence; + setError(loadError instanceof Error ? loadError.message : String(loadError)); + } finally { + if (requestScopeRef.current === scope) { + if (sequence >= acceptedSequence.current) setLoading(false); + if (foregroundSequence.current === sequence) { + foregroundSequence.current = null; + setRefreshing(false); + } + } + } + }, [acceptReadSnapshot, desktopRuntime, remoteWorkspace, requestScope, workspacePath]); + + useEffect(() => { + setSnapshot(null); + setError(null); + setBusyKey(null); + setLoading(desktopRuntime && !remoteWorkspace); + void loadSnapshot(false, false); + if (!desktopRuntime || remoteWorkspace) return undefined; + const timer = window.setInterval(() => void loadSnapshot(false, false), 5000); + return () => window.clearInterval(timer); + }, [desktopRuntime, loadSnapshot, remoteWorkspace, workspacePath]); + + useEffect(() => { + if (!desktopRuntime || remoteWorkspace || !snapshot?.discoveryPending) return undefined; + const timer = window.setInterval(() => void loadSnapshot(false, false), 750); + return () => window.clearInterval(timer); + }, [desktopRuntime, loadSnapshot, remoteWorkspace, snapshot?.discoveryPending]); + + const commandCounts = useMemo(() => { + const namesBySource = new Map>(); + const add = (providerId: string, sourceId: string, commandName: string) => { + const key = `${providerId}\u0000${sourceId}`; + const names = namesBySource.get(key) ?? new Set(); + names.add(commandName.toLowerCase()); + namesBySource.set(key, names); + }; + for (const command of snapshot?.commands ?? []) { + const source = command.definition.id.source; + add(source.providerId, source.sourceId, command.definition.name); + } + for (const conflict of snapshot?.commandConflicts ?? []) { + for (const candidate of conflict.candidates) { + add(candidate.source.providerId, candidate.source.sourceId, conflict.commandName); + } + } + return new Map( + Array.from(namesBySource, ([source, names]) => [source, names.size]), + ); + }, [snapshot]); + + const pendingConflicts = useMemo( + () => (snapshot?.commandConflicts ?? []).filter( + (conflict) => !conflict.selectedCandidateId, + ), + [snapshot?.commandConflicts], + ); + + const setEnabled = useCallback(async (sourceKey: string, enabled: boolean) => { + const scope = requestScope; + const sequence = ++requestSequence.current; + pendingMutations.current.set(sequence, scope); + latestMutationByScope.current.set(scope, sequence); + setBusyKey(sourceKey); + try { + setError(null); + const next = await externalSourcesAPI.setSourceEnabled(workspacePath, sourceKey, enabled); + acceptMutationSnapshot(next, scope, sequence); + } catch (updateError) { + if (requestScopeRef.current === scope + && latestMutationByScope.current.get(scope) === sequence) { + acceptedSequence.current = sequence; + setError(updateError instanceof Error ? updateError.message : String(updateError)); + } + } finally { + pendingMutations.current.delete(sequence); + if (requestScopeRef.current === scope) { + setBusyKey((current) => (current === sourceKey ? null : current)); + } + } + }, [acceptMutationSnapshot, requestScope, workspacePath]); + + const chooseConflict = useCallback(async (conflictKey: string, candidateId: string) => { + const scope = requestScope; + const sequence = ++requestSequence.current; + pendingMutations.current.set(sequence, scope); + latestMutationByScope.current.set(scope, sequence); + setBusyKey(conflictKey); + try { + setError(null); + const next = await externalSourcesAPI.setConflictChoice( + workspacePath, + conflictKey, + candidateId, + ); + acceptMutationSnapshot(next, scope, sequence); + } catch (updateError) { + if (requestScopeRef.current === scope + && latestMutationByScope.current.get(scope) === sequence) { + acceptedSequence.current = sequence; + setError(updateError instanceof Error ? updateError.message : String(updateError)); + } + } finally { + pendingMutations.current.delete(sequence); + if (requestScopeRef.current === scope) { + setBusyKey((current) => (current === conflictKey ? null : current)); + } + } + }, [acceptMutationSnapshot, requestScope, workspacePath]); + + if (loading || snapshot?.discoveryPending) { + return ; + } + + const unavailableReason = !desktopRuntime + ? t('unavailable.desktopOnly') + : remoteWorkspace + ? t('unavailable.remoteWorkspace') + : null; + + return ( + + void loadSnapshot(true, true)} + > + + {refreshing ? t('actions.refreshing') : t('actions.refresh')} + + ) : undefined} + /> + + {unavailableReason ? ( + + {null} + + ) : ( + <> + {error ? ( +
+ {t('errors.nonBlocking', { error })} +
+ ) : null} + {(snapshot?.diagnostics?.length ?? 0) > 0 ? ( +
+ + {t('diagnostics.summary', { count: snapshot?.diagnostics?.length ?? 0 })} + +
    + {snapshot?.diagnostics?.map((diagnostic, index) => ( +
  • {diagnostic.message}
  • + ))} +
+
+ ) : null} + {!workspacePath ? ( +
+ {t('sources.globalOnly')} +
+ ) : null} + + + {(snapshot?.sources.length ?? 0) === 0 ? ( +
{t('sources.empty')}
+ ) : snapshot?.sources.map((source) => { + const sourcePair = `${source.record.key.providerId}\u0000${source.record.key.sourceId}`; + const removed = source.lifecycle === 'removed'; + const enabled = !removed && source.lifecycle !== 'suppressed'; + return ( + + + {abbreviatedLocation(source.record.location)} + + {' · '} + {source.record.scope === 'workspace_local' + ? t('shared:features.workspace') + : t(`scope.${source.record.scope}`)} + {' · '} + {t('sources.commandCount', { count: commandCounts.get(sourcePair) ?? 0 })} + + )} + align="center" + > +
+ + {t(`lifecycle.${source.lifecycle}`)} + + void setEnabled(source.stableKey, event.currentTarget.checked)} + /> +
+
+ ); + })} +
+ + {pendingConflicts.length > 0 ? ( + + {pendingConflicts.map((conflict) => ( +
+
+ {t('conflicts.commandName', { name: conflict.commandName })} +
+
+ {conflict.candidates.map((candidate) => { + const selected = conflict.selectedCandidateId === candidate.candidateId; + const available = candidate.availability.state === 'available'; + return ( +
+ +
+ {candidate.commandDescription} + {' · '} + {candidate.sourceScope === 'workspace_local' + ? t('shared:features.workspace') + : t(`scope.${candidate.sourceScope}`)} + {' · '} + + {abbreviatedLocation(candidate.sourceLocation)} + + {!available ? ` · ${t('conflicts.restricted')}` : ''} +
+
+ ); + })} +
+ {!conflict.selectedCandidateId ? ( +
+ {t('conflicts.pending')} +
+ ) : null} +
+ ))} +
+ ) : null} + + )} +
+
+ ); +}; + +export default ExternalSourcesConfig; diff --git a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts index 884c41923d..6d7d117962 100644 --- a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts +++ b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts @@ -29,6 +29,7 @@ export const ALL_NAMESPACES = [ 'settings/debug', 'settings/default-model', 'settings/editor', + 'settings/external-sources', 'settings/lsp', 'settings/mcp', 'settings/mcp-tools', diff --git a/src/web-ui/src/locales/en-US/settings.json b/src/web-ui/src/locales/en-US/settings.json index 516df12675..f4a1f47e26 100644 --- a/src/web-ui/src/locales/en-US/settings.json +++ b/src/web-ui/src/locales/en-US/settings.json @@ -24,6 +24,7 @@ "review": "Review strategy, coverage depth, capacity, cost, and latency controls.", "memories": "Automatic memory generation, injection, retention windows, and memory models.", "mcpTools": "MCP servers and tool integrations.", + "externalSources": "Load compatible commands and extensions from other AI applications.", "acpAgents": "External ACP agents such as opencode, Claude Code, and Codex.", "editor": "Editor font, display, and formatting.", "lsp": "Language servers and code intelligence.", @@ -46,6 +47,7 @@ "memories": "Memory", "skills": "Skills", "mcpTools": "MCP", + "externalSources": "External AI Apps", "acpAgents": "ACP Agents", "agents": "Agents", "editor": "Editor", 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 new file mode 100644 index 0000000000..d1dbf8c984 --- /dev/null +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -0,0 +1,50 @@ +{ + "title": "External AI applications", + "subtitle": "Use compatible work from other AI applications without copying it into BitFun.", + "loading": "Checking external sources…", + "actions": { + "refresh": "Refresh", + "refreshing": "Refreshing…" + }, + "unavailable": { + "title": "Sources unavailable", + "desktopOnly": "External sources are available in the desktop app.", + "remoteWorkspace": "Remote workspace sources are not supported yet. No local configuration was loaded in their place." + }, + "errors": { + "nonBlocking": "Some external sources could not be refreshed. Existing valid commands remain available. {{error}}" + }, + "diagnostics": { + "summary": "{{count}} source entries need attention. Existing valid commands remain available." + }, + "sources": { + "title": "Detected sources", + "description": "Sources remain in their original locations. Changes are picked up automatically while this page is open.", + "globalOnly": "Showing user-global sources. Open a workspace to include its project sources.", + "empty": "No compatible external sources were detected.", + "commandCount": "{{count}} commands", + "toggleLabel": "Enable {{name}}" + }, + "scope": { + "user_global": "User", + "project": "Project", + "remote_user": "Remote user", + "remote_project": "Remote project" + }, + "lifecycle": { + "available": "Available", + "restricted": "Restricted", + "degraded": "Needs attention", + "unavailable": "Unavailable", + "removed": "Removed", + "suppressed": "Disabled", + "using_last_valid_version": "Using last valid version" + }, + "conflicts": { + "title": "Needs your choice", + "description": "Same-name extensions are not activated silently. Your choice is remembered until a participant changes version.", + "commandName": "/{{name}} is provided by multiple sources", + "pending": "Choose one available source to make this command available.", + "restricted": "Not available in this release" + } +} diff --git a/src/web-ui/src/locales/zh-CN/settings.json b/src/web-ui/src/locales/zh-CN/settings.json index 15cea5ca07..e7a3ca25ef 100644 --- a/src/web-ui/src/locales/zh-CN/settings.json +++ b/src/web-ui/src/locales/zh-CN/settings.json @@ -45,6 +45,7 @@ "review": "Review 策略、覆盖深度、容量、成本和耗时控制。", "memories": "自动记忆生成、注入、整理窗口与记忆模型。", "mcpTools": "MCP 服务器与工具集成。", + "externalSources": "加载其他 AI 应用中兼容的命令与扩展。", "acpAgents": "opencode、Claude Code、Codex 等外部 ACP Agent。", "editor": "编辑器字体、显示与格式化。", "lsp": "语言服务与代码智能。", @@ -67,6 +68,7 @@ "memories": "记忆", "skills": "技能", "mcpTools": "MCP", + "externalSources": "外部 AI 应用", "acpAgents": "ACP Agent", "agents": "智能体", "editor": "编辑器", 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 new file mode 100644 index 0000000000..93601b7811 --- /dev/null +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -0,0 +1,50 @@ +{ + "title": "外部 AI 应用", + "subtitle": "无需复制,即可使用其他 AI 应用中兼容的工作内容。", + "loading": "正在检查外部来源…", + "actions": { + "refresh": "刷新", + "refreshing": "正在刷新…" + }, + "unavailable": { + "title": "来源暂不可用", + "desktopOnly": "外部来源目前仅在桌面应用中可用。", + "remoteWorkspace": "暂不支持远程工作区来源,且不会用本地配置代替加载。" + }, + "errors": { + "nonBlocking": "部分外部来源刷新失败,已有的有效命令会继续保留。{{error}}" + }, + "diagnostics": { + "summary": "有 {{count}} 条来源信息需要关注,已有的有效命令会继续保留。" + }, + "sources": { + "title": "已识别来源", + "description": "来源保留在原始位置;此页面打开期间会自动感知变更。", + "globalOnly": "当前仅显示用户全局来源;打开工作区后会同时包含项目来源。", + "empty": "未识别到兼容的外部来源。", + "commandCount": "{{count}} 个命令", + "toggleLabel": "启用 {{name}}" + }, + "scope": { + "user_global": "用户全局", + "project": "项目", + "remote_user": "远程用户", + "remote_project": "远程项目" + }, + "lifecycle": { + "available": "可用", + "restricted": "受限", + "degraded": "需要关注", + "unavailable": "不可用", + "removed": "已移除", + "suppressed": "已停用", + "using_last_valid_version": "使用上一个有效版本" + }, + "conflicts": { + "title": "需要你的选择", + "description": "同名扩展不会被静默激活;在参与方版本变化前只需选择一次。", + "commandName": "多个来源都提供了 /{{name}}", + "pending": "选择一个当前可用的来源后,该命令才会变为可用。", + "restricted": "当前版本不可用" + } +} diff --git a/src/web-ui/src/locales/zh-TW/settings.json b/src/web-ui/src/locales/zh-TW/settings.json index dbc2db8ffe..02a522ca46 100644 --- a/src/web-ui/src/locales/zh-TW/settings.json +++ b/src/web-ui/src/locales/zh-TW/settings.json @@ -44,6 +44,7 @@ "review": "Review 策略、覆蓋深度、容量、成本和耗時控制。", "memories": "自動記憶生成、注入、整理窗口與記憶模型。", "mcpTools": "MCP 伺服器與工具集成。", + "externalSources": "載入其他 AI 應用中相容的命令與擴充。", "acpAgents": "opencode、Claude Code、Codex 等外部 ACP Agent。", "editor": "編輯器字體、顯示與格式化。", "lsp": "語言服務與代碼智能。", @@ -66,6 +67,7 @@ "memories": "記憶", "skills": "技能", "mcpTools": "MCP", + "externalSources": "外部 AI 應用", "acpAgents": "ACP Agent", "agents": "智能體", "editor": "編輯器", 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 new file mode 100644 index 0000000000..9bffc3553f --- /dev/null +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -0,0 +1,50 @@ +{ + "title": "外部 AI 應用", + "subtitle": "無需複製,即可使用其他 AI 應用中相容的工作內容。", + "loading": "正在檢查外部來源…", + "actions": { + "refresh": "重新整理", + "refreshing": "正在重新整理…" + }, + "unavailable": { + "title": "來源暫不可用", + "desktopOnly": "外部來源目前僅在桌面應用中可用。", + "remoteWorkspace": "暫不支援遠端工作區來源,且不會以本機設定代替載入。" + }, + "errors": { + "nonBlocking": "部分外部來源重新整理失敗,已有的有效命令會繼續保留。{{error}}" + }, + "diagnostics": { + "summary": "有 {{count}} 條來源資訊需要注意,已有的有效命令會繼續保留。" + }, + "sources": { + "title": "已識別來源", + "description": "來源保留在原始位置;此頁面開啟期間會自動感知變更。", + "globalOnly": "目前僅顯示使用者全域來源;開啟工作區後會同時包含專案來源。", + "empty": "未識別到相容的外部來源。", + "commandCount": "{{count}} 個命令", + "toggleLabel": "啟用 {{name}}" + }, + "scope": { + "user_global": "使用者全域", + "project": "專案", + "remote_user": "遠端使用者", + "remote_project": "遠端專案" + }, + "lifecycle": { + "available": "可用", + "restricted": "受限", + "degraded": "需要注意", + "unavailable": "不可用", + "removed": "已移除", + "suppressed": "已停用", + "using_last_valid_version": "使用上一個有效版本" + }, + "conflicts": { + "title": "需要你的選擇", + "description": "同名擴充不會被靜默啟用;在參與方版本變化前只需選擇一次。", + "commandName": "多個來源都提供了 /{{name}}", + "pending": "選擇一個目前可用的來源後,該命令才會變為可用。", + "restricted": "目前版本不可用" + } +}