diff --git a/docs/architecture/extensions/opencode-config-assets-adapter-design.md b/docs/architecture/extensions/opencode-config-assets-adapter-design.md index 79bf1127c..a6449d1f3 100644 --- a/docs/architecture/extensions/opencode-config-assets-adapter-design.md +++ b/docs/architecture/extensions/opencode-config-assets-adapter-design.md @@ -109,6 +109,12 @@ OpenCode 当前版本的真实合并/去重语义,不用 BitFun 常规配置 不移动到末尾。`OPENCODE_DISABLE_PROJECT_CONFIG` 会关闭项目配置、项目目录资产和对应监听根;显式目录不替换 XDG 用户根,并在 BitFun 来源标签中保持 `WorkspaceLocal`。 +当前 References 切片按审计时 OpenCode Core V2 `e4bd9757` 的配置插件顺序独立组装: +`OPENCODE_CONFIG_DIR` 存在时替换默认全局配置根,而不是追加更高优先级目录;随后依次应用 project direct config +(root-to-nearest)和 project `.opencode` config(root-to-nearest)。该生产路径不读取 `OPENCODE_CONFIG`、旧用户目录 +或 `config.json`,也不复用 Command/Subagent 的目录资产顺序。相同 alias 由后出现的有效声明覆盖;不同 alias 即使指向 +同一路径也保留。若总量超过 1024,先完成全部覆盖,再保留最高优先级声明,不能让低优先级填满限额而屏蔽项目声明。 + `OPENCODE_CONFIG_CONTENT`、远程、组织、系统管理员与 MDM 来源尚未接入,不能因三类本地候选可运行就把目标来源 序列标记为完整实现。 @@ -201,7 +207,7 @@ OpenCode adapter 在来源发现、解析和审批前不 import module、不读 | Rules / Instructions | 项目/全局 `AGENTS.md`、Claude fallback、`instructions` glob、本地文件、远程 URL | 各生态 adapter 保留原生用户来源语义;Workspace Instructions owner 解析项目来源;Product Assembly 有序合成 | 当前支持 OpenCode 用户 `AGENTS.md`/Claude fallback、三份全局配置的最终本地 `instructions`,以及既有项目根与 `.opencode` 本地精确文件/glob;不获取远程 URL | 单个用户生态的无效配置、glob 或文件 I/O 失败会隔离该生态,保留其他用户生态与项目来源,并使本次构建不可缓存;项目配置继续按项目解析器的逐项降级语义处理。 | | Agents / Modes | 当前生产 V1 `agent/prompt/disable/permission`、Core V2 `agents/system/disabled/permissions` 输入形状,以及 Markdown、description、mode、model、variant、temperature、top_p、steps、deprecated `maxSteps`、deprecated `tools`、options、hidden、color | Agent 归属模块创建兼容定义和使用范围视图;OpenCode adapter 只翻译来源语义 | 当前支持 Subagent 安全子集和 Agent-local 权限约束;V1 是生产兼容主路径,Core V2 字段只按已验证安全子集解析;首次按行为、来源、模型、工具与权限范围确认,fresh single-run 调用 | primary/mode、variant/options、采样、steps 与续接保持诊断或阻断;root ambient 权限和 V1 嵌套 resource map 尚不激活,不影响其他 Agent。 | | Skills | `.opencode/.claude/.agents` 项目与用户根、`SKILL.md`、`skills.paths/urls` | OpenCode adapter 只由 `bitfun-core/external_sources` 组合并投影有序本地配置根;Skill 归属模块负责有界递归、解析、覆盖与按需加载 | 标准根及 V1 `skills.paths`/当前本地字符串数组可用;项目配置限项目根,用户配置限项目根或用户目录;配置根最多 64 个、每根 512 个 Skill、单文件 256 KiB、可选策略 64 KiB,实际加载再次执行有界非链接读取;配置根在同 scope 覆盖标准 OpenCode 根,但不重排更早的 BitFun/Claude/Codex/Cursor 来源 | URL、下载/缓存、脚本与外部依赖不加载;无效根不影响标准 Skill。 | -| References | `references` / 旧 `reference`,本地 path 或 Git repository/branch/description/hidden | **基础能力缺失**:先补 Workspace Reference 的异步准备与 `@alias` 消费接口 | 本地引用保留相对来源;Git 拉取按 L2 确认并保留缓存/隐藏语义 | 拉取失败不阻止项目,外部目录仍遵守工具权限。 | +| References | `references` / 旧 `reference`,本地 path 或 Git repository/branch/description/hidden | OpenCode adapter 输出来源无关的 Reference provider snapshot;Product Assembly 生命周期协调器与 BitFun 原生关联目录合成唯一有效引用目录;关联目录视图和既有目录选择器消费 | 当前支持本地声明路径、description/hidden、异步刷新和 `@alias` 展示;原生关联目录始终在 OpenCode 引用之前,外部引用只读、不自动进入 Prompt 且不改变权限 | Git 引用、Remote 发现和下载/缓存不实现;无效高优先级 entry 阻断同 alias 的旧值并给出诊断,不回退到更宽松来源。 | | Commands | JSON/JSONC、Markdown、`$ARGUMENTS`、位置参数、`@file`、`!shell`、agent/model/variant/subtask | Prompt Command 专属契约;adapter 提取静态文件引用,Product Assembly 经共享本地文本服务完成有界装配 | prompt-only 与静态 workspace 相对 UTF-8 `@file` 可发送;动态/绝对/越界文件、shell、agent/model/variant/subtask 整体受限 | 任一文件失败则本次调用原子失败;最多 8 文件、单文件 64 KiB、文件总量 128 KiB、最终命令 1 MiB。 | | MCP | local 的 command/environment/cwd/timeout,remote 的 URL/headers/oauth/timeout,Agent 选择 | MCP 归属模块创建兼容配置视图 | 当前支持 local stdio 和 HTTPS remote 的静态发现、首次/行为变化审批、冲突选择与 workspace 隔离的运行期接纳;C0a 快照导入只复制无 env/cwd 的 local command/args 或无 header/query/fragment 的 HTTPS remote,并保持 disabled | `{env:NAME}` 当前只允许用于运行期兼容来源的 environment/Header 值,不进入 C0a 快照;SSE、OpenCode OAuth client 配置、完整 timeout/Agent 范围与 Remote 执行域保持明确不支持;凭据或网络失败只影响单个 Server。 | | LSP | command、extensions、env、initialization | LSP 归属模块注册兼容实例 | 首次确认外部进程和使用范围后按文件类型启动 | 自定义 Server 缺少 extensions 或启动失败时只禁用该项。 | @@ -298,6 +304,30 @@ V1 `skills.paths` 和当前 `skills: string[]`;字段类型错误只拒绝该 拒绝符号链接/reparse point;加载时重新校验规范化根及其稳定 source slot,防止目录整体替换改变已发现来源身份。同 scope 内配置根位于标准 OpenCode 根之前,较后的不同配置根覆盖同名 Skill,但不重排更早的 BitFun/Claude/Codex/Cursor 来源。 +### 5.2.1 References + +Workspace Reference 不是第二套 Workspace,也不是文件权限入口。OpenCode adapter 只解析有界 JSON/JSONC 配置, +把相对 path 固定到声明它的配置文档目录;`~/` 与绝对路径保留 OpenCode 语义。与 OpenCode 相同,声明路径不要求 +发现时已经存在:存在路径用规范身份,尚未创建的路径保留词法绝对路径,因此目标目录创建/删除不需要另一套生命周期。 +Provider snapshot 经过 `ExternalSourceControlPlane` 的独立 Reference 生命周期通道发布。配置文件临时 I/O 失败可保留 +上一代结果;稳定的 JSON/UTF-8/大小错误按本次静态诊断处理,明确删除、策略禁用或不支持的 Git entry 不借旧值放宽结果。 +配置枚举阶段只有 `NotFound` 表示来源不存在,权限或其他元数据错误同样按临时 I/O 失败处理。Provider 最多发布 +256 条诊断(含一条截断汇总),避免单个有界配置文件放大为无界 IPC/DOM 内容。 + +Product Assembly 生成唯一的有效引用目录:BitFun `WorkspaceInfo.related_paths` 按用户顺序在前,OpenCode 引用按其 +最终 alias 顺序在后。工作树以会话实际根发现 OpenCode 配置,同时用稳定 workspace id 取得主工作空间的原生关联目录; +会话根必须等于已注册根,或由服务端现有 Git worktree owner 实时解析为同一 repository 的 live worktree 根;缓存中的 +prunable 路径不能授权扫描。缺少注册元数据、id 失效、 +路径不匹配或元数据标记为 Remote 时整体 fail closed,不按前缀或同名控制端路径猜测本地工作空间。不同 alias +指向同一路径不会去重,因为 alias 本身是用户可见身份。只有策略为 `Auto`/`AskBeforeUse` 的生态进入有效快照; +`DiscoverOnly` 可以刷新来源但不能投影为可用目录。关联目录弹窗只读展示可见外部项,现有 `@` 目录选择器直接导航 +同一快照;`hidden` 项不进入这些消费点。 + +本切片不下载 Git repository、不创建缓存目录、不接入 Remote workspace,也不启动任何 plugin host runtime。 +Git/裸 repository 形状返回稳定的 `git_unsupported` 诊断;本地引用只提供目录发现和用户显式导航,不自动写入 +Agent Prompt,不把配置声明解释成工作区外文件授权。只有用户在现有目录选择器中显式选择后,才沿用既有 context/tool +权限裁决。 + ### 5.3 Commands 当前 Prompt Command 子集展开 `$ARGUMENTS` 与 `$1`、`$2` 等位置参数,并支持模板中可静态确认的 workspace 相对 UTF-8 diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index 59ef53cbb..0fb0a41ee 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -109,7 +109,7 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | Rules / Instructions | 转换参数 | 部分实现:用户/项目本地文件与 glob | 可完整适配 | OC-R1 | OpenCode adapter 已读取用户全局 `AGENTS.md`/Claude fallback 和最终本地 `instructions`;Product Assembly 在 Codex/Claude 用户来源与项目来源之前合成并去重。远程 URL、managed/organization policy、完整配置来源图和变化监听仍未实现 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | | Agents / Modes | 融合现有能力 + 转换参数 | 部分实现:Subagent 安全子集与 Agent-local 权限约束 | 可主要适配 | OC-R1 | 已支持当前生产 V1 `agent/prompt/disable/permission` 与 Core V2 `agents/system/disabled/permissions` 的已验证安全子集、全局/项目 Markdown 和 JSON/JSONC、subagent/all、description、精确模型和工具映射,并接入审批、冲突、更新、撤下和 fresh single-run Task;V1 `disable` 保持 deep-merge,V2 `disabled` 保持 remove/re-add,不能混用生命周期语义。有序 V2 permission rules 与 V1 扁平精确 action map 会成为只可收紧的独立约束。primary/mode、root ambient permission、V1 action pattern/嵌套 resource map、跨路径与命令资源域的歧义 pattern、variant/options、采样与续接明确阻断或降级 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | | Skills | 转换参数 | 部分实现:标准根与本地配置根 | 可完整适配 | OC-R2 | 现有 Registry 除标准用户/项目根外,也通过 `bitfun-core/external_sources` 组合边界按 OpenCode 配置来源顺序累加 V1 `skills.paths` 与当前迁移后的本地字符串数组;仅接受项目根/用户目录内的本地目录并做有界递归发现。同 scope 配置根覆盖标准 OpenCode 根,但不重排更早的 BitFun/Claude/Codex/Cursor 来源。URL、下载/缓存、完整 allow/deny/ask 顺序及外部来源策略仍未实现 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | -| References | 补基础能力 + 转换参数 | 未实现 | 可主要适配 | OC-R2 | R1 解析;R2 支持本地目录和 Git repository/branch/description/hidden,异步准备并接入 `@alias` | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | +| References | 融合现有能力 + 转换参数 | 部分实现:本地目录与既有 Workspace 消费点 | 可主要适配 | OC-R2 | 已按 OpenCode V2 `e4bd9757` 的独立来源顺序解析 `references`/旧 `reference` 的本地 path、description/hidden,相同 alias 后者覆盖;通过独立生命周期协调器与 BitFun 原生关联目录合成 native-first 有效快照,接入关联目录弹窗和既有 `@` 目录选择器。外部声明不自动进入 Prompt、不授予文件权限;Git、Remote、下载/缓存明确不支持且不做临时实现 | [References](opencode-config-assets-adapter-design.md#521-references) | | Commands | 补扩展接口 + 转换参数 | 部分实现:prompt-only + 本地文本文件子集 | 可完整适配 | OC-R2 | 已支持全局/项目 JSON、JSONC、Markdown 命令、参数展开、动态目录、刷新和显式冲突选择;模板中的静态 workspace 相对 `@file` 可在调用时有界读取,动态/绝对/越界文件引用、shell、Agent/model/variant/subtask 保持受限且不做部分执行 | [Commands](opencode-config-assets-adapter-design.md#53-commands) | | Models / Providers 配置 | 融合现有能力 | 未实现 | 可主要适配 | OC-R1 | 静态字段进入模型归属模块;动态模型、鉴权和请求头交给插件运行时 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | | MCP | 转换参数 | 部分实现:local stdio 与 HTTPS remote | 可完整适配 | OC-R2 | 已接入发现、审批、冲突、workspace 隔离、更新和启动反馈;SSE、OAuth、完整 timeout/Agent 范围仍不支持;Remote 不回退本机实例 | [MCP、LSP 与 Formatter](opencode-config-assets-adapter-design.md#54-mcplsp-与-formatter) | diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 4b66e1079..33f835602 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -4119,6 +4119,7 @@ export const forbiddenContentUnderRules = [ 'src/crates/adapters/opencode-adapter/tests/opencode_source_adapter.rs', 'src/crates/adapters/opencode-adapter/tests/opencode_command_adapter.rs', 'src/crates/adapters/opencode-adapter/tests/opencode_skill_roots.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_workspace_references.rs', 'src/crates/adapters/opencode-adapter/tests/tool_source_contracts.rs', 'src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs', 'src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs', diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index c64bcc2e2..794f1516e 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -11,6 +11,7 @@ export const publicApiContractSlices = [ 'external-source-subagent-contract', 'external-source-mcp-contract', 'external-source-hook-contract', + 'external-source-reference-contract', 'user-instruction-source-boundary', 'external-integration-policy-contract', ]; @@ -26,6 +27,7 @@ const contractSlices = { externalSourceSubagentContract: 'external-source-subagent-contract', externalSourceMcpContract: 'external-source-mcp-contract', externalSourceHookContract: 'external-source-hook-contract', + externalSourceReferenceContract: 'external-source-reference-contract', userInstructionSourceBoundary: 'user-instruction-source-boundary', externalIntegrationPolicyContract: 'external-integration-policy-contract', }; @@ -200,6 +202,23 @@ function opencodeHookAdapterEntry(symbol, consumer) { }; } +function opencodeReferenceAdapterEntry(symbol, consumer) { + return { + symbol, + owner: 'opencode-adapter workspace Reference source owner', + consumer, + verification: + 'OpenCode workspace Reference fixtures, bitfun-core composition tests, and core-boundary public API budget checks', + p0: 'runtime-free OpenCode local workspace Reference discovery', + contractSlice: contractSlices.opencodeAdapterBoundary, + wireImpact: false, + rationale: + 'the product catalog needs one OpenCode-specific parser behind the ecosystem-neutral workspace Reference provider contract', + exit: + 'remove only if OpenCode workspace Reference discovery moves behind another reviewed adapter with equivalent precedence and fail-closed tests', + }; +} + export const opencodeAdapterPublicApiEntries = [ opencodeAdapterEntry( 'load_opencode_package_adapter', @@ -257,6 +276,14 @@ export const opencodeAdapterPublicApiEntries = [ 'OpenCodeHookProviderOptions', 'OpenCode static Hook fixture tests and explicit environment injection', ), + opencodeReferenceAdapterEntry( + 'OpenCodeWorkspaceReferenceProvider', + 'bitfun-core workspace Reference composition root and OpenCode adapter fixtures', + ), + opencodeReferenceAdapterEntry( + 'OpenCodeWorkspaceReferenceProviderOptions', + 'OpenCode workspace Reference fixtures and explicit environment injection', + ), userInstructionSourceAdapterEntry('load_opencode_user_instructions', 'OpenCode'), userInstructionSourceAdapterEntry('OpenCodeInstructionSourceOptions', 'OpenCode'), ]; @@ -638,6 +665,40 @@ function externalMcpEntry(symbol, owner, consumer, wireImpact = false) { }; } +function externalReferenceEntry(symbol, owner, consumer, wireImpact = false) { + return { + symbol, + owner, + consumer, + verification: + 'workspace Reference contract, coordinator, OpenCode adapter, product composition, Desktop, and Web tests', + p0: 'runtime-free named local workspace Reference discovery and native-first product projection', + contractSlice: contractSlices.externalSourceReferenceContract, + wireImpact, + rationale: + 'workspace Reference discovery needs typed provider facts and one product projection without granting filesystem permissions or leaking ecosystem payloads', + exit: + 'remove only through a reviewed workspace Reference contract migration with equivalent precedence, policy, remote, and permission-boundary tests', + }; +} + +export const workspaceReferenceContractPublicApiEntries = [ + 'ExternalWorkspaceReferenceProviderIdentity', + 'ExternalWorkspaceReferenceDefinition', + 'ExternalWorkspaceReferenceProviderSnapshot', + 'ExternalWorkspaceReferenceSourceProvider', + 'WorkspaceReferenceOrigin', + 'WorkspaceReferenceCatalogEntry', + 'WorkspaceReferenceSnapshot', +].map((symbol) => + externalReferenceEntry( + symbol, + 'product-domains workspace Reference contract owner', + 'OpenCode provider, external-sources coordinator, bitfun-core composition, and Desktop/Web workspace surfaces', + true, + ), +); + export const externalSourceContractPublicApiEntries = [ 'ExternalSourceContractError', 'SourceKey', @@ -883,6 +944,18 @@ export const externalSourceCoordinatorPublicApiEntries = [ 'bitfun-core bounded concurrent external-MCP provider scheduler', ), ), + ...[ + 'ExternalWorkspaceReferenceCoordinator', + 'ExternalWorkspaceReferenceCoordinatorSnapshot', + 'ExternalWorkspaceReferenceDiscoveryRequest', + 'ExternalWorkspaceReferenceDiscoveryResult', + ].map((symbol) => + externalReferenceEntry( + symbol, + 'external-sources workspace Reference coordinator owner', + 'bitfun-core bounded concurrent workspace Reference provider scheduler', + ), + ), ]; export const externalSourceCorePublicApiEntries = [ @@ -918,6 +991,7 @@ export const externalSourceCorePublicApiEntries = [ 'EXTERNAL_CAPABILITY_TOOL', 'EXTERNAL_CAPABILITY_SUBAGENT', 'EXTERNAL_CAPABILITY_MCP', + 'EXTERNAL_CAPABILITY_REFERENCE', 'update_external_integration_policy', ].map((symbol) => externalIntegrationPolicyEntry( @@ -968,6 +1042,12 @@ export const externalSourceCorePublicApiEntries = [ 'BitFun CLI and desktop host APIs', ), ), + externalReferenceEntry( + 'workspace_reference_snapshot', + 'bitfun-core workspace Reference composition facade', + 'Desktop and Web workspace directory surfaces', + true, + ), externalSourceEntry( 'external_source_location_for_host_action', 'bitfun-core external source composition owner', @@ -1202,6 +1282,12 @@ export const publicApiAllowlistRules = [ 'external Hook catalog contracts must stay ecosystem-neutral, runtime-free, redacted, bounded, and explicitly consumer-backed', allowedSymbolEntries: externalHookCatalogPublicApiEntries, }, + { + path: 'src/crates/contracts/product-domains/src/workspace_references.rs', + reason: + 'workspace Reference contracts must stay ecosystem-neutral, runtime-free, non-authorizing, bounded, and explicitly consumer-backed', + allowedSymbolEntries: workspaceReferenceContractPublicApiEntries, + }, { path: 'src/crates/assembly/external-sources/src/lib.rs', reason: diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 2489d1c3b..36d279b60 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -913,6 +913,9 @@ export function runManifestParserSelfTest({ const externalHookCatalogPublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/contracts/product-domains/src/external_hook_catalog.rs', ); + const workspaceReferencePublicApiRule = publicApiAllowlistRules.find( + (rule) => rule.path === 'src/crates/contracts/product-domains/src/workspace_references.rs', + ); const externalSourcePublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/contracts/product-domains/src/external_sources.rs', ); @@ -1037,10 +1040,10 @@ export function runManifestParserSelfTest({ ).map((entry) => entry.symbol); if ( opencodeAdapterPublicApiSymbols.join(',') !== - 'load_opencode_package_adapter,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeConfiguredSkillRoot,OpenCodeSkillRootProvider,OpenCodeSkillRootProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions,OpenCodeSubagentProvider,OpenCodeSubagentProviderOptions,OpenCodeMcpProvider,OpenCodeMcpProviderOptions,OpenCodeHookProvider,OpenCodeHookProviderOptions,load_opencode_user_instructions,OpenCodeInstructionSourceOptions' + 'load_opencode_package_adapter,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeConfiguredSkillRoot,OpenCodeSkillRootProvider,OpenCodeSkillRootProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions,OpenCodeSubagentProvider,OpenCodeSubagentProviderOptions,OpenCodeMcpProvider,OpenCodeMcpProviderOptions,OpenCodeHookProvider,OpenCodeHookProviderOptions,OpenCodeWorkspaceReferenceProvider,OpenCodeWorkspaceReferenceProviderOptions,load_opencode_user_instructions,OpenCodeInstructionSourceOptions' ) { throw new Error( - 'OpenCode adapter public API budget must stay limited to the reviewed package factory and capability-specific command, configured Skill root, tool, subagent, MCP, static Hook, and user Instruction providers', + 'OpenCode adapter public API budget must stay limited to the reviewed package factory and capability-specific command, configured Skill root, tool, subagent, MCP, static Hook, workspace Reference, and user Instruction providers', ); } const opencodeInstructionSymbols = new Set([ @@ -1127,6 +1130,9 @@ export function runManifestParserSelfTest({ if (!externalHookCatalogPublicApiRule) { throw new Error('external Hook catalog contracts must have an independent public API budget rule'); } + if (!workspaceReferencePublicApiRule) { + throw new Error('workspace Reference contracts must have an independent public API budget rule'); + } if (!publicApiContractSlices.includes('external-source-hook-contract')) { throw new Error('external Hook contracts must have an independent contract slice'); } @@ -1173,6 +1179,26 @@ export function runManifestParserSelfTest({ if (!publicApiContractSlices.includes('external-source-control-contract')) { throw new Error('external source control contracts must have an independent contract slice'); } + if (!publicApiContractSlices.includes('external-source-reference-contract')) { + throw new Error('workspace Reference contracts must have an independent contract slice'); + } + for (const requiredSymbol of [ + 'ExternalWorkspaceReferenceProviderIdentity', + 'ExternalWorkspaceReferenceDefinition', + 'ExternalWorkspaceReferenceSourceProvider', + 'WorkspaceReferenceCatalogEntry', + 'WorkspaceReferenceSnapshot', + ]) { + if (!workspaceReferencePublicApiRule.allowedSymbolEntries.some( + (entry) => entry.symbol === requiredSymbol + && entry.contractSlice === 'external-source-reference-contract' + && entry.wireImpact === true + && entry.consumer + && entry.verification, + )) { + throw new Error(`workspace Reference public API budget is missing a consumer-backed symbol: ${requiredSymbol}`); + } + } for (const requiredSymbol of [ 'EXTERNAL_SOURCE_CONTROL_SCHEMA_V1', 'ExternalSourceControlSnapshotV1', @@ -1212,6 +1238,20 @@ export function runManifestParserSelfTest({ )) { throw new Error('external source coordinator public API budget is missing the typed Hook discovery result'); } + for (const requiredSymbol of [ + 'ExternalWorkspaceReferenceCoordinator', + 'ExternalWorkspaceReferenceCoordinatorSnapshot', + 'ExternalWorkspaceReferenceDiscoveryRequest', + 'ExternalWorkspaceReferenceDiscoveryResult', + ]) { + if (!externalSourceCoordinatorPublicApiRule.allowedSymbolEntries.some( + (entry) => entry.symbol === requiredSymbol + && entry.contractSlice === 'external-source-reference-contract' + && entry.wireImpact === false, + )) { + throw new Error(`external source coordinator public API budget is missing workspace Reference symbol: ${requiredSymbol}`); + } + } for (const requiredSymbol of [ 'ExternalSourceControlSnapshotV1', 'ExternalSourceControlRequestV1', @@ -1225,6 +1265,15 @@ export function runManifestParserSelfTest({ throw new Error(`external source core public API budget is missing control symbol: ${requiredSymbol}`); } } + for (const requiredSymbol of ['EXTERNAL_CAPABILITY_REFERENCE', 'workspace_reference_snapshot']) { + if (!externalSourceCorePublicApiRule?.allowedSymbolEntries.some( + (entry) => entry.symbol === requiredSymbol + && entry.consumer + && entry.verification, + )) { + throw new Error(`external source core public API budget is missing workspace Reference symbol: ${requiredSymbol}`); + } + } for (const requiredSymbol of [ 'ExternalMcpServerDefinition', 'ExternalMcpSourceProvider', diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index d19e8b031..337f1a314 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -9,18 +9,26 @@ use bitfun_core::external_sources::{ set_external_prompt_command_conflict_choice, set_external_source_enabled, set_external_subagent_activation, set_external_tool_conflict_choice, set_external_tool_target_decision, set_native_prompt_command_conflict_choice, - update_external_integration_policy, ExpandedPromptCommand, ExternalIntegrationPolicyMutation, - ExternalSourceControlRequestV1, ExternalSourceHostCapabilities, ExternalSourceOperationError, - ExternalSourceOperationErrorCode, ExternalSourceOperationResult, ExternalSourcePublicSnapshot, - ExternalSourceSurfaceSnapshotV1, NativePromptCommandConflictSnapshot, - NativePromptCommandDescriptor, + update_external_integration_policy, workspace_reference_snapshot, ExpandedPromptCommand, + ExternalIntegrationPolicyMutation, ExternalSourceControlRequestV1, + ExternalSourceHostCapabilities, ExternalSourceOperationError, ExternalSourceOperationErrorCode, + ExternalSourceOperationResult, ExternalSourcePublicSnapshot, ExternalSourceSurfaceSnapshotV1, + NativePromptCommandConflictSnapshot, NativePromptCommandDescriptor, }; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; +use bitfun_core::service::remote_ssh::workspace_state::{ + canonicalize_local_workspace_root, local_workspace_roots_equal, +}; +use bitfun_core::service::workspace::manager::WorkspaceKind; use bitfun_product_domains::external_sources::{ ExternalMcpImportApplyRequestV1, ExternalMcpImportApplyResultV1, ExternalMcpImportPlanV1, }; +use bitfun_product_domains::workspace_references::WorkspaceReferenceSnapshot; use serde::{Deserialize, Serialize}; use std::path::Path; +use tauri::State; + +use super::AppState; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -30,6 +38,16 @@ pub struct ExternalSourceSnapshotRequest { pub force_refresh: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WorkspaceReferenceSnapshotRequest { + pub workspace_path: String, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] + pub force_refresh: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceControlCommandRequest { @@ -183,6 +201,7 @@ pub type ExternalSourceSnapshotResponse = ExternalSourcePublicSnapshot; pub type ExternalSourceControlResponse = ExternalSourceSurfaceSnapshotV1; pub type ExpandExternalPromptCommandResponse = ExpandedPromptCommand; pub type NativePromptCommandConflictsResponse = NativePromptCommandConflictSnapshot; +pub type WorkspaceReferenceResponse = WorkspaceReferenceSnapshot; #[tauri::command] pub async fn plan_external_mcp_import_command( @@ -249,6 +268,98 @@ pub async fn get_external_source_snapshot( .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } +#[tauri::command] +pub async fn get_workspace_reference_snapshot( + state: State<'_, AppState>, + request: WorkspaceReferenceSnapshotRequest, +) -> ExternalSourceOperationResult { + let requested_workspace = Path::new(&request.workspace_path); + if !requested_workspace.is_absolute() { + return Err(ExternalSourceOperationError::invalid_request( + "Workspace references require an absolute workspace path", + )); + } + let workspace_id = request + .workspace_id + .as_deref() + .map(str::trim) + .filter(|workspace_id| !workspace_id.is_empty()); + let workspace_info = match workspace_id { + Some(workspace_id) => state.workspace_service.get_workspace(workspace_id).await, + None => { + state + .workspace_service + .get_workspace_by_path(requested_workspace) + .await + } + }; + ensure_registered_workspace_reference_kind( + workspace_info + .as_ref() + .map(|workspace| &workspace.workspace_kind), + )?; + let workspace_info = workspace_info.expect("registered workspace was validated above"); + let path_matches_registered_workspace = if workspace_reference_path_matches_registered_root( + &workspace_info.root_path, + requested_workspace, + ) { + true + } else { + state + .workspace_service + .is_live_worktree_root_in_same_repository( + &workspace_info.root_path, + requested_workspace, + ) + .await + .unwrap_or(false) + }; + if !path_matches_registered_workspace { + return Err(ExternalSourceOperationError::invalid_request( + "Workspace reference path does not match the registered workspace or one of its Git worktrees", + )); + } + let workspace = require_local_workspace(Some(&request.workspace_path)) + .await? + .ok_or_else(|| { + ExternalSourceOperationError::invalid_request( + "Workspace references require a local workspace path", + ) + })?; + let native_related_paths = workspace_info.related_paths; + workspace_reference_snapshot(workspace, &native_related_paths, request.force_refresh) + .await + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + +fn ensure_registered_workspace_reference_kind( + workspace_kind: Option<&WorkspaceKind>, +) -> ExternalSourceOperationResult<()> { + match workspace_kind { + None => Err(ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::NotFound, + "Workspace references require a registered workspace", + false, + )), + Some(WorkspaceKind::Remote) => Err(ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::HostUnavailable, + "The remote workspace is not running the external compatibility service", + true, + )), + Some(WorkspaceKind::Normal | WorkspaceKind::Assistant) => Ok(()), + } +} + +fn workspace_reference_path_matches_registered_root( + registered_root: &Path, + requested_path: &Path, +) -> bool { + let Ok((requested_path, _)) = canonicalize_local_workspace_root(requested_path) else { + return false; + }; + local_workspace_roots_equal(registered_root, &requested_path) +} + #[tauri::command] pub async fn reveal_external_source_location( request: RevealExternalSourceLocationRequest, @@ -470,6 +581,47 @@ mod tests { ExternalSourceCatalogSnapshot, ExternalSourceControlActionV1, }; + #[test] + fn workspace_references_fail_closed_without_registered_local_metadata() { + let missing = ensure_registered_workspace_reference_kind(None).unwrap_err(); + assert_eq!(missing.code, ExternalSourceOperationErrorCode::NotFound); + + let remote = + ensure_registered_workspace_reference_kind(Some(&WorkspaceKind::Remote)).unwrap_err(); + assert_eq!( + remote.code, + ExternalSourceOperationErrorCode::HostUnavailable + ); + + assert!(ensure_registered_workspace_reference_kind(Some(&WorkspaceKind::Normal)).is_ok()); + assert!( + ensure_registered_workspace_reference_kind(Some(&WorkspaceKind::Assistant)).is_ok() + ); + } + + #[test] + fn workspace_reference_paths_do_not_accept_unrelated_or_stale_local_paths() { + let directory = tempfile::tempdir().unwrap(); + let registered_root = directory.path().join("registered"); + let unrelated_root = directory.path().join("unrelated"); + for path in [®istered_root, &unrelated_root] { + std::fs::create_dir_all(path).unwrap(); + } + + assert!(workspace_reference_path_matches_registered_root( + ®istered_root, + ®istered_root + )); + assert!(!workspace_reference_path_matches_registered_root( + ®istered_root, + &unrelated_root + )); + assert!(!workspace_reference_path_matches_registered_root( + ®istered_root, + Path::new("/stale/remote/workspace") + )); + } + #[test] fn desktop_snapshot_never_serializes_prompt_templates() { let snapshot: ExternalSourceCatalogSnapshot = serde_json::from_value(serde_json::json!({ diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index d04b4b701..1107fe5b6 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -549,6 +549,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "get_external_source_snapshot", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "get_workspace_reference_snapshot", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "get_native_prompt_command_conflicts_command", RemoteWorkspacePolicy::RemoteUnsupported, @@ -2033,6 +2037,15 @@ mod tests { } } + #[test] + fn workspace_reference_snapshot_explicitly_rejects_remote_workspaces() { + assert_eq!( + remote_workspace_policy("get_workspace_reference_snapshot"), + Some(RemoteWorkspacePolicy::RemoteUnsupported), + "workspace references must never scan controller-local OpenCode config for a remote workspace" + ); + } + #[test] fn external_hook_import_commands_explicitly_reject_remote_workspaces() { for command in [ diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index cdf42d1c3..6e29c8d85 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1176,6 +1176,7 @@ pub async fn run() { apply_external_hook_import_command, mutate_external_hook_import_command, get_external_source_snapshot, + get_workspace_reference_snapshot, plan_external_mcp_import_command, apply_external_mcp_import_command, reveal_external_source_location, diff --git a/src/crates/adapters/opencode-adapter/src/command_source.rs b/src/crates/adapters/opencode-adapter/src/command_source.rs index f89c05ed7..abf090480 100644 --- a/src/crates/adapters/opencode-adapter/src/command_source.rs +++ b/src/crates/adapters/opencode-adapter/src/command_source.rs @@ -1,7 +1,7 @@ use crate::local_source_paths::{ find_project_root, local_watch_roots, ordered_local_config_directories, - project_asset_directories, project_config_directories, user_config_dir, - LocalConfigDirectoryKind, + project_asset_directories, project_config_directories, LocalConfigDirectoryKind, + LocalConfigFileLayer, OpenCodeLocalConfigOptions, }; use bitfun_product_domains::external_sources::{ EcosystemId, ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, @@ -33,38 +33,7 @@ const MAX_COMMAND_TEMPLATE_BYTES: usize = 8 * 1024 * 1024; const MAX_CONFIG_FILE_BYTES: usize = 1024 * 1024; const MAX_EXPANDED_COMMAND_BYTES: usize = 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 = 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 type OpenCodeCommandProviderOptions = OpenCodeLocalConfigOptions; pub struct OpenCodeCommandProvider { options: OpenCodeCommandProviderOptions, @@ -162,14 +131,11 @@ impl OpenCodeCommandProvider { deduplicate_layers_keep_last(layers) } - pub(crate) fn config_file_layers( - &self, - workspace_root: Option<&Path>, - ) -> Vec { + fn config_file_layers(&self, workspace_root: Option<&Path>) -> Vec { self.discover_layers(workspace_root) .into_iter() .filter_map(|layer| match layer.kind { - SourceLayerKind::ConfigFile(path) => Some(OpenCodeConfigFileLayer { + SourceLayerKind::ConfigFile(path) => Some(LocalConfigFileLayer { path, scope: layer.scope, }), @@ -179,6 +145,13 @@ impl OpenCodeCommandProvider { } } +pub(crate) fn command_config_file_layers( + options: &OpenCodeLocalConfigOptions, + workspace_root: Option<&Path>, +) -> Vec { + OpenCodeCommandProvider::new(options.clone()).config_file_layers(workspace_root) +} + impl Default for OpenCodeCommandProvider { fn default() -> Self { Self::new(OpenCodeCommandProviderOptions::default()) @@ -433,12 +406,6 @@ struct SourceLayer { source_kind: &'static str, } -#[derive(Debug, Clone)] -pub(crate) struct OpenCodeConfigFileLayer { - pub(crate) path: PathBuf, - pub(crate) scope: ExternalSourceScope, -} - #[derive(Debug)] enum SourceLayerKind { ConfigFile(PathBuf), @@ -993,12 +960,6 @@ fn command_content_version(name: &str, input: &OpenCodeCommandInput) -> String { format!("sha256:{}", hex::encode(hasher.finalize())) } -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 expand_template(template: &str, arguments: &str) -> String { let args = argument_regex() .find_iter(arguments) diff --git a/src/crates/adapters/opencode-adapter/src/lib.rs b/src/crates/adapters/opencode-adapter/src/lib.rs index 10fa1abca..c22c160ab 100644 --- a/src/crates/adapters/opencode-adapter/src/lib.rs +++ b/src/crates/adapters/opencode-adapter/src/lib.rs @@ -13,6 +13,7 @@ mod hook_source; mod instruction_source; mod local_source_paths; mod mcp_source; +mod reference_source; mod skill_source; mod source_adapter; mod tool_source; @@ -22,6 +23,9 @@ pub use command_source::{OpenCodeCommandProvider, OpenCodeCommandProviderOptions pub use hook_source::{OpenCodeHookProvider, OpenCodeHookProviderOptions}; pub use instruction_source::{load_opencode_user_instructions, OpenCodeInstructionSourceOptions}; pub use mcp_source::{OpenCodeMcpProvider, OpenCodeMcpProviderOptions}; +pub use reference_source::{ + OpenCodeWorkspaceReferenceProvider, OpenCodeWorkspaceReferenceProviderOptions, +}; pub use skill_source::{ OpenCodeConfiguredSkillRoot, OpenCodeSkillRootProvider, OpenCodeSkillRootProviderOptions, }; diff --git a/src/crates/adapters/opencode-adapter/src/local_source_paths.rs b/src/crates/adapters/opencode-adapter/src/local_source_paths.rs index b77c04e98..bf8d2da9a 100644 --- a/src/crates/adapters/opencode-adapter/src/local_source_paths.rs +++ b/src/crates/adapters/opencode-adapter/src/local_source_paths.rs @@ -2,6 +2,43 @@ use bitfun_product_domains::external_sources::{ExternalSourceScope, ExternalWatc use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +#[derive(Debug, Clone)] +pub struct OpenCodeLocalConfigOptions { + 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 OpenCodeLocalConfigOptions { + pub fn from_environment() -> Self { + let home = dirs::home_dir(); + Self { + user_config_dir: user_config_dir( + std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), + home.clone(), + ), + legacy_user_config_dir: home.map(|home| home.join(".opencode")), + 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 OpenCodeLocalConfigOptions { + fn default() -> Self { + Self::from_environment() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LocalConfigFileLayer { + pub(crate) path: PathBuf, + pub(crate) scope: ExternalSourceScope, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum LocalConfigDirectoryKind { User, @@ -157,11 +194,120 @@ fn push_config_directory( }); } -fn path_identity(path: &Path) -> PathBuf { +pub(crate) fn path_identity(path: &Path) -> PathBuf { dunce::canonicalize(path).unwrap_or_else(|_| normalize_path_lexically(path)) } -fn normalize_path_lexically(path: &Path) -> PathBuf { +/// Configuration documents consumed by OpenCode V2 configuration plugins, +/// ordered from lowest to highest priority. `OPENCODE_CONFIG_DIR` replaces +/// the default global root; project files then override global files, and +/// `.opencode` files override direct project files. +pub(crate) fn reference_config_file_layers( + global_config_dir: &Path, + workspace_root: Option<&Path>, +) -> std::io::Result> { + let mut layers = Vec::new(); + push_config_files( + &mut layers, + global_config_dir, + ExternalSourceScope::UserGlobal, + false, + )?; + let project_directories = workspace_root + .map(|workspace| project_config_directories(&find_project_root(workspace), workspace)) + .unwrap_or_default(); + for directory in &project_directories { + push_config_files(&mut layers, directory, ExternalSourceScope::Project, false)?; + } + for directory in &project_directories { + push_config_files( + &mut layers, + &directory.join(".opencode"), + ExternalSourceScope::Project, + false, + )?; + } + Ok(deduplicate_config_file_layers_keep_last(layers)) +} + +pub(crate) fn reference_watch_roots( + global_config_dir: &Path, + workspace_root: Option<&Path>, +) -> Vec { + let mut roots = BTreeMap::new(); + add_directory_watch_roots(&mut roots, global_config_dir); + if let Some(workspace) = workspace_root { + for directory in project_config_directories(&find_project_root(workspace), workspace) { + 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() +} + +fn push_config_files( + layers: &mut Vec, + directory: &Path, + scope: ExternalSourceScope, + include_legacy_config_name: bool, +) -> std::io::Result<()> { + if include_legacy_config_name { + push_config_file(layers, &directory.join("config.json"), scope)?; + } + for name in ["opencode.json", "opencode.jsonc"] { + push_config_file(layers, &directory.join(name), scope)?; + } + Ok(()) +} + +fn push_config_file( + layers: &mut Vec, + path: &Path, + scope: ExternalSourceScope, +) -> std::io::Result<()> { + match config_file_metadata(std::fs::metadata(path))? { + true => { + layers.push(LocalConfigFileLayer { + path: path.to_path_buf(), + scope, + }); + Ok(()) + } + false => Ok(()), + } +} + +fn config_file_metadata(metadata: std::io::Result) -> std::io::Result { + match metadata { + Ok(metadata) => Ok(metadata.is_file()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +fn deduplicate_config_file_layers_keep_last( + layers: Vec, +) -> Vec { + let mut seen = std::collections::BTreeSet::new(); + let mut unique = layers + .into_iter() + .rev() + .filter(|layer| seen.insert(path_identity(&layer.path))) + .collect::>(); + unique.reverse(); + unique +} + +fn environment_truthy(key: &str) -> bool { + std::env::var(key) + .ok() + .is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true")) +} + +pub(crate) fn normalize_path_lexically(path: &Path) -> PathBuf { use std::path::Component; let mut normalized = PathBuf::new(); @@ -221,8 +367,9 @@ fn add_directory_watch_roots(roots: &mut BTreeMap, directory: &Pa #[cfg(test)] mod tests { use super::{ - ordered_local_config_directories, project_asset_directories, project_config_directories, - user_config_dir, LocalConfigDirectory, LocalConfigDirectoryKind, + config_file_metadata, ordered_local_config_directories, project_asset_directories, + project_config_directories, user_config_dir, LocalConfigDirectory, + LocalConfigDirectoryKind, }; use bitfun_product_domains::external_sources::ExternalSourceScope; use std::path::{Path, PathBuf}; @@ -242,6 +389,17 @@ mod tests { ); } + #[test] + fn config_metadata_errors_are_not_treated_as_missing_files() { + let error = config_file_metadata(Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "denied", + ))) + .unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + } + #[test] fn project_config_and_asset_phases_use_distinct_upstream_orders() { let root = Path::new("workspace"); diff --git a/src/crates/adapters/opencode-adapter/src/reference_source.rs b/src/crates/adapters/opencode-adapter/src/reference_source.rs new file mode 100644 index 000000000..b063a79b3 --- /dev/null +++ b/src/crates/adapters/opencode-adapter/src/reference_source.rs @@ -0,0 +1,611 @@ +use crate::command_source::strip_jsonc; +use crate::local_source_paths::{ + normalize_path_lexically, path_identity, reference_config_file_layers, reference_watch_roots, + user_config_dir, +}; +use bitfun_product_domains::external_sources::{ + EcosystemId, ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, + ExternalSourceDiagnosticSeverity, ExternalSourceHealth, ExternalSourceProviderError, + ExternalSourceRecord, ExternalSourceScope, ExternalWatchRoot, SourceKey, +}; +use bitfun_product_domains::workspace_references::{ + ExternalWorkspaceReferenceDefinition, ExternalWorkspaceReferenceProviderIdentity, + ExternalWorkspaceReferenceProviderSnapshot, ExternalWorkspaceReferenceSourceProvider, +}; +use bitfun_services_core::bounded_fs::{read_bounded_text, BoundedTextRead}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +const PROVIDER_ID: &str = "opencode.references"; +const ECOSYSTEM_ID: &str = "opencode"; +const MAX_CONFIG_FILE_BYTES: usize = 1024 * 1024; +const MAX_REFERENCES: usize = 1024; +const MAX_DIAGNOSTICS: usize = 256; + +#[derive(Debug, Clone)] +pub struct OpenCodeWorkspaceReferenceProviderOptions { + pub global_config_dir: PathBuf, + pub home_dir: Option, +} + +impl OpenCodeWorkspaceReferenceProviderOptions { + pub fn from_environment() -> Self { + let home_dir = dirs::home_dir(); + Self { + global_config_dir: std::env::var_os("OPENCODE_CONFIG_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| { + user_config_dir( + std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), + home_dir.clone(), + ) + }), + home_dir, + } + } +} + +impl Default for OpenCodeWorkspaceReferenceProviderOptions { + fn default() -> Self { + Self::from_environment() + } +} + +pub struct OpenCodeWorkspaceReferenceProvider { + options: OpenCodeWorkspaceReferenceProviderOptions, +} + +struct EffectiveReference { + precedence: usize, + definition: ExternalWorkspaceReferenceDefinition, +} + +impl OpenCodeWorkspaceReferenceProvider { + pub fn new(options: OpenCodeWorkspaceReferenceProviderOptions) -> Self { + Self { options } + } +} + +impl Default for OpenCodeWorkspaceReferenceProvider { + fn default() -> Self { + Self::new(OpenCodeWorkspaceReferenceProviderOptions::default()) + } +} + +impl ExternalWorkspaceReferenceSourceProvider for OpenCodeWorkspaceReferenceProvider { + fn identity(&self) -> ExternalWorkspaceReferenceProviderIdentity { + ExternalWorkspaceReferenceProviderIdentity::new(PROVIDER_ID, ECOSYSTEM_ID, "OpenCode") + .expect("static OpenCode reference provider identity must be valid") + } + + fn discover( + &self, + context: &ExternalSourceContext, + ) -> Result { + if !self.options.global_config_dir.is_absolute() { + return Err(ExternalSourceProviderError::new( + "opencode.reference.global_config_invalid", + "OpenCode global configuration root must be absolute", + false, + )); + } + if context + .workspace_root + .as_ref() + .is_some_and(|workspace_root| !workspace_root.is_absolute()) + { + return Err(ExternalSourceProviderError::new( + "opencode.reference.workspace_invalid", + "workspace root must be absolute", + false, + )); + } + + let mut sources = Vec::new(); + let mut diagnostics = Vec::new(); + let mut dropped_diagnostics = 0usize; + let mut effective = BTreeMap::::new(); + let mut precedence = 0usize; + + let layers = reference_config_file_layers( + &self.options.global_config_dir, + context.workspace_root.as_deref(), + ) + .map_err(|_| { + ExternalSourceProviderError::new( + "opencode.reference.config_unreadable", + "OpenCode reference configuration metadata could not be read", + true, + ) + })?; + for layer in layers { + let parsed = read_reference_document(&layer.path); + let (content, entries) = match parsed { + Ok(Some(parsed)) => parsed, + Ok(None) => continue, + Err(ReferenceDocumentReadError::Diagnostic(diagnostic)) => { + push_bounded_diagnostic(&mut diagnostics, &mut dropped_diagnostics, diagnostic); + continue; + } + Err(ReferenceDocumentReadError::TransientIo) => { + return Err(ExternalSourceProviderError::new( + "opencode.reference.config_unreadable", + "OpenCode reference configuration could not be read", + true, + )); + } + }; + let source_key = source_key(&layer.path); + let mut source_diagnostics = Vec::new(); + let source_dropped_before = dropped_diagnostics; + for (alias, entry) in entries { + let current_precedence = precedence; + precedence = precedence.saturating_add(1); + if !valid_alias(&alias) { + push_source_diagnostic( + &mut diagnostics, + &mut source_diagnostics, + &mut dropped_diagnostics, + reference_diagnostic( + ExternalSourceDiagnosticSeverity::Warning, + "opencode.reference.alias_invalid", + format!( + "OpenCode reference alias '{}' is invalid", + diagnostic_alias(&alias) + ), + Some(source_key.clone()), + ), + ); + continue; + } + match parse_entry(&entry) { + Ok(ParsedEntry::Local { + path, + description, + hidden, + }) => { + let Some(path) = resolve_local_path( + &path, + layer.path.parent().unwrap_or_else(|| Path::new(".")), + self.options.home_dir.as_deref(), + ) else { + effective.remove(&alias); + push_source_diagnostic( + &mut diagnostics, + &mut source_diagnostics, + &mut dropped_diagnostics, + reference_diagnostic( + ExternalSourceDiagnosticSeverity::Warning, + "opencode.reference.path_invalid", + format!( + "OpenCode reference '{alias}' has an invalid local path" + ), + Some(source_key.clone()), + ), + ); + continue; + }; + // OpenCode references are declarative paths. Resolve an + // existing target for stable identity, but retain a + // lexical absolute path when it has not been created + // yet. Catalog discovery never grants filesystem access. + let path = path_identity(&path); + let definition = ExternalWorkspaceReferenceDefinition { + source: source_key.clone(), + alias: alias.clone(), + content_version: reference_version( + &alias, + &path, + description.as_deref(), + hidden, + ), + path, + description, + hidden, + }; + effective.insert( + alias, + EffectiveReference { + precedence: current_precedence, + definition, + }, + ); + } + Ok(ParsedEntry::Git) => { + effective.remove(&alias); + push_source_diagnostic( + &mut diagnostics, + &mut source_diagnostics, + &mut dropped_diagnostics, + reference_diagnostic( + ExternalSourceDiagnosticSeverity::Info, + "opencode.reference.git_unsupported", + format!( + "OpenCode Git reference '{alias}' is not available in this release" + ), + Some(source_key.clone()), + ), + ); + } + Err(reason) => { + effective.remove(&alias); + push_source_diagnostic( + &mut diagnostics, + &mut source_diagnostics, + &mut dropped_diagnostics, + reference_diagnostic( + ExternalSourceDiagnosticSeverity::Warning, + "opencode.reference.entry_invalid", + format!("OpenCode reference '{alias}' is invalid: {reason}"), + Some(source_key.clone()), + ), + ); + } + } + } + + sources.push(ExternalSourceRecord { + key: source_key, + ecosystem_id: EcosystemId::new(ECOSYSTEM_ID) + .expect("static OpenCode ecosystem id must be valid"), + display_name: source_display_name(layer.scope).to_string(), + source_kind: "opencode_config".to_string(), + scope: layer.scope, + location: layer.path.to_string_lossy().to_string(), + execution_domain_id: context.execution_domain_id.clone(), + health: if source_diagnostics.is_empty() + && dropped_diagnostics == source_dropped_before + { + ExternalSourceHealth::Available + } else { + ExternalSourceHealth::Partial + }, + content_version: content_version(&content), + diagnostics: source_diagnostics, + }); + } + + let dropped_reference_count = effective.len().saturating_sub(MAX_REFERENCES); + let mut references = effective.into_values().collect::>(); + references.sort_by(|left, right| { + right + .precedence + .cmp(&left.precedence) + .then(left.definition.alias.cmp(&right.definition.alias)) + }); + references.truncate(MAX_REFERENCES); + references.sort_by(|left, right| left.definition.alias.cmp(&right.definition.alias)); + if dropped_reference_count > 0 { + push_bounded_diagnostic( + &mut diagnostics, + &mut dropped_diagnostics, + reference_diagnostic( + ExternalSourceDiagnosticSeverity::Warning, + "opencode.reference.limit", + format!( + "OpenCode reference limit of {MAX_REFERENCES} retained the highest-priority declarations and omitted {dropped_reference_count} lower-priority aliases" + ), + None, + ), + ); + } + finish_bounded_diagnostics(&mut diagnostics, dropped_diagnostics); + + let snapshot = ExternalWorkspaceReferenceProviderSnapshot { + provider: self.identity(), + sources, + references: references + .into_iter() + .map(|reference| reference.definition) + .collect(), + diagnostics, + }; + snapshot.validate().map_err(|error| { + ExternalSourceProviderError::new( + "opencode.reference.snapshot_invalid", + error.to_string(), + false, + ) + })?; + Ok(snapshot) + } + + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { + if !self.options.global_config_dir.is_absolute() { + return Vec::new(); + } + reference_watch_roots( + &self.options.global_config_dir, + context.workspace_root.as_deref(), + ) + } +} + +enum ParsedEntry { + Local { + path: String, + description: Option, + hidden: bool, + }, + Git, +} + +enum ReferenceDocumentReadError { + Diagnostic(ExternalSourceDiagnostic), + TransientIo, +} + +fn read_reference_document( + path: &Path, +) -> Result)>, ReferenceDocumentReadError> { + let content = match read_bounded_text(path, MAX_CONFIG_FILE_BYTES) { + Ok(BoundedTextRead::Content(content)) => content, + Ok(BoundedTextRead::TooLarge) => { + return Err(ReferenceDocumentReadError::Diagnostic( + reference_diagnostic( + ExternalSourceDiagnosticSeverity::Warning, + "opencode.reference.config_too_large", + "OpenCode reference configuration exceeds the size limit".to_string(), + None, + ), + )); + } + Ok(BoundedTextRead::InvalidUtf8) => { + return Err(ReferenceDocumentReadError::Diagnostic( + reference_diagnostic( + ExternalSourceDiagnosticSeverity::Warning, + "opencode.reference.config_invalid_utf8", + "OpenCode reference configuration is not UTF-8".to_string(), + None, + ), + )); + } + Err(_) => return Err(ReferenceDocumentReadError::TransientIo), + }; + let document = serde_json::from_str::(&strip_jsonc(&content)).map_err(|_| { + ReferenceDocumentReadError::Diagnostic(reference_diagnostic( + ExternalSourceDiagnosticSeverity::Warning, + "opencode.reference.config_invalid", + "OpenCode reference configuration is invalid".to_string(), + None, + )) + })?; + let Some(entries) = document + .get("references") + .or_else(|| document.get("reference")) + else { + return Ok(None); + }; + let entries = entries.as_object().cloned().ok_or_else(|| { + ReferenceDocumentReadError::Diagnostic(reference_diagnostic( + ExternalSourceDiagnosticSeverity::Warning, + "opencode.reference.config_invalid", + "OpenCode references must be an object".to_string(), + None, + )) + })?; + Ok(Some((content, entries))) +} + +fn parse_entry(value: &Value) -> Result { + match value { + Value::String(value) => { + if value.starts_with('.') || value.starts_with('/') || value.starts_with('~') { + Ok(ParsedEntry::Local { + path: value.clone(), + description: None, + hidden: false, + }) + } else { + Ok(ParsedEntry::Git) + } + } + Value::Object(entry) if entry.contains_key("path") && !entry.contains_key("repository") => { + let path = entry + .get("path") + .and_then(Value::as_str) + .ok_or("path must be a string")? + .to_string(); + let description = optional_string(entry.get("description"), "description")?; + let hidden = optional_bool(entry.get("hidden"), "hidden")?.unwrap_or(false); + Ok(ParsedEntry::Local { + path, + description: description.filter(|description| !description.is_empty()), + hidden, + }) + } + Value::Object(entry) if entry.contains_key("repository") && !entry.contains_key("path") => { + if entry.get("repository").and_then(Value::as_str).is_none() { + return Err("repository must be a string"); + } + optional_string(entry.get("branch"), "branch")?; + optional_string(entry.get("description"), "description")?; + optional_bool(entry.get("hidden"), "hidden")?; + Ok(ParsedEntry::Git) + } + Value::Object(_) => Err("entry must contain exactly one of path or repository"), + _ => Err("entry must be a string or object"), + } +} + +fn optional_string( + value: Option<&Value>, + _field: &'static str, +) -> Result, &'static str> { + match value { + None => Ok(None), + Some(value) => value + .as_str() + .map(|value| Some(value.to_string())) + .ok_or("optional text field must be a string"), + } +} + +fn optional_bool( + value: Option<&Value>, + _field: &'static str, +) -> Result, &'static str> { + match value { + None => Ok(None), + Some(value) => value + .as_bool() + .map(Some) + .ok_or("optional boolean field must be a boolean"), + } +} + +fn resolve_local_path( + value: &str, + config_directory: &Path, + home: Option<&Path>, +) -> Option { + if value.is_empty() || value.contains('\0') { + return None; + } + if let Some(relative) = value + .strip_prefix("~/") + .or_else(|| value.strip_prefix("~\\")) + { + return home + .filter(|home| home.is_absolute()) + .map(|home| normalize_path_lexically(&home.join(relative))); + } + let path = PathBuf::from(value); + if path.is_absolute() { + Some(normalize_path_lexically(&path)) + } else { + Some(normalize_path_lexically(&config_directory.join(path))) + } +} + +fn valid_alias(alias: &str) -> bool { + !alias.is_empty() + && alias.len() <= 160 + && !alias.chars().any(|character| { + character == '/' || character.is_whitespace() || matches!(character, '`' | ',') + }) +} + +fn source_key(path: &Path) -> SourceKey { + let identity = path_identity(path); + let digest = content_version(identity.to_string_lossy().as_ref()); + SourceKey::new(PROVIDER_ID, format!("opencode-config-{}", &digest[..24])) + .expect("hashed OpenCode reference source id must be valid") +} + +fn source_display_name(scope: ExternalSourceScope) -> &'static str { + match scope { + ExternalSourceScope::UserGlobal => "OpenCode user references", + ExternalSourceScope::Project => "OpenCode project references", + ExternalSourceScope::WorkspaceLocal => "OpenCode workspace references", + ExternalSourceScope::RemoteUser | ExternalSourceScope::RemoteProject => { + "OpenCode remote references" + } + _ => "OpenCode references", + } +} + +fn content_version(content: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + hex::encode(hasher.finalize()) +} + +fn reference_version(alias: &str, path: &Path, description: Option<&str>, hidden: bool) -> String { + let mut hasher = Sha256::new(); + for part in [ + alias, + path.to_string_lossy().as_ref(), + description.unwrap_or(""), + ] { + hasher.update(part.as_bytes()); + hasher.update([0]); + } + hasher.update([u8::from(hidden)]); + hex::encode(hasher.finalize()) +} + +fn reference_diagnostic( + severity: ExternalSourceDiagnosticSeverity, + code: impl Into, + message: String, + source: Option, +) -> ExternalSourceDiagnostic { + ExternalSourceDiagnostic { + severity, + asset_kind: ExternalSourceAssetKind::Reference, + code: code.into(), + message, + source, + } +} + +fn push_source_diagnostic( + diagnostics: &mut Vec, + source_diagnostics: &mut Vec, + dropped: &mut usize, + diagnostic: ExternalSourceDiagnostic, +) { + if push_bounded_diagnostic(diagnostics, dropped, diagnostic.clone()) { + source_diagnostics.push(diagnostic); + } +} + +fn push_bounded_diagnostic( + diagnostics: &mut Vec, + dropped: &mut usize, + diagnostic: ExternalSourceDiagnostic, +) -> bool { + if diagnostics.len() < MAX_DIAGNOSTICS.saturating_sub(1) { + diagnostics.push(diagnostic); + true + } else { + *dropped = dropped.saturating_add(1); + false + } +} + +fn finish_bounded_diagnostics(diagnostics: &mut Vec, dropped: usize) { + if dropped == 0 { + return; + } + diagnostics.push(reference_diagnostic( + ExternalSourceDiagnosticSeverity::Warning, + "opencode.reference.diagnostic_limit", + format!( + "OpenCode reference diagnostics were limited to {MAX_DIAGNOSTICS}; {dropped} additional diagnostics were omitted" + ), + None, + )); +} + +fn diagnostic_alias(alias: &str) -> String { + const MAX_ALIAS_CHARS: usize = 160; + let mut characters = alias.chars(); + let prefix = characters + .by_ref() + .take(MAX_ALIAS_CHARS) + .collect::(); + if characters.next().is_some() { + format!("{prefix}...") + } else { + prefix + } +} + +#[cfg(test)] +mod tests { + use super::{read_reference_document, ReferenceDocumentReadError}; + + #[test] + fn unreadable_configuration_is_classified_as_transient() { + let directory = tempfile::TempDir::new().unwrap(); + + assert!(matches!( + read_reference_document(directory.path()), + Err(ReferenceDocumentReadError::TransientIo) + )); + } +} diff --git a/src/crates/adapters/opencode-adapter/src/skill_source.rs b/src/crates/adapters/opencode-adapter/src/skill_source.rs index 2007b3c86..26e816bbe 100644 --- a/src/crates/adapters/opencode-adapter/src/skill_source.rs +++ b/src/crates/adapters/opencode-adapter/src/skill_source.rs @@ -1,5 +1,5 @@ -use crate::command_source::{strip_jsonc, OpenCodeCommandProvider, OpenCodeCommandProviderOptions}; -use crate::local_source_paths::find_project_root; +use crate::command_source::{command_config_file_layers, strip_jsonc}; +use crate::local_source_paths::{find_project_root, OpenCodeLocalConfigOptions}; use bitfun_product_domains::external_sources::ExternalSourceScope; use bitfun_services_core::bounded_fs::{read_bounded_text, BoundedTextRead}; use serde_json::Value; @@ -11,14 +11,14 @@ const MAX_CONFIGURED_SKILL_ROOTS: usize = 64; #[derive(Debug, Clone)] pub struct OpenCodeSkillRootProviderOptions { - pub command: OpenCodeCommandProviderOptions, + pub config: OpenCodeLocalConfigOptions, pub home_dir: Option, } impl OpenCodeSkillRootProviderOptions { pub fn from_environment() -> Self { Self { - command: OpenCodeCommandProviderOptions::from_environment(), + config: OpenCodeLocalConfigOptions::from_environment(), home_dir: dirs::home_dir(), } } @@ -38,14 +38,14 @@ pub struct OpenCodeConfiguredSkillRoot { } pub struct OpenCodeSkillRootProvider { - command_provider: OpenCodeCommandProvider, + config: OpenCodeLocalConfigOptions, home_dir: Option, } impl OpenCodeSkillRootProvider { pub fn new(options: OpenCodeSkillRootProviderOptions) -> Self { Self { - command_provider: OpenCodeCommandProvider::new(options.command), + config: options.config, home_dir: options.home_dir, } } @@ -61,7 +61,7 @@ impl OpenCodeSkillRootProvider { let mut configured_paths = Vec::new(); let mut precedence = 0usize; - for layer in self.command_provider.config_file_layers(workspace_root) { + for layer in command_config_file_layers(&self.config, workspace_root) { let Some(paths) = read_local_skill_paths(&layer.path) else { continue; }; diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_skill_roots.rs b/src/crates/adapters/opencode-adapter/tests/opencode_skill_roots.rs index a560f7188..bbd3b9a93 100644 --- a/src/crates/adapters/opencode-adapter/tests/opencode_skill_roots.rs +++ b/src/crates/adapters/opencode-adapter/tests/opencode_skill_roots.rs @@ -34,7 +34,7 @@ impl Fixture { fn provider(&self) -> OpenCodeSkillRootProvider { OpenCodeSkillRootProvider::new(OpenCodeSkillRootProviderOptions { - command: OpenCodeCommandProviderOptions { + config: OpenCodeCommandProviderOptions { user_config_dir: self.user_config.clone(), legacy_user_config_dir: Some(self.home.join(".opencode")), explicit_config_file: None, diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_workspace_references.rs b/src/crates/adapters/opencode-adapter/tests/opencode_workspace_references.rs new file mode 100644 index 000000000..aacb282aa --- /dev/null +++ b/src/crates/adapters/opencode-adapter/tests/opencode_workspace_references.rs @@ -0,0 +1,322 @@ +use bitfun_opencode_adapter::{ + OpenCodeWorkspaceReferenceProvider, OpenCodeWorkspaceReferenceProviderOptions, +}; +use bitfun_product_domains::external_sources::{ExecutionDomainId, ExternalSourceContext}; +use bitfun_product_domains::workspace_references::ExternalWorkspaceReferenceSourceProvider; +use std::fs; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +struct Fixture { + _temp: TempDir, + home: PathBuf, + user_config: PathBuf, + project: PathBuf, + opened: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let temp = TempDir::new().unwrap(); + let home = temp.path().join("home"); + let user_config = home.join(".config/opencode"); + let project = temp.path().join("project"); + let opened = project.join("packages/app"); + fs::create_dir_all(project.join(".git")).unwrap(); + fs::create_dir_all(&opened).unwrap(); + fs::create_dir_all(&user_config).unwrap(); + Self { + _temp: temp, + home, + user_config, + project, + opened, + } + } + + fn provider(&self) -> OpenCodeWorkspaceReferenceProvider { + self.provider_with_global_config_dir(self.user_config.clone()) + } + + fn provider_with_global_config_dir( + &self, + global_config_dir: PathBuf, + ) -> OpenCodeWorkspaceReferenceProvider { + OpenCodeWorkspaceReferenceProvider::new(OpenCodeWorkspaceReferenceProviderOptions { + global_config_dir, + home_dir: Some(self.home.clone()), + }) + } + + fn context(&self) -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(self.opened.clone()), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } + } + + fn write_config(&self, path: impl AsRef, body: &str) { + let path = path.as_ref(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, body).unwrap(); + } +} + +#[test] +fn nearest_project_reference_layer_wins_without_dropping_other_aliases() { + let fixture = Fixture::new(); + let user_dir = fixture.home.join("user-docs"); + let outer_dir = fixture.project.join("outer-docs"); + let inner_dir = fixture.opened.join("inner-docs"); + let shared_dir = fixture.project.join("shared-specs"); + for path in [&user_dir, &outer_dir, &inner_dir, &shared_dir] { + fs::create_dir_all(path).unwrap(); + } + + fixture.write_config( + fixture.user_config.join("opencode.json"), + r#"{"references":{"docs":{"path":"../../../user-docs","description":"user"}}}"#, + ); + fixture.write_config( + fixture.project.join("opencode.json"), + r#"{"references":{"docs":{"path":"./outer-docs","description":"outer"},"specs":{"path":"./shared-specs"}}}"#, + ); + fixture.write_config( + fixture.opened.join(".opencode/opencode.jsonc"), + r#"{ + // the closest .opencode layer has the highest priority + "references": {"docs": {"path": "../inner-docs", "description": "inner", "hidden": true}}, + }"#, + ); + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + snapshot.validate().unwrap(); + + assert_eq!(snapshot.references.len(), 2); + let docs = snapshot + .references + .iter() + .find(|reference| reference.alias == "docs") + .unwrap(); + assert_eq!(docs.path, dunce::canonicalize(inner_dir).unwrap()); + assert_eq!(docs.description.as_deref(), Some("inner")); + assert!(docs.hidden); + + let specs = snapshot + .references + .iter() + .find(|reference| reference.alias == "specs") + .unwrap(); + assert_eq!(specs.path, dunce::canonicalize(shared_dir).unwrap()); +} + +#[test] +fn keeps_alias_identity_and_reports_invalid_or_git_entries_without_materializing_them() { + let fixture = Fixture::new(); + let shared = fixture.project.join("shared"); + fs::create_dir_all(&shared).unwrap(); + fixture.write_config( + fixture.opened.join("opencode.json"), + r#"{ + "references": { + "docs": {"path": "../../shared"}, + "specs": {"path": "../../shared"}, + "bad alias": {"path": "../../shared"}, + "remote": {"repository": "https://example.test/docs.git", "branch": "main"}, + "bare": "github.com/example/docs" + } + }"#, + ); + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + + assert_eq!( + snapshot + .references + .iter() + .map(|reference| reference.alias.as_str()) + .collect::>(), + vec!["docs", "specs"] + ); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "opencode.reference.alias_invalid")); + assert_eq!( + snapshot + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.code == "opencode.reference.git_unsupported") + .count(), + 2 + ); +} + +#[test] +fn legacy_reference_field_and_home_relative_paths_follow_opencode_migration_rules() { + let fixture = Fixture::new(); + let handbook = fixture.home.join("handbook"); + fs::create_dir_all(&handbook).unwrap(); + fixture.write_config( + fixture.opened.join("opencode.json"), + r#"{"reference":{"handbook":{"path":"~/handbook","description":"Team handbook"}}}"#, + ); + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + + assert_eq!(snapshot.references.len(), 1); + assert_eq!(snapshot.references[0].alias, "handbook"); + assert_eq!( + snapshot.references[0].path, + dunce::canonicalize(handbook).unwrap() + ); +} + +#[test] +fn missing_local_directories_remain_declarative_and_config_roots_are_watched() { + let fixture = Fixture::new(); + fixture.write_config( + fixture.opened.join("opencode.json"), + r#"{"references":{"missing":{"path":"./missing"}}}"#, + ); + let provider = fixture.provider(); + let context = fixture.context(); + + let snapshot = provider.discover(&context).unwrap(); + let roots = provider.watch_roots(&context); + + assert_eq!(snapshot.references.len(), 1); + assert_eq!(snapshot.references[0].path, fixture.opened.join("missing")); + assert!(roots + .iter() + .any(|root| root.path == fixture.opened.join(".opencode") && root.recursive)); + assert!(roots + .iter() + .any(|root| root.path == fixture.user_config && root.recursive)); +} + +#[test] +fn explicit_global_directory_replaces_the_default_global_root() { + let fixture = Fixture::new(); + let explicit_global = fixture.home.join("alternate-opencode"); + fixture.write_config( + fixture.user_config.join("opencode.json"), + r#"{"references":{"default-only":{"path":"./default"}}}"#, + ); + fixture.write_config( + explicit_global.join("opencode.json"), + r#"{"references":{"explicit-only":{"path":"./explicit"}}}"#, + ); + + let snapshot = fixture + .provider_with_global_config_dir(explicit_global) + .discover(&fixture.context()) + .unwrap(); + + assert!(snapshot + .references + .iter() + .any(|reference| reference.alias == "explicit-only")); + assert!(!snapshot + .references + .iter() + .any(|reference| reference.alias == "default-only")); +} + +#[test] +fn relative_global_configuration_roots_fail_closed() { + let fixture = Fixture::new(); + let provider = fixture.provider_with_global_config_dir(PathBuf::from("relative-opencode")); + + let error = provider.discover(&fixture.context()).unwrap_err(); + + assert_eq!(error.code, "opencode.reference.global_config_invalid"); + assert!(!error.transient); + assert!(provider.watch_roots(&fixture.context()).is_empty()); +} + +#[test] +fn bounded_catalog_retains_a_new_alias_from_the_highest_priority_layer() { + let fixture = Fixture::new(); + let references = (0..1024) + .map(|index| { + ( + format!("low-{index:04}"), + serde_json::json!({ "path": format!("./missing-{index:04}") }), + ) + }) + .collect::>(); + fixture.write_config( + fixture.user_config.join("opencode.json"), + &serde_json::json!({ "references": references }).to_string(), + ); + fixture.write_config( + fixture.opened.join(".opencode/opencode.json"), + r#"{"references":{"highest":{"path":"./future"}}}"#, + ); + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + + assert_eq!(snapshot.references.len(), 1024); + assert!(snapshot + .references + .iter() + .any(|reference| reference.alias == "highest")); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "opencode.reference.limit")); +} + +#[test] +fn unsupported_higher_priority_git_reference_does_not_reveal_a_lower_local_alias() { + let fixture = Fixture::new(); + let local_docs = fixture.home.join("local-docs"); + fs::create_dir_all(&local_docs).unwrap(); + fixture.write_config( + fixture.user_config.join("opencode.json"), + r#"{"references":{"docs":{"path":"../../../local-docs"}}}"#, + ); + fixture.write_config( + fixture.opened.join(".opencode/opencode.json"), + r#"{"references":{"docs":{"repository":"https://example.test/docs.git"}}}"#, + ); + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + + assert!(snapshot.references.is_empty()); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| { diagnostic.code == "opencode.reference.git_unsupported" })); +} + +#[test] +fn invalid_reference_diagnostics_are_bounded_and_summarized() { + let fixture = Fixture::new(); + let references = (0..400) + .map(|index| { + ( + format!("invalid alias {index:04}"), + serde_json::json!({ "path": "./future" }), + ) + }) + .collect::>(); + fixture.write_config( + fixture.opened.join("opencode.json"), + &serde_json::json!({ "references": references }).to_string(), + ); + + let snapshot = fixture.provider().discover(&fixture.context()).unwrap(); + + assert_eq!(snapshot.diagnostics.len(), 256); + assert_eq!(snapshot.sources[0].diagnostics.len(), 255); + assert_eq!( + snapshot.sources[0].health, + bitfun_product_domains::external_sources::ExternalSourceHealth::Partial + ); + assert_eq!( + snapshot.diagnostics.last().unwrap().code, + "opencode.reference.diagnostic_limit" + ); +} diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 6b0b6b979..c13ac866e 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -59,11 +59,11 @@ use bitfun_codex_adapter::{CodexMcpProvider, CodexSubagentProvider}; use bitfun_external_sources::{ DeferredDiscovery, ExternalMcpDiscoveryResult, ExternalSourceControlPlane, ExternalSourceCoordinator, ExternalSourceDiscoveryResult, ExternalSubagentDiscoveryResult, - ExternalToolDiscoveryResult, + ExternalToolDiscoveryResult, ExternalWorkspaceReferenceDiscoveryResult, }; use bitfun_opencode_adapter::{ OpenCodeCommandProvider, OpenCodeMcpProvider, OpenCodeSkillRootProvider, - OpenCodeSubagentProvider, OpenCodeToolProvider, + OpenCodeSubagentProvider, OpenCodeToolProvider, OpenCodeWorkspaceReferenceProvider, }; #[cfg(test)] use bitfun_opencode_adapter::{ @@ -82,6 +82,10 @@ use bitfun_product_domains::external_sources::{ PromptCommandSourceProvider, }; use bitfun_product_domains::external_subagents::ExternalSubagentSourceProvider; +use bitfun_product_domains::workspace_references::{ + ExternalWorkspaceReferenceSourceProvider, WorkspaceReferenceCatalogEntry, + WorkspaceReferenceOrigin, WorkspaceReferenceSnapshot, +}; use bitfun_services_core::json_store::JsonFileStore; use bitfun_services_core::workspace_text::read_workspace_relative_text_bounded; use bitfun_services_integrations::file_watch::{FileWatchService, FileWatcherConfig}; @@ -106,6 +110,7 @@ pub const EXTERNAL_CAPABILITY_COMMAND: &str = "command"; pub const EXTERNAL_CAPABILITY_TOOL: &str = "tool"; pub const EXTERNAL_CAPABILITY_SUBAGENT: &str = "subagent"; pub const EXTERNAL_CAPABILITY_MCP: &str = "mcp"; +pub const EXTERNAL_CAPABILITY_REFERENCE: &str = "reference"; const EXTERNAL_ADAPTER_CONTRACT_MAJOR: u32 = 1; const MAX_PROMPT_COMMAND_FILE_REFERENCES: usize = 8; const MAX_PROMPT_COMMAND_FILE_BYTES: usize = 64 * 1024; @@ -252,6 +257,7 @@ struct ExternalEcosystemRegistration { tool_provider: Option>, subagent_provider: Option>, mcp_provider: Option>, + workspace_reference_provider: Option>, } impl ExternalEcosystemRegistration { @@ -294,6 +300,12 @@ impl ExternalEcosystemRegistration { .as_ref() .map(|provider| provider.identity().ecosystem_id), ), + ( + EXTERNAL_CAPABILITY_REFERENCE, + self.workspace_reference_provider + .as_ref() + .map(|provider| provider.identity().ecosystem_id), + ), ]; for (capability_id, provider_ecosystem) in providers { if capabilities.contains(capability_id) != provider_ecosystem.is_some() { @@ -343,6 +355,11 @@ fn default_external_integration_registry() -> Vec ExternalIntegrationAccess::AskBeforeUse, ExternalIntegrationAccess::AskBeforeUse, ), + external_capability_descriptor( + EXTERNAL_CAPABILITY_REFERENCE, + ExternalIntegrationAccess::Auto, + ExternalIntegrationAccess::Auto, + ), ], }, contract_major: EXTERNAL_ADAPTER_CONTRACT_MAJOR, @@ -351,6 +368,9 @@ fn default_external_integration_registry() -> Vec tool_provider: Some(Arc::new(OpenCodeToolProvider::default())), subagent_provider: Some(Arc::new(OpenCodeSubagentProvider::default())), mcp_provider: Some(Arc::new(OpenCodeMcpProvider::default())), + workspace_reference_provider: Some(Arc::new( + OpenCodeWorkspaceReferenceProvider::default(), + )), }, ExternalEcosystemRegistration { descriptor: ExternalIntegrationEcosystemDescriptor { @@ -382,6 +402,7 @@ fn default_external_integration_registry() -> Vec tool_provider: None, subagent_provider: Some(Arc::new(ClaudeCodeSubagentProvider::default())), mcp_provider: Some(Arc::new(ClaudeCodeMcpProvider::default())), + workspace_reference_provider: None, }, ExternalEcosystemRegistration { descriptor: ExternalIntegrationEcosystemDescriptor { @@ -408,6 +429,7 @@ fn default_external_integration_registry() -> Vec tool_provider: None, subagent_provider: Some(Arc::new(CodexSubagentProvider::default())), mcp_provider: Some(Arc::new(CodexMcpProvider::default())), + workspace_reference_provider: None, }, ] } @@ -984,6 +1006,16 @@ impl WorkspaceExternalSourceService { .iter() .filter_map(|registration| registration.mcp_provider.as_ref().map(Arc::clone)) .collect(); + let workspace_reference_providers: Vec> = + registrations + .iter() + .filter_map(|registration| { + registration + .workspace_reference_provider + .as_ref() + .map(Arc::clone) + }) + .collect(); let (preferences, mcp_revision_key) = external_sources_config_with_mcp_revision_key().await?; let control_plane = Arc::new(ExternalSourceControlPlane::new( @@ -993,6 +1025,7 @@ impl WorkspaceExternalSourceService { tool_providers, subagent_providers, mcp_providers, + workspace_reference_providers, )?); let suppressed_sources = preferences .suppressed_source_keys @@ -1056,6 +1089,57 @@ impl WorkspaceExternalSourceService { .await } + async fn refresh_workspace_references(self: &Arc, force: bool) -> Result<(), String> { + sync_service_preferences(self).await?; + let _refresh_guard = self.refresh_gate.lock().await; + if !force + && !lock_workspace_reference_coordinator(&self.control_plane) + .snapshot() + .discovery_pending + { + return Ok(()); + } + let preferences = read_external_sources_config().await?; + let policy = integration_policy_snapshot(&preferences, self.workspace_root.as_deref())?; + let mut requests = Vec::new(); + let mut disabled_results = Vec::new(); + for request in + lock_workspace_reference_coordinator(&self.control_plane).discovery_requests() + { + if integration_capability_is_discoverable( + &policy, + request.ecosystem_id().as_str(), + EXTERNAL_CAPABILITY_REFERENCE, + ) { + requests.push(request); + } else { + disabled_results.push(request.disabled()); + } + } + let mut batch = self + .control_plane + .discover_workspace_references(requests, PROVIDER_DISCOVERY_TIMEOUT) + .await; + batch.immediate.append(&mut disabled_results); + lock_workspace_reference_coordinator(&self.control_plane) + .apply_discovery_results(batch.immediate); + for deferred in batch.deferred { + self.schedule_deferred_workspace_reference_discovery(deferred); + } + self.ensure_watch_roots(&policy).await; + Ok(()) + } + + async fn ensure_workspace_reference_refresh(self: &Arc) -> Result<(), String> { + let pending = lock_workspace_reference_coordinator(&self.control_plane) + .snapshot() + .discovery_pending; + if pending { + self.refresh_workspace_references(false).await?; + } + Ok(()) + } + async fn refresh_with_runtime_invalidation( self: &Arc, ) -> Result { @@ -1157,7 +1241,22 @@ impl WorkspaceExternalSourceService { disabled_mcp_results.push(request.disabled()); } } - let (command_batch, tool_batch, subagent_batch, mcp_batch) = tokio::join!( + let mut workspace_reference_requests = Vec::new(); + let mut disabled_workspace_reference_results = Vec::new(); + for request in + lock_workspace_reference_coordinator(&self.control_plane).discovery_requests() + { + if integration_capability_is_discoverable( + &policy, + request.ecosystem_id().as_str(), + EXTERNAL_CAPABILITY_REFERENCE, + ) { + workspace_reference_requests.push(request); + } else { + disabled_workspace_reference_results.push(request.disabled()); + } + } + let (command_batch, tool_batch, subagent_batch, mcp_batch, workspace_reference_batch) = tokio::join!( self.control_plane .discover_commands(requests, PROVIDER_DISCOVERY_TIMEOUT), self.control_plane @@ -1166,6 +1265,10 @@ impl WorkspaceExternalSourceService { .discover_subagents(subagent_requests, PROVIDER_DISCOVERY_TIMEOUT), self.control_plane .discover_mcp(mcp_requests, PROVIDER_DISCOVERY_TIMEOUT), + self.control_plane.discover_workspace_references( + workspace_reference_requests, + PROVIDER_DISCOVERY_TIMEOUT, + ), ); let mut results = command_batch.immediate; results.append(&mut disabled_command_results); @@ -1175,12 +1278,16 @@ impl WorkspaceExternalSourceService { subagent_results.append(&mut disabled_subagent_results); let mut mcp_results = mcp_batch.immediate; mcp_results.append(&mut disabled_mcp_results); + let mut workspace_reference_results = workspace_reference_batch.immediate; + workspace_reference_results.append(&mut disabled_workspace_reference_results); let command_snapshot = lock_coordinator(&self.control_plane).apply_discovery_results(results); lock_tool_coordinator(&self.control_plane).apply_discovery_results(tool_results); let subagent_snapshot = lock_subagent_coordinator(&self.control_plane) .apply_discovery_results(subagent_results); lock_mcp_coordinator(&self.control_plane).apply_discovery_results(mcp_results); + lock_workspace_reference_coordinator(&self.control_plane) + .apply_discovery_results(workspace_reference_results); for deferred in command_batch.deferred { self.schedule_deferred_command_discovery(deferred); } @@ -1193,6 +1300,9 @@ impl WorkspaceExternalSourceService { for deferred in mcp_batch.deferred { self.schedule_deferred_mcp_discovery(deferred); } + for deferred in workspace_reference_batch.deferred { + self.schedule_deferred_workspace_reference_discovery(deferred); + } self.schedule_subagent_last_valid_expiry(&subagent_snapshot); self.ensure_watch_roots(&policy).await; let snapshot = self @@ -1888,6 +1998,47 @@ impl WorkspaceExternalSourceService { }); } + fn schedule_deferred_workspace_reference_discovery( + self: &Arc, + deferred: DeferredDiscovery, + ) { + let weak = Arc::downgrade(self); + let control_plane = Arc::clone(&self.control_plane); + tokio::spawn(async move { + let mut deferred = deferred; + loop { + let Some((completed, observer)) = + control_plane.complete_workspace_reference(deferred).await + else { + return; + }; + { + let Some(service) = weak.upgrade() else { + return; + }; + let _refresh_guard = service.refresh_gate.lock().await; + let Some(result) = control_plane.finalize_workspace_reference(completed).await + else { + return; + }; + service + .complete_deferred_workspace_reference_discovery(result) + .await; + } + let Some(observer) = observer else { + return; + }; + let Some(next) = control_plane + .resume_abandoned_workspace_reference(observer) + .await + else { + return; + }; + deferred = next; + } + }); + } + async fn complete_deferred_discovery(&self, result: ExternalSourceDiscoveryResult) { let provider_id = result.provider_id().clone(); let Ok(preferences) = read_external_sources_config().await else { @@ -2008,6 +2159,34 @@ impl WorkspaceExternalSourceService { } } + async fn complete_deferred_workspace_reference_discovery( + &self, + result: ExternalWorkspaceReferenceDiscoveryResult, + ) { + let provider_id = result.provider_id().clone(); + let Ok(preferences) = read_external_sources_config().await else { + return; + }; + let Ok(policy) = integration_policy_snapshot(&preferences, self.workspace_root.as_deref()) + else { + return; + }; + let ecosystem_id = lock_workspace_reference_coordinator(&self.control_plane) + .ecosystem_for_provider(&provider_id); + if ecosystem_id.is_none_or(|ecosystem_id| { + !integration_capability_is_discoverable( + &policy, + ecosystem_id.as_str(), + EXTERNAL_CAPABILITY_REFERENCE, + ) + }) { + self.ensure_watch_roots(&policy).await; + return; + } + lock_workspace_reference_coordinator(&self.control_plane).apply_discovery_result(result); + self.ensure_watch_roots(&policy).await; + } + fn schedule_subagent_last_valid_expiry( self: &Arc, snapshot: &bitfun_external_sources::ExternalSubagentCoordinatorSnapshot, @@ -2368,7 +2547,18 @@ impl WorkspaceExternalSourceService { let known = coordinator.set_source_enabled(stable_key, enabled).is_ok(); (previous, known) }; - if !command_known && !tool_known && !subagent_known && !mcp_known { + let (previous_workspace_references, workspace_reference_known) = { + let mut coordinator = lock_workspace_reference_coordinator(&self.control_plane); + let previous = coordinator.suppressed_sources().clone(); + let known = coordinator.set_source_enabled(stable_key, enabled).is_ok(); + (previous, known) + }; + if !command_known + && !tool_known + && !subagent_known + && !mcp_known + && !workspace_reference_known + { return Err(missing_candidate_error(format!( "External source '{stable_key}' is no longer available" ))); @@ -2387,6 +2577,8 @@ impl WorkspaceExternalSourceService { .replace_suppressed_sources(previous_subagents); lock_mcp_coordinator(&self.control_plane) .replace_suppressed_sources(previous_mcps); + lock_workspace_reference_coordinator(&self.control_plane) + .replace_suppressed_sources(previous_workspace_references); return Err(error); } }; @@ -2396,6 +2588,8 @@ impl WorkspaceExternalSourceService { lock_subagent_coordinator(&self.control_plane) .replace_suppressed_sources(authoritative.clone()); lock_mcp_coordinator(&self.control_plane).replace_suppressed_sources(authoritative.clone()); + lock_workspace_reference_coordinator(&self.control_plane) + .replace_suppressed_sources(authoritative.clone()); propagate_suppressed_sources(&authoritative, self); // Refresh acquires the same gate. Release the mutation critical section // after the preference and in-memory coordinators agree, then refresh @@ -3083,6 +3277,12 @@ impl WorkspaceExternalSourceService { provider_roots.extend( lock_mcp_coordinator(&self.control_plane).watch_roots_for_ecosystems(&mcp_ecosystems), ); + let workspace_reference_ecosystems = + ecosystems_with_discoverable_capability(policy, EXTERNAL_CAPABILITY_REFERENCE); + provider_roots.extend( + lock_workspace_reference_coordinator(&self.control_plane) + .watch_roots_for_ecosystems(&workspace_reference_ecosystems), + ); for root in provider_roots { roots .entry(root.path) @@ -3130,6 +3330,12 @@ fn lock_mcp_coordinator( control_plane.lock_mcp() } +fn lock_workspace_reference_coordinator( + control_plane: &ExternalSourceControlPlane, +) -> MutexGuard<'_, bitfun_external_sources::ExternalWorkspaceReferenceCoordinator> { + control_plane.lock_workspace_references() +} + fn lock_snapshot( snapshot: &StdMutex, ) -> MutexGuard<'_, ExternalSourceCatalogSnapshot> { @@ -4585,6 +4791,8 @@ fn propagate_suppressed_sources( lock_subagent_coordinator(&service.control_plane) .replace_suppressed_sources(sources.clone()); lock_mcp_coordinator(&service.control_plane).replace_suppressed_sources(sources.clone()); + lock_workspace_reference_coordinator(&service.control_plane) + .replace_suppressed_sources(sources.clone()); tokio::spawn(async move { if let Err(error) = service.refresh_preserving_worker_recovery().await { log::warn!( @@ -4781,6 +4989,15 @@ async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> R false } }; + let workspace_reference_changed = { + let mut coordinator = lock_workspace_reference_coordinator(&service.control_plane); + if coordinator.suppressed_sources() != &suppressed_sources { + coordinator.replace_suppressed_sources(suppressed_sources.clone()); + true + } else { + false + } + }; let subagent_preferences_changed = service.snapshot().preference_revision != preferences.preference_revision; let policy_changed = service.snapshot().integration_policy != policy; @@ -4788,6 +5005,7 @@ async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> R || tool_changed || subagent_changed || mcp_changed + || workspace_reference_changed || subagent_preferences_changed || policy_changed { @@ -5475,6 +5693,85 @@ pub async fn external_source_snapshot( } } +pub async fn workspace_reference_snapshot( + workspace_root: &Path, + native_related_paths: &[crate::service::workspace::RelatedPath], + force_refresh: bool, +) -> Result { + let service = service_for(Some(workspace_root)).await?; + sync_service_preferences(&service).await?; + if force_refresh { + service.refresh_workspace_references(true).await?; + } else { + service.ensure_workspace_reference_refresh().await?; + } + let active_ecosystems = ecosystems_with_active_capability( + &service.snapshot().integration_policy, + EXTERNAL_CAPABILITY_REFERENCE, + ); + let external = lock_workspace_reference_coordinator(&service.control_plane).snapshot(); + Ok(compose_workspace_reference_snapshot( + native_related_paths, + external, + &active_ecosystems, + )) +} + +fn compose_workspace_reference_snapshot( + native_related_paths: &[crate::service::workspace::RelatedPath], + external: bitfun_external_sources::ExternalWorkspaceReferenceCoordinatorSnapshot, + active_ecosystems: &BTreeSet, +) -> WorkspaceReferenceSnapshot { + let external_sources = external + .sources + .iter() + .filter(|source| active_ecosystems.contains(&source.record.ecosystem_id)) + .map(|source| (source.record.key.clone(), &source.record)) + .collect::>(); + let mut references = native_related_paths + .iter() + .map(|related_path| WorkspaceReferenceCatalogEntry { + stable_key: native_workspace_reference_key(related_path), + alias: None, + path: PathBuf::from(&related_path.path), + description: related_path.description.clone(), + hidden: false, + origin: WorkspaceReferenceOrigin::Native, + ecosystem_id: None, + source_display_name: None, + source_scope: None, + }) + .collect::>(); + references.extend(external.references.into_iter().filter_map(|reference| { + let source = external_sources.get(&reference.source)?; + Some(WorkspaceReferenceCatalogEntry { + stable_key: reference.stable_key(), + alias: Some(reference.alias), + path: reference.path, + description: reference.description, + hidden: reference.hidden, + origin: WorkspaceReferenceOrigin::External, + ecosystem_id: Some(source.ecosystem_id.clone()), + source_display_name: Some(source.display_name.clone()), + source_scope: Some(source.scope), + }) + })); + WorkspaceReferenceSnapshot { + generation: external.generation, + discovery_pending: external.discovery_pending, + references, + diagnostics: external.diagnostics, + } +} + +fn native_workspace_reference_key(related_path: &crate::service::workspace::RelatedPath) -> String { + let mut hasher = Sha256::new(); + hasher.update(related_path.path.as_bytes()); + hasher.update([0]); + hasher.update(related_path.description.as_deref().unwrap_or("").as_bytes()); + format!("native:{}", hex::encode(hasher.finalize())) +} + /// Resolves an opaque source identity to its host-local location for a host /// action. The raw path must not be serialized into the public snapshot. pub async fn external_source_location_for_host_action( @@ -5863,6 +6160,7 @@ mod tests { PromptCommandAvailability, PromptCommandDefinition, PromptCommandProviderIdentity, PromptCommandProviderSnapshot, SourceQualifiedCommandId, }; + use bitfun_product_domains::workspace_references::ExternalWorkspaceReferenceDefinition; use std::sync::atomic::{AtomicUsize, Ordering}; fn native_mcp_config_with_pin(pin: &str) -> MCPServerConfig { @@ -5889,6 +6187,72 @@ mod tests { } } + #[test] + fn effective_workspace_references_keep_native_order_before_external_aliases() { + let native = vec![crate::service::workspace::RelatedPath { + path: "D:/native-docs".to_string(), + description: Some("BitFun workspace setting".to_string()), + }]; + let source_key = SourceKey::new("opencode.references", "project-config").unwrap(); + let external = bitfun_external_sources::ExternalWorkspaceReferenceCoordinatorSnapshot { + generation: 7, + discovery_pending: false, + sources: vec![ExternalSourceCatalogEntry { + stable_key: source_key.stable_key(), + presentation_group_id: None, + record: ExternalSourceRecord { + key: source_key.clone(), + ecosystem_id: EcosystemId::new("opencode").unwrap(), + display_name: "OpenCode project references".to_string(), + source_kind: "opencode_config".to_string(), + scope: ExternalSourceScope::Project, + location: "D:/workspace/opencode.json".to_string(), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + health: + bitfun_product_domains::external_sources::ExternalSourceHealth::Available, + content_version: "source-v1".to_string(), + diagnostics: Vec::new(), + }, + lifecycle: ExternalSourceLifecycleState::Available, + }], + references: vec![ExternalWorkspaceReferenceDefinition { + source: source_key, + alias: "docs".to_string(), + path: PathBuf::from("D:/external-docs"), + description: Some("OpenCode reference".to_string()), + hidden: false, + content_version: "reference-v1".to_string(), + }], + diagnostics: Vec::new(), + }; + + let inactive = + compose_workspace_reference_snapshot(&native, external.clone(), &BTreeSet::new()); + assert_eq!(inactive.references.len(), 1); + assert_eq!( + inactive.references[0].origin, + WorkspaceReferenceOrigin::Native + ); + + let active_ecosystems = [EcosystemId::new("opencode").unwrap()] + .into_iter() + .collect(); + let snapshot = compose_workspace_reference_snapshot(&native, external, &active_ecosystems); + + assert_eq!(snapshot.generation, 7); + assert_eq!(snapshot.references.len(), 2); + assert_eq!( + snapshot.references[0].origin, + WorkspaceReferenceOrigin::Native + ); + assert_eq!(snapshot.references[0].alias, None); + assert_eq!( + snapshot.references[1].origin, + WorkspaceReferenceOrigin::External + ); + assert_eq!(snapshot.references[1].alias.as_deref(), Some("docs")); + } + #[test] fn native_mcp_behavior_versions_are_keyed_before_public_projection() { let key = ExternalMcpRevisionKey::new([7; 32]); @@ -6558,6 +6922,7 @@ mod tests { Vec::new(), Vec::new(), Vec::new(), + Vec::new(), ) .unwrap(), ); @@ -6609,7 +6974,7 @@ mod tests { ) .unwrap(); let provider = OpenCodeSkillRootProvider::new(OpenCodeSkillRootProviderOptions { - command: OpenCodeCommandProviderOptions { + config: OpenCodeCommandProviderOptions { user_config_dir: user_config, legacy_user_config_dir: Some(home.join(".opencode")), explicit_config_file: None, @@ -7059,6 +7424,11 @@ mod tests { ExternalIntegrationAccess::AskBeforeUse, ExternalIntegrationAccess::AskBeforeUse, ), + ( + EXTERNAL_CAPABILITY_REFERENCE, + ExternalIntegrationAccess::Auto, + ExternalIntegrationAccess::Auto, + ), ] { let capability = descriptor .capabilities @@ -7087,6 +7457,7 @@ mod tests { EXTERNAL_CAPABILITY_TOOL, EXTERNAL_CAPABILITY_SUBAGENT, EXTERNAL_CAPABILITY_MCP, + EXTERNAL_CAPABILITY_REFERENCE, ]), ), ( @@ -7117,7 +7488,10 @@ mod tests { assert_eq!(capabilities, expected[ecosystem]); for capability in ®istration.descriptor.capabilities { let expected_access = if ecosystem == "opencode" - && capability.capability_id.as_str() == EXTERNAL_CAPABILITY_COMMAND + && matches!( + capability.capability_id.as_str(), + EXTERNAL_CAPABILITY_COMMAND | EXTERNAL_CAPABILITY_REFERENCE + ) || ecosystem == "claude-code" && capability.capability_id.as_str() == EXTERNAL_CAPABILITY_COMMAND { @@ -7176,6 +7550,7 @@ mod tests { EXTERNAL_CAPABILITY_TOOL, EXTERNAL_CAPABILITY_SUBAGENT, EXTERNAL_CAPABILITY_MCP, + EXTERNAL_CAPABILITY_REFERENCE, ] { assert_eq!( ecosystems_with_discoverable_capability(&policy, capability), diff --git a/src/crates/assembly/core/src/external_sources/opencode_local_source_order_tests.rs b/src/crates/assembly/core/src/external_sources/opencode_local_source_order_tests.rs index 05ff17b56..35a57af52 100644 --- a/src/crates/assembly/core/src/external_sources/opencode_local_source_order_tests.rs +++ b/src/crates/assembly/core/src/external_sources/opencode_local_source_order_tests.rs @@ -117,6 +117,7 @@ impl Fixture { self.subagent_provider(explicit_config_dir.clone()), )], vec![Arc::new(self.mcp_provider(explicit_config_dir))], + Vec::new(), ) .expect("OpenCode control plane") } diff --git a/src/crates/assembly/core/src/service/workspace/service.rs b/src/crates/assembly/core/src/service/workspace/service.rs index 9e77e86a8..1181d88d1 100644 --- a/src/crates/assembly/core/src/service/workspace/service.rs +++ b/src/crates/assembly/core/src/service/workspace/service.rs @@ -628,6 +628,17 @@ impl WorkspaceService { .await } + #[cfg(feature = "service-integrations")] + pub async fn is_live_worktree_root_in_same_repository( + &self, + registered_path: &Path, + candidate: &Path, + ) -> Result { + super::worktree_topology::global_worktree_topology_service() + .is_live_worktree_root_in_same_repository(registered_path, candidate) + .await + } + #[cfg(feature = "service-integrations")] pub async fn invalidate_worktree_topology(&self, path: &Path) { super::worktree_topology::global_worktree_topology_service() diff --git a/src/crates/assembly/core/src/service/workspace/worktree_topology.rs b/src/crates/assembly/core/src/service/workspace/worktree_topology.rs index e5e187dc5..4600b857b 100644 --- a/src/crates/assembly/core/src/service/workspace/worktree_topology.rs +++ b/src/crates/assembly/core/src/service/workspace/worktree_topology.rs @@ -53,6 +53,28 @@ impl Default for WorktreeTopologyService { } impl WorktreeTopologyService { + /// Proves that `candidate` is a live worktree root for the same repository + /// as `registered_path`. This deliberately bypasses the cached topology: + /// cached `git worktree list` entries can be prunable after their directory + /// is deleted and must never authorize a newly-created ordinary directory. + pub async fn is_live_worktree_root_in_same_repository( + &self, + registered_path: &Path, + candidate: &Path, + ) -> Result { + let (registered, candidate_repository) = tokio::join!( + GitService::resolve_worktree_repository(registered_path), + GitService::resolve_worktree_repository(candidate), + ); + let registered = registered?; + let candidate_repository = candidate_repository?; + let candidate = dunce::canonicalize(candidate)?; + let candidate_root = dunce::canonicalize(&candidate_repository.query_path)?; + let registered_common_git_dir = dunce::canonicalize(®istered.common_git_dir)?; + let candidate_common_git_dir = dunce::canonicalize(&candidate_repository.common_git_dir)?; + Ok(candidate == candidate_root && registered_common_git_dir == candidate_common_git_dir) + } + pub async fn list_worktrees( &self, path: &Path, @@ -344,6 +366,47 @@ mod tests { assert_eq!(service.query_count(), 3); } + #[tokio::test] + async fn live_repository_identity_rejects_a_recreated_prunable_worktree_path() { + let repository = initialized_repository(); + let linked_name = format!( + "{}-live-membership", + repository.path().file_name().unwrap().to_string_lossy() + ); + let linked_root = repository.path().parent().unwrap().join(linked_name); + git( + repository.path(), + &[ + "worktree", + "add", + "-b", + "live-membership-test", + linked_root.to_string_lossy().as_ref(), + ], + ); + let service = WorktreeTopologyService::default(); + + assert!(service + .is_live_worktree_root_in_same_repository(repository.path(), &linked_root) + .await + .unwrap()); + service + .list_worktrees(repository.path(), WorktreeTopologyFreshness::Cached) + .await + .expect("topology should be cached before the worktree becomes stale"); + + std::fs::remove_dir_all(&linked_root).expect("linked worktree should be removed manually"); + std::fs::create_dir_all(&linked_root) + .expect("ordinary directory should replace the stale worktree path"); + + assert!(!service + .is_live_worktree_root_in_same_repository(repository.path(), &linked_root) + .await + .unwrap_or(false)); + + std::fs::remove_dir_all(&linked_root).expect("replacement directory should be cleaned up"); + } + #[tokio::test] async fn invalidation_during_query_does_not_restore_stale_cache_data() { let repository = initialized_repository(); diff --git a/src/crates/assembly/external-sources/src/control_plane.rs b/src/crates/assembly/external-sources/src/control_plane.rs index 96a09c3fd..94be74157 100644 --- a/src/crates/assembly/external-sources/src/control_plane.rs +++ b/src/crates/assembly/external-sources/src/control_plane.rs @@ -7,12 +7,15 @@ use crate::{ ExternalSourceDiscoveryRequest, ExternalSourceDiscoveryResult, ExternalSubagentCoordinator, ExternalSubagentDiscoveryRequest, ExternalSubagentDiscoveryResult, ExternalToolCoordinator, ExternalToolDiscoveryRequest, ExternalToolDiscoveryResult, + ExternalWorkspaceReferenceCoordinator, ExternalWorkspaceReferenceDiscoveryRequest, + ExternalWorkspaceReferenceDiscoveryResult, }; use bitfun_product_domains::external_sources::{ ExternalMcpRevisionKey, ExternalMcpSourceProvider, ExternalSourceContext, ExternalToolSourceProvider, PromptCommandSourceProvider, }; use bitfun_product_domains::external_subagents::ExternalSubagentSourceProvider; +use bitfun_product_domains::workspace_references::ExternalWorkspaceReferenceSourceProvider; use std::collections::BTreeSet; use std::fmt; use std::sync::{Arc, Mutex, MutexGuard}; @@ -23,10 +26,12 @@ pub struct ExternalSourceControlPlane { tools: Mutex, subagents: Mutex, mcp: Mutex, + workspace_references: Mutex, command_lane: DiscoveryLane, tool_lane: DiscoveryLane, subagent_lane: DiscoveryLane, mcp_lane: DiscoveryLane, + workspace_reference_lane: DiscoveryLane, } impl fmt::Debug for ExternalSourceControlPlane { @@ -46,6 +51,10 @@ impl fmt::Debug for ExternalSourceControlPlane { &self.subagents(|coordinator| format!("{coordinator:?}")), ) .field("mcp", &self.mcp(|coordinator| format!("{coordinator:?}"))) + .field( + "workspace_references", + &self.workspace_references(|coordinator| format!("{coordinator:?}")), + ) .finish_non_exhaustive() } } @@ -58,6 +67,7 @@ impl ExternalSourceControlPlane { tool_providers: Vec>, subagent_providers: Vec>, mcp_providers: Vec>, + workspace_reference_providers: Vec>, ) -> Result { Ok(Self { commands: Mutex::new(ExternalSourceCoordinator::new( @@ -73,14 +83,19 @@ impl ExternalSourceControlPlane { subagent_providers, )?), mcp: Mutex::new(ExternalMcpCoordinator::new( - context, + context.clone(), mcp_revision_key, mcp_providers, )?), + workspace_references: Mutex::new(ExternalWorkspaceReferenceCoordinator::new( + context, + workspace_reference_providers, + )?), command_lane: DiscoveryLane::new(), tool_lane: DiscoveryLane::new(), subagent_lane: DiscoveryLane::new(), mcp_lane: DiscoveryLane::new(), + workspace_reference_lane: DiscoveryLane::new(), }) } @@ -142,11 +157,35 @@ impl ExternalSourceControlPlane { update(&mut lock(&self.mcp, "MCP")) } + pub fn workspace_references( + &self, + read: impl FnOnce(&ExternalWorkspaceReferenceCoordinator) -> T, + ) -> T { + read(&lock(&self.workspace_references, "workspace reference")) + } + + #[doc(hidden)] + pub fn lock_workspace_references( + &self, + ) -> MutexGuard<'_, ExternalWorkspaceReferenceCoordinator> { + lock(&self.workspace_references, "workspace reference") + } + + pub fn workspace_references_mut( + &self, + update: impl FnOnce(&mut ExternalWorkspaceReferenceCoordinator) -> T, + ) -> T { + update(&mut lock(&self.workspace_references, "workspace reference")) + } + pub fn replace_suppressed_sources(&self, sources: BTreeSet) { self.commands_mut(|coordinator| coordinator.replace_suppressed_sources(sources.clone())); self.tools_mut(|coordinator| coordinator.replace_suppressed_sources(sources.clone())); self.subagents_mut(|coordinator| coordinator.replace_suppressed_sources(sources.clone())); - self.mcp_mut(|coordinator| coordinator.replace_suppressed_sources(sources)); + self.mcp_mut(|coordinator| coordinator.replace_suppressed_sources(sources.clone())); + self.workspace_references_mut(|coordinator| { + coordinator.replace_suppressed_sources(sources) + }); } pub async fn discover_commands( @@ -276,6 +315,46 @@ impl ExternalSourceControlPlane { ) -> Option { self.mcp_lane.finalize_deferred(completed).await } + + pub async fn discover_workspace_references( + &self, + requests: Vec, + timeout: Duration, + ) -> DiscoveryBatch { + self.workspace_reference_lane + .discover(requests, timeout) + .await + } + + pub async fn complete_workspace_reference( + &self, + deferred: DeferredDiscovery, + ) -> Option<( + CompletedDeferredDiscovery, + Option>, + )> { + self.workspace_reference_lane + .complete_deferred(deferred) + .await + } + + pub async fn resume_abandoned_workspace_reference( + &self, + deferred: DeferredDiscovery, + ) -> Option> { + self.workspace_reference_lane + .resume_abandoned(deferred) + .await + } + + pub async fn finalize_workspace_reference( + &self, + completed: CompletedDeferredDiscovery, + ) -> Option { + self.workspace_reference_lane + .finalize_deferred(completed) + .await + } } fn lock<'a, T>(mutex: &'a Mutex, capability: &str) -> MutexGuard<'a, T> { diff --git a/src/crates/assembly/external-sources/src/lib.rs b/src/crates/assembly/external-sources/src/lib.rs index ea8f50081..39efcd8f1 100644 --- a/src/crates/assembly/external-sources/src/lib.rs +++ b/src/crates/assembly/external-sources/src/lib.rs @@ -10,6 +10,7 @@ mod mcp; mod refresh; mod subagent; mod tool; +mod workspace_reference; pub use control_plane::ExternalSourceControlPlane; pub use hook::{ExternalHookCatalogCoordinator, ExternalHookDiscoveryResult}; @@ -29,6 +30,10 @@ pub use tool::{ ExternalToolCoordinator, ExternalToolCoordinatorSnapshot, ExternalToolDiscoveryRequest, ExternalToolDiscoveryResult, }; +pub use workspace_reference::{ + ExternalWorkspaceReferenceCoordinator, ExternalWorkspaceReferenceCoordinatorSnapshot, + ExternalWorkspaceReferenceDiscoveryRequest, ExternalWorkspaceReferenceDiscoveryResult, +}; use bitfun_product_domains::external_sources::{ prompt_command_conflict_key, EcosystemId, ExternalSourceCatalogEntry, diff --git a/src/crates/assembly/external-sources/src/workspace_reference.rs b/src/crates/assembly/external-sources/src/workspace_reference.rs new file mode 100644 index 000000000..32e78d9b3 --- /dev/null +++ b/src/crates/assembly/external-sources/src/workspace_reference.rs @@ -0,0 +1,390 @@ +use bitfun_product_domains::external_sources::{ + EcosystemId, ExternalSourceAssetKind, ExternalSourceCatalogEntry, ExternalSourceContext, + ExternalSourceDiagnostic, ExternalSourceDiagnosticSeverity, ExternalSourceLifecycleState, + ExternalSourceProviderError, ExternalWatchRoot, ProviderId, +}; +use bitfun_product_domains::workspace_references::{ + ExternalWorkspaceReferenceDefinition, ExternalWorkspaceReferenceProviderIdentity, + ExternalWorkspaceReferenceProviderSnapshot, ExternalWorkspaceReferenceSourceProvider, +}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::sync::Arc; + +impl crate::DiscoveryRequest for ExternalWorkspaceReferenceDiscoveryRequest { + type Result = ExternalWorkspaceReferenceDiscoveryResult; + + const DIAGNOSTIC_PREFIX: &'static str = "external_workspace_reference"; + const PROVIDER_LABEL: &'static str = "workspace reference"; + + fn provider_id(&self) -> ProviderId { + self.provider_id.clone() + } + + fn execute(self) -> Self::Result { + ExternalWorkspaceReferenceDiscoveryRequest::execute(self) + } + + fn failed(provider_id: ProviderId, error: ExternalSourceProviderError) -> Self::Result { + ExternalWorkspaceReferenceDiscoveryResult::failed(provider_id, error) + } +} + +struct ProviderGeneration { + provider: Arc, + identity: ExternalWorkspaceReferenceProviderIdentity, + initial_result_received: bool, + last_success: Option, + last_error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalWorkspaceReferenceCoordinatorSnapshot { + pub generation: u64, + pub discovery_pending: bool, + pub sources: Vec, + pub references: Vec, + pub diagnostics: Vec, +} + +pub struct ExternalWorkspaceReferenceDiscoveryRequest { + provider_id: ProviderId, + ecosystem_id: EcosystemId, + provider: Arc, + context: ExternalSourceContext, +} + +impl ExternalWorkspaceReferenceDiscoveryRequest { + pub fn provider_id(&self) -> &ProviderId { + &self.provider_id + } + + pub fn ecosystem_id(&self) -> &EcosystemId { + &self.ecosystem_id + } + + pub fn disabled(self) -> ExternalWorkspaceReferenceDiscoveryResult { + ExternalWorkspaceReferenceDiscoveryResult { + provider_id: self.provider_id, + candidate: Ok(ExternalWorkspaceReferenceProviderSnapshot { + provider: self.provider.identity(), + sources: Vec::new(), + references: Vec::new(), + diagnostics: Vec::new(), + }), + } + } + + pub fn execute(self) -> ExternalWorkspaceReferenceDiscoveryResult { + ExternalWorkspaceReferenceDiscoveryResult { + provider_id: self.provider_id, + candidate: self.provider.discover(&self.context), + } + } +} + +#[derive(Clone)] +pub struct ExternalWorkspaceReferenceDiscoveryResult { + provider_id: ProviderId, + candidate: Result, +} + +impl ExternalWorkspaceReferenceDiscoveryResult { + pub fn provider_id(&self) -> &ProviderId { + &self.provider_id + } + + pub fn failed(provider_id: ProviderId, error: ExternalSourceProviderError) -> Self { + Self { + provider_id, + candidate: Err(error), + } + } +} + +pub struct ExternalWorkspaceReferenceCoordinator { + context: ExternalSourceContext, + providers: Vec, + suppressed_sources: BTreeSet, + generation: u64, + snapshot: ExternalWorkspaceReferenceCoordinatorSnapshot, +} + +impl fmt::Debug for ExternalWorkspaceReferenceCoordinator { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ExternalWorkspaceReferenceCoordinator") + .field("context", &self.context) + .field("providers", &self.providers.len()) + .field("suppressed_sources", &self.suppressed_sources) + .field("generation", &self.generation) + .finish() + } +} + +impl ExternalWorkspaceReferenceCoordinator { + 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(); + if !provider_ids.insert(identity.provider_id.clone()) { + return Err(format!( + "duplicate workspace reference provider id: {}", + identity.provider_id + )); + } + generations.push(ProviderGeneration { + provider, + identity, + initial_result_received: false, + last_success: None, + last_error: None, + }); + } + let discovery_pending = !generations.is_empty(); + Ok(Self { + context, + providers: generations, + suppressed_sources: BTreeSet::new(), + generation: 0, + snapshot: ExternalWorkspaceReferenceCoordinatorSnapshot { + generation: 0, + discovery_pending, + sources: Vec::new(), + references: Vec::new(), + diagnostics: Vec::new(), + }, + }) + } + + pub fn refresh(&mut self) -> ExternalWorkspaceReferenceCoordinatorSnapshot { + let results = self + .discovery_requests() + .into_iter() + .map(ExternalWorkspaceReferenceDiscoveryRequest::execute) + .collect(); + self.apply_discovery_results(results) + } + + pub fn discovery_requests(&self) -> Vec { + self.providers + .iter() + .map(|generation| ExternalWorkspaceReferenceDiscoveryRequest { + provider_id: generation.identity.provider_id.clone(), + ecosystem_id: generation.identity.ecosystem_id.clone(), + provider: Arc::clone(&generation.provider), + context: self.context.clone(), + }) + .collect() + } + + pub fn apply_discovery_results( + &mut self, + results: Vec, + ) -> ExternalWorkspaceReferenceCoordinatorSnapshot { + 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_workspace_reference.discovery_result_missing", + "workspace reference provider discovery did not return a result", + true, + )) + }); + apply_provider_candidate(generation, candidate); + } + self.rebuild_snapshot() + } + + pub fn apply_discovery_result( + &mut self, + result: ExternalWorkspaceReferenceDiscoveryResult, + ) -> ExternalWorkspaceReferenceCoordinatorSnapshot { + 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) -> ExternalWorkspaceReferenceCoordinatorSnapshot { + self.snapshot.clone() + } + + pub fn ecosystem_for_provider(&self, provider_id: &ProviderId) -> Option { + self.providers + .iter() + .find(|provider| &provider.identity.provider_id == provider_id) + .map(|provider| provider.identity.ecosystem_id.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 workspace reference 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, sources: BTreeSet) { + self.suppressed_sources = sources; + self.rebuild_snapshot(); + } + + pub fn suppressed_sources(&self) -> &BTreeSet { + &self.suppressed_sources + } + + pub fn watch_roots_for_ecosystems( + &self, + ecosystems: &BTreeSet, + ) -> Vec { + let mut roots = BTreeMap::new(); + for provider in &self.providers { + if !ecosystems.contains(&provider.identity.ecosystem_id) { + continue; + } + for root in provider.provider.watch_roots(&self.context) { + roots + .entry(root.path) + .and_modify(|recursive| *recursive |= root.recursive) + .or_insert(root.recursive); + } + } + roots + .into_iter() + .map(|(path, recursive)| ExternalWatchRoot { path, recursive }) + .collect() + } + + fn rebuild_snapshot(&mut self) -> ExternalWorkspaceReferenceCoordinatorSnapshot { + self.generation = self.generation.saturating_add(1); + let mut sources = Vec::new(); + let mut references = Vec::new(); + let mut diagnostics = Vec::new(); + for provider in &self.providers { + let use_last_valid = provider + .last_error + .as_ref() + .is_some_and(|error| error.transient) + && provider.last_success.is_some(); + if let Some(snapshot) = &provider.last_success { + diagnostics.extend(snapshot.diagnostics.clone()); + for source in &snapshot.sources { + let suppressed = self.suppressed_sources.contains(&source.preference_key()); + let available = provider.last_error.is_none() || use_last_valid; + sources.push(ExternalSourceCatalogEntry { + stable_key: source.preference_key(), + presentation_group_id: None, + record: source.clone(), + lifecycle: if suppressed { + ExternalSourceLifecycleState::Suppressed + } else if use_last_valid { + ExternalSourceLifecycleState::UsingLastValidVersion + } else if available { + ExternalSourceLifecycleState::Available + } else { + ExternalSourceLifecycleState::Unavailable + }, + }); + if !suppressed && available { + references.extend( + snapshot + .references + .iter() + .filter(|reference| reference.source == source.key) + .cloned(), + ); + } + } + } + if let Some(error) = &provider.last_error { + diagnostics.push(ExternalSourceDiagnostic { + severity: if error.transient { + ExternalSourceDiagnosticSeverity::Warning + } else { + ExternalSourceDiagnosticSeverity::Error + }, + asset_kind: ExternalSourceAssetKind::Reference, + code: error.code.clone(), + message: error.message.clone(), + source: None, + }); + } + } + sources.sort_by(|left, right| left.stable_key.cmp(&right.stable_key)); + references.sort_by(|left, right| { + left.alias + .cmp(&right.alias) + .then(left.stable_key().cmp(&right.stable_key())) + }); + self.snapshot = ExternalWorkspaceReferenceCoordinatorSnapshot { + generation: self.generation, + discovery_pending: self + .providers + .iter() + .any(|provider| !provider.initial_result_received), + sources, + references, + diagnostics, + }; + self.snapshot.clone() + } +} + +fn apply_provider_candidate( + generation: &mut ProviderGeneration, + candidate: Result, +) { + generation.initial_result_received = true; + match candidate { + Ok(snapshot) => match snapshot.validate() { + Ok(()) if snapshot.provider == generation.identity => { + generation.last_success = Some(snapshot); + generation.last_error = None; + } + Ok(()) => { + generation.last_error = Some(ExternalSourceProviderError::new( + "external_workspace_reference.provider_identity_mismatch", + "workspace reference provider returned a mismatched identity", + false, + )); + } + Err(error) => { + generation.last_error = Some(ExternalSourceProviderError::new( + "external_workspace_reference.snapshot_invalid", + error.to_string(), + false, + )); + } + }, + Err(error) => generation.last_error = Some(error), + } +} diff --git a/src/crates/assembly/external-sources/tests/control_plane.rs b/src/crates/assembly/external-sources/tests/control_plane.rs index 1fe47a408..c17b1ec4e 100644 --- a/src/crates/assembly/external-sources/tests/control_plane.rs +++ b/src/crates/assembly/external-sources/tests/control_plane.rs @@ -24,6 +24,7 @@ fn control_plane_owns_all_typed_coordinator_snapshots() { Vec::new(), Vec::new(), Vec::new(), + Vec::new(), ) .unwrap(); @@ -31,6 +32,7 @@ fn control_plane_owns_all_typed_coordinator_snapshots() { assert!(plane.tools(|coordinator| coordinator.snapshot().sources.is_empty())); assert!(plane.subagents(|coordinator| coordinator.snapshot().sources.is_empty())); assert!(plane.mcp(|coordinator| coordinator.snapshot().sources.is_empty())); + assert!(plane.workspace_references(|coordinator| coordinator.snapshot().sources.is_empty())); } #[test] @@ -42,6 +44,7 @@ fn suppression_replacement_is_applied_to_every_typed_coordinator() { Vec::new(), Vec::new(), Vec::new(), + Vec::new(), ) .unwrap(); let suppressed = ["source-key".to_string()] @@ -66,4 +69,8 @@ fn suppression_replacement_is_applied_to_every_typed_coordinator() { plane.mcp(|coordinator| coordinator.suppressed_sources().clone()), suppressed ); + assert_eq!( + plane.workspace_references(|coordinator| coordinator.suppressed_sources().clone()), + suppressed + ); } diff --git a/src/crates/assembly/external-sources/tests/workspace_reference.rs b/src/crates/assembly/external-sources/tests/workspace_reference.rs new file mode 100644 index 000000000..fde54108a --- /dev/null +++ b/src/crates/assembly/external-sources/tests/workspace_reference.rs @@ -0,0 +1,146 @@ +use bitfun_external_sources::ExternalWorkspaceReferenceCoordinator; +use bitfun_product_domains::external_sources::{ + EcosystemId, ExecutionDomainId, ExternalSourceContext, ExternalSourceHealth, + ExternalSourceLifecycleState, ExternalSourceProviderError, ExternalSourceRecord, + ExternalSourceScope, ExternalWatchRoot, SourceKey, +}; +use bitfun_product_domains::workspace_references::{ + ExternalWorkspaceReferenceDefinition, ExternalWorkspaceReferenceProviderIdentity, + ExternalWorkspaceReferenceProviderSnapshot, ExternalWorkspaceReferenceSourceProvider, +}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +struct ToggleProvider { + fail: Arc, + path: PathBuf, +} + +impl ExternalWorkspaceReferenceSourceProvider for ToggleProvider { + fn identity(&self) -> ExternalWorkspaceReferenceProviderIdentity { + ExternalWorkspaceReferenceProviderIdentity::new( + "opencode.references", + "opencode", + "OpenCode", + ) + .unwrap() + } + + fn discover( + &self, + context: &ExternalSourceContext, + ) -> Result { + if self.fail.load(Ordering::SeqCst) { + return Err(ExternalSourceProviderError::new( + "opencode.reference.refresh_failed", + "refresh failed", + true, + )); + } + let source = ExternalSourceRecord { + key: SourceKey::new("opencode.references", "project").unwrap(), + ecosystem_id: EcosystemId::new("opencode").unwrap(), + display_name: "OpenCode project references".to_string(), + source_kind: "opencode_config".to_string(), + scope: ExternalSourceScope::Project, + location: self.path.join("opencode.json").display().to_string(), + execution_domain_id: context.execution_domain_id.clone(), + health: ExternalSourceHealth::Available, + content_version: "source-v1".to_string(), + diagnostics: Vec::new(), + }; + Ok(ExternalWorkspaceReferenceProviderSnapshot { + provider: self.identity(), + sources: vec![source.clone()], + references: vec![ExternalWorkspaceReferenceDefinition { + source: source.key, + alias: "docs".to_string(), + path: self.path.clone(), + description: Some("Documentation".to_string()), + hidden: false, + content_version: "docs-v1".to_string(), + }], + diagnostics: Vec::new(), + }) + } + + fn watch_roots(&self, _context: &ExternalSourceContext) -> Vec { + vec![ExternalWatchRoot { + path: self.path.clone(), + recursive: true, + }] + } +} + +fn context() -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(std::env::temp_dir()), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } +} + +#[test] +fn transient_failure_keeps_the_last_valid_reference_generation() { + let fail = Arc::new(AtomicBool::new(false)); + let path = std::env::temp_dir(); + let provider = Arc::new(ToggleProvider { + fail: Arc::clone(&fail), + path, + }); + let mut coordinator = + ExternalWorkspaceReferenceCoordinator::new(context(), vec![provider]).unwrap(); + + let current = coordinator.refresh(); + assert_eq!(current.references.len(), 1); + assert_eq!( + current.sources[0].lifecycle, + ExternalSourceLifecycleState::Available + ); + + fail.store(true, Ordering::SeqCst); + let degraded = coordinator.refresh(); + assert_eq!(degraded.references, current.references); + assert_eq!( + degraded.sources[0].lifecycle, + ExternalSourceLifecycleState::UsingLastValidVersion + ); + assert!(degraded + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "opencode.reference.refresh_failed")); +} + +#[test] +fn suppression_and_ecosystem_watch_filter_use_the_shared_source_identity() { + let path = std::env::temp_dir(); + let provider = Arc::new(ToggleProvider { + fail: Arc::new(AtomicBool::new(false)), + path: path.clone(), + }); + let mut coordinator = + ExternalWorkspaceReferenceCoordinator::new(context(), vec![provider]).unwrap(); + let snapshot = coordinator.refresh(); + let stable_key = snapshot.sources[0].stable_key.clone(); + + coordinator.set_source_enabled(&stable_key, false).unwrap(); + + assert!(coordinator.snapshot().references.is_empty()); + assert_eq!( + coordinator.snapshot().sources[0].lifecycle, + ExternalSourceLifecycleState::Suppressed + ); + coordinator.set_source_enabled(&stable_key, true).unwrap(); + assert_eq!(coordinator.snapshot().references.len(), 1); + assert_eq!( + coordinator.watch_roots_for_ecosystems( + &[EcosystemId::new("opencode").unwrap()] + .into_iter() + .collect() + ), + vec![ExternalWatchRoot { + path, + recursive: true, + }] + ); +} diff --git a/src/crates/contracts/product-domains/Cargo.toml b/src/crates/contracts/product-domains/Cargo.toml index 3d6be7baa..1d56936d1 100644 --- a/src/crates/contracts/product-domains/Cargo.toml +++ b/src/crates/contracts/product-domains/Cargo.toml @@ -29,6 +29,11 @@ name = "external_hook_catalog_contracts" path = "tests/external_hook_catalog_contracts.rs" required-features = ["external-sources"] +[[test]] +name = "workspace_reference_contracts" +path = "tests/workspace_reference_contracts.rs" +required-features = ["external-sources"] + [dependencies] serde = { workspace = true } serde_json = { workspace = true } diff --git a/src/crates/contracts/product-domains/src/external_sources.rs b/src/crates/contracts/product-domains/src/external_sources.rs index 851339196..5d607ff8b 100644 --- a/src/crates/contracts/product-domains/src/external_sources.rs +++ b/src/crates/contracts/product-domains/src/external_sources.rs @@ -1371,6 +1371,7 @@ pub enum ExternalSourceAssetKind { Subagent, Mcp, Hook, + Reference, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/crates/contracts/product-domains/src/lib.rs b/src/crates/contracts/product-domains/src/lib.rs index d8ab07bbe..a190cf994 100644 --- a/src/crates/contracts/product-domains/src/lib.rs +++ b/src/crates/contracts/product-domains/src/lib.rs @@ -27,6 +27,9 @@ pub mod external_sources; #[cfg(feature = "external-sources")] pub mod external_subagents; +#[cfg(feature = "external-sources")] +pub mod workspace_references; + #[cfg(feature = "plugin-source")] pub mod plugin_source; diff --git a/src/crates/contracts/product-domains/src/workspace_references.rs b/src/crates/contracts/product-domains/src/workspace_references.rs new file mode 100644 index 000000000..23451f52e --- /dev/null +++ b/src/crates/contracts/product-domains/src/workspace_references.rs @@ -0,0 +1,201 @@ +//! Ecosystem-neutral contracts for named workspace reference directories. +//! +//! Providers translate external configuration into declared local directory +//! paths. A declared path may not exist yet. +//! Product Assembly remains responsible for composing those facts with native +//! workspace related paths and for deciding which product surfaces consume +//! them. A reference never grants filesystem permissions by itself. + +use crate::external_sources::{ + validate_id, EcosystemId, ExternalSourceContext, ExternalSourceContractError, + ExternalSourceDiagnostic, ExternalSourceProviderError, ExternalSourceRecord, + ExternalSourceScope, ExternalWatchRoot, ProviderId, SourceKey, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::path::PathBuf; + +const MAX_REFERENCE_DESCRIPTION_LENGTH: usize = 4096; +const MAX_REFERENCE_COUNT: usize = 1024; +const MAX_PROVIDER_DIAGNOSTICS: usize = 256; +const MAX_SNAPSHOT_DIAGNOSTIC_ENTRIES: usize = MAX_PROVIDER_DIAGNOSTICS * 2; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalWorkspaceReferenceProviderIdentity { + pub provider_id: ProviderId, + pub ecosystem_id: EcosystemId, + pub display_name: String, +} + +impl ExternalWorkspaceReferenceProviderIdentity { + pub fn new( + provider_id: impl Into, + ecosystem_id: impl Into, + display_name: impl Into, + ) -> Result { + let display_name = display_name.into(); + validate_reference_text(&display_name, "workspace reference 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 ExternalWorkspaceReferenceDefinition { + pub source: SourceKey, + pub alias: String, + pub path: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default)] + pub hidden: bool, + pub content_version: String, +} + +impl ExternalWorkspaceReferenceDefinition { + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + validate_id(&self.alias, "workspace reference alias")?; + validate_id(&self.content_version, "workspace reference content version")?; + if !self.path.is_absolute() { + return Err(ExternalSourceContractError::InvalidIdentifier( + "workspace reference path", + )); + } + if let Some(description) = &self.description { + validate_reference_text(description, "workspace reference description")?; + } + Ok(()) + } + + pub fn stable_key(&self) -> String { + format!( + "{}{}:{}", + self.source.stable_key(), + self.alias.len(), + self.alias + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalWorkspaceReferenceProviderSnapshot { + pub provider: ExternalWorkspaceReferenceProviderIdentity, + pub sources: Vec, + pub references: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +impl ExternalWorkspaceReferenceProviderSnapshot { + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + let diagnostic_entries = self + .sources + .iter() + .fold(self.diagnostics.len(), |count, source| { + count.saturating_add(source.diagnostics.len()) + }); + if self.sources.len() > MAX_REFERENCE_COUNT + || self.references.len() > MAX_REFERENCE_COUNT + || self.diagnostics.len() > MAX_PROVIDER_DIAGNOSTICS + || diagnostic_entries > MAX_SNAPSHOT_DIAGNOSTIC_ENTRIES + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "workspace reference provider snapshot size", + )); + } + + 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( + "workspace reference provider-qualified source", + )); + } + } + + let mut aliases = BTreeSet::new(); + for reference in &self.references { + reference.validate()?; + if reference.source.provider_id != self.provider.provider_id + || !source_keys.contains(&reference.source) + || !aliases.insert(reference.alias.as_str()) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "workspace reference provider-qualified definition", + )); + } + } + Ok(()) + } +} + +pub trait ExternalWorkspaceReferenceSourceProvider: Send + Sync { + fn identity(&self) -> ExternalWorkspaceReferenceProviderIdentity; + + fn discover( + &self, + context: &ExternalSourceContext, + ) -> Result; + + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceReferenceOrigin { + Native, + External, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WorkspaceReferenceCatalogEntry { + pub stable_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, + pub path: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default)] + pub hidden: bool, + pub origin: WorkspaceReferenceOrigin, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ecosystem_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_scope: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WorkspaceReferenceSnapshot { + pub generation: u64, + pub discovery_pending: bool, + pub references: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +fn validate_reference_text( + value: &str, + label: &'static str, +) -> Result<(), ExternalSourceContractError> { + if value.is_empty() + || value.len() > MAX_REFERENCE_DESCRIPTION_LENGTH + || value.chars().any(char::is_control) + { + return Err(ExternalSourceContractError::InvalidText(label)); + } + Ok(()) +} diff --git a/src/crates/contracts/product-domains/tests/workspace_reference_contracts.rs b/src/crates/contracts/product-domains/tests/workspace_reference_contracts.rs new file mode 100644 index 000000000..7d907f8e4 --- /dev/null +++ b/src/crates/contracts/product-domains/tests/workspace_reference_contracts.rs @@ -0,0 +1,114 @@ +use bitfun_product_domains::external_sources::{ + EcosystemId, ExecutionDomainId, ExternalSourceAssetKind, ExternalSourceDiagnostic, + ExternalSourceHealth, ExternalSourceRecord, ExternalSourceScope, ProviderId, SourceKey, +}; +use bitfun_product_domains::workspace_references::{ + ExternalWorkspaceReferenceDefinition, ExternalWorkspaceReferenceProviderIdentity, + ExternalWorkspaceReferenceProviderSnapshot, +}; +use std::path::PathBuf; + +fn source() -> ExternalSourceRecord { + ExternalSourceRecord { + key: SourceKey::new("opencode.references", "project-config").unwrap(), + ecosystem_id: EcosystemId::new("opencode").unwrap(), + display_name: "OpenCode project configuration".to_string(), + source_kind: "opencode_config".to_string(), + scope: ExternalSourceScope::Project, + location: "/opencode.json".to_string(), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + health: ExternalSourceHealth::Available, + content_version: "source-v1".to_string(), + diagnostics: Vec::new(), + } +} + +#[test] +fn provider_snapshot_accepts_distinct_aliases_for_the_same_directory() { + let source = source(); + let shared_path = std::env::temp_dir().join("workspace-reference-shared"); + let snapshot = ExternalWorkspaceReferenceProviderSnapshot { + provider: ExternalWorkspaceReferenceProviderIdentity::new( + "opencode.references", + "opencode", + "OpenCode", + ) + .unwrap(), + sources: vec![source.clone()], + references: vec![ + ExternalWorkspaceReferenceDefinition { + source: source.key.clone(), + alias: "docs".to_string(), + path: shared_path.clone(), + description: Some("Product documentation".to_string()), + hidden: false, + content_version: "docs-v1".to_string(), + }, + ExternalWorkspaceReferenceDefinition { + source: source.key, + alias: "specs".to_string(), + path: shared_path, + description: None, + hidden: true, + content_version: "specs-v1".to_string(), + }, + ], + diagnostics: Vec::new(), + }; + + snapshot.validate().unwrap(); +} + +#[test] +fn provider_snapshot_rejects_duplicate_aliases_and_relative_paths() { + let source = source(); + let identity = ExternalWorkspaceReferenceProviderIdentity { + provider_id: ProviderId::new("opencode.references").unwrap(), + ecosystem_id: EcosystemId::new("opencode").unwrap(), + display_name: "OpenCode".to_string(), + }; + let definition = ExternalWorkspaceReferenceDefinition { + source: source.key.clone(), + alias: "docs".to_string(), + path: PathBuf::from("relative/docs"), + description: None, + hidden: false, + content_version: "docs-v1".to_string(), + }; + let snapshot = ExternalWorkspaceReferenceProviderSnapshot { + provider: identity, + sources: vec![source], + references: vec![definition.clone(), definition], + diagnostics: Vec::new(), + }; + + assert!(snapshot.validate().is_err()); +} + +#[test] +fn provider_snapshot_rejects_unbounded_diagnostics() { + let source = source(); + let diagnostics = (0..257) + .map(|index| { + ExternalSourceDiagnostic::warning( + format!("opencode.reference.invalid_{index}"), + "invalid reference", + Some(source.key.clone()), + ) + .with_asset_kind(ExternalSourceAssetKind::Reference) + }) + .collect(); + let snapshot = ExternalWorkspaceReferenceProviderSnapshot { + provider: ExternalWorkspaceReferenceProviderIdentity::new( + "opencode.references", + "opencode", + "OpenCode", + ) + .unwrap(), + sources: vec![source], + references: Vec::new(), + diagnostics, + }; + + assert!(snapshot.validate().is_err()); +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.scss index 1b940fe71..387ce54b0 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.scss @@ -118,6 +118,53 @@ gap: 10px; } + &__section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--color-text-secondary); + font-size: 12px; + font-weight: 650; + } + + &__section-description { + color: var(--color-text-muted); + font-size: 12px; + line-height: 1.5; + } + + &__external-section { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__read-only { + color: var(--color-text-muted); + font-size: 11px; + font-weight: 500; + } + + &__diagnostics { + color: var(--color-text-muted); + font-size: 11px; + + summary { + cursor: pointer; + } + + ul { + margin: 6px 0 0; + padding-left: 18px; + } + + code { + font-family: var(--font-family-mono); + overflow-wrap: anywhere; + } + } + &__card { display: flex; flex-direction: column; @@ -132,6 +179,31 @@ border-color: color-mix(in srgb, var(--border-medium) 74%, transparent); background: color-mix(in srgb, var(--element-bg-soft) 70%, transparent); } + + &--external { + gap: 7px; + background: color-mix(in srgb, var(--element-bg-subtle) 52%, transparent); + } + } + + &__external-alias { + color: var(--color-accent-500); + font-family: var(--font-family-mono); + font-size: 12px; + font-weight: 650; + } + + &__external-source { + color: var(--color-text-muted); + font-size: 11px; + } + + &__external-path { + overflow-wrap: anywhere; + color: var(--color-text-secondary); + font-family: var(--font-family-mono); + font-size: 11px; + line-height: 1.45; } &__card-header { diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.tsx index 9001ccecf..57ecaf766 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.tsx @@ -2,6 +2,11 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Button, Input, Modal, Textarea } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { + externalSourcesAPI, + type WorkspaceReferenceEntry, + type WorkspaceReferenceSnapshot, +} from '@/infrastructure/api/service-api/ExternalSourcesAPI'; import { sshApi } from '@/features/ssh-remote/sshApi'; import RemoteFileBrowser from '@/features/ssh-remote/RemoteFileBrowser'; import { createLogger } from '@/shared/utils/logger'; @@ -23,6 +28,8 @@ interface DraftRelatedPath { description: string; } +type WorkspaceReferenceDiagnostic = NonNullable[number]; + function createDraft(path?: Partial): DraftRelatedPath { return { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, @@ -52,10 +59,18 @@ export const WorkspaceRelatedPathsDialog: React.FC(null); const [browsingIndex, setBrowsingIndex] = useState(null); const [remoteHomePath, setRemoteHomePath] = useState(undefined); + const [externalReferences, setExternalReferences] = useState([]); + const [externalReferencesLoading, setExternalReferencesLoading] = useState(false); + const [externalReferencesFailed, setExternalReferencesFailed] = useState(false); + const [externalReferenceDiagnostics, setExternalReferenceDiagnostics] = useState([]); const remoteWorkspace = isRemoteWorkspace(workspace); const connectionId = workspace.connectionId?.trim() || undefined; - const relatedPathCount = drafts.length; + const visibleExternalReferences = useMemo( + () => externalReferences.filter(reference => reference.origin === 'external' && !reference.hidden), + [externalReferences], + ); + const relatedPathCount = drafts.length + visibleExternalReferences.length; const scopeDescription = remoteWorkspace ? t('nav.workspaces.relatedPaths.dialog.remoteScope', { connectionName: workspace.connectionName || workspace.name, @@ -72,6 +87,48 @@ export const WorkspaceRelatedPathsDialog: React.FC { + if (!isOpen || remoteWorkspace) { + setExternalReferences([]); + setExternalReferencesLoading(false); + setExternalReferencesFailed(false); + setExternalReferenceDiagnostics([]); + return; + } + + let cancelled = false; + setExternalReferencesLoading(true); + setExternalReferencesFailed(false); + void externalSourcesAPI + .getWorkspaceReferences(workspace.rootPath, workspace.id) + .then(snapshot => { + if (!cancelled) { + setExternalReferences(snapshot.references); + setExternalReferenceDiagnostics(snapshot.diagnostics ?? []); + } + }) + .catch(fetchError => { + log.warn('Failed to load external workspace references', { + workspaceId: workspace.id, + error: fetchError, + }); + if (!cancelled) { + setExternalReferences([]); + setExternalReferencesFailed(true); + setExternalReferenceDiagnostics([]); + } + }) + .finally(() => { + if (!cancelled) { + setExternalReferencesLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [isOpen, remoteWorkspace, workspace.id, workspace.rootPath]); + useEffect(() => { if (!isOpen || !remoteWorkspace || !connectionId) { setRemoteHomePath(undefined); @@ -202,6 +259,10 @@ export const WorkspaceRelatedPathsDialog: React.FC +
+ {t('nav.workspaces.relatedPaths.dialog.nativeHeading')} +
+ {drafts.length === 0 ? (
{t('nav.workspaces.relatedPaths.dialog.empty')} @@ -266,6 +327,75 @@ export const WorkspaceRelatedPathsDialog: React.FC )} + {!remoteWorkspace ? ( +
+
+ {t('nav.workspaces.relatedPaths.dialog.externalHeading')} + + {t('nav.workspaces.relatedPaths.dialog.readOnly')} + +
+
+ {t('nav.workspaces.relatedPaths.dialog.externalDescription')} +
+ {externalReferenceDiagnostics.length > 0 ? ( +
+ + {t('nav.workspaces.relatedPaths.dialog.externalDiagnostics', { + count: externalReferenceDiagnostics.length, + })} + +
    + {externalReferenceDiagnostics.map((diagnostic, index) => ( +
  • + {diagnostic.code} +
  • + ))} +
+
+ ) : null} + {externalReferencesLoading ? ( +
+ {t('nav.workspaces.relatedPaths.dialog.externalLoading')} +
+ ) : externalReferencesFailed ? ( +
+ {t('nav.workspaces.relatedPaths.messages.externalLoadFailed')} +
+ ) : visibleExternalReferences.length === 0 ? ( +
+ {t('nav.workspaces.relatedPaths.dialog.externalEmpty')} +
+ ) : ( +
+ {visibleExternalReferences.map(reference => ( +
+
+ + @{reference.alias} + + + {reference.sourceDisplayName} + +
+
+ {reference.path} +
+ {reference.description ? ( +
+ {reference.description} +
+ ) : null} +
+ ))} +
+ )} +
+ ) : null} + {error ? (
{error} diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index d0d748112..ec53cbaee 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -5149,6 +5149,9 @@ export const ChatInput: React.FC = ({ isOpen={mentionState.isActive} searchQuery={mentionState.query} workspacePath={sessionBoundWorkspacePath} + workspaceId={hasRegisteredWorkspace + ? undefined + : effectiveTargetSession?.workspaceId || workspace?.id} excludeSessionId={effectiveTargetSessionId || undefined} onSelect={(context: FileContext | DirectoryContext | SessionReferenceContext) => { addContext(context); diff --git a/src/web-ui/src/flow_chat/components/FileMentionPicker.test.tsx b/src/web-ui/src/flow_chat/components/FileMentionPicker.test.tsx new file mode 100644 index 000000000..8628933df --- /dev/null +++ b/src/web-ui/src/flow_chat/components/FileMentionPicker.test.tsx @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import type { WorkspaceReferenceEntry } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import { workspaceReferenceItems } from './FileMentionPicker'; + +function reference( + stableKey: string, + origin: WorkspaceReferenceEntry['origin'], + path: string, + alias?: string, +): WorkspaceReferenceEntry { + return { + stableKey, + origin, + path, + alias, + hidden: false, + }; +} + +describe('workspaceReferenceItems', () => { + it('preserves native-first catalog order and distinct aliases for one path', () => { + const items = workspaceReferenceItems([ + reference('native', 'native', 'D:/shared/native'), + reference('external-docs', 'external', 'D:/shared/external', 'docs'), + reference('external-api', 'external', 'D:/shared/external', 'api'), + { ...reference('hidden', 'external', 'D:/hidden', 'private'), hidden: true }, + ]); + + expect(items.map(item => item.referenceStableKey)).toEqual([ + 'native', + 'external-docs', + 'external-api', + ]); + expect(items.map(item => item.name)).toEqual(['native', '@docs', '@api']); + }); + + it('matches aliases and descriptions without recursively searching reference roots', () => { + const items = workspaceReferenceItems([ + { + ...reference('external-docs', 'external', 'D:/shared/external', 'docs'), + description: 'Architecture notes', + }, + reference('external-api', 'external', 'D:/shared/api', 'api'), + ], 'architecture'); + + expect(items.map(item => item.referenceStableKey)).toEqual(['external-docs']); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/FileMentionPicker.tsx b/src/web-ui/src/flow_chat/components/FileMentionPicker.tsx index a011f8b2d..8c4805f64 100644 --- a/src/web-ui/src/flow_chat/components/FileMentionPicker.tsx +++ b/src/web-ui/src/flow_chat/components/FileMentionPicker.tsx @@ -15,6 +15,10 @@ import { ChevronLeft, } from 'lucide-react'; import { sessionAPI, workspaceAPI } from '@/infrastructure/api'; +import { + externalSourcesAPI, + type WorkspaceReferenceEntry, +} from '@/infrastructure/api/service-api/ExternalSourcesAPI'; import type { ExplorerNodeDto, FileSearchResult, @@ -37,6 +41,7 @@ export interface FileMentionPickerProps { isOpen: boolean; searchQuery: string; workspacePath?: string; + workspaceId?: string; /** The composing session itself must not appear as a reference candidate. */ excludeSessionId?: string; onSelect: (context: FileContext | DirectoryContext | SessionReferenceContext) => void; @@ -50,6 +55,30 @@ interface FileItem { name: string; isDirectory: boolean; relativePath: string; + referenceStableKey?: string; + referenceDescription?: string; +} + +export function workspaceReferenceItems( + references: WorkspaceReferenceEntry[], + query = '', +): FileItem[] { + const normalizedQuery = query.trim().toLocaleLowerCase(); + return references + .filter(reference => !reference.hidden) + .filter(reference => { + if (!normalizedQuery) return true; + return [reference.alias, reference.description, reference.path] + .some(value => value?.toLocaleLowerCase().includes(normalizedQuery)); + }) + .map(reference => ({ + path: reference.path, + name: reference.alias ? `@${reference.alias}` : reference.path.replace(/\\/g, '/').split('/').pop() || reference.path, + isDirectory: true, + relativePath: reference.path, + referenceStableKey: reference.stableKey, + referenceDescription: reference.description, + })); } type MentionItem = @@ -60,6 +89,7 @@ export const FileMentionPicker: React.FC = ({ isOpen, searchQuery, workspacePath, + workspaceId, excludeSessionId, onSelect, onClose, @@ -68,6 +98,7 @@ export const FileMentionPicker: React.FC = ({ const { t } = useTranslation('flow-chat'); const [results, setResults] = useState([]); const [sessionResults, setSessionResults] = useState([]); + const [workspaceReferences, setWorkspaceReferences] = useState([]); const [currentFiles, setCurrentFiles] = useState([]); const [isFileLoading, setIsFileLoading] = useState(false); const [isSessionLoading, setIsSessionLoading] = useState(false); @@ -83,6 +114,7 @@ export const FileMentionPicker: React.FC = ({ const directoryLoadRequestIdRef = useRef(0); const fileSearchRequestIdRef = useRef(0); const sessionSearchRequestIdRef = useRef(0); + const workspaceReferenceRequestIdRef = useRef(0); const skipNextPathLoadRef = useRef(false); const getRelativePath = useCallback((fullPath: string): string => { @@ -167,6 +199,27 @@ export const FileMentionPicker: React.FC = ({ loadDirectory('', null); }, [isOpen, workspacePath, loadDirectory]); + useEffect(() => { + if (!isOpen || !workspacePath) { + workspaceReferenceRequestIdRef.current += 1; + setWorkspaceReferences([]); + return; + } + const requestId = ++workspaceReferenceRequestIdRef.current; + void externalSourcesAPI + .getWorkspaceReferences(workspacePath, workspaceId) + .then(snapshot => { + if (requestId === workspaceReferenceRequestIdRef.current) { + setWorkspaceReferences(snapshot.references); + } + }) + .catch(() => { + if (requestId === workspaceReferenceRequestIdRef.current) { + setWorkspaceReferences([]); + } + }); + }, [isOpen, workspaceId, workspacePath]); + useEffect(() => { if (!isOpen || searchQuery.trim()) return; if (skipNextPathLoadRef.current) { @@ -292,14 +345,22 @@ export const FileMentionPicker: React.FC = ({ }, [excludeSessionId, isOpen, searchQuery]); const isSearchMode = searchQuery.trim().length > 0; + const referenceItems = useMemo( + () => workspaceReferenceItems(workspaceReferences, isSearchMode ? searchQuery : ''), + [isSearchMode, searchQuery, workspaceReferences], + ); const displayItems = useMemo(() => ( isSearchMode ? [ ...results.map(item => ({ kind: 'file' as const, item })), + ...referenceItems.map(item => ({ kind: 'file' as const, item })), ...sessionResults.map(item => ({ kind: 'session' as const, item })), ] - : currentFiles.map(item => ({ kind: 'file' as const, item })) - ), [currentFiles, isSearchMode, results, sessionResults]); + : [ + ...currentFiles.map(item => ({ kind: 'file' as const, item })), + ...(currentPath ? [] : referenceItems.map(item => ({ kind: 'file' as const, item }))), + ] + ), [currentFiles, currentPath, isSearchMode, referenceItems, results, sessionResults]); const currentDirName = currentPath ? currentPath.replace(/\\/g, '/').split('/').pop() || '' : workspacePath?.replace(/\\/g, '/').split('/').pop() || t('fileMention.rootDirectory'); @@ -451,7 +512,9 @@ export const FileMentionPicker: React.FC = ({ const isSession = mention.kind === 'session'; const file = mention.kind === 'file' ? mention.item : null; const session = mention.kind === 'session' ? mention.item : null; - const key = isSession ? `session-${session?.sessionId}-${session?.workspacePath}` : `file-${file?.path}`; + const key = isSession + ? `session-${session?.sessionId}-${session?.workspacePath}` + : `file-${file?.referenceStableKey || file?.path}`; return (
= ({ {isSession ? : file?.isDirectory ? : } {session?.sessionName ?? file?.name} {session && {session.workspaceLabel}} + {file?.referenceStableKey && ( + + {file.referenceDescription || file.path} + + )} {file?.isDirectory && !isSearchMode && }
); diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx index ce7c67987..3c740da58 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx @@ -24,6 +24,7 @@ interface UserMessageEditComposerProps { onCancel: () => void; presentation?: ComposerPresentation | null; workspacePath?: string; + workspaceId?: string; excludeSessionId?: string; } @@ -42,6 +43,7 @@ const RichUserMessageEditComposer: React.FC = onCancel, presentation, workspacePath, + workspaceId, excludeSessionId, }) => { const editorRef = useRef(null); @@ -129,6 +131,7 @@ const RichUserMessageEditComposer: React.FC = isOpen={mentionState.isActive} searchQuery={mentionState.query} workspacePath={workspacePath} + workspaceId={workspaceId} excludeSessionId={excludeSessionId} onSelect={handleSelectContext} onClose={() => editorRef.current?.closeMention?.()} @@ -171,6 +174,7 @@ export const UserMessageEditComposer: React.FC = ( onCancel, presentation, workspacePath, + workspaceId, excludeSessionId, }) => { const textareaRef = useRef(null); @@ -216,6 +220,7 @@ export const UserMessageEditComposer: React.FC = ( onCancel={onCancel} presentation={presentation} workspacePath={workspacePath} + workspaceId={workspaceId} excludeSessionId={excludeSessionId} /> ); diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index 5f3a41bd5..992351db9 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -499,6 +499,7 @@ export const UserMessageItem = React.memo( onCancel={cancelEdit} presentation={composerPresentation} workspacePath={currentSession?.workspacePath} + workspaceId={currentSession?.workspaceId} excludeSessionId={resolvedSessionId} /> ) : ( diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts index b7c8fd1c7..a8969b7c7 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts @@ -76,6 +76,28 @@ describe('ExternalSourcesAPI', () => { }); }); + it('keeps the execution root and workspace metadata identity in reference discovery', async () => { + invokeMock.mockResolvedValueOnce({ + generation: 3, + discoveryPending: false, + references: [], + }); + + await externalSourcesAPI.getWorkspaceReferences( + 'D:/workspace/project/.bitfun/worktrees/task', + 'workspace-1', + true, + ); + + expect(invokeMock).toHaveBeenCalledWith('get_workspace_reference_snapshot', { + request: { + workspacePath: 'D:/workspace/project/.bitfun/worktrees/task', + workspaceId: 'workspace-1', + forceRefresh: true, + }, + }); + }); + it('treats an empty workspace path as the global scope', async () => { await externalSourcesAPI.getSnapshot('', false); diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts index 5b1c0032b..f0158e9b9 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -115,7 +115,7 @@ export interface ExternalSourceRecord { contentVersion: string; diagnostics?: Array<{ severity: string; - assetKind?: 'source' | 'command' | 'tool' | 'subagent' | 'mcp'; + assetKind?: 'source' | 'command' | 'tool' | 'subagent' | 'mcp' | 'reference'; code: string; message: string; }>; @@ -184,7 +184,7 @@ export interface ExternalSourceCatalogSnapshot { integrationPolicy: ExternalIntegrationPolicySnapshot; diagnostics?: Array<{ severity: string; - assetKind?: 'source' | 'command' | 'tool' | 'subagent' | 'mcp'; + assetKind?: 'source' | 'command' | 'tool' | 'subagent' | 'mcp' | 'reference'; code: string; message: string; }>; @@ -557,6 +557,30 @@ export interface NativePromptCommandConflictSnapshot { }>; } +export interface WorkspaceReferenceEntry { + stableKey: string; + alias?: string; + path: string; + description?: string; + hidden: boolean; + origin: 'native' | 'external'; + ecosystemId?: string; + sourceDisplayName?: string; + sourceScope?: ExternalSourceScope; +} + +export interface WorkspaceReferenceSnapshot { + generation: number; + discoveryPending: boolean; + references: WorkspaceReferenceEntry[]; + diagnostics?: Array<{ + severity: string; + assetKind?: 'reference'; + code: string; + message: string; + }>; +} + const READ_ONLY_HOST_CAPABILITIES: ExternalSourceCatalogSnapshot['hostCapabilities'] = { canRefresh: false, canMutatePolicy: false, @@ -1187,6 +1211,30 @@ export const externalSourcesAPI = { })).catalog; }, + async getWorkspaceReferences( + workspacePath: string, + workspaceId?: string, + forceRefresh = false, + ) { + const snapshot = await invokeExternalSourceCommand( + 'get_workspace_reference_snapshot', + { + request: { + workspacePath: normalizeOptionalWorkspacePath(workspacePath), + workspaceId: workspaceId?.trim() || undefined, + forceRefresh, + }, + }, + ); + return { + ...snapshot, + references: normalizeOptionalArray(snapshot.references), + diagnostics: normalizeOptionalArray[number]>( + snapshot.diagnostics, + ), + }; + }, + expandPromptCommand( workspacePath: string | undefined, name: string, diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 18e6c3572..1762738da 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -420,6 +420,13 @@ "relatedPaths": { "badge": "{{count}} linked", "dialog": { + "nativeHeading": "BitFun workspace directories", + "externalHeading": "OpenCode references", + "externalDescription": "Discovered from OpenCode configuration using OpenCode's source priority. These entries are read-only here and do not grant additional file access.", + "externalLoading": "Loading OpenCode references…", + "externalEmpty": "No visible local OpenCode references were found for this workspace.", + "externalDiagnostics": "{{count}} reference entries need attention", + "readOnly": "Read-only", "title": "Related directories", "heading": "Add supporting directories to this workspace", "description": "These directories and descriptions are injected into workspace context, which is useful for cross-project migrations and focused monorepo slices.", @@ -433,6 +440,7 @@ "descriptionPlaceholder": "Optional: briefly explain why this directory matters, for example: legacy TypeScript implementation / billing package inside the monorepo" }, "messages": { + "externalLoadFailed": "OpenCode references could not be loaded. Your BitFun workspace directories are unaffected.", "saveFailed": "Failed to save related directories", "selectFailed": "Failed to select directory" }, diff --git a/src/web-ui/src/locales/en-US/settings/external-sources.json b/src/web-ui/src/locales/en-US/settings/external-sources.json index 66bfad6bd..f59ab4837 100644 --- a/src/web-ui/src/locales/en-US/settings/external-sources.json +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -177,6 +177,7 @@ "auto": "Use automatically" }, "capability": { + "reference": "References", "command": "Commands", "tool": "Tools", "subagent": "Agents", @@ -535,6 +536,11 @@ "configLocations": "Configuration locations", "inherited": "Inherited from overall mode", "capability": { + "reference": { + "description": "Named local directories from OpenCode configuration", + "effect": "Shows references in workspace directory views and explicit directory navigation.", + "risk": "References do not grant additional file access." + }, "subagent": { "description": "{{count}} agents found · from {{scope}}", "effect": "Allows calling agents defined in OpenCode.", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 0d5cb7370..957986cd4 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -420,6 +420,13 @@ "relatedPaths": { "badge": "{{count}} 关联", "dialog": { + "nativeHeading": "BitFun 工作空间目录", + "externalHeading": "OpenCode 引用", + "externalDescription": "按 OpenCode 的来源优先级从其配置中发现。此处仅只读展示,不会授予额外的文件访问权限。", + "externalLoading": "正在加载 OpenCode 引用…", + "externalEmpty": "当前工作空间未发现可见的本地 OpenCode 引用。", + "externalDiagnostics": "{{count}} 个引用条目需要注意", + "readOnly": "只读", "title": "关联目录", "heading": "为当前工作区补充相关目录", "description": "这些目录会连同说明一起进入工作区上下文,适合跨项目迁移、monorepo 子目录协作等场景。", @@ -433,6 +440,7 @@ "descriptionPlaceholder": "选填:简短说明这个目录为什么重要,例如:旧版 TypeScript 实现 / monorepo 中的 billing 子目录" }, "messages": { + "externalLoadFailed": "无法加载 OpenCode 引用,BitFun 工作空间目录不受影响。", "saveFailed": "保存关联目录失败", "selectFailed": "选择目录失败" }, diff --git a/src/web-ui/src/locales/zh-CN/settings/external-sources.json b/src/web-ui/src/locales/zh-CN/settings/external-sources.json index a9b13bb65..6e3692775 100644 --- a/src/web-ui/src/locales/zh-CN/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -177,6 +177,7 @@ "auto": "自动使用" }, "capability": { + "reference": "引用目录", "command": "命令", "tool": "工具", "subagent": "Agent", @@ -535,6 +536,11 @@ "configLocations": "配置位置", "inherited": "继承自整体模式", "capability": { + "reference": { + "description": "OpenCode 配置中的具名本地目录", + "effect": "在工作空间目录视图和显式目录导航中展示引用。", + "risk": "引用不会授予额外的文件访问权限。" + }, "subagent": { "description": "发现 {{count}} 个 Agent · 来自{{scope}}", "effect": "启用后允许调用 OpenCode 中定义的 Agent。", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 73fd4d53f..8c6480345 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -420,6 +420,13 @@ "relatedPaths": { "badge": "{{count}} 關聯", "dialog": { + "nativeHeading": "BitFun 工作空間目錄", + "externalHeading": "OpenCode 引用", + "externalDescription": "依 OpenCode 的來源優先順序從其設定中探索。此處僅供唯讀顯示,不會授予額外的檔案存取權限。", + "externalLoading": "正在載入 OpenCode 引用…", + "externalEmpty": "目前工作空間未探索到可見的本機 OpenCode 引用。", + "externalDiagnostics": "{{count}} 個引用項目需要留意", + "readOnly": "唯讀", "title": "關聯目錄", "heading": "為目前工作區補充相關目錄", "description": "這些目錄會連同說明一起進入工作區上下文,適合跨項目遷移、monorepo 子目錄協作等場景。", @@ -433,6 +440,7 @@ "descriptionPlaceholder": "選填:簡短說明這個目錄為什麼重要,例如:舊版 TypeScript 實現 / monorepo 中的 billing 子目錄" }, "messages": { + "externalLoadFailed": "無法載入 OpenCode 引用,BitFun 工作空間目錄不受影響。", "saveFailed": "儲存關聯目錄失敗", "selectFailed": "選擇目錄失敗" }, diff --git a/src/web-ui/src/locales/zh-TW/settings/external-sources.json b/src/web-ui/src/locales/zh-TW/settings/external-sources.json index 05d78290f..5b4736b40 100644 --- a/src/web-ui/src/locales/zh-TW/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -177,6 +177,7 @@ "auto": "自動使用" }, "capability": { + "reference": "引用目錄", "command": "命令", "tool": "工具", "subagent": "Agent", @@ -535,6 +536,11 @@ "configLocations": "設定位置", "inherited": "繼承自整體模式", "capability": { + "reference": { + "description": "OpenCode 設定中的具名本機目錄", + "effect": "在工作空間目錄檢視與明確目錄導覽中顯示引用。", + "risk": "引用不會授予額外的檔案存取權限。" + }, "subagent": { "description": "發現 {{count}} 個 Agent · 來自{{scope}}", "effect": "啟用後允許呼叫 OpenCode 中定義的 Agent。",