diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 4b711baf7..611112375 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -18,7 +18,7 @@ BitFun 是一个由 Rust workspace 与 React 前端组成的项目。 | 模块 | 路径 | Agent 文档 | |---|---|---| | Core(产品逻辑) | `src/crates/core` | [AGENTS.md](src/crates/core/AGENTS.md) | -| 已拆出的 core 支撑 crate | `src/crates/{core-types,agent-stream,runtime-ports,terminal,tool-runtime}` | (使用 core 指南) | +| 已拆出的 core 支撑 crate | `src/crates/{core-types,agent-stream,runtime-ports,runtime-services,terminal,tool-runtime}` | (使用 core 指南) | | Service core owner crate | `src/crates/services-core` | [AGENTS.md](src/crates/services-core/AGENTS.md) | | Service integrations owner crate | `src/crates/services-integrations` | [AGENTS.md](src/crates/services-integrations/AGENTS.md) | | Agent tool contracts | `src/crates/agent-tools` | [AGENTS.md](src/crates/agent-tools/AGENTS.md) | diff --git a/AGENTS.md b/AGENTS.md index f97644c47..b130df399 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Repository rule: **keep product logic platform-agnostic, then expose it through | Module | Path | Agent doc | |---|---|---| | Core (product logic) | `src/crates/core` | [AGENTS.md](src/crates/core/AGENTS.md) | -| Extracted core support | `src/crates/{core-types,agent-stream,runtime-ports,terminal,tool-runtime}` | (use core guide) | +| Extracted core support | `src/crates/{core-types,agent-stream,runtime-ports,runtime-services,terminal,tool-runtime}` | (use core guide) | | Service core owner crate | `src/crates/services-core` | [AGENTS.md](src/crates/services-core/AGENTS.md) | | Service integrations owner crate | `src/crates/services-integrations` | [AGENTS.md](src/crates/services-integrations/AGENTS.md) | | Agent tool contracts | `src/crates/agent-tools` | [AGENTS.md](src/crates/agent-tools/AGENTS.md) | diff --git a/Cargo.toml b/Cargo.toml index f32d8e3f7..53c8cd58a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "src/crates/ai-adapters", "src/crates/agent-stream", "src/crates/runtime-ports", + "src/crates/runtime-services", "src/crates/services-core", "src/crates/services-integrations", "src/crates/product-domains", diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index 35178e595..9fac82f35 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -1,8 +1,9 @@ # Agent Runtime SDK 与 Runtime Services 设计 本文是 [`core-decomposition.md`](core-decomposition.md) 的开发设计文档,描述目标模块、 -接口、crate 内部结构和迁移保护。本文中的 `bitfun-runtime-services`、 -`bitfun-agent-runtime`、`bitfun-harness` 是目标 crate;在实际创建前不得把它们当作 +接口、crate 内部结构和迁移保护。`bitfun-runtime-services` 已建立 PR1 基础壳层, +当前只承载 typed service bundle、builder、provider registry、capability availability 和 +fake provider;`bitfun-agent-runtime`、`bitfun-harness` 仍是目标 crate,在实际创建前不得把它们当作 已完成事实。 ## 1. 设计目标与边界 @@ -20,7 +21,7 @@ bitfun-core-types bitfun-events bitfun-runtime-ports -bitfun-runtime-services # 目标 +bitfun-runtime-services # PR1 基础壳层 bitfun-agent-tools tool-runtime bitfun-agent-runtime # 目标 @@ -68,8 +69,7 @@ tool-runtime bitfun-runtime-services -> bitfun-runtime-ports - -> bitfun-core-types - -> bitfun-events + -> bitfun-core-types / bitfun-events(仅当 service DTO 或 event contract 需要时引入) 具体 service crates -> bitfun-runtime-ports @@ -87,10 +87,10 @@ bitfun-runtime-services - `bitfun-agent-runtime` -> Tauri / CLI / ACP protocol / Web UI - `bitfun-harness` -> 具体 filesystem / Git / terminal manager -目标 crate 创建准入: +目标 crate 创建或继续扩展准入: - 只有当 owner 边界、旧路径兼容、focused tests、依赖收益和 boundary check 都能同时落地时,才创建新的目标 crate。 -- `bitfun-runtime-services` 的创建前提是 typed builder 至少承载本地 service、remote service 和 fake provider 三类注入路径。 +- `bitfun-runtime-services` 已按该准入建立基础壳层;继续扩展时仍必须保持 typed builder、本地 service、remote service 和 fake provider 三类注入路径可测试。 - `bitfun-agent-runtime` 的创建前提是 session / turn / scheduler / prompt loop 中至少一个 owner 可以脱离 `bitfun-core` 构建,并有旧路径 facade。 - `bitfun-harness` 的创建前提是至少两个 workflow 可以通过 provider contract 注册,例如 Deep Review 与 MiniApp / DeepResearch。 - 若目标 crate 只能承接单个 helper 或只能通过 `bitfun-core` 才能测试,继续留在迁移期 facade,不提前拆 crate。 @@ -157,7 +157,7 @@ pub trait WorkspacePort: Send + Sync { ### 2.2 Runtime Services -目标 crate:`bitfun-runtime-services`。 +当前 crate:`bitfun-runtime-services`。 职责: diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index 1717c32f8..7c048fec0 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -323,7 +323,7 @@ flowchart TB | 注册器 / 组装点 | 所属目标层级 | 目标或迁移期模块 | 注册内容 | |---|---|---|---| | `ProductAssembler` / `ProductAssemblyPlan` | 产品组装层(Product Assembly) | 迁移期在 `bitfun-core` facade 或产品入口;目标可收敛为 assembly owner | `DeliveryProfile`、`CapabilitySet`、feature group、provider 选择 | -| `RuntimeServicesBuilder` | 运行时服务层(Runtime Services) | 目标 `bitfun-runtime-services`;迁移期连接 `bitfun-runtime-ports`、`bitfun-services-*` 和 `bitfun-core` service wiring | filesystem、workspace、session store、Git、terminal、network、MCP catalog、remote connection / workspace / projection port | +| `RuntimeServicesBuilder` | 运行时服务层(Runtime Services) | `bitfun-runtime-services` PR1 基础壳层;迁移期连接 `bitfun-runtime-ports`、`bitfun-services-*` 和 `bitfun-core` service wiring | filesystem、workspace、session store、Git、terminal、network、MCP catalog、remote connection / workspace / projection port | | `ToolRuntimeBuilder` | 工具运行时(Tool Runtime) | `tool-runtime`、`bitfun-agent-tools`、`bitfun-tool-packs` | tool provider、tool pack、manifest、permission gate、tool hook | | `HarnessRegistryBuilder` | 工作流编排层(Harness Layer) | 目标 `bitfun-harness`;迁移期在 `bitfun-core` 和产品能力代码中 | SDD、Deep Review、DeepResearch、MiniApp 等 harness provider | | `AgentDefinitionRegistry` | Agent 运行时 SDK(Agent Runtime SDK) | 目标 `bitfun-agent-runtime`;迁移期在 `bitfun-core` agent definition 代码中 | agent、subagent、prompt module、skill definition | diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 96a31f80a..27021d481 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -38,10 +38,10 @@ - SSH、relay、本地隧道、远端 OS 差异、认证方式属于具体 Remote provider。 - remote workspace、terminal pre-warm、scheduler submit、session restore、file chunk / image fallback 等行为必须用等价测试保护。 -### 2.4 目标 crate 创建准入 +### 2.4 目标 crate 创建或扩展准入 - 新目标 crate 不能为了“架构完整”提前创建。必须同时满足 owner 边界清晰、旧路径兼容可保留、focused tests 可落地、依赖收益可解释、boundary check 可防回流。 -- `bitfun-runtime-services` 优先级最高,但创建前必须先有最小 `RuntimeServicesBuilder` skeleton、Remote ports 和 fake provider 测试。 +- `bitfun-runtime-services` 已按该准入建立基础壳层;继续扩展时仍必须保持 `RuntimeServicesBuilder` skeleton、Remote ports 和 fake provider 测试同时成立。 - `bitfun-agent-runtime` 只能在 session / turn / scheduler / prompt loop 中至少一个 owner 可脱离 `bitfun-core` 构建时创建。 - `bitfun-harness` 只能在至少两个 workflow 通过 provider contract 接入时创建,不能只为单个 Deep Review 或 MiniApp helper 拆 crate。 - 若某项迁移只能承接单个 helper,或测试仍必须依赖完整 `bitfun-core`,继续留在迁移期 facade。 @@ -60,17 +60,31 @@ ## 4. 后续迁移队列 -| 顺序 | 主题 | 完整范围 | 不允许混入 | 合入门禁 | +后续迁移固定收敛为 7 个大块 PR。每个 PR 都必须先补保护,再迁移 owner,最后回看文档和边界;如果发现必须改变功能语义,需要在 PR 中单独说明原因、影响范围和回滚边界。 + +| PR | 主题 | 完整范围 | 不允许混入 | 合入门禁 | |---|---|---|---|---| -| 0 | Product Assembly / Runtime Services Foundation | 建立最小 Product Assembly skeleton、`RuntimeServicesBuilder` skeleton、Remote ports、fake provider 和 boundary check 入口 | 具体 remote runtime、tool IO、product-domain IO、default feature 调整 | provider 注册路径可测试,Remote ports 不暴露 SSH / relay concrete handle | -| 1 | Service / Agent Remote Runtime Owner | 在 remote connection、remote workspace、remote FS / terminal projection、workspace-root / persistence、`ImageContextData`、remote-SSH / relay provider 中选择一个 owner 主题,完成 port、provider、旧路径兼容和行为等价验证 | tool runtime、product-domain runtime、feature matrix、产品命令或 UI 行为变更 | remote/session/file/image/terminal/scheduler 行为等价,产品 surface 不变 | -| 2 | Agent Runtime SDK Owner | 拆分 mode-scoped subagent visibility、agent registry facts、queue policy decision、scheduler submit/cancel facts 和 background delivery 边界;concrete scheduler 生命周期按保护程度逐步外移 | remote provider、tool IO、product-domain IO、默认 feature 调整 | subagent 可见性、queue/preempt/cancel、background reply、DeepResearch hook 等价 | -| 3 | Harness / Product Capability Boundary | 建立 Harness provider contract,让 Deep Review、DeepResearch、MiniApp 等 workflow 通过 provider 注册,不侵入 Agent Runtime SDK | concrete service IO、tool IO、surface 命令语义变更 | 至少两个 workflow 可通过 provider contract 表达,旧路径兼容 | -| 4 | Product-Domain Runtime Owner | MiniApp filesystem IO / worker / host / builtin seed 或 function-agent Git/AI 中选择一个 owner 主题,建立最小 port/provider 和 core adapter | tool runtime、service/agent runtime、surface 行为变更 | MiniApp/function-agent focused regression,PathManager/process/Git/AI 边界清晰 | -| 5 | Tool Runtime Owner | 仅在收益明确时迁移 `ToolUseContext` projection、manifest execution、`GetToolSpecTool` execution、snapshot wrapper、collapsed unlock state 或具体工具 IO 中的一个 owner 主题 | service/agent runtime、product-domain runtime、feature matrix、产品行为变更 | tool visibility、manifest、`GetToolSpec`、snapshot、Deep Review tool flow 等价 | -| 6 | Feature / Build-Benefit Evaluation | 评估 feature matrix、dependency profile、no-default 编译面和构建收益数据 | runtime owner 迁移、default feature 副作用、构建脚本变更 | cargo metadata / cargo tree 证据,产品入口完整能力不变 | - -当前优先级更偏向 **Product Assembly / Runtime Services Foundation**,随后进入 **Service / Agent Remote Runtime Owner**。原因是 Remote 与 OS/terminal/file/network 的实现边界最容易继续牵引 core,但必须先有 typed registration 和 Remote ports,避免继续临时接线。 +| PR1 | Product Assembly / Runtime Services Foundation | 创建 `bitfun-runtime-services`,补 `RuntimeServicesBuilder`、typed provider registration、capability availability、Remote ports、fake provider 和 boundary check 入口 | 具体 remote runtime、tool IO、product-domain IO、default feature 调整 | provider 注册路径可测试,Remote ports 不暴露 SSH / relay concrete handle,新增 crate 不依赖 `bitfun-core` | +| PR2 | Service / Agent Remote Runtime Owner | 在 remote connection、remote workspace、remote FS / terminal projection、workspace-root / persistence、`ImageContextData`、remote-SSH / relay provider 中完成一个完整 owner 主题的 port、provider、旧路径兼容和行为等价验证 | tool runtime、product-domain runtime、feature matrix、产品命令或 UI 行为变更 | remote/session/file/image/terminal/scheduler 行为等价,产品 surface 不变 | +| PR3 | Agent Runtime SDK Owner | 拆分 mode-scoped subagent visibility、agent registry facts、queue policy decision、scheduler submit/cancel facts 和 background delivery 边界;concrete scheduler 生命周期按保护程度逐步外移 | remote provider、tool IO、product-domain IO、默认 feature 调整 | subagent 可见性、queue/preempt/cancel、background reply、DeepResearch hook 等价 | +| PR4 | Harness / Product Capability Boundary | 建立 Harness provider contract,让 Deep Review、DeepResearch、MiniApp 等 workflow 通过 provider 注册,不侵入 Agent Runtime SDK | concrete service IO、tool IO、surface 命令语义变更 | 至少两个 workflow 可通过 provider contract 表达,旧路径兼容 | +| PR5 | Product-Domain Runtime Owner | MiniApp filesystem IO / worker / host / builtin seed 或 function-agent Git/AI 中完成一个完整 owner 主题,建立最小 port/provider 和 core adapter | tool runtime、service/agent runtime、surface 行为变更 | MiniApp/function-agent focused regression,PathManager/process/Git/AI 边界清晰 | +| PR6 | Tool Runtime Owner | 在 `ToolUseContext` projection、manifest execution、`GetToolSpecTool` execution、snapshot wrapper、collapsed unlock state 或具体工具 IO 中完成一个收益明确的完整 owner 主题 | service/agent runtime、product-domain runtime、feature matrix、产品行为变更 | tool visibility、manifest、`GetToolSpec`、snapshot、Deep Review tool flow 等价 | +| PR7 | Feature / Build-Benefit Evaluation | 评估 feature matrix、dependency profile、no-default 编译面和构建收益数据,确认是否具备收敛默认 feature 的条件 | runtime owner 迁移、default feature 副作用、构建脚本变更 | cargo metadata / cargo tree 证据,产品入口完整能力不变 | + +### 4.1 PR1 具体实施计划 + +PR1 是后续高风险迁移的前置门禁,目标是提供可测试的 typed assembly 基础,而不是移动任何既有业务行为。 + +1. 新建 `bitfun-runtime-services` crate,并加入 workspace。 +2. 在 `bitfun-runtime-ports` 中补齐 Runtime Services 所需的轻量 port trait 和 Remote port trait;这些 trait 只能描述能力和请求边界,不携带 SSH、relay、Tauri、process、filesystem manager 等 concrete handle。 +3. 在 `bitfun-runtime-services` 中实现 `RuntimeServices`、`RuntimeServicesBuilder`、capability availability、typed unsupported error 和 provider registry。 +4. 提供 `test_support` fake provider,覆盖本地 mandatory service、optional remote service 和 unsupported capability 三类注入路径。 +5. 更新 `scripts/check-core-boundaries.mjs`,把 `bitfun-runtime-services` 纳入 no-core dependency 和轻量依赖边界检查。 +6. 更新仓库入口文档中的模块索引,说明 `bitfun-runtime-services` 仍使用 core decomposition guardrails。 +7. 运行 focused tests、边界检查和最小 Rust 验证;提交前从第三方视角检查是否出现 service locator、全局 mutable registry、反向依赖或功能语义漂移。 + +PR1 不迁移任何 concrete service owner,因此预期不会修改产品行为、默认能力集合、权限语义、工具曝光、事件语义、session 生命周期或构建脚本。 ## 5. 每类 PR 的保护重点 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index d4c4be7de..8a4261e6b 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -14,6 +14,7 @@ const noCoreDependencyCrates = [ 'ai-adapters', 'agent-stream', 'runtime-ports', + 'runtime-services', 'services-core', 'services-integrations', 'agent-tools', @@ -84,6 +85,34 @@ const lightweightBoundaryRules = [ 'syntect-tui', ], }, + { + crateName: 'runtime-services', + reason: 'runtime-services must stay a typed service assembly contract without concrete runtime implementations', + forbiddenDeps: [ + 'bitfun-core', + 'bitfun-ai-adapters', + 'bitfun-agent-stream', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-agent-tools', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, { crateName: 'agent-tools', reason: 'agent-tools must not depend on concrete service or product runtime implementations', @@ -215,6 +244,35 @@ const dependencyProfileRules = [ 'syntect-tui', ], }, + { + crateName: 'runtime-services', + profileName: 'default runtime service assembly profile', + reason: 'runtime-services default profile must not compile concrete service or product runtime implementations', + forbiddenNonOptionalDeps: [ + 'bitfun-core', + 'bitfun-ai-adapters', + 'bitfun-agent-stream', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-agent-tools', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, { crateName: 'agent-tools', profileName: 'tool contract-only profile', @@ -6730,6 +6788,21 @@ function runManifestParserSelfTest() { if (!runtimePortsProfile?.forbiddenNonOptionalDeps.includes('bitfun-services-core')) { throw new Error('runtime-ports dependency profile must forbid service implementations'); } + const runtimeServicesRule = lightweightBoundaryRules.find( + (rule) => rule.crateName === 'runtime-services', + ); + if (!runtimeServicesRule?.forbiddenDeps.includes('bitfun-core')) { + throw new Error('runtime-services lightweight boundary must forbid bitfun-core'); + } + if (!runtimeServicesRule?.forbiddenDeps.includes('bitfun-services-integrations')) { + throw new Error('runtime-services lightweight boundary must forbid concrete service integrations'); + } + const runtimeServicesProfile = dependencyProfileRules.find( + (rule) => rule.crateName === 'runtime-services', + ); + if (!runtimeServicesProfile?.forbiddenNonOptionalDeps.includes('tool-runtime')) { + throw new Error('runtime-services dependency profile must forbid tool runtime implementations'); + } const agentToolsManifestRule = forbiddenContentUnderRules.find( (rule) => rule.path === 'src/crates/agent-tools/src', ); @@ -6827,6 +6900,28 @@ function runManifestParserSelfTest() { 'subagent_context_mode_preserves_fork_wire_value', ], }, + { + path: 'src/crates/runtime-services/src/lib.rs', + contracts: [ + 'RuntimeServices', + 'RuntimeServicesBuilder', + 'CapabilityAvailability', + 'RuntimeServicesProvider', + 'RuntimeServicesRegistry', + 'CapabilityMismatch', + 'require_capability', + ], + }, + { + path: 'src/crates/runtime-services/tests/runtime_services_contracts.rs', + contracts: [ + 'builder_requires_mandatory_runtime_services', + 'fake_provider_registers_required_and_remote_services_through_registry', + 'missing_optional_capability_returns_typed_unsupported_error', + 'capability_availability_reports_optional_service_status_without_side_effects', + 'builder_rejects_port_registered_under_the_wrong_capability', + ], + }, { path: 'src/crates/core/src/agentic/subagent_runtime/mod.rs', contracts: [ diff --git a/src/crates/runtime-ports/src/lib.rs b/src/crates/runtime-ports/src/lib.rs index 6edd6ff80..ce73b2291 100644 --- a/src/crates/runtime-ports/src/lib.rs +++ b/src/crates/runtime-ports/src/lib.rs @@ -44,6 +44,113 @@ impl std::fmt::Display for PortError { impl std::error::Error for PortError {} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeServiceCapability { + FileSystem, + Workspace, + SessionStore, + Permission, + Events, + Clock, + Terminal, + Network, + Git, + McpCatalog, + RemoteConnection, + RemoteWorkspace, + RemoteProjection, + RemoteCapabilities, +} + +impl RuntimeServiceCapability { + pub const fn as_str(self) -> &'static str { + match self { + Self::FileSystem => "filesystem", + Self::Workspace => "workspace", + Self::SessionStore => "session_store", + Self::Permission => "permission", + Self::Events => "events", + Self::Clock => "clock", + Self::Terminal => "terminal", + Self::Network => "network", + Self::Git => "git", + Self::McpCatalog => "mcp_catalog", + Self::RemoteConnection => "remote_connection", + Self::RemoteWorkspace => "remote_workspace", + Self::RemoteProjection => "remote_projection", + Self::RemoteCapabilities => "remote_capabilities", + } + } +} + +impl std::fmt::Display for RuntimeServiceCapability { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +pub trait RuntimeServicePort: Send + Sync { + fn capability(&self) -> RuntimeServiceCapability; +} + +pub trait FileSystemPort: RuntimeServicePort {} + +pub trait WorkspacePort: RuntimeServicePort {} + +pub trait SessionStorePort: RuntimeServicePort {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionRequest { + pub scope: String, + pub action: String, + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub metadata: serde_json::Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PermissionDecision { + Allow, + Deny { reason: String }, +} + +#[async_trait::async_trait] +pub trait PermissionPort: RuntimeServicePort { + async fn request_permission( + &self, + request: PermissionRequest, + ) -> PortResult; +} + +pub trait ClockPort: RuntimeServicePort { + fn now_unix_millis(&self) -> i64; +} + +pub trait TerminalPort: RuntimeServicePort {} + +pub trait NetworkPort: RuntimeServicePort {} + +pub trait GitPort: RuntimeServicePort {} + +pub trait McpCatalogPort: RuntimeServicePort {} + +/// Typed registration boundary for remote connection providers. +/// +/// PR1 intentionally keeps this trait handle-free; PR2 adds owner-specific +/// lifecycle methods once behavior-equivalence tests are in place. +pub trait RemoteConnectionPort: RuntimeServicePort {} + +/// Typed registration boundary for remote workspace providers. +pub trait RemoteWorkspacePort: RuntimeServicePort {} + +/// Typed registration boundary for remote filesystem/terminal/image projection providers. +pub trait RemoteProjectionPort: RuntimeServicePort {} + +/// Typed registration boundary for remote host capability facts. +pub trait RemoteCapabilityPort: RuntimeServicePort {} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionCreateRequest { @@ -957,11 +1064,11 @@ mod tests { fn agent_session_reply_route_keeps_requester_fields() { let route = AgentSessionReplyRoute { source_session_id: "requester_session".to_string(), - source_workspace_path: "D:\\workspace\\requester".to_string(), + source_workspace_path: "/workspace/requester".to_string(), }; assert_eq!(route.source_session_id, "requester_session"); - assert_eq!(route.source_workspace_path, "D:\\workspace\\requester"); + assert_eq!(route.source_workspace_path, "/workspace/requester"); } #[test] @@ -1126,13 +1233,13 @@ mod tests { #[test] fn related_path_serializes_as_request_context_fact() { let related = RelatedPath { - path: "D:/workspace/shared".to_string(), + path: "/workspace/shared".to_string(), description: Some("shared fixtures".to_string()), }; let json = serde_json::to_value(related).expect("serialize related path"); - assert_eq!(json["path"], "D:/workspace/shared"); + assert_eq!(json["path"], "/workspace/shared"); assert_eq!(json["description"], "shared fixtures"); assert!(json.get("related_path").is_none()); } diff --git a/src/crates/runtime-services/Cargo.toml b/src/crates/runtime-services/Cargo.toml new file mode 100644 index 000000000..7852b4087 --- /dev/null +++ b/src/crates/runtime-services/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "bitfun-runtime-services" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "Typed runtime service assembly for BitFun runtimes" + +[lib] +name = "bitfun_runtime_services" +crate-type = ["rlib"] + +[dependencies] +bitfun-runtime-ports = { path = "../runtime-ports" } +async-trait = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/src/crates/runtime-services/src/lib.rs b/src/crates/runtime-services/src/lib.rs new file mode 100644 index 000000000..76ab4a082 --- /dev/null +++ b/src/crates/runtime-services/src/lib.rs @@ -0,0 +1,380 @@ +//! Typed Runtime Services assembly. + +use std::sync::Arc; + +use bitfun_runtime_ports::{ + ClockPort, FileSystemPort, GitPort, McpCatalogPort, NetworkPort, PermissionPort, + RemoteCapabilityPort, RemoteConnectionPort, RemoteProjectionPort, RemoteWorkspacePort, + RuntimeEventSink, RuntimeServiceCapability, RuntimeServicePort, SessionStorePort, TerminalPort, + WorkspacePort, +}; + +pub mod test_support; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum RuntimeServicesError { + #[error("required runtime service {capability} is not registered")] + MissingRequired { + capability: RuntimeServiceCapability, + }, + #[error("runtime service {capability} is not registered")] + Unsupported { + capability: RuntimeServiceCapability, + }, + #[error("runtime service registered under {expected} reported {actual}")] + CapabilityMismatch { + expected: RuntimeServiceCapability, + actual: RuntimeServiceCapability, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CapabilityAvailability { + pub capability: RuntimeServiceCapability, + pub available: bool, +} + +#[derive(Clone)] +pub struct RuntimeServices { + pub filesystem: Arc, + pub workspace: Arc, + pub session_store: Arc, + pub permission: Arc, + pub events: Arc, + pub clock: Arc, + pub terminal: Option>, + pub network: Option>, + pub git: Option>, + pub mcp_catalog: Option>, + pub remote_connection: Option>, + pub remote_workspace: Option>, + pub remote_projection: Option>, + pub remote_capabilities: Option>, +} + +impl std::fmt::Debug for RuntimeServices { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RuntimeServices") + .field("filesystem", &self.filesystem.capability()) + .field("workspace", &self.workspace.capability()) + .field("session_store", &self.session_store.capability()) + .field("permission", &self.permission.capability()) + .field("events", &RuntimeServiceCapability::Events) + .field("clock", &self.clock.capability()) + .field( + "terminal", + &self.terminal.as_ref().map(|port| port.capability()), + ) + .field( + "network", + &self.network.as_ref().map(|port| port.capability()), + ) + .field("git", &self.git.as_ref().map(|port| port.capability())) + .field( + "mcp_catalog", + &self.mcp_catalog.as_ref().map(|port| port.capability()), + ) + .field( + "remote_connection", + &self + .remote_connection + .as_ref() + .map(|port| port.capability()), + ) + .field( + "remote_workspace", + &self.remote_workspace.as_ref().map(|port| port.capability()), + ) + .field( + "remote_projection", + &self + .remote_projection + .as_ref() + .map(|port| port.capability()), + ) + .field( + "remote_capabilities", + &self + .remote_capabilities + .as_ref() + .map(|port| port.capability()), + ) + .finish() + } +} + +impl RuntimeServices { + pub fn has_capability(&self, capability: RuntimeServiceCapability) -> bool { + match capability { + RuntimeServiceCapability::FileSystem + | RuntimeServiceCapability::Workspace + | RuntimeServiceCapability::SessionStore + | RuntimeServiceCapability::Permission + | RuntimeServiceCapability::Events + | RuntimeServiceCapability::Clock => true, + RuntimeServiceCapability::Terminal => self.terminal.is_some(), + RuntimeServiceCapability::Network => self.network.is_some(), + RuntimeServiceCapability::Git => self.git.is_some(), + RuntimeServiceCapability::McpCatalog => self.mcp_catalog.is_some(), + RuntimeServiceCapability::RemoteConnection => self.remote_connection.is_some(), + RuntimeServiceCapability::RemoteWorkspace => self.remote_workspace.is_some(), + RuntimeServiceCapability::RemoteProjection => self.remote_projection.is_some(), + RuntimeServiceCapability::RemoteCapabilities => self.remote_capabilities.is_some(), + } + } + + pub fn capability_availability( + &self, + capability: RuntimeServiceCapability, + ) -> CapabilityAvailability { + CapabilityAvailability { + capability, + available: self.has_capability(capability), + } + } + + pub fn require_capability( + &self, + capability: RuntimeServiceCapability, + ) -> Result<(), RuntimeServicesError> { + if self.has_capability(capability) { + Ok(()) + } else { + Err(RuntimeServicesError::Unsupported { capability }) + } + } +} + +#[derive(Default, Clone)] +pub struct RuntimeServicesBuilder { + filesystem: Option>, + workspace: Option>, + session_store: Option>, + permission: Option>, + events: Option>, + clock: Option>, + terminal: Option>, + network: Option>, + git: Option>, + mcp_catalog: Option>, + remote_connection: Option>, + remote_workspace: Option>, + remote_projection: Option>, + remote_capabilities: Option>, +} + +impl RuntimeServicesBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn with_filesystem(mut self, port: Arc) -> Self { + self.filesystem = Some(port); + self + } + + pub fn with_workspace(mut self, port: Arc) -> Self { + self.workspace = Some(port); + self + } + + pub fn with_session_store(mut self, port: Arc) -> Self { + self.session_store = Some(port); + self + } + + pub fn with_permission(mut self, port: Arc) -> Self { + self.permission = Some(port); + self + } + + pub fn with_events(mut self, port: Arc) -> Self { + self.events = Some(port); + self + } + + pub fn with_clock(mut self, port: Arc) -> Self { + self.clock = Some(port); + self + } + + pub fn with_optional_terminal(mut self, port: Option>) -> Self { + self.terminal = port; + self + } + + pub fn with_optional_network(mut self, port: Option>) -> Self { + self.network = port; + self + } + + pub fn with_optional_git(mut self, port: Option>) -> Self { + self.git = port; + self + } + + pub fn with_optional_mcp_catalog(mut self, port: Option>) -> Self { + self.mcp_catalog = port; + self + } + + pub fn with_optional_remote_connection( + mut self, + port: Option>, + ) -> Self { + self.remote_connection = port; + self + } + + pub fn with_optional_remote_workspace( + mut self, + port: Option>, + ) -> Self { + self.remote_workspace = port; + self + } + + pub fn with_optional_remote_projection( + mut self, + port: Option>, + ) -> Self { + self.remote_projection = port; + self + } + + pub fn with_optional_remote_capabilities( + mut self, + port: Option>, + ) -> Self { + self.remote_capabilities = port; + self + } + + pub fn build(self) -> Result { + Ok(RuntimeServices { + filesystem: Self::required_service( + self.filesystem, + RuntimeServiceCapability::FileSystem, + )?, + workspace: Self::required_service(self.workspace, RuntimeServiceCapability::Workspace)?, + session_store: Self::required_service( + self.session_store, + RuntimeServiceCapability::SessionStore, + )?, + permission: Self::required_service( + self.permission, + RuntimeServiceCapability::Permission, + )?, + events: Self::required(self.events, RuntimeServiceCapability::Events)?, + clock: Self::required_service(self.clock, RuntimeServiceCapability::Clock)?, + terminal: Self::optional_service(self.terminal, RuntimeServiceCapability::Terminal)?, + network: Self::optional_service(self.network, RuntimeServiceCapability::Network)?, + git: Self::optional_service(self.git, RuntimeServiceCapability::Git)?, + mcp_catalog: Self::optional_service( + self.mcp_catalog, + RuntimeServiceCapability::McpCatalog, + )?, + remote_connection: Self::optional_service( + self.remote_connection, + RuntimeServiceCapability::RemoteConnection, + )?, + remote_workspace: Self::optional_service( + self.remote_workspace, + RuntimeServiceCapability::RemoteWorkspace, + )?, + remote_projection: Self::optional_service( + self.remote_projection, + RuntimeServiceCapability::RemoteProjection, + )?, + remote_capabilities: Self::optional_service( + self.remote_capabilities, + RuntimeServiceCapability::RemoteCapabilities, + )?, + }) + } + + fn required( + port: Option>, + capability: RuntimeServiceCapability, + ) -> Result, RuntimeServicesError> + where + T: ?Sized, + { + port.ok_or(RuntimeServicesError::MissingRequired { capability }) + } + + fn required_service( + port: Option>, + expected: RuntimeServiceCapability, + ) -> Result, RuntimeServicesError> + where + T: RuntimeServicePort + ?Sized, + { + let port = Self::required(port, expected)?; + Self::validate_capability(&port, expected)?; + Ok(port) + } + + fn optional_service( + port: Option>, + expected: RuntimeServiceCapability, + ) -> Result>, RuntimeServicesError> + where + T: RuntimeServicePort + ?Sized, + { + if let Some(port) = port { + Self::validate_capability(&port, expected)?; + Ok(Some(port)) + } else { + Ok(None) + } + } + + fn validate_capability( + port: &Arc, + expected: RuntimeServiceCapability, + ) -> Result<(), RuntimeServicesError> + where + T: RuntimeServicePort + ?Sized, + { + let actual = port.capability(); + if actual == expected { + Ok(()) + } else { + Err(RuntimeServicesError::CapabilityMismatch { expected, actual }) + } + } +} + +pub trait RuntimeServicesProvider: Send + Sync { + fn register(&self, builder: RuntimeServicesBuilder) -> RuntimeServicesBuilder; +} + +#[derive(Default)] +pub struct RuntimeServicesRegistry { + providers: Vec>, +} + +impl RuntimeServicesRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn with_provider

(mut self, provider: P) -> Self + where + P: RuntimeServicesProvider + 'static, + { + self.providers.push(Box::new(provider)); + self + } + + pub fn build( + &self, + mut builder: RuntimeServicesBuilder, + ) -> Result { + for provider in &self.providers { + builder = provider.register(builder); + } + builder.build() + } +} diff --git a/src/crates/runtime-services/src/test_support.rs b/src/crates/runtime-services/src/test_support.rs new file mode 100644 index 000000000..7e52357ea --- /dev/null +++ b/src/crates/runtime-services/src/test_support.rs @@ -0,0 +1,136 @@ +use std::sync::Arc; + +use bitfun_runtime_ports::{ + ClockPort, FileSystemPort, GitPort, McpCatalogPort, NetworkPort, PermissionDecision, + PermissionPort, PermissionRequest, PortResult, RemoteCapabilityPort, RemoteConnectionPort, + RemoteProjectionPort, RemoteWorkspacePort, RuntimeEventEnvelope, RuntimeEventSink, + RuntimeServiceCapability, RuntimeServicePort, SessionStorePort, TerminalPort, WorkspacePort, +}; + +use crate::{ + RuntimeServices, RuntimeServicesBuilder, RuntimeServicesError, RuntimeServicesProvider, +}; + +#[derive(Debug)] +pub struct FakeRuntimePort { + capability: RuntimeServiceCapability, +} + +impl FakeRuntimePort { + pub fn new(capability: RuntimeServiceCapability) -> Self { + Self { capability } + } +} + +impl RuntimeServicePort for FakeRuntimePort { + fn capability(&self) -> RuntimeServiceCapability { + self.capability + } +} + +impl FileSystemPort for FakeRuntimePort {} +impl WorkspacePort for FakeRuntimePort {} +impl SessionStorePort for FakeRuntimePort {} +impl TerminalPort for FakeRuntimePort {} +impl NetworkPort for FakeRuntimePort {} +impl GitPort for FakeRuntimePort {} +impl McpCatalogPort for FakeRuntimePort {} +impl RemoteConnectionPort for FakeRuntimePort {} +impl RemoteWorkspacePort for FakeRuntimePort {} +impl RemoteProjectionPort for FakeRuntimePort {} +impl RemoteCapabilityPort for FakeRuntimePort {} + +#[async_trait::async_trait] +impl PermissionPort for FakeRuntimePort { + async fn request_permission( + &self, + _request: PermissionRequest, + ) -> PortResult { + Ok(PermissionDecision::Allow) + } +} + +impl ClockPort for FakeRuntimePort { + fn now_unix_millis(&self) -> i64 { + 0 + } +} + +#[derive(Debug, Default)] +pub struct FakeRuntimeEventSink; + +#[async_trait::async_trait] +impl RuntimeEventSink for FakeRuntimeEventSink { + async fn publish_runtime_event(&self, _event: RuntimeEventEnvelope) -> PortResult<()> { + Ok(()) + } +} + +#[derive(Debug, Clone, Default)] +pub struct FakeRuntimeServicesProvider { + include_remote: bool, +} + +impl FakeRuntimeServicesProvider { + pub fn with_all_required() -> Self { + Self { + include_remote: false, + } + } + + pub fn with_all_remote(mut self) -> Self { + self.include_remote = true; + self + } + + pub fn build_services(self) -> Result { + self.register(RuntimeServicesBuilder::new()).build() + } +} + +impl RuntimeServicesProvider for FakeRuntimeServicesProvider { + fn register(&self, builder: RuntimeServicesBuilder) -> RuntimeServicesBuilder { + let filesystem: Arc = + Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::FileSystem)); + let workspace: Arc = + Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::Workspace)); + let session_store: Arc = + Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::SessionStore)); + let permission: Arc = + Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::Permission)); + let events: Arc = Arc::new(FakeRuntimeEventSink); + let clock: Arc = + Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::Clock)); + + let builder = builder + .with_filesystem(filesystem) + .with_workspace(workspace) + .with_session_store(session_store) + .with_permission(permission) + .with_events(events) + .with_clock(clock); + + if !self.include_remote { + return builder; + } + + let remote_connection: Arc = Arc::new(FakeRuntimePort::new( + RuntimeServiceCapability::RemoteConnection, + )); + let remote_workspace: Arc = Arc::new(FakeRuntimePort::new( + RuntimeServiceCapability::RemoteWorkspace, + )); + let remote_projection: Arc = Arc::new(FakeRuntimePort::new( + RuntimeServiceCapability::RemoteProjection, + )); + let remote_capabilities: Arc = Arc::new(FakeRuntimePort::new( + RuntimeServiceCapability::RemoteCapabilities, + )); + + builder + .with_optional_remote_connection(Some(remote_connection)) + .with_optional_remote_workspace(Some(remote_workspace)) + .with_optional_remote_projection(Some(remote_projection)) + .with_optional_remote_capabilities(Some(remote_capabilities)) + } +} diff --git a/src/crates/runtime-services/tests/runtime_services_contracts.rs b/src/crates/runtime-services/tests/runtime_services_contracts.rs new file mode 100644 index 000000000..e079a5db4 --- /dev/null +++ b/src/crates/runtime-services/tests/runtime_services_contracts.rs @@ -0,0 +1,100 @@ +use std::sync::Arc; + +use bitfun_runtime_ports::FileSystemPort; +use bitfun_runtime_ports::RuntimeServiceCapability; +use bitfun_runtime_services::test_support::{FakeRuntimePort, FakeRuntimeServicesProvider}; +use bitfun_runtime_services::{ + CapabilityAvailability, RuntimeServicesBuilder, RuntimeServicesError, RuntimeServicesProvider, + RuntimeServicesRegistry, +}; + +#[test] +fn builder_requires_mandatory_runtime_services() { + let error = RuntimeServicesBuilder::new().build().unwrap_err(); + + assert_eq!( + error, + RuntimeServicesError::MissingRequired { + capability: RuntimeServiceCapability::FileSystem, + } + ); +} + +#[test] +fn fake_provider_registers_required_and_remote_services_through_registry() { + let registry = RuntimeServicesRegistry::new() + .with_provider(FakeRuntimeServicesProvider::with_all_required().with_all_remote()); + let services = registry + .build(RuntimeServicesBuilder::new()) + .expect("fake provider should satisfy runtime services"); + + assert!(services.has_capability(RuntimeServiceCapability::FileSystem)); + assert!(services.has_capability(RuntimeServiceCapability::Workspace)); + assert!(services.has_capability(RuntimeServiceCapability::SessionStore)); + assert!(services.has_capability(RuntimeServiceCapability::Permission)); + assert!(services.has_capability(RuntimeServiceCapability::Events)); + assert!(services.has_capability(RuntimeServiceCapability::Clock)); + assert!(services.has_capability(RuntimeServiceCapability::RemoteConnection)); + assert!(services.has_capability(RuntimeServiceCapability::RemoteWorkspace)); + assert!(services.has_capability(RuntimeServiceCapability::RemoteProjection)); + assert!(services.has_capability(RuntimeServiceCapability::RemoteCapabilities)); +} + +#[test] +fn missing_optional_capability_returns_typed_unsupported_error() { + let services = FakeRuntimeServicesProvider::with_all_required() + .build_services() + .expect("required fake services should build"); + + let error = services + .require_capability(RuntimeServiceCapability::RemoteConnection) + .unwrap_err(); + + assert_eq!( + error, + RuntimeServicesError::Unsupported { + capability: RuntimeServiceCapability::RemoteConnection, + } + ); +} + +#[test] +fn capability_availability_reports_optional_service_status_without_side_effects() { + let services = FakeRuntimeServicesProvider::with_all_required() + .build_services() + .expect("required fake services should build"); + + assert_eq!( + services.capability_availability(RuntimeServiceCapability::FileSystem), + CapabilityAvailability { + capability: RuntimeServiceCapability::FileSystem, + available: true, + } + ); + assert_eq!( + services.capability_availability(RuntimeServiceCapability::RemoteWorkspace), + CapabilityAvailability { + capability: RuntimeServiceCapability::RemoteWorkspace, + available: false, + } + ); +} + +#[test] +fn builder_rejects_port_registered_under_the_wrong_capability() { + let mismatched_filesystem: Arc = + Arc::new(FakeRuntimePort::new(RuntimeServiceCapability::Git)); + let builder = FakeRuntimeServicesProvider::with_all_required() + .register(RuntimeServicesBuilder::new()) + .with_filesystem(mismatched_filesystem); + + let error = builder.build().unwrap_err(); + + assert_eq!( + error, + RuntimeServicesError::CapabilityMismatch { + expected: RuntimeServiceCapability::FileSystem, + actual: RuntimeServiceCapability::Git, + } + ); +}