diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d837fbe1e..122af334f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,11 +94,8 @@ jobs: - name: Check compilation run: cargo check --workspace --exclude bitfun-cli - - name: Run core Rust tests - run: cargo test --locked -p bitfun-core - - - name: Run desktop Rust tests - run: cargo test --locked -p bitfun-desktop + - name: Run core and desktop Rust tests + run: cargo test --locked -p bitfun-core -p bitfun-desktop # ── Frontend: build ──────────────────────────────────────────────── frontend-build: diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 0c75337b2a..9793a79e7d 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -15,9 +15,10 @@ BitFun 是一个由 Rust workspace 与 React 前端组成的项目。 ## 分层模块索引 -依赖关系按自上而下读取。下表是物理 crate 布局,不是完整概念架构;Product Feature、Agent Kernel、 -Execution、Extension、Cross-platform Adapter 的边界以 -[`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md) +依赖关系按自上而下读取。下表是物理 crate 布局,不是完整概念架构;Product Surface、Product Assembly、 +Product Feature、Agent Kernel、Execution、Extension、Cross-platform Adapter、 +Stable Contracts and Security Control Plane 的边界以 +[`docs/architecture/product-architecture.md`](docs/architecture/product-architecture.md) 为准。同层 crate 也应保持最小依赖。 | # | 层级 | 路径 | 职责 | 模块 / 入口 | 层级文档 | @@ -144,11 +145,11 @@ await api.invoke('your_command', { request: { ... } }); ## 架构 -### Core 拆解护栏 +### 产品架构护栏 任何 `bitfun-core` 拆解、feature 边界、依赖边界或 Rust 构建提速重构, 都必须先阅读 -[`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md)。 +[`docs/architecture/product-architecture.md`](docs/architecture/product-architecture.md)。 顶层文档只作为入口;模块级 ownership 细节应放到离代码最近的模块 `AGENTS.md`。 仓库级拆解规则: diff --git a/AGENTS.md b/AGENTS.md index 1d2a93d128..ddcc55714d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,9 +16,10 @@ Repository rule: **keep product logic platform-agnostic, then expose it through ## Layered Module Index Dependencies flow top to bottom. This table is the physical crate layout, not -the full conceptual architecture. For Product Feature / Agent Kernel / -Execution / Extension / Cross-platform Adapter boundaries, read -[`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md). +the full conceptual architecture. For Product Surface / Product Assembly / +Product Feature / Agent Kernel / Execution / Extension / Cross-platform Adapter / +Stable Contracts and Security Control Plane boundaries, read +[`docs/architecture/product-architecture.md`](docs/architecture/product-architecture.md). Keep crate dependencies inside each layer to the smallest set needed. | # | Layer | Path | Owns | Modules / entries | Layer doc | @@ -152,11 +153,11 @@ await api.invoke('your_command', { request: { ... } }); ## Architecture -### Core decomposition guardrails +### Product architecture guardrails For any `bitfun-core` decomposition, feature-boundary, dependency-boundary, or Rust build-speed refactor, read -[`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md) +[`docs/architecture/product-architecture.md`](docs/architecture/product-architecture.md) before editing. Keep this file as an entry point; put module-specific ownership details in the nearest module `AGENTS.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4b6f80e06..2f45674d18 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,8 +74,8 @@ terms: adapters and flow back through transport/API layers. - Tauri commands use `snake_case` command names and structured `request` payloads. -- Core decomposition, feature-boundary, dependency-boundary, and build-speed - work must follow `docs/architecture/core-decomposition.md`. +- Product architecture, feature-boundary, dependency-boundary, and build-speed + work must follow `docs/architecture/product-architecture.md`. - Feature-specific rules belong in the nearest module `AGENTS.md`. ## Key Contribution Focus Areas diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md index d0aea1c0d7..f8944748e1 100644 --- a/CONTRIBUTING_CN.md +++ b/CONTRIBUTING_CN.md @@ -67,7 +67,7 @@ DevTools;`Cmd/Ctrl + Shift + I` 切换 BitFun 元素检查器,`Cmd/Ctrl + Sh - shared core 必须保持平台无关;Desktop/Tauri 细节属于 app adapter,并通过 transport / API layer 回流。 - Tauri command 使用 `snake_case` 命令名和结构化 `request` 参数。 - core 拆解、feature 边界、依赖边界和构建提速重构必须遵循 - `docs/architecture/core-decomposition.md`。 + `docs/architecture/product-architecture.md`。 - 功能级规则应放在离代码最近的模块 `AGENTS.md` 中。 ## 重点关注的贡献方向 diff --git a/Cargo.toml b/Cargo.toml index fc651be44d..6e7e014453 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,6 +106,10 @@ filetime = "0.2" fs2 = "0.4" zip = { version = "4.6", default-features = false, features = ["deflate"] } # plugin load flate2 = "1.0" +tar = "0.4" +bzip2 = "0.5" +xz2 = "0.1" +zstd = "0.13" toml = "0.9" # Git @@ -138,6 +142,7 @@ sse-stream = "0.2.3" which = "8.0" similar = "2.5" urlencoding = "2.1" +oxc = { version = "0.137.0", default-features = false, features = ["ast_visit", "codegen", "semantic", "transformer"] } # Tauri (desktop only) tauri = { git = "https://github.com/richerfu/tauri", branch = "feat/open-harmony", features = [] } diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index c0ea18968a..a866014cc2 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -1,10 +1,14 @@ # Agent Kernel、Runtime Services 与 Extension API 设计 -本文是 [`core-decomposition.md`](core-decomposition.md) 的开发设计文档,描述目标模块、接口、 +本文是 [`product-architecture.md`](product-architecture.md) 的开发设计文档,描述目标模块、接口、 crate 内部结构和行为保护。本文只记录设计约束,不记录实现过程或验证记录。插件运行时主机、 生态兼容适配层、IPC 和候选效果契约见 [`plugin-runtime-host-design.md`](plugin-runtime-host-design.md)。 +阅读路径:第 1 节确认 SDK、内核、产品特性、Extension API 和 crate 边界;第 2-3 节说明稳定接口、 +Runtime Services、Kernel、Tool 和 Harness;第 4 节说明 Product Assembly 与扩展注册;第 5 节作为质量保护和 +目标态判定口径。 + ## 1. 设计目标与边界 - Agent Kernel 可被 Desktop、CLI、Server、Remote、ACP、Web 和独立 SDK 形态嵌入。 @@ -65,8 +69,9 @@ registry 或依赖全局 mutable state,SDK 发布边界就不成立。 - 会改变用户入口、命令、设置、UI contribution、默认策略或产品文案的能力,归 Product Feature。 - 会接触 OS、network、terminal、filesystem、remote host、MCP server 或 AI provider concrete 的能力,归 Cross-platform Adapter 或 protocol adapter。 -- 来自外部插件、OpenCode、ACP、external skill 或第三方 package 的能力,先进入 Extension Layer,再由 Product - Assembly 注册到 feature / kernel / execution 的稳定接口。 +- 来自外部插件、OpenCode、ACP external agent/tool bridge、external skill 或第三方 package 的能力,先进入 + Extension Layer,再由 Product Assembly 注册到 feature / kernel / execution 的稳定接口;ACP protocol lifecycle + 仍由 interfaces/acp 和对应入口 adapter 拥有。 ### 1.3 Product API 与 Extension API @@ -697,7 +702,8 @@ Host 和 Cross-platform Adapter。 - 建立产品 feature matrix。 - 把 interface 命令映射到 capability / harness / runtime request。 - 把 feature bundle 映射为 Rust runtime request、UI contribution、plugin capability 和安全策略。 -- 根据交付形态选择 `DeliveryProfile`、`CapabilitySet`、adapter manifest set 和 service provider 集合。 +- 根据 `ProductProfile` 和 `SurfaceContract` 派生 `DeliveryProfile`、capability plan、capability availability、 + adapter manifest set 和 service provider 集合。 - 对不支持能力返回 typed unsupported / unavailable 错误,而不是让下层 runtime 判断产品形态。 - 通过 typed `PluginRuntimeBinding` 向 Kernel 注入插件运行时 client 或 disabled stub,不使用全局 registry。 @@ -706,8 +712,10 @@ Host 和 Cross-platform Adapter。 ```text product-assembly full.rs + product_profile.rs delivery_profile.rs - capability_set.rs + capability_plan.rs + capability_availability.rs feature_bundle.rs desktop.rs cli.rs @@ -723,6 +731,18 @@ product-assembly 核心结构: ```rust +pub enum ProductProfile { + ProductFull, + DesktopFull, + CliFull, + Server, + Remote, + Acp, + Web, + MobileWeb, + SdkMinimal, +} + pub enum DeliveryProfile { ProductFull, Desktop, @@ -735,7 +755,9 @@ pub enum DeliveryProfile { Sdk, } -pub struct CapabilitySet { +// 迁移期 ProductProfile 与 DeliveryProfile 可能一一映射;长期 ProductProfile 表示产品包、SKU 或白标策略, +// DeliveryProfile 表示 Product Assembly 派生出的交付结果,二者允许分化。 +pub struct CapabilityPlan { pub agent_modes: Vec, pub tool_packs: Vec, pub harness_packs: Vec, @@ -745,9 +767,47 @@ pub struct CapabilitySet { pub extensions: ExtensionCapabilitySet, } +pub struct CapabilityAvailabilitySet { + pub entries: Vec, +} + +pub struct CapabilityAvailability { + pub capability: CapabilityId, + pub state: CapabilityAvailabilityState, + pub reason: Option, + pub provider: Option, +} + +pub enum CapabilityAvailabilityState { + Full, + ArtifactOnly, + StatusOnly, + TemporarilyUnavailable, + Unsupported, + PolicyDenied, +} + +pub struct CapabilityAvailabilityReason { + pub code: CapabilityAvailabilityReasonCode, + pub message_key: Option, + pub policy_owner: Option, + pub security_decision: Option, + pub provider_health: Option, +} + +pub enum CapabilityAvailabilityReasonCode { + UnsupportedSurface, + MissingProvider, + TemporarilyUnavailable, + PolicyDenied, + ProviderUnhealthy, +} + pub struct ProductAssemblyPlan { - pub profile: DeliveryProfile, - pub capabilities: CapabilitySet, + pub product_profile: ProductProfile, + pub delivery_profile: DeliveryProfile, + pub capability_plan: CapabilityPlan, + pub capability_availability: CapabilityAvailabilitySet, pub feature_groups: Vec, pub feature_bundles: Vec, } @@ -776,8 +836,13 @@ pub enum PluginRuntimeBinding { Client(Arc), } +pub struct ProductAssemblyPlanInput { + pub product_profile: ProductProfile, + pub surface: SurfaceContractRef, +} + pub trait ProductAssembler { - fn plan(&self, profile: DeliveryProfile) -> Result; + fn plan(&self, input: ProductAssemblyPlanInput) -> Result; fn build(&self, plan: ProductAssemblyPlan) -> Result; } ``` @@ -788,7 +853,7 @@ pub trait ProductAssembler { ```rust pub struct ProductAssemblyInput { - pub profile: DeliveryProfile, + pub plan: ProductAssemblyPlan, pub services: ConcreteServiceProviders, pub tool_providers: Vec>, pub harness_providers: Vec>, @@ -866,8 +931,8 @@ pub fn build_desktop_runtime(input: DesktopAssemblyInput) -> Result Result CapabilityId; + fn shape(&self) -> CapabilityShapeDescriptor; fn required_services(&self) -> Vec; fn tool_packs(&self) -> Vec; fn harness_packs(&self) -> Vec; @@ -918,6 +988,18 @@ pub trait CapabilityPack: Send + Sync { fn ui_contributions(&self) -> Vec; fn extension_capabilities(&self) -> Vec; } + +pub struct CapabilityShapeDescriptor { + pub id: CapabilityId, + pub supported_surfaces: Vec, + pub dependencies: Vec, + pub conflicts: Vec, + pub required_providers: Vec, + pub effects: Vec, + pub artifacts: Vec, + pub degradation: Vec, + pub verification_owner: VerificationOwnerRef, +} ``` 分层规则: @@ -927,8 +1009,8 @@ pub trait CapabilityPack: Send + Sync { 但 target resolution 和 UI construction 留在 surface。 - MiniApp pack 允许声明 MiniApp harness、domain ports、artifact policy,但 worker process 和 filesystem IO 通过 Runtime Services provider。 -- MCP App pack 允许声明 MCP tool/resource/prompt capability,但 MCP transport 属于 - `bitfun-services-integrations`。 +- MCP App pack 允许声明 MCP tool/resource/prompt capability;MCP transport / catalog 属于 Platform / Provider Adapter, + materialized tool/resource/prompt projection 属于 Execution / Stable Contracts。 - Input command pack 只声明 command 到 capability/harness/runtime request 的映射,不共享具体 UI。 - Long-running task pack 只声明任务入口、默认 policy、UI contribution 和 command 映射;任务生命周期属于 Agent Kernel。 - Plugin extension pack 只声明插件能力、UI contribution 和外部 API 映射;安全决策和最终状态写入属于 Kernel / Security Boundary。 diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md deleted file mode 100644 index affdea9887..0000000000 --- a/docs/architecture/core-decomposition.md +++ /dev/null @@ -1,471 +0,0 @@ -# BitFun Core 拆解架构 - -本文描述 BitFun core runtime 拆解的稳定设计:先说明设计建立时的初始状态,再定义目标状态的逻辑分层、 -接口与实现分离、扩展方式、安全边界和风险约束。本文只描述架构设计本身,不记录阶段进度;执行计划见 -[`../plans/core-decomposition-plan.md`](../plans/core-decomposition-plan.md),详细接口和 crate 内部设计见 -[`agent-runtime-services-design.md`](agent-runtime-services-design.md),插件运行时主机、生态兼容适配层和 -主进程通信边界见 [`plugin-runtime-host-design.md`](plugin-runtime-host-design.md)。 - -## 1. 背景与目标 - -设计建立时,BitFun 已经从 `bitfun-core` 中抽出了若干 owner crate,但 `bitfun-core` 仍承担兼容 facade、 -完整产品 runtime 组装、agent loop、service 接线、tool materialization、产品命令适配和部分 product domain -adapter。该初始形态具备运行能力,但会让架构演进持续面临三个问题: - -- 产品特性、Agent 内核、平台实现和具体 service 接线混在同一条路径中。 -- Desktop、CLI、Server、Remote、ACP、Web 和未来 SDK 容易被完整 `bitfun-core/product-full` 牵引。 -- Tool、MCP、ACP、subagent、skills、harness、plugin、hook 和 UI 扩展缺少统一分层归属。 - -目标状态不是将所有能力迁入一个新的巨型 `AgentRuntime` crate,而是将 BitFun 拆成可组合的产品架构: - -- **Agent Kernel** 提供稳定、可嵌入、平台无关的 Agent 能力,包括 session / workspace、长程任务、调度、 - 权限、模型调度、上下文、记忆、DFX、hook 和 event。 -- **Product Feature** 负责把内核能力组装为用户可见功能。例如长程任务支持属于内核,`/goal` 命令、设置项、 - UI 展示和默认策略属于产品特性。 -- **Execution / Extension / Platform Adapter** 分别承接工具执行、生态扩展和跨平台实现,避免内核直接依赖 - Tauri、UI、MCP client、SSH、filesystem manager 或 provider concrete。 -- **Product Assembly** 是 composition root,按 Desktop、CLI、Web、ACP、Remote、SDK 等形态选择 feature、 - Rust kernel provider、UI contribution、插件扩展和平台实现。 - -目标状态必须保持产品行为、默认能力集合、权限语义、工具曝光、事件语义、session 生命周期、remote 行为和 -release 构建形态等价。任何会改变功能边界的设计变更都必须先单独评审。 - -## 2. 架构原则 - -- **内核与特性分离**:Agent Kernel 只拥有平台无关的固化 Agent 逻辑和扩展机制;用户侧命令、UI、设置、 - feature 默认策略和产品文案属于 Product Feature / Product Assembly。 -- **依赖单向流动**:产品入口和组装允许依赖下层,内核、执行、扩展、跨平台适配和契约层不得反向读取产品入口、 - UI state、Tauri handle 或 delivery profile。 -- **接口与实现分离**:接口属于稳定契约、Kernel API、Runtime Services、Tool / Harness contract 或 UI - extension contract;具体实现属于 Product Assembly 注册边界、Plugin Runtime Host、protocol adapter 或 - cross-platform adapter。 -- **Product API 覆盖 Rust 与 UI**:BitFun API 不应仅指 Rust 后端 API。目标状态需要同时提供 Rust Kernel API - 和 UI Extension Contract,再由 OpenCode / ACP / plugin adapter 映射到外部生态 API。 -- **扩展只产出受控候选效果**:plugin、OpenCode adapter、MCP、skills 和 hooks 通过声明式契约贡献事件订阅、UI - contribution、tool、prompt、permission candidate 或 workflow provider,但授权、状态写入、审计和最终执行 - 仍由内核事实与安全控制面决定。 -- **外部系统只在边界被调用**:OS、Git、MCP server、AI provider、remote host、browser/desktop 环境是仓库外部 - 系统。它们由跨平台或协议 adapter 调用,不作为服务层上游依赖写入核心逻辑视图。 -- **feature group 不等于产品能力**:feature group 是 Cargo 构建边界,CapabilitySet 是运行时产品能力边界。 - 两者必须由 Product Assembly 显式映射。 - -## 3. 初始状态逻辑视图 - -初始状态的核心事实是:多个 crate 已经承接稳定类型、事件、stream、tool contract、部分 service helper 和 -product domain 纯逻辑,但完整运行时仍以 `bitfun-core` 为中心。 - -```mermaid -flowchart TB - Surfaces["产品入口
Desktop / CLI / Server / Relay / Remote / Web"] - Core["bitfun-core
兼容 facade + 完整产品 runtime 组装"] - Acp["bitfun-acp
ACP protocol surface / client behavior"] - Transport["transport / api-layer
API 与传输 adapter"] - CoreTypes["bitfun-core-types
稳定 DTO 子集"] - Events["bitfun-events
事件事实与 emitter 抽象"] - Ports["bitfun-runtime-ports
trait-only runtime 边界"] - Stream["bitfun-agent-stream
stream 聚合"] - AgentTools["bitfun-agent-tools
tool contract 与纯策略"] - ToolRuntime["tool-execution
tool-runtime package / 低层 helper"] - ToolPacks["tool-provider-groups
bitfun-tool-packs package / provider plan"] - ServicesCore["bitfun-services-core
基础 service helper / filesystem facade"] - ServicesIntegrations["bitfun-services-integrations
MCP / Git / Remote helper owner"] - ProductDomains["bitfun-product-domains
MiniApp / function-agent 纯 domain"] - Terminal["terminal-core
terminal domain"] - Ai["bitfun-ai-adapters
模型 provider adapter"] - External["外部系统
OS / Git / MCP / ACP / AI provider / remote host"] - - Surfaces --> Core - Surfaces --> Transport - Surfaces --> Acp - Acp --> Core - Core --> CoreTypes - Core --> Events - Core --> Ports - Core --> Stream - Core --> AgentTools - Core --> ToolRuntime - Core --> ToolPacks - Core --> ServicesCore - Core --> ServicesIntegrations - Core --> ProductDomains - Core --> Terminal - Core --> Ai - Core --> Transport - ServicesCore --> External - ServicesIntegrations --> External - Terminal --> External - Ai --> External -``` - -初始状态主要问题: - -- `bitfun-core` 仍是事实上的 runtime owner,产品命令、service wiring、tool registry、manager handle 和兼容导出 - 交叉存在。 -- 产品特性没有与内核能力分层。例如 `/goal`、DeepReview、MiniApp、custom agent、tool exposure 同时触达 UI、 - Rust runtime、tool、service 和权限。 -- 现有六层物理目录有助于控制依赖,但不能直接表达 Product Feature、Agent Kernel、Plugin Runtime Host 和 - Cross-platform Adapter 的目标关系。 -- 适配层与服务层描述偏实现目录,不能清楚区分 protocol/provider translation、OS/service implementation、 - UI Extension Contract 和外部生态 adapter。 -- 安全边界散落在 SDLC Harness、tool permission、MCP、hook、shell、网络和平台执行路径中,缺少贯穿内核、 - 执行、扩展和 UI 投影的一致控制面。 -- `product-full` 是完整产品能力的安全网,不是最终按产品形态和 feature bundle 拆分的能力矩阵。 -- Agent Runtime SDK 仍缺少可对外稳定承诺的统一 facade、事件流协议、provider 注册边界、UI/API 扩展契约和 - 最小依赖构建形态。 - -## 4. 对照分析 - -本节只提炼对 BitFun 分层有用的架构信号,不把其他项目的实现形态直接复制到 BitFun。 - -### 4.1 Claude Code - -Claude Code 官方文档把 Agent SDK、hooks、slash commands、subagents、MCP、permissions 和 sessions 作为可组合 -能力暴露;hooks 在生命周期事件上触发,permission / settings 管理工具使用和团队配置,slash commands 通过 SDK -控制 session。该结构表明,用户可见命令、Agent Kernel、权限、MCP、skills 和 hooks 能够通过稳定 API 组合,而不是混在 -同一个 runtime owner 中。 - -对 BitFun 的设计结论: - -- 长程任务、session、权限、事件、hook 和 tool 调度属于内核能力;`/goal`、`/plan`、设置项和 UI 展示属于特性组装。 -- SDK 边界必须暴露稳定 builder / runner / event stream / permission / session API,而不是要求调用方理解产品命令。 -- hooks 能改变行为时必须有顺序、timeout、错误策略和安全审计;不得只是事件通知。 - -### 4.2 OpenCode - -OpenCode 的产品结构展示了 terminal、desktop、IDE、server 和 SDK 多入口;agents / subagents 可配置 prompt、model 和 -tool access;tools 通过 permission 控制并可由 custom tools 或 MCP 扩展;plugins 同时覆盖 server hooks 和 TUI -contribution。其代码层面还体现了几个关键边界:server plugin API 与 TUI plugin API 分离;tool registry 在执行前 -materialize 当前可用工具快照并按 permission 过滤;event service 使用 manifest、version、aggregate sequence 和 -durable replay;permission service 保持最终权威,插件 hook 只参与候选决策。 - -对 BitFun 的设计结论: - -- OpenCode adapter 不应仅绑定 Rust 内核。它需要映射 BitFun 的 Rust Kernel API、UI Extension Contract、tool / MCP / - skills / plugin contract 和安全能力声明。 -- 插件扩展范围覆盖 UI 和内核能力,但只能通过 Product Assembly 注册到明确的 feature / capability;不能直接持有产品状态或 - 绕过安全边界。 -- API 需要按层暴露,避免把所有能力集中到单一 `bitfun-core` 或单一后端 API crate。 -- OpenCode API 不应直接成为 BitFun 内部 owner。BitFun 先定义自己的 Plugin Runtime Host、Tool ABI、Event Manifest 和 - Permission/Effect control plane,再由 adapter 做兼容映射。 - -### 4.3 Claw Code - -Claw Code 的 Rust workspace 将完整 CLI、轻量 automation harness、RAG service、runtime、tools、commands 和 plugins -分成不同产品能力。其设计强调权限模式、workspace 边界、结构化输出、session 和可测试的 tool contract。该结构对 -BitFun 的价值在于:完整产品形态和轻量 SDK / automation 形态应共享稳定能力,而不是共享全量产品入口。 - -对 BitFun 的设计结论: - -- SDK、CLI、Web、ACP、Remote 等形态应通过 capability matrix 选择能力,不应被 `product-full` 全量牵引。 -- 轻量形态需要可解释的最小 API 和安全默认值;完整形态可以通过 Product Assembly 注入更多 provider。 -- runtime / tools / commands 的公共导出面需要控制,否则长期会退化为难以承诺的 workspace-internal API。 - -### 4.4 Codex - -Codex 官方文档把 sandbox、approval、network control 和 auto-review 放在 agent 操作安全模型中,而不是把所有确认 -都交给 UI 或模型判断。对 BitFun 而言,权限确认、沙箱、网络、凭据、插件执行域和远程执行域必须由稳定控制面表达, -再由产品入口投影给用户。 - -### 4.5 对照结论 - -Claude Code、OpenCode、Claw Code 和 Codex 的共同信号不是“所有能力放入一个 runtime”,而是把 agent 内核、安全控制、 -扩展声明、工具执行和平台 provider 分开。用户可见功能通过命令、设置、UI contribution 或 SDK API 组装;外部插件 -通过 descriptor、hook、tool provider 或 MCP/ACP bridge 接入;OS、network、remote、browser 和 provider client -留在边界 adapter 中。BitFun 的目标架构也应遵循这一点:Product Assembly 负责组装 concrete provider,普通层级 -只消费稳定 API、port 和 capability/effect contract。 - -## 5. 目标逻辑视图 - -目标逻辑按概念层表达。概念层决定职责、接口和依赖方向;物理目录决定 crate 的放置位置。二者必须一致,但不要求 -一层只对应一个 crate。 - -本图区分两类关系: - -- **编译依赖**:普通模块只能依赖本层公开 API、下层稳定契约或被注入的 port,不直接依赖更底层 concrete 实现。 -- **组装依赖**:Product Assembly 是 composition root,允许在构建期认识具体 feature、kernel、execution、 - extension 和 platform provider,并把它们装配为 typed runtime parts。 - -外部系统不是仓库内层级。OS、Git、MCP server、AI provider、remote host、browser/desktop runtime 和 plugin -package 都是边界外资源,只能由被注入的跨平台或协议 adapter 调用。 - -```mermaid -flowchart TB - Surface["产品入口(Product Surfaces)
Desktop / CLI / Web / ACP / Remote / SDK / UI host"] - Assembly["产品组装层(Product Assembly)
delivery profile / feature bundle / provider registration / capability matrix"] - Feature["产品特性层(Product Feature Layer)
/goal / DeepReview / MiniApp / commands / settings / product workflows"] - Kernel["核心层(Agent Kernel)
session / workspace / scheduler / permission / model routing / context / memory / DFX / hook-event bus"] - Execution["执行层(Execution Layer)
tool runtime / skills / MCP tool bridge / sandbox / harness / artifact semantics"] - Extension["扩展层(Extension Layer)
Plugin Runtime Host / compatibility adapters / ACP bridge / external skills / UI contribution descriptors"] - Platform["跨平台适配实现(Platform and Provider Adapters)
filesystem / network / process / terminal / remote / Git / MCP transport / AI provider"] - Contracts["稳定契约与安全控制面(Stable Contracts and Security Control Plane)
DTO / ports / events / capability-effect / permission / audit / UI extension contract"] - External[("边界外资源(External Resources)
OS / Git binary / MCP server / AI provider / remote host / browser runtime / plugin package")] - - Surface --> Assembly - Assembly --> Feature - Assembly --> Kernel - Assembly --> Execution - Assembly --> Extension - Assembly --> Platform - Feature --> Kernel - Feature --> Contracts - Kernel --> Contracts - Execution --> Contracts - Extension --> Contracts - Platform --> Contracts - Kernel -. "通过注入的 execution port 调用" .-> Execution - Execution -. "通过注入的 platform port 调用" .-> Platform - Assembly -. "注册 extension descriptor/provider" .-> Extension - Platform -. "边界调用,不是仓库依赖层" .-> External -``` - -普通模块的依赖方向只允许流向稳定接口,而不是直接认识所有下层实现: - -```text -Product Surfaces - -> Product Assembly API -Product Assembly - -> Product Feature API / Agent Kernel API / Execution API / Extension API / Platform Provider API -Product Feature - -> Agent Kernel API / Stable Contracts and Security Control Plane -Agent Kernel - -> Stable Contracts and Security Control Plane -Execution - -> Stable Contracts and Security Control Plane -Extension - -> Stable Contracts and Security Control Plane -Platform and Provider Adapters - -> Stable Contracts and Security Control Plane -``` - -运行时调用可以通过 Product Assembly 注入的 typed port 从 Kernel 进入 Execution、从 Execution 进入 Platform -provider,但这不是对 concrete crate 的编译依赖。除 Product Assembly 外,任何层都不应为了“方便调用”直接依赖下层 -concrete implementation。外部系统只存在于 adapter 的 I/O 边界,不参与仓库内层级依赖。 - -同层 crate 之间也必须按 owner 最小化依赖,禁止为了复用 helper 形成循环依赖或让下层读取产品形态。 - -## 6. 层级放置规则 - -### 6.1 产品组装与接口层(Product Assembly and Interfaces) - -放置 Desktop、CLI、Server、Remote、ACP、Web、Mobile Web、Installer、E2E 和 SDK 入口相关代码。该层负责选择 -`DeliveryProfile`、feature bundle、capability matrix、Rust provider、UI contribution、Plugin Runtime Host binding 和 -platform provider,并把它们组装成产品可用能力。 - -当前主要路径:`src/apps/*`、`src/web-ui`、`src/mobile-web`、`BitFun-Installer`、`tests/e2e`、 -`src/crates/interfaces`、`src/crates/assembly/core` 和 `src/crates/assembly/product-capabilities`。 - -应该放这里: - -- 产品入口、host adapter、UI host、Tauri command、CLI command、HTTP/transport entrypoint、ACP surface。 -- 产品形态选择、feature bundle 选择、capability matrix、provider 注册和 unsupported/unavailable 映射。 -- UI contribution 的渲染 host、输入框命令入口、产品命令到 runtime request 的映射。 - -不应该放这里: - -- Agent session/turn/scheduler 的状态机。 -- Tool、MCP、filesystem、terminal、remote、AI provider 的通用实现。 -- 可被 SDK 独立复用的内核 API 语义。 - -### 6.2 产品特性层(Product Feature Layer) - -放置把底层能力组合成用户功能的逻辑。产品特性可以同时有 Rust 编排、UI contribution、命令入口和默认策略, -但它只编排能力,不拥有内核状态机或平台实现。 - -当前主要路径:`src/crates/assembly/product-capabilities`、`src/crates/contracts/product-domains`、部分 -`src/web-ui` feature host、app command adapter,以及迁移期 `bitfun-core` 兼容路径。 - -应该放这里: - -- `/goal`、DeepReview、DeepResearch、MiniApp、input command、settings、review UI、custom agent/mode 的产品编排。 -- feature pack / capability pack、默认策略、UI contribution descriptor、命令到 kernel request 或稳定 descriptor 的映射。 -- 特性级 DTO 和纯规则,例如 MiniApp domain contract、review report domain contract。 - -不应该放这里: - -- 长程任务生命周期、session/workspace 状态、scheduler、permission decision、event queue。 -- Tool/MCP/sandbox 的实际执行、filesystem/terminal/remote 的具体实现。 -- 依赖某个产品形态的 UI 组件实现;特性只声明 descriptor,由入口层渲染。 - -### 6.3 核心层(Agent Kernel) - -放置平台无关、产品形态无关的 Agent 固化逻辑。该层是未来 Agent Runtime SDK 的核心候选边界。 - -当前主要路径:`src/crates/execution/agent-runtime`、`src/crates/execution/agent-stream`、 -`src/crates/execution/runtime-services`、`src/crates/contracts/runtime-ports`、`src/crates/contracts/events` 和 -`src/crates/contracts/core-types`。 - -应该放这里: - -- session/workspace facts、turn/model round 生命周期、long-running task、scheduler、cancellation。 -- permission coordination、model route request、context assembly、memory、DFX、event queue/router。 -- hook registry、post-turn processor、agent/subagent registry 查询和 SDK-facing runtime facade。 - -不应该放这里: - -- `/goal`、DeepReview、MiniApp、settings、UI panel、产品默认文案。 -- Tauri、Web UI、CLI TUI、ACP protocol、filesystem/Git/terminal/MCP/AI/remote provider concrete。 -- 具体 tool 实现或具体 platform service manager。 - -### 6.4 执行层(Execution Layer) - -放置可被 Agent Kernel 调度的执行原语。该层定义 tool、skills、MCP tool bridge、sandbox、local/remote tool -runtime 和 harness 的执行语义,但不决定产品形态。 - -当前主要路径:`src/crates/execution/tool-contracts`、`src/crates/execution/tool-provider-groups`、 -`src/crates/execution/tool-execution`、`src/crates/execution/harness`,以及迁移期 core tool/harness 兼容路径。 - -应该放这里: - -- tool manifest、permission request shape、tool result/artifact policy、tool group plan。 -- built-in tool provider 的 provider-neutral 部分、MCP tool bridge、skills execution contract。 -- harness descriptor、route plan、workflow provider contract、sandbox execution contract。 - -不应该放这里: - -- MCP transport/client concrete、remote SSH/SFTP/PTY、filesystem manager、terminal PTY、network client。 -- 产品命令、UI 展示、产品能力选择。 -- 权限最终策略实现;执行层只消费安全控制面的 decision / facts。 - -### 6.5 扩展层(Extension Layer) - -放置外部生态接入逻辑。扩展层把 OpenCode、ACP、plugin、external skills、external tools 和 hook adapter -转换为 BitFun 的稳定契约。 - -当前主要路径:`src/crates/interfaces/acp`、OpenCode / plugin 相关设计文档、部分 -`src/crates/services/services-integrations`、app command adapter 和扩展 host owner。 - -应该放这里: - -- Plugin Runtime Host、compatibility adapter、ACP bridge、external skill/plugin 的注册与映射。 -- UI contribution descriptor、external command/tool/hook/workflow provider descriptor。 -- capability/effect declaration、plugin source identity、插件能力声明和外部 API 映射。 - -不应该放这里: - -- Web UI React 组件实现、Tauri app state、kernel 权威状态。 -- permission decision、audit result、sandbox policy 的最终写入。 -- 绕过 Product Assembly 直接注入产品功能。 - -### 6.6 跨平台适配层(Cross-platform Adapter Layer) - -放置仓库内与边界外资源交互的具体实现。该层不是“外部系统层”,也不是所有模块都应依赖的底座;它只是实现稳定 -ports 的 provider / driver / protocol adapter。Product Assembly 按产品形态选择并注册具体实现,Kernel、Execution、 -Extension 和 Product Feature 只通过稳定 port 或 provider contract 消费能力。 - -外部系统指仓库外资源,例如 OS API、Git binary、MCP server、AI provider、remote host、browser/desktop runtime、 -plugin package 或 cloud service。它们不属于仓库内依赖层,不能被普通模块作为上游依赖建模。 - -当前主要路径:`src/crates/services/services-core`、`src/crates/services/services-integrations`、 -`src/crates/services/terminal`、`src/crates/adapters/ai-adapters`、`src/crates/adapters/api-layer`、 -`src/crates/adapters/transport`、`src/crates/adapters/webdriver` 和 app-local provider。 - -应该放这里: - -- filesystem、network、process/thread/time、terminal、remote、Git、MCP transport、AI/provider protocol。 -- browser/desktop automation、WebDriver、HTTP/transport adapter、OS-specific permission/projection provider。 -- local/remote provider 的 concrete implementation、第三方库适配、外部协议错误映射和不可用状态转换。 - -不应该放这里: - -- 产品能力选择、feature pack、UI 命令、Agent Kernel 状态机。 -- Tool manifest 的产品曝光策略或 permission 最终策略。 -- 直接依赖上层产品入口来决定行为;形态差异由 Product Assembly 注入。 -- 被其他层当作“必须依赖的基础层”。除组装层外,调用方应依赖 port / contract,而不是依赖 concrete adapter。 - -### 6.7 稳定契约与安全控制面(Stable Contracts and Security Control Plane) - -放置跨层稳定语义。该层定义 DTO、event、port、capability/effect model、permission facts、sandbox facts、 -audit facts、UI extension contract、artifact ref、unsupported/unavailable 错误和产品领域纯规则。 - -当前主要路径:`src/crates/contracts/*`、`docs/sdlc-harness/*` 中的安全/治理契约,以及下层 crate 中的 -provider-neutral contract。 - -应该放这里: - -- 可序列化 DTO、identity、event、port trait、artifact ref、typed error。 -- 跨框架 frontend event projection:把内核事件映射为稳定 event name / event type / payload,不包含 Tauri、 - React、WebSocket delivery 或 OpenCode adapter 实现。 -- capability/effect、permission、sandbox、execution domain、audit facts。 -- UI extension descriptor contract 和跨产品领域的纯规则。 - -不应该放这里: - -- 具体策略实现、具体 service manager、具体 provider、UI rendering、runtime state machine。 -- 依赖上层 crate 的 helper。稳定契约只能向下保持 behavior-light。 - -本文中的安全控制面指稳定契约和注入式策略边界,不表示具体策略实现必须位于 contracts crate。具体策略实现由 -Product Assembly 注入,通过稳定 port 供 Kernel、Execution、Extension、Platform Adapter 和 UI projection 消费。 -最终授权和审计不能由模型输出、外部插件或平台 adapter 直接写入。 - -## 7. 接口与实现关系 - -接口定义在稳定 owner;实现由 Product Assembly 或产品宿主在组装边界注入。注册发生在构建期,运行时热路径应消费 -typed parts 和 port handle,而不是通过无类型 service locator 动态查找。 - -除 Product Assembly 外,普通层级不应同时依赖接口和实现。Kernel 只依赖 kernel/runtime contracts;Execution 只依赖 -tool/harness/sandbox contracts 和被注入的 platform ports;Extension 只产出 descriptor、provider 和 candidate effect; -Platform adapter 只实现 ports 并调用边界外资源。 - -| 接口 / API | 定义 owner | 实现 owner | 注册者 | 消费者 | 边界 | -|---|---|---|---|---|---| -| `ProductAssembler` / `ProductAssemblyPlan` | Product Assembly | 产品入口或 assembly crate | 产品入口 | Desktop / CLI / Server / Remote / Web / Mobile Web / SDK / ACP | 选择能力,不写 Agent 状态机 | -| `ProductFeaturePack` | Product Feature | feature owner | Product Assembly | 产品入口、Kernel API、UI host | 编排 feature,不拥有 OS concrete 或 Plugin Runtime Host | -| `AgentRuntimeBuilder` / Kernel API | Agent Kernel | `bitfun-agent-runtime` | Product Assembly / SDK host | Product Feature、SDK、Extension adapter | 接收 typed parts,不创建 concrete manager | -| `ToolRuntimeBuilder` / Tool contracts | Execution | tool/execution owner | Product Assembly | Agent Kernel 通过 execution port、Harness | 执行 tool,不选择产品形态 | -| `HarnessRegistryBuilder` | Execution | harness owner | Product Assembly | Agent Kernel 通过 registry contract、Product Feature | 注册 workflow provider,不执行 UI 展示 | -| `PluginRuntimeHost` / compatibility adapter | Extension | plugin runtime host / adapter | Product Assembly | Product Assembly、UI host、Execution provider registry | 产出 descriptor/provider,不写权威状态 | -| `RuntimeServicesBuilder` / platform providers | Cross-platform Adapter | services/adapters/app provider | Product Assembly | Kernel/Execution/Harness 通过 port handle | 实现边界外 I/O,不读取 product profile | -| `SecurityDecisionPort` / `CapabilityEffectPolicy` | Stable Contracts and Security Control Plane | 注入式策略 owner | Product Assembly | Kernel、Execution、Extension、UI projection | 决策可审计,模型/插件不能直接授权 | -| `AgenticFrontendEvent` / frontend event projection | Stable Contracts and Security Control Plane | `bitfun-events` | 无运行时注册 | Tauri/WebSocket/UI extension/OpenCode adapter | 只定义 event name/type/payload;delivery 由 adapter 执行 | - -典型调用链: - -1. 产品入口选择 `DeliveryProfile` 和 feature bundle。 -2. Product Assembly 创建或接收 concrete providers,并构建 `RuntimeServices`、tool registry、harness registry、 - Plugin Runtime Host binding、UI contribution registry 和 security policy。 -3. Product Feature 把 command、settings 和 UI contribution 映射为 kernel request、feature policy 或稳定 descriptor。 -4. Agent Kernel 消费 typed runtime parts 和稳定 contract,产生 event、permission request、tool request 和 task facts。 -5. Execution 通过 execution contract 处理 tool/harness/sandbox/MCP tool bridge;具体 I/O 只通过被注入的 platform - port 完成。 -6. Platform adapter 实现 platform/provider ports 并调用边界外资源;它不读取产品 profile,也不决定产品能力。 -7. Extension 只产出 descriptor、provider 和 candidate effects;最终授权、状态写入和审计仍走安全控制面。 - -## 8. 安全与鲁棒性约束 - -- 安全边界是跨层控制面,不是 UI 弹窗或 tool 名称规则。每次 tool、MCP、plugin、hook、shell、network、file、 - browser/desktop 或 remote 动作都必须能归一为 capability/effect/security decision。 -- Agent Kernel 负责维护可审计事实:session、turn、agent/subagent、permission source、execution domain、event - sequence、cancellation、resume/checkpoint 和 DFX facts。 -- Execution 层必须在 tool / MCP / skills / harness 执行前消费 permission、sandbox 和 capability facts。 -- Extension 层必须声明来源、hash、能力、数据类别、副作用、执行域和 UI contribution 范围;未知或声明不完整的能力默认受限。 -- Cross-platform Adapter 必须表达执行位置和降级原因,例如 local host、remote SSH、container、ACP client、MCP server、 - plugin domain、browser/desktop 或 cloud task。 -- UI 只投影安全状态和用户选项,不成为最终授权来源;组织策略、安全拒绝和凭据保护不能被本地确认绕过。 -- 性能保护要求 registry 构建期注入优先,热路径避免无类型 service lookup、全局 mutable registry 和高成本同步扫描。 - -## 9. 风险 - -| 风险 | 保护方式 | -|---|---| -| 新 Agent Kernel 膨胀为新的巨型 core | Kernel 只拥有平台无关 Agent 状态机;feature、tool concrete、platform adapter 和 UI 扩展必须留在对应层 | -| 产品特性下沉到内核 | 用 feature pack / capability matrix 表达 `/goal`、MiniApp、DeepReview 等产品功能;内核只暴露通用能力 | -| Product Assembly 变成全局状态中心 | assembly 只做构建期注册,输出不可变 runtime parts;产品状态归 surface、feature 或 kernel owner | -| Product Assembly 之外的层直接依赖 concrete provider | 将 concrete provider 依赖限制在组装边界;普通调用方只消费 typed port、descriptor 或 stable contract | -| Plugin Runtime Host 绕过安全边界 | 插件只能产出候选效果和 contribution;授权、审计和状态写入由内核事实与安全控制面决定 | -| UI API 和 Rust API 分裂 | Product API 必须同时定义 Rust Kernel API 与 UI Extension Contract,并由 assembly 统一注册 | -| Adapter / Services 边界继续模糊 | 协议/provider translation 和 OS/service implementation 都属于平台/provider adapter 实现边界;不得拥有产品能力选择 | -| 外部系统被误建模为底层依赖层 | 外部资源只在 adapter I/O 边界出现;架构图和依赖规则不得要求 Kernel、Execution、Feature 直接依赖外部系统 | -| 多产品形态能力漂移 | capability matrix、unsupported/unavailable contract、Desktop/CLI/Web/SDK/ACP/Remote 验证矩阵同步维护 | -| 权限、工具、MCP、ACP 语义迁移后不等价 | 保留兼容 facade,补 manifest snapshot、permission decision、event mapping 和 product shape focused tests | -| 抽象只增不替换 owner | 设计不能只新增 facade 或空接口;新 owner 必须能承接真实职责,并让旧主体路径退化为兼容入口 | - -## 10. 目标状态判定 - -- `bitfun-core` 不再是事实上的完整 runtime owner,而是 compatibility facade、`product-full` 组装边界和少量迁移期 adapter。 -- Agent Kernel 能够在不依赖 `bitfun-core`、app crate、Tauri 或 Web UI 的情况下完成最小 session / turn / event stream。 -- 产品特性通过 feature pack / capability matrix 组装,`/goal` 等命令只配置和外放内核能力,不拥有内核状态机。 -- Product API 同时包含 Rust Kernel API 和 UI Extension Contract;OpenCode / ACP / plugin adapter 通过扩展层映射到这些 API。 -- Tool、MCP、skills、sandbox、local/remote runtime 和 harness 执行原语归执行层;具体 OS / provider / remote 实现归跨平台适配层。 -- 权限、sandbox、capability/effect、audit、unsupported/unavailable 和 execution domain 由稳定控制面表达。 -- Desktop、CLI、Web、ACP、Remote 和独立 SDK 都通过 Product Assembly 显式选择能力,不通过下层 `if desktop/cli/web` 分支表达差异。 -- 所有高风险迁移都有行为等价保护、依赖边界检查、产品形态验证和必要的性能/构建影响说明。 diff --git a/docs/architecture/opencode-plugin-surface-audit.md b/docs/architecture/opencode-plugin-surface-audit.md index d26c23abd9..9d8d85accf 100644 --- a/docs/architecture/opencode-plugin-surface-audit.md +++ b/docs/architecture/opencode-plugin-surface-audit.md @@ -1,8 +1,8 @@ # OpenCode 插件兼容暴露面审计 本文独立审视 BitFun 当前核心迁移、公共 API 暴露面和未来受控接入 OpenCode -插件生态的风险。本文不替代 `core-decomposition.md` 和 -`agent-runtime-services-design.md`,也不记录单次 PR 进度或维护独立执行路线图。 +插件生态的风险。本文不替代 [`product-architecture.md`](product-architecture.md) 和 +[`agent-runtime-services-design.md`](agent-runtime-services-design.md),也不记录单次 PR 进度或维护独立执行路线图。 ## 1. 复核方式 @@ -34,9 +34,9 @@ - 插件或 SDK 入口需要直接导入 `bitfun-core/product-full`、产品命令 registry 或完整 `RuntimeServices` bundle 才能工作。 -因此,后续计划不应追求“马上重写成 OpenCode 插件系统”,而应先做公共面收口: +因此,后续计划不应追求“立即重写成 OpenCode 插件系统”,而应先做公共面收口: 把 stable external API、workspace-internal API 和 compatibility API 分开,再基于 BitFun -自己的 Plugin Runtime Host 承接 OpenCode adapter。 +自身的 Plugin Runtime Host 承接 OpenCode adapter。 ## 3. 竞品结构信号 @@ -61,10 +61,10 @@ Permission/Effect Control Plane,再把 OpenCode API 映射到这些合同。 ### 3.2 Claw Code -Claw Code 的产品拆分提供了另一个有用信号:完整 CLI、轻量 automation harness 和 +Claw Code 的产品拆分提供了另一个参考信号:完整 CLI、轻量 automation harness 和 独立 RAG service 被分成不同产品能力;安全、权限、NDJSON 输出、session 和 tool contract 被明确约束。它也暴露了一个反例风险:runtime / tools / commands 的公共 -导出面很容易随功能增加而变宽,长期会降低 SDK 边界的可解释性。 +导出面容易随功能增加而变宽,长期会降低 SDK 边界的可解释性。 对 BitFun 的结论:完整产品、SDK、CLI、Web、ACP、Remote 不应共享同一套全量公开面。 轻量形态需要窄 API 和明确能力矩阵;完整形态可以由 Product Assembly 注入更多 provider。 @@ -217,9 +217,9 @@ BitFun 产品策略仍有混合。 ## 7. 执行准则 -- 不把 OpenCode API 直接稳定成 BitFun 内部 API。 -- 不把 full `RuntimeServices` bundle 或 `bitfun-core/product-full` 暴露给插件或 SDK。 -- 不把 UI implementation、Tauri state、React component 或具体 provider handle 下沉到内核。 +- 不将 OpenCode API 直接稳定成 BitFun 内部 API。 +- 不将 full `RuntimeServices` bundle 或 `bitfun-core/product-full` 暴露给插件或 SDK。 +- 不将 UI implementation、Tauri state、React component 或具体 provider handle 下沉到内核。 - 不接受只新增抽象、不删除或收敛旧路径的迁移 PR。 - 不在安全控制面未完成前开放可写插件 hook。 - 任何会改变工具曝光、权限语义、事件字段、remote 行为或产品能力矩阵的变更必须单独评审。 diff --git a/docs/architecture/plugin-runtime-host-design.md b/docs/architecture/plugin-runtime-host-design.md index 641216a04d..e030899fab 100644 --- a/docs/architecture/plugin-runtime-host-design.md +++ b/docs/architecture/plugin-runtime-host-design.md @@ -1,19 +1,22 @@ # 插件运行时主机与生态兼容适配层设计 -本文补充 [`core-decomposition.md`](core-decomposition.md)、 +本文补充 [`product-architecture.md`](product-architecture.md)、 [`agent-runtime-services-design.md`](agent-runtime-services-design.md) 和 [`../sdlc-harness/features/opencode-compatibility.md`](../sdlc-harness/features/opencode-compatibility.md)。 本文只描述目标架构、合同和风险边界,不记录实施进度。 +阅读路径:第 1-3 节说明插件运行时主机的目标、非目标和总体关系;第 4-6 节说明主体进程 API、 +Product Assembly 能力模型和领域对象;第 7-14 节说明运行时、IPC、时序、OpenCode 映射、安全和验证细节。 + ## 1. 方案判断 -插件扩展能力需要独立于 Agent Kernel、Product Assembly 和具体生态适配器。合理的目标模型是: +插件扩展能力需要独立于 Agent Kernel、Product Assembly 和具体生态适配器。目标模型如下: - **Plugin Runtime Host** 管理插件兼容层生命周期、项目执行域、IPC、隔离、健康、超时、幂等和候选效果路由。 - **Compatibility Adapter** 只负责把 OpenCode、Claude Code、Codex 插件或 BitFun 原生插件 API 映射为 BitFun 稳定合同。 - **BitFun 主体进程** 只依赖 `PluginRuntimeClient`、规范信封、候选效果、能力声明和 UI descriptor,不依赖任何具体生态适配器类型。 -该模型是合理的,因为它把“插件运行时治理”和“生态 API 翻译”拆开:主体进程可替换适配器,插件失败可降级,OpenCode +该模型将“插件运行时治理”和“生态 API 翻译”分离:主体进程可替换适配器,插件失败可降级,OpenCode 兼容不会反向成为 BitFun 内部 owner。若主体进程需要按 `OpenCodeAdapter`、`ClaudeCodeAdapter` 等类型分支, 或需要读取插件运行单元内部对象,则设计不成立。 @@ -27,21 +30,40 @@ - 保持 Kernel 是任务状态、事件和审计事实的权威源;Execution 是工具结果权威源;Security Boundary 是权限权威源。 插件只能返回候选效果。 - 让 Desktop、CLI、Server、Remote、ACP、Web、Mobile Web 和 SDK 显式启用、禁用或降级插件能力。 +- 支持插件在已声明 extension point 上追加或覆写贡献,但覆写必须由 Product Assembly、能力 owner 和安全控制面共同约束。 非目标: - 不复制完整 OpenCode runtime,不承诺任意社区插件无修改运行。 -- 不把 JS/TS runtime、worker、WebView 或子进程视为安全边界。 +- 不将插件系统作为 first-party 产品能力裁剪的主要机制;产品形态裁剪仍由 `ProductProfile`、`CapabilityPack` 和 + Product Assembly 负责。 +- 不将 JS/TS runtime、worker、WebView 或子进程视为安全边界。 - 不允许插件直接写通过、失败、阻断、授权、审计、工具结果或产品状态。 -- 不把插件 API 暴露成无约束 localhost 服务;默认使用受控 IPC。 -- 不把插件运行时主机塞进 Agent Kernel、Tool Runtime、Harness 或 Product Assembly 的内部实现。 +- 不将插件 API 暴露成无约束 localhost 服务;默认使用受控 IPC。 +- 不将插件运行时主机内嵌到 Agent Kernel、Tool Runtime、Harness 或 Product Assembly 的内部实现。 + +### 2.1 产品形态、运行策略与扩展贡献 + +`ProductProfile`、`CapabilityPack`、`CapabilitySet` 和 `OverridePoint` 的权威定义见 +[`product-architecture.md`](product-architecture.md#3-产品如何成形)。本文限定说明插件运行时涉及的子集: + +| 类别 | 进入 BitFun 的方式 | 插件运行时关系 | +|---|---|---| +| 产品形态 | 产品入口、release 配置或白标配置选择 `ProductProfile`,Product Assembly 选择 first-party `CapabilityPack` | 不由插件决定;Host 只按 assembly binding 启用、禁用或降级 | +| 运行策略 | Product Assembly 由 `CapabilityPlan`、provider health、license、workspace policy 和安全策略派生 `CapabilityAvailabilitySet`,再形成 `CapabilitySet` | Host 消费 `CapabilitySet` 与 policy snapshot;不能启用未构建能力 | +| MCP provider | Assembly 注册外部 provider;MCP transport / catalog 在 Platform Adapter,tool/resource/prompt projection 在 Execution / Stable Contracts | 不属于 Plugin Runtime Host,除非插件显式提供 MCP adapter | +| Plugin contribution | Host 校验 descriptor、provider candidate、event subscription 和 effect candidate | 默认追加;只能在已声明 `OverridePoint` 上覆写 | +| Compatibility adapter | Host 内部 adapter 把 OpenCode、Claude Code、Codex 或其他生态 API 转为 BitFun canonical envelope | adapter 只做映射,不成为产品能力、权限或工具结果 owner | + +因此,插件运行时主机只负责运行期扩展治理。外部生态可在受控位置增加或替换贡献,但不得替代 Product Assembly +决定产品形态,也不得将运行时扩展结果写成新的内核事实。 ## 3. 目标逻辑视图 ```mermaid flowchart TB Surface["产品入口与 UI
Desktop / CLI / Server / Remote / ACP / Web / Mobile Web / SDK"] - Assembly["Product Assembly
delivery profile / capability matrix / runtime binding"] + Assembly["Product Assembly
ProductProfile to DeliveryProfile / capability availability / runtime binding"] Kernel["Agent Kernel
event facts / permission coordination / task state / audit facts"] Execution["Execution
Tool ABI / tool runtime / harness / sandbox"] UIExt["UI Extension Contract
descriptor validation / fallback / projection"] @@ -105,10 +127,14 @@ BitFun 主体进程的插件相关 API 只允许包含下列稳定概念: ## 5. Product Assembly 能力模型 -插件能力进入 Product Assembly 的 typed capability matrix,不隐藏在 hook、service locator 或全局 registry 中。 +插件能力进入 Product Assembly 的 typed capability matrix,不得隐藏在 hook、service locator 或全局 registry 中。 这里的 `ExtensionCapabilitySet` 表示产品扩展能力聚合,不表示旧的扩展主机类型;插件运行时边界由 `PluginRuntimeAvailability` 和 `PluginRuntimeBinding` 表达。 +Product Assembly 必须先根据 `ProductProfile` 和 `SurfaceContract` 派生 `DeliveryProfile`、first-party capability plan +和 extension availability,再把插件 contribution 叠加到允许的 extension point 上。叠加规则是:默认追加、显式覆写、 +失败可回滚、状态可诊断。插件不得通过运行时发现改变 `ProductProfile`、隐式启用未构建能力,或替换没有 owner 的内部实现。 + ```rust pub struct ExtensionCapabilitySet { pub plugin_runtime: PluginRuntimeAvailability, @@ -138,6 +164,13 @@ pub enum PluginRuntimeBinding { SDK minimal feature 只能依赖 disabled stub 或测试 fake,不得隐式启动 JS/TS runtime。`DeliveryProfile` 只能影响 binding 和 capability 选择,不得让 Agent Kernel 出现 `if desktop`、`if cli` 或 `if opencode` 分支。 +允许覆写的贡献必须满足: + +- 有稳定 `OverridePoint` id、能力 owner、适用 surface 和 fallback。 +- 有冲突策略,例如 single-winner、ordered-chain、first-party-pinned 或 policy-denied。 +- 有 permission/effect 声明,且最终授权、审计和状态写入仍由安全控制面完成。 +- 有产品形态验证,证明启用、禁用、失败和回滚时不会改变未声明的产品行为。 + ## 6. 领域模型 | 领域对象 | 定义 | 关键约束 | @@ -179,7 +212,7 @@ src/crates/services src/crates/assembly product-capabilities / core - selects DeliveryProfile, PluginRuntimeBinding, adapter set and fallback policy + derives DeliveryProfile, PluginRuntimeBinding, adapter set and fallback policy from ProductProfile and SurfaceContract src/apps/* / src/web-ui / src/mobile-web UI host and product entrypoints @@ -285,21 +318,51 @@ interface PluginEffectBase { source_ref: PluginSourceRef; } +interface OverrideScopedContribution { + override_point?: OverridePointRef; + conflict_policy?: OverrideConflictPolicy; + rollback?: OverrideRollbackPolicy; +} + +interface OverridePointRef { + id: string; + owner: CapabilityOwnerRef; + surface?: "desktop" | "web" | "mobile_web" | "cli" | "server" | "remote" | "acp" | "sdk"; +} + +interface CapabilityOwnerRef { + kind: "product_feature" | "extension_contract" | "assembly_policy"; + id: string; +} + +type OverrideConflictPolicy = + | "single_winner" + | "ordered_chain" + | "first_party_pinned" + | "policy_denied"; + +interface OverrideRollbackPolicy { + mode: "remove_contribution" | "restore_previous" | "disable_plugin"; + reason_ref?: string; +} + type PluginEffectCandidate = | (PluginEffectBase & { kind: "suggestion"; body: CanonicalPayload | PayloadRef }) | (PluginEffectBase & { kind: "evidence_candidate"; body: CanonicalPayload | PayloadRef }) | (PluginEffectBase & { kind: "tool_input_patch_candidate"; tool_call_id: string; patch: CanonicalPayload | PayloadRef }) - | (PluginEffectBase & { kind: "tool_provider_candidate"; manifest: ToolProviderDescriptor; source: PluginSourceRef }) + | (PluginEffectBase & OverrideScopedContribution & { kind: "tool_provider_candidate"; manifest: ToolProviderDescriptor; source: PluginSourceRef }) | (PluginEffectBase & { kind: "post_tool_evidence_candidate"; tool_call_id: string; body: CanonicalPayload | PayloadRef }) | (PluginEffectBase & { kind: "permission_candidate"; request_id: string; recommendation: "allow" | "deny"; reason: string }) - | (PluginEffectBase & { kind: "ui_contribution_candidate"; descriptor: UiContributionDescriptor }) - | (PluginEffectBase & { kind: "event_subscription_candidate"; events: string[] }) + | (PluginEffectBase & OverrideScopedContribution & { kind: "ui_contribution_candidate"; descriptor: UiContributionDescriptor }) + | (PluginEffectBase & OverrideScopedContribution & { kind: "event_subscription_candidate"; events: string[] }) | (PluginEffectBase & { kind: "unsupported"; capability: string; reason: string }); interface UiContributionDescriptor { descriptor_version: 1; contribution_id: string; source_ref: PluginSourceRef; + override_point?: OverridePointRef; + conflict_policy?: OverrideConflictPolicy; surfaces: Array<"desktop" | "web" | "mobile_web" | "cli" | "server" | "remote" | "acp" | "sdk">; slot: "panel" | "command_palette" | "settings" | "notification" | "status" | "text_projection"; component_kind: "text" | "summary" | "form" | "list" | "status_badge" | "command"; @@ -324,6 +387,8 @@ API 中绕过 schema 传递裸 JSON。 `UiContributionDescriptor` 不得包含 React component、HTML script、DOM selector、Tauri command、store mutation 或任意可执行代码。未知 slot、未知 action 或缺失 capability 时,入口必须按 fallback 降级。 +`override_point` 只表示插件请求进入已声明覆写点;是否接受、排序、回滚和审计由 Product Assembly、安全控制面与 +能力 owner 共同裁决。 `tool_result`、`permission_granted`、`audit_written` 和 `state_changed` 不允许作为插件响应类型。真实工具结果由 Execution 写入;真实权限状态由 Security Boundary 写入;审计事实由 Kernel 写入。 @@ -340,8 +405,8 @@ sequenceDiagram participant Host as Plugin Runtime Host participant Adapter as Compatibility Adapter - Surface->>Assembly: select DeliveryProfile - Assembly->>Assembly: build ExtensionCapabilitySet + Surface->>Assembly: provide ProductProfile + SurfaceContract + Assembly->>Assembly: derive DeliveryProfile and ExtensionCapabilitySet Assembly->>Host: runtime.initialize(adapter manifest set, policy snapshot) Host->>Adapter: load adapter descriptors Adapter-->>Host: supported capabilities @@ -415,7 +480,7 @@ DTO。 | CLI | 可启用本地 Host 或只读投影 | 文本 descriptor / warning | unsupported 明确输出,不静默忽略 | | Server | 默认受控启用或 disabled stub,取决于部署策略 | API 返回 typed unsupported 或 descriptor projection | 不自动启动本地 JS/TS runtime | | Remote | Host 靠近远端执行域 | UI 只接收 logical path 和 descriptor | 不回落到本地路径执行 | -| ACP | 以 protocol adapter 暴露可组合 capability | 以 ACP 能力或 unsupported 表达 | 不把插件失败解释为 agent 失败 | +| ACP | 以 protocol adapter 暴露可组合 capability | 以 ACP 能力或 unsupported 表达 | 不将插件失败解释为 agent 失败 | | Web / Mobile Web | 不启动本地 Host | 只消费后端投影 descriptor | 不持有插件执行单元 | | SDK minimal | disabled stub 或调用方注入 fake/client | 无默认 UI host | 不牵引 product-full 或 concrete provider | @@ -436,7 +501,7 @@ DTO。 ## 13. 安全与供应链 -插件能力必须绑定: +插件能力必须声明以下绑定: - 来源:project、global、enterprise registry、signed bundle 或 remote source。 - 身份:adapter id、plugin id、版本、hash、签名状态。 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md new file mode 100644 index 0000000000..65328e77c5 --- /dev/null +++ b/docs/architecture/product-architecture.md @@ -0,0 +1,215 @@ +# BitFun 产品运行时架构 + +本文作为 BitFun 产品运行时架构入口,说明三件事:产品能力如何组合,代理运行时如何保持稳定,插件和外部生态如何受控接入。 +执行计划见 [`../plans/core-decomposition-plan.md`](../plans/core-decomposition-plan.md);接口、产品组装和扩展注册开发约束见 +[`agent-runtime-services-design.md`](agent-runtime-services-design.md);插件运行时、IPC、待确认效果和外部生态适配契约见 +[`plugin-runtime-host-design.md`](plugin-runtime-host-design.md)。 + +本文不记录实现进度,不展开 crate 内部设计,也不复述外部产品调研。本文聚焦职责和约束,细节文档补充接口形态、模块归属和验证要求。 + +## 1. 要解决的问题 + +`bitfun-core` 曾同时承担兼容入口、完整运行时组装、代理循环、服务接线、工具注册、产品命令和部分领域逻辑。 +该形态能够支撑现有产品,但会让后续演进成本持续升高: + +- 新产品形态容易被完整桌面产品能力牵引,无法清晰裁剪。 +- `/goal`、DeepReview、MiniApp、工具、MCP、内置 hook、插件 hook 和界面扩展缺少统一能力边界。 +- 权限、事件、产物(artifact)、远程执行和审计事实分散在多条路径中,难以证明行为等价。 +- 插件或外部生态如果直接进入内部状态,会反向改写产品形态和安全边界。 + +目标是将 BitFun 从“一个完整运行时中心”调整为“可组合的产品运行时”。这不是增加更多抽象,而是明确三条主线: + +1. **产品成形路径**:产品入口声明自身约束,产品组装选择内置能力(first-party)和服务实现,产品能力负责体验与验证。 +2. **任务运行路径**:代理内核维护会话、任务、权限事实和事件,通过注入端口调用执行能力和平台能力。 +3. **扩展接入路径**:插件、MCP、ACP 外部代理/工具桥接和外部生态只能提交能力声明或待确认效果,不能直接写最终状态。 + +## 2. 总体结构 + +逻辑视图只保留三条主线。每条主线内部有必要的职责角色,但不要求读者先理解全部 crate。 + +```mermaid +flowchart TB + subgraph Product["产品主线:决定交付形态"] + Surface["产品入口"] + Assembly["产品组装"] + Capability["产品能力"] + end + + subgraph Runtime["运行主线:处理任务事实"] + Kernel["代理内核"] + Execution["执行层"] + Extension["扩展治理"] + end + + Guard["共同规则\n稳定契约、安全决策、DTO、端口、产物和审计"] + + subgraph Boundary["边界主线:调用外部系统"] + Adapter["平台适配"] + end + + External[("外部资源")] + + Surface --> Assembly + Assembly --> Capability + Assembly --> Kernel + Assembly --> Execution + Assembly --> Extension + Assembly --> Adapter + Capability --> Kernel + Kernel --> Execution + Kernel --> Extension + Execution --> Adapter + Extension --> Adapter + Assembly --> Guard + Capability --> Guard + Kernel --> Guard + Execution --> Guard + Extension --> Guard + Adapter --> Guard + Adapter -. "边界外调用" .-> External +``` + +| 职责角色 | 负责 | 不负责 | +|---|---|---| +| 产品入口 | 桌面、CLI、Web、Server、Remote、ACP、SDK 的交互、协议转换和状态展示 | 选择完整产品形态;创建具体服务实现 | +| 产品组装 | 根据产品配置和入口约束选择能力包、服务实现、插件绑定和默认策略 | 实现代理内核状态机;在任务运行时临时查找服务 | +| 产品能力 | 维护用户可见功能的命令、界面贡献、权限/副作用、产物、降级和验证责任 | 直接执行系统 I/O;拥有代理内核状态;实现具体 UI/React/Tauri 组件 | +| 代理内核 | 维护会话、工作区、任务、权限事实、事件、hook 事实、上下文、记忆和调度 | 依赖 Tauri、React、ACP 协议、模型客户端或文件系统具体实现 | +| 执行层 | 执行工具、技能、MCP 工具、沙箱和评审工作流 | 决定产品形态;直接授权 | +| 扩展治理 | 管理插件、外部 hook 贡献、外部生态适配和待确认效果 | 绕过安全控制;写审计或最终状态;改写内核 hook 顺序、超时或错误策略 | +| 平台适配 | 实现文件、终端、Git、远程、模型服务、MCP 连接等外部系统调用 | 决定产品能力;要求普通模块直接依赖具体实现 | +| 稳定契约与安全控制 | 定义跨产品、运行和边界的 DTO、事件、端口、能力/副作用、权限、审计、产物和类型化错误 | 依赖上层实现;承载具体界面或具体策略实现 | + +依赖方向只允许流向稳定契约或注入端口。运行时可以从代理内核调用执行端口、从执行层调用平台端口,但这不等于编译期依赖具体实现。 +稳定契约与安全控制不是外部调用的子层,而是三条主线都要遵守的共同规则。除产品组装边界外,普通模块不应同时认识接口和 +具体服务实现(provider)。 + +划分原则: + +- 先按责任划分,再映射到 crate;不要用当前目录结构反推架构边界。 +- 用户可见能力归产品能力;会话、任务、权限事实和事件归代理内核;外部系统调用归平台适配。 +- 产品差异在产品入口、产品组装和产品能力中表达,不进入代理内核和执行层的通用逻辑。 +- 能力是否启用、降级或替换,由产品组装、安全控制面和对应能力责任方共同决定。 + +依赖关系: + +- 产品组装是组装入口,可以选择具体服务实现,并把稳定端口注入代理内核、执行层、扩展治理和平台适配。 +- 代理内核只通过端口调用执行层;执行层和扩展治理只通过端口访问平台适配。 +- 稳定契约与安全控制为所有层提供共同语言;它定义 DTO、事件、端口、权限、审计和产物,不承载具体业务实现。 +- 外部资源只能通过平台适配进入系统;其他层不得直接调用 OS、Git、MCP server、模型服务或远程主机。 + +## 3. 产品如何成形 + +产品形态由组装期决定,不由运行时插件或 Cargo feature 单独决定。核心对象如下: + +| 概念 | 含义 | +|---|---| +| `SurfaceContract` | 产品入口声明:入口类型、宿主/协议、执行域、展示能力、权限和产物展示方式。它不选择完整产品形态。 | +| `ProductProfile` | 产品配置:构建、发布或白标配置选择的内置能力、默认服务、默认策略和裁剪范围。 | +| `CapabilityPack` | 能力包:一个产品能力的最小声明单元,包含命令、界面贡献、权限/副作用、产物、工具或服务实现需求。 | +| `DeliveryProfile` | 组装结果:产品组装消费 `ProductProfile` 与 `SurfaceContract` 后生成的当前入口交付形态。 | +| `CapabilityPlan` | 组装期能力计划:当前产品形态准备注册哪些能力、命令、服务和扩展入口。 | +| `CapabilityAvailabilitySet` | 运行时可用性:当前环境、策略、授权状态和服务健康状态下哪些能力可用,哪些需要降级。 | +| `CapabilitySet` | 产品组装输出的能力集合视图:由 `CapabilityPlan` 和 `CapabilityAvailabilitySet` 组成,用于策略判断和插件运行时绑定;不作为第三套权威来源。 | +| `OverridePoint` | 显式覆写点:允许插件或能力包替换已声明扩展点时,必须声明稳定 id、责任方、适用入口、冲突策略、回退、权限/副作用、验证和回滚方式。 | + +组装流程: + +1. 产品入口提供 `SurfaceContract`,发布或白标配置选择 `ProductProfile`。 +2. 产品组装校验能力包的依赖、冲突、适用入口、服务实现需求、权限/副作用和覆写规则。 +3. 产品组装生成 `DeliveryProfile`、`CapabilityPlan`、`CapabilityAvailabilitySet`,并以 `CapabilitySet` 暴露能力集合视图。 +4. 产品组装注入稳定端口、服务实现、工具注册表、界面贡献、插件运行时绑定和安全策略。 + +必须保持的规则: + +- 内置功能通过 `ProductProfile` 与 `CapabilityPack` 加入或裁剪;插件不是裁剪内置功能的主要机制。 +- 产品能力是 `/goal`、DeepReview、MiniApp、设置、评审入口等用户功能的责任方;它维护体验边界和验证责任,但不拥有内核状态机。 +- 运行时策略、授权状态和服务健康状态只能让能力降级,不能启用构建包里不存在的能力。 +- 能力事实不能散落在 Rust feature、前端路由、Tauri command、工具注册表和插件配置里各自维护;至少要有共同的能力 id、 + 责任方、依赖/冲突、适用入口、权限/副作用、产物、降级语义和验证责任方。 + +## 4. 任务如何运行 + +产品入口只负责把用户动作转换成稳定请求;代理内核负责维护任务事实;执行、扩展和平台访问都通过注入端口完成。 + +运行步骤: + +1. 产品入口把命令、设置或界面贡献映射为稳定请求。 +2. 代理内核产生事件、权限请求、工具请求、任务状态、hook 事实和审计事实。 +3. 执行层处理工具、技能、MCP 工具、沙箱和评审工作流;执行前必须消费权限、沙箱和能力事实。 +4. 扩展治理处理插件生命周期、外部生态适配和待确认效果;待确认效果必须回到安全控制面后才能生效。 +5. 平台适配通过稳定端口访问文件、终端、Git、远程、MCP 服务端、模型服务等外部资源。 +6. 产品入口只展示状态、产物、错误和确认选项,不成为最终授权或审计来源。 + +跨入口能力不要求界面完全一致,但降级语义必须一致。每个能力在每个入口上只能落入以下状态之一:`full`、 +`artifact-only`、`status-only`、`temporarily-unavailable`、`unsupported` 或 `policy-denied`。入口只能展示这些状态, +不能自行发明新的状态;产物降级必须能追踪产出方和执行域。 + +## 5. 扩展如何进入 + +插件、MCP、ACP 外部代理/工具桥接和外部生态都属于边界能力,不得直接成为内部最终责任方。 + +| 入口 | 正确位置 | 边界 | +|---|---|---| +| 插件运行时 | 扩展治理 | 管理插件生命周期、隔离执行域和待确认效果;不写代理内核状态、权限结果或审计事实。 | +| 外部生态适配 | 扩展治理 | 把 OpenCode、Claude Code、Codex 等外部 API 映射为 BitFun 稳定描述符和待确认贡献。 | +| MCP | 平台适配 + 执行层 + 稳定契约 | MCP 连接和目录在平台适配;tool/resource/prompt 的展示和执行映射在执行层和稳定契约。 | +| ACP 协议入口 | 产品入口与接口责任方 | 拥有协议层请求/响应、客户端生命周期、启动探测和工作区选择。 | +| ACP 外部 agent/tool bridge | 扩展治理 | 只暴露外部代理/工具描述符和权限/事件桥接,不写最终状态。 | + +插件和兼容适配默认只能追加贡献或产出待确认效果。只有产品明确声明 `OverridePoint` 时,才允许替换已声明扩展点。 +最终排序、冲突处理、回滚和审计必须由产品组装、安全控制面和能力责任方共同裁决。ACP 的 allow/ask 只能表达授权事实, +不能替代 BitFun 对文件、shell、网络、凭据、远程执行和本地平台动作的安全判定。 + +扩展点设计原则: + +- 每个扩展点必须有稳定 id、责任方、适用入口和默认行为。 +- 扩展点必须声明输入、输出、权限、副作用、数据类别和执行域。 +- 扩展点必须定义冲突处理、排序、回退和回滚方式;未定义时只能追加,不能替换。 +- 插件或外部生态只能返回待确认效果;最终是否生效由安全控制面和能力责任方判断。 +- 每个扩展点都必须能被测试和审计;不支持的入口必须返回明确的不可用或不支持状态。 + +## 6. 安全与风险 + +安全边界贯穿三条主线:产品主线声明能力和入口约束,运行主线消费权限和能力事实,边界主线执行外部调用并保留审计。 +每次工具、MCP、插件、hook、shell、网络、文件、浏览器/桌面或远程动作,都必须归一为能力、副作用和安全决策事实。 + +关键约束: + +- 代理内核维护可审计事实:session、workspace、turn、agent/subagent、权限来源、执行域、事件序列、 + hook 顺序/超时/错误策略、取消、恢复/检查点和可诊断性事实(DFX facts)。 +- 执行层在工具、MCP、skills、评审工作流执行前消费权限、沙箱和能力事实。 +- 扩展治理声明来源、hash、能力、数据类别、副作用、执行域和界面贡献范围;未知或声明不完整的能力默认受限。 +- 平台适配表达执行位置和降级原因,例如本地主机、远程 SSH、容器、ACP client、MCP server、plugin domain。 +- 界面只展示安全状态和用户选项;组织策略、安全拒绝和凭据保护不能被本地确认绕过。 +- 注册尽量在组装期完成;任务运行期间避免临时查找全局服务、修改全局注册表或做高成本扫描。 + +默认不变要求是:除非经过单独设计评审并明确记录影响,迁移必须保持默认能力集合、权限语义、工具曝光、事件语义、 +session 生命周期、remote 行为和 release 构建形态等价。 + +主要风险与保护方式: + +| 风险 | 保护方式 | +|---|---| +| 代理内核膨胀为新的巨型核心 | 内核只拥有平台无关任务状态;产品功能、工具具体实现、平台适配和界面留在对应边界 | +| 产品组装变成全局状态中心 | 组装只输出不可变运行时部件;产品状态归入口、能力责任方或代理内核 | +| 产品配置、Cargo feature、运行时可用性和插件配置互相替代 | 明确区分构建期产品形态、构建依赖、运行时状态和扩展贡献 | +| 插件反向改变产品形态 | 插件默认只追加或产出待确认效果;覆写只能发生在显式 `OverridePoint` | +| 外部系统被当成底层依赖层 | 外部资源只在平台适配输入输出边界出现;普通层依赖端口 / 契约 | +| 权限、工具、MCP、ACP 语义迁移后不等价 | 保留兼容门面,补安全决策、事件映射、能力清单快照和产品形态检查 | + +## 7. 完成判定 + +- `bitfun-core` 不再是事实上的完整运行时权威入口,而是兼容门面、`product-full` 组装边界和迁移期适配。 +- 代理内核能在不依赖 app crate、Tauri、Web UI 或服务具体实现的情况下完成最小 session / turn / event stream。 +- `/goal`、DeepReview、MiniApp 等产品功能通过能力包组装,不拥有代理内核状态机。 +- 内置能力通过 `ProductProfile` 和 `CapabilityPack` 加入或裁剪;运行时可用性通过 `CapabilityPlan`、 + `CapabilityAvailabilitySet`、服务健康状态和策略结果表达。 +- 产品 API 同时包含 Rust 内核 API 和界面扩展契约;OpenCode、ACP 外部代理/工具桥接和 plugin adapter 通过扩展治理映射到这些 API。 +- 工具、MCP、skills、sandbox、local/remote runtime 和 harness 执行能力归执行层;具体 OS、服务实现和远程实现归平台适配层。 +- Desktop、CLI、Web、Server、ACP、Remote 和独立 SDK 都通过产品组装显式选择能力,不通过下层 `if desktop/cli/web/server` + 分支表达差异。 +- 所有会影响默认能力、权限、工具、事件、session、remote 或 release 形态的迁移,都有行为等价保护、依赖边界检查、 + 产品形态验证和必要的性能/构建影响说明。 + +外部产品和技术调研不写入本文主线;相关证据沉淀到 [`../sdlc-harness/research/`](../sdlc-harness/research/)。 diff --git a/docs/architecture/theme-token-optimization.md b/docs/architecture/theme-token-optimization.md index d4d81d885c..3e9528ba6e 100644 --- a/docs/architecture/theme-token-optimization.md +++ b/docs/architecture/theme-token-optimization.md @@ -155,7 +155,7 @@ Mermaid、Monaco/editor、Prism syntax、terminal ANSI、language identity 和 U registry。Mobile Web 和 Installer 已纳入同一审计口径,但各自使用独立 baseline, 避免移动端或安装器的独立 token 被误算为 web-ui 游离 key。 -`342` 个 web-ui 唯一颜色是前端生产文件的全域审计数,不是普通 app UI 的色值预算。 +`309` 个 web-ui 唯一颜色是前端生产文件的全域审计数,不是普通 app UI 的色值预算。 其中包含主题 preset、token contract、Mermaid、Monaco/editor、terminal、syntax、 language identity 和 UI exception 等专用 palette。language identity 已收敛为 8 个大类身份色, 不再按每种语言或文件类型保留独立色值。真正需要继续压缩的是这些专用域 @@ -177,14 +177,16 @@ label、icon、扩展名或文件名之一,不允许构建只靠颜色区分 | 指标 | 当前基线 | | --- | ---: | -| 扫描的生产前端文件数 | 1543 | -| 忽略的测试文件数 | 224 | -| 忽略的构建生成文件数 | 0 | -| 包含颜色字面量的文件数 | 25 | -| 颜色字面量出现次数 | 535 | -| 唯一颜色字面量数量 | 342 | +| 扫描的生产前端文件数 | 1565 | +| 忽略的测试文件数 | 237 | +| 忽略的构建生成文件数 | 1 | +| 包含颜色字面量的文件数 | 24 | +| 颜色字面量出现次数 | 490 | +| 唯一颜色字面量数量 | 309 | | 组件或非 token 文件中的颜色出现次数 | 0 | | 组件或非 token 唯一颜色数量 | 0 | +| token 文件颜色出现次数 | 295 | +| token 文件唯一颜色数量 | 184 | | App UI 颜色出现次数 | 0 | | App UI 唯一颜色数量 | 0 | | `var(--token, fallback)` 出现次数 | 0 | @@ -200,7 +202,7 @@ label、icon、扩展名或文件名之一,不允许构建只靠颜色区分 | root | 颜色出现次数 | 唯一颜色 | App UI raw | fallback var | unresolved / non-contract key | dynamic family | 说明 | | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | -| `src/web-ui/src` | 535 | 342 | 0 | 0 | 0 | 13 | 主应用完整主题、专用 palette、widget payload 和 editor/terminal/Mermaid 域;UI exception、syntax、language identity 与 boundary fallback 已收敛到小型语义 palette,未读取的 git 同义 runtime/static key 和未消费 legacy mixin 已退役。 | +| `src/web-ui/src` | 490 | 309 | 0 | 0 | 0 | 13 | 主应用完整主题、专用 palette、widget payload 和 editor/terminal/Mermaid 域;UI exception、syntax、language identity 与 boundary fallback 已收敛到小型语义 palette,Mermaid status/pie fallback 复用既有语义/类别色,未接入的 Mermaid SCSS token 路径已退役;未读取的 git 同义 runtime/static key、未消费 legacy mixin、文件图标一扩展一色 root key、局部预览派生 key、nav 字体 root family、GitGraph/tool/search/action/inline-tag/windowControls 等低复用 root helper 已退役。 | | `src/mobile-web/src` | 35 | 30 | 0 | 0 | 0 | 3 | mobile-web 图片缩略图关闭按钮已读取 `--color-static-white`,普通 app UI raw 归零。 | | `BitFun-Installer/src` | 76 | 62 | 0 | 0 | 0 | 1 | 安装器主题数据保留主题卡可见的 primary/secondary background、accent、text 和状态反馈;runtime 只导出实际消费的 accent family、基础 text/border/element/status key,不复制主应用 purple/info/tooltip 等完整主题面。 | @@ -235,6 +237,11 @@ CLI/TUI 使用独立审计,不参与 CSS var root 计数: | non-contract cross-file vars | 0 | | non-contract dynamic inputs | 0 | | non-contract component-private vars | 0 | +| runtime-only legacy required vars | 1 | +| static root contract key | 320 | +| static root contract external usage key | 320 | +| static root contract internal-only key | 0 | +| static root contract low external usage key | 116 | 审计补充了机器可校验的治理契约,用于把“可删除债务”和“必须保留的兼容/边界” 分开: @@ -265,7 +272,7 @@ CLI/TUI 使用独立审计,不参与 CSS var root 计数: | fallback token contracts | 0 | 组件 fallback 已通过根 token 或组件根默认值收敛;新增 fallback 会重新要求 owner、reason 和 boundary | | uncontracted fallback tokens | 0 | 防止新增未解释的 fallback key | | stale fallback token contracts | 0 | 防止已删除 fallback 继续留在 registry 中 | -| color domain contracts | 13 | 每个专用域都有 owner、reason 和 merge policy | +| color domain contracts | 14 | 每个专用域都有 owner、reason 和 merge policy | | active uncontracted color domains | 0 | 防止新增专用域但没有 owner/合并策略 | 跨 root 审计必须使用 `pnpm run theme:color-audit:all`。`theme:color-audit` 只覆盖 @@ -279,13 +286,13 @@ PR 通过而放宽 `appUi`、fallback、unresolved、non-contract 或 dynamic fa | 区域 | 当前出现次数 | 当前唯一色数 | 说明 | | --- | ---: | ---: | --- | -| Theme presets | 170 | 116 | 主题个性与 palette 映射;跨主题深色 neutral、弱文本、非状态浅色背景和同概念 success 色已收敛;相邻 surface、主题识别主背景、状态色和 editor lineHighlight 继续保留 | -| Token contracts | 108 | 97 | `tokens.scss` 等静态契约根;黑白 overlay alpha stop 继续保留相邻状态层级,未消费 legacy mixin 和自引用别名已移除,不按数值相近强行合并 | +| Theme presets | 168 | 115 | 主题个性与 palette 映射;跨主题深色 neutral、弱文本、非状态浅色背景和同概念 success 色已收敛;相邻 surface、主题识别主背景、状态色和 editor lineHighlight 继续保留 | +| Token contracts | 94 | 84 | `tokens.scss` 等静态契约根;黑白 overlay alpha stop 继续保留相邻状态层级,未消费 legacy mixin、自引用别名和低复用单 surface helper 已移除,不按数值相近强行合并 | | Editor | 52 | 48 | Monaco/editor 专用域,不能直接泛化到 app token;被动 selection/word highlight 已收敛,但 diff text/line/gutter 继续保留用户可见层级 | -| Mermaid | 82 | 65 | Mermaid 专用渲染域;light status fallback 已改读 app semantic status token,critical/error 背景弱重复已收敛,note 文本保留专用高对比值 | +| Mermaid | 53 | 48 | Mermaid 专用渲染域;status fallback 复用 app semantic status 默认值,pie 5-8 复用紧凑类别色,dark info/activation 恢复 accent 类别感;节点、边、cluster、note 文本和 light 高亮仍保留相邻层级差异。未接入当前 Markdown Mermaid 渲染路径的 SCSS token 文件已删除 | | Theme runtime | 27 | 26 | `ThemeService.ts` 运行时注入;黑白 overlay alpha 与静态 token、payload shell 保持相同 stop,避免 early render 与 runtime 状态层级漂移 | | Language identity | 8 | 8 | 语言/文件身份色,已收敛到 8 个大类色;具体识别继续由 key、label、icon 和扩展名承担 | -| Terminal | 37 | 29 | terminal/ANSI 专用域;工具命令空状态已复用 `--tool-command-empty-rgb`,不再保留独立 raw 色 | +| Terminal | 37 | 29 | terminal/ANSI 专用域;工具命令空状态由 canonical `--color-error` 派生,不再保留独立 root helper 或 raw 色 | | Boundary fallback | 18 | 18 | iframe/miniapp/截图兜底值,不作为普通 app token;generated widget 初始 CSS 需要覆盖 retired alias 的 canonical 目标 | | Visual effects | 0 | 0 | StreamText/TextStroke raw literal 已迁出普通组件层 | | UI exception registry | 17 | 17 | 已归档的 UI 例外色,包含 review team、agent capability、template context、insights 和 inspector 等固定身份色;非相邻同语义私有色已归并到小型 exception palette | @@ -317,14 +324,13 @@ root 的当前队列,不是自动合并指令, | 文件 | 颜色出现次数 | 后续处理策略 | | --- | ---: | --- | -| `src/web-ui/src/component-library/styles/tokens.scss` | 95 | 根 token 契约;优先处理同语义 alias,避免把状态/层级 ramp 按数值强合并 | -| `src/web-ui/src/tools/mermaid-editor/theme/mermaidThemeFallbacks.ts` | 62 | Mermaid 专用渲染兜底;需以节点、边、文本、错误态截图为依据 | +| `src/web-ui/src/component-library/styles/tokens.scss` | 81 | 根 token 契约;优先处理同语义 alias,避免把状态/层级 ramp 按数值强合并 | +| `src/web-ui/src/tools/mermaid-editor/theme/mermaidThemeFallbacks.ts` | 53 | Mermaid 专用渲染兜底;需以节点、边、文本、错误态截图为依据 | | `src/web-ui/src/tools/editor/themes/bitfun-dark.theme.ts` | 46 | Monaco theme palette;不拆散到普通 app token | | `src/web-ui/src/infrastructure/theme/core/ThemeService.ts` | 27 | 运行时注入;需保持 early render、system theme 和 payload 导出兼容 | | `src/web-ui/src/tools/terminal/utils/xtermTheme.ts` | 36 | terminal ANSI palette;不与 app semantic color 合并 | -| `src/web-ui/src/tools/mermaid-editor/theme/_tokens.scss` | 20 | Mermaid SCSS token 兜底;只在 Mermaid owner 内治理 | -| `src/web-ui/src/infrastructure/theme/presets/midnight-theme.ts` | 23 | theme preset palette;保留主题个性、状态色和 alpha ramp 边界 | -| `src/web-ui/src/infrastructure/theme/presets/slate-theme.ts` | 22 | theme preset palette;保留主题个性、状态色和 alpha ramp 边界 | +| `src/web-ui/src/infrastructure/theme/presets/midnight-theme.ts` | 22 | theme preset palette;保留主题个性、状态色和 alpha ramp 边界 | +| `src/web-ui/src/infrastructure/theme/presets/slate-theme.ts` | 21 | theme preset palette;保留主题个性、状态色和 alpha ramp 边界 | | `src/web-ui/src/shared/theme/themeBoundaryFallbacks.ts` | 18 | iframe/miniapp/截图兜底 palette;只作为边界 fallback 治理 | | `src/web-ui/src/shared/theme/uiExceptionAccents.ts` | 17 | 固定 UI 身份/角色色 registry;新增必须说明 owner/role | @@ -389,7 +395,7 @@ Phase 5 决策记录: | inspector active overlay alpha | merge to existing accent stop | `uiExceptionAccents.ts` | inspector active background 与 blue accent `0.15` 是同一强调背景概念,合并后提升可见性并减少一个全局唯一色;border 和 selected overlay 仍保留独立状态色 | | dark preset neutral surface literals | merge within theme preset namespace | `bitfun-midnight`、`bitfun-cyber`、`bitfun-tokyo-night` | Midnight workbench 复用 panel;Cyber secondary/tertiary/quaternary 复用既有 dark neutral;Tokyo tertiary/elevated 复用 secondary/quaternary;只处理中性背景 surface,不合并 accent、semantic、Monaco syntax 或状态色 | | boundary fallback white overlay | merge to coarse fallback stops | `themeBoundaryFallbacks.ts` | iframe/截图兜底只在 root token 不可用时使用,`borderBase`、`elementBgBase`、`elementBgMedium` 收敛到 `0.12`,保留 key 以维持边界语义 | -| Mermaid fallback near surfaces | merge within Mermaid namespace | `_tokens.scss`、`mermaidThemeFallbacks.ts` | light section/active/cluster 和 dark edge-label/note/activation fallback 使用既有 Mermaid surface stop;只在 Mermaid 专用域内合并,不映射到普通 app status token | +| Mermaid fallback near surfaces | merge within Mermaid namespace | `mermaidThemeFallbacks.ts` | light section/cluster 和 dark edge-label/note fallback 使用既有 Mermaid surface stop;dark active/info/activation 保留 accent 类别感。已退役未接入当前 Markdown Mermaid 渲染路径的 SCSS token 文件 | | Monaco light highlight and dark unchanged diff | merge within editor namespace | `MonacoThemeSync.ts`、`bitfun-dark.theme.ts` | light inactive selection/word highlight 收敛到相同弱高亮 stop;dark unchanged diff 使用 editor base background,降低不承担状态含义的深色微差 | | markdown light code/table neutral | merge to existing text/surface token | `flowchat-markdown-code-vars.scss`、`flowchat-markdown-table-vars.scss` | inline code 前景复用 light text token,table header 复用 code block light surface;避免跨 Markdown 子表面的近似浅灰重复 | | accent/glass low alpha values | merge to existing accent stops | `tokens.scss` | blue/purple/green glass 和 card accent 的 `0.06/0.10/0.18` 低 alpha 值收敛到相邻既有 stop,保留 hover/base key,避免扩张独立透明度阶梯 | @@ -415,7 +421,8 @@ Phase 5 决策记录: | web overlay alpha ladder | preserve adjacent state stops | `tokens.scss`、`ThemeService.ts`、`themePayload.ts` | 复审后保留 `0.04/0.08/0.12/0.15/0.20` 等黑白 overlay stop;card、markdown、shadow 等相邻状态仍通过语义 key 表达,不再保留 `0.06/0.10` 微弱中间 stop。生成式 widget host payload 仍不转发静态 overlay,由 iframe fallback/static shell 派生 | | generated widget static overlay payload | move to iframe shell | `src/web-ui/src/tools/generative-widget/themePayload.ts`、`themePayload.test.ts` | 静态黑白 overlay 不再由 host payload 转发,iframe fallback/static shell 直接派生;payload allowlist 98 -> 94,历史低阶 overlay alias 继续通过 iframe fallback 映射,不影响已生成 widget | | canonical overlay micro-stop retirement | retire keys | `tokens.scss`、`ThemeService.ts`、`themePayload.ts`、内部 CSS var 读取 | `--color-overlay-white-06` 合并到 `--color-overlay-white-08`,`--color-overlay-white-10` 合并到 `--color-overlay-white-12`,`--color-overlay-black-10` 合并到 `--color-overlay-black-12`;只处理非语义弱 overlay,历史 generated widget 读取通过 iframe alias fallback 保留 | -| Mermaid light semantic status tokens | derive fill/stroke from app semantic status, preserve readable note text | `_tokens.scss`、`mermaidThemeFallbacks.ts` | light done/crit 颜色改读 success/error semantic token,note fill/stroke 由 warning 派生;note text 保留专用深色值以满足说明文字可读性。critical/error fallback fill 共享同一弱 error 背景,Mermaid unique 73 -> 65 且 near pair 保持 0 | +| Mermaid light semantic status tokens | derive fill/stroke from app semantic status, preserve readable note text | `mermaidThemeFallbacks.ts` | light done/crit 颜色改读 success/error semantic fallback,note text 保留专用深色值以满足说明文字可读性。critical/error fallback fill 共享同一弱 error 背景,Mermaid unique 73 -> 65 且 near pair 保持 0 | +| Mermaid fallback and dead SCSS cleanup | merge non-adjacent fallback literals, retire unused path | `mermaidThemeFallbacks.ts`、删除 `_tokens.scss` | dark/light done、crit、error 和 pie 5-8 fallback 复用同一组 app semantic status 与紧凑类别色;dark active/info/task clickable 和 activation fallback 保留已有 accent 类别感,避免无 CSS var fallback 下 active 或 sequence activation 被误读为 neutral note;节点、边、cluster、note 文本和 light highlight 不合并。删除未接入当前 Markdown Mermaid 渲染路径的 SCSS token 文件,避免维护一套不生效的主题入口。Mermaid 82/65 -> 53/48,web unique 331 -> 309,near pair 保持 0 | | Monaco diff background strength | preserve per-layer strength | `bitfun-dark.theme.ts` | 复审后保留 inserted/removed/modified 的 text、line、gutter 背景强度阶梯;代码审查场景需要同时识别整行变更、行内片段和 gutter 定位锚点,不能只按同 change type 合并 | | generated widget derived payload keys | move derived keys to iframe fallback/static shell | `themePayload.ts`、`themePayloadCompatibility.ts`、`themePayload.test.ts` | accent 细分 stop、status border、strong/prominent border、strong element 和 `--size-radius-md` 不再从 host payload 读取;iframe fallback/static shell 继续解析这些派生 key,payload allowlist 94 -> 80 | | CLI preset/runtime near cleanup | merge non-semantic close surfaces | `src/apps/cli/themes/presets/*.json` | Dark/Ink/Midnight/Cyber 的非状态 surface、input、border 和极近前景值复用已有 preset stops;CLI preset unique 122 -> 118、total unique 152 -> 148,preset/runtime near pair 均降为 0。OpenCode compatibility 字段单独计数,不冒充 runtime 收益 | @@ -431,6 +438,12 @@ Phase 5 决策记录: | Flow Chat capture fallback | boundary fallback | `ExportImageButton.tsx`、`captureElementToDownloadsPng.tsx` -> `themeBoundaryFallbacks.ts` | `#121214` 只在 root theme 变量不可用时兜底截图背景,集中 owner 后避免截图工具重复携带 raw fallback | | git runtime alias surface | retire unused keys | `tokens.scss`、`ThemeService.ts` | `--git-color-pull*`、`--git-color-push*`、`--git-color-branch-border`、`--git-color-added-border`、`--git-color-changes-border`、`--git-color-changes-bg-hover` 和 `--git-color-deleted-border` 没有产品读取,且分别只是 branch/staged/status 派生同义 key;删除后实际渲染仍读取 canonical branch、staged、added、changes、deleted key,web-ui 定义 key 956 -> 945 | | installer minimal runtime projection | retire unused installer keys | `BitFun-Installer/src/theme/installerThemesData.ts`、`installerThemeRuntime.ts`、`variables.css`、`ThemeSetup.tsx` | 安装器是简单首启/安装 surface,不消费完整 app 主题 schema;删除未读取的 background tertiary/quaternary/elevated/workbench/flowchat/tooltip、text disabled、purple family、info/highlight、border strong/prominent、element base/elevated 和额外 radius key。ThemeSetup 预览仍保留 primary/secondary background、element soft、muted text 和 accent;安装器定义 key 54 -> 30,唯一色 72 -> 66 | +| web root export compression | retire derived and extension-specific root keys | `tokens.scss`、`FileExplorer.scss`、`markdown-preview.css`、`SnapshotRollbackButton.scss`、`registerDefaultTypes.ts` | 删除未提供独立主题语义的 `--color-purple-soft`、`--color-cyan-400`、`--color-error-soft`、diff fullscreen panel RGB、preview/markdown preview RGB、miniapp/app card gradient/action RGB 和一扩展一色 file explorer icon RGB。FlowChat inline tag 的保留 root helper 改为 canonical accent/status token,但当前可见 tag 主路径仍由 `config.tagColor` 驱动;miniapp/card gradient 改从 canonical accent/status token 派生;miniapp card action 背景改为组件局部 static-white overlay;preview demo 与 diff fullscreen 因内部仍使用白色 overlay 文本与边框,保留由 static black/white 和品牌 accent 派生的固定暗色 surface;markdown light code 文字读取当前主题 text token。文件树图标按 code/markup/media/config/text 类别复用现有语义 token,文件名、扩展名和图标继续承担主识别。static root contract 566 -> 508,external usage key 515 -> 493,internal-only key 51 -> 15,tokenContract 颜色出现次数 108 -> 102,token 唯一色 197 -> 192。剩余 15 个 internal-only key 主要是 nav font dynamic family、blur/glass/motion 静态族,不在本轮硬删;generated widget payload 中公开给 iframe 的 key 已按外部消费计入审计。 | +| internal-only static root cleanup | derive locally or runtime-only | `tokens.scss`、`app/styles/nav-panel-font-scope.scss`、`FontPreferenceService.ts`、`ThemeService.ts`、`theme-css-var-contract.mjs` | 删除剩余 internal-only static root key:`--nav-font-size-*` 不再作为公共 root family,由 app nav 私有 scope 从 `--flowchat-font-size-*` 通过 CSS `calc()`/`max()` 派生完整 `xxs` 到 `4xl` 字体阶梯,保持“比 FlowChat 小一个 baseline step”的体验并减少运行时注入;root `--font-size-4xl` 不再作为静态 contract 暴露,`--flowchat-font-size-4xl` 继续镜像 runtime `--font-size-4xl`;`--glass-base`、`--blur-subtle`、`--blur-base`、`--motion-slow` 不再作为 static root helper,相关 public key 直接写入 SCSS fallback 或由 runtime owner 同步注入,运行时主题仍继续注入 `--blur-*`、`--motion-*`、`--font-size-*` 等真正可扩展 family。static root contract 508 -> 493,internal-only key 15 -> 0,dynamic family 14 -> 13;没有新增 unresolved、fallback-only、non-contract 或 dynamic-family export 错误。 | +| single-surface static root helper cleanup | localize component-private helpers | `tokens.scss`、`ChatInput.scss`、`CubeLogo.scss`、`SessionScene.scss`、`SnapshotFullscreenDiffViewer.css`、`Markdown.scss`、`RichTextInput.scss` 等 | 将只被单个组件或单个 surface 消费、且不属于插件/runtime/generated widget 扩展入口的 RGB/helper key 下沉到组件局部作用域:ChatInput send/stop/capsule、CubeLogo face/particle(保留 light override)、session pane resizer、snapshot card neutral、snapshot fullscreen 独有 diff 状态、workspace batch、Markdown inline code、RichText context tag 和 Tiptap highlight。下沉后的 helper 使用 `--private-` 私有前缀,旧 root 名称不再作为 custom CSS/plugin theme 覆写入口;如果未来需要让插件或自定义主题配置这些角色,必须新增明确的 TS projection 或公开 theme contract,而不是复活单组件 root helper。computed value 保持不变,普通 app UI raw color、unresolved、fallback-only、non-contract 仍为 0。static root contract 493 -> 412,low external usage key 283 -> 202。剩余低使用量 key 不能仅因使用次数低继续删除;必须先证明它不是设计系统/runtime/payload/多文件共享语义,或提供更小的 TS projection owner。 | +| low-usage root contract compression | retire local surface and same-concept helper keys | `tokens.scss`、`ThemeService.ts`、theme presets、`GitGraphView.tsx`、`cardGradients.ts`、`miniAppIcons.tsx`、`MiniAppCard.tsx`、`UserMessage.tsx`、`DeepReviewConsentDialog.scss`、`markdown-preview.css`、Button/IconButton、tool-card/search/fullscreen diff styles | 将 app/miniapp gradients、FlowChat inline tag、DeepReview consent、Markdown preview、fullscreen diff surface、action RGB 和 windowControls close hover 从全局 root contract 移出;GitGraph lane 与 tool search/Git/terminal/MCP identity 改由小型 `UI_EXCEPTION_ACCENTS` 身份 palette 维护,避免 custom theme 下 accent/status token 合并导致 lane 或工具身份不可辨;GitGraph canvas 绘制时基于当前背景做最小对比度调整,避免浅色/custom light 主题下 lane 过淡。snapshot accept/reject/selected 保留私有状态 RGB,避免用户可见状态被合并。内置主题不再维护 windowControls 空扩展面;close hover 默认直接使用 `colors.semantic.error` 对应 token,旧 custom theme 的 `components.windowControls.close.hoverColor` 仅通过 runtime attribute 激活 deprecated 覆盖兼容,不恢复 static root token,也不作为新主题扩展入口。static root contract 412 -> 352,low external usage key 202 -> 144,token unique 192 -> 184,web unique colors 337 -> 331;普通 app raw、unresolved、fallback-only、non-contract 仍为 0。删除 windowControls 后暴露的未使用 midnight close-hover 色同步移除,使 web color occurrences 进一步降到 519。 | + +| component layout contract compression | retire implementation/layout aliases | `tokens.scss`、`ThemeService.ts`、`Button.scss`、`Badge.scss`、`Markdown.scss`、`BaseToolCard.scss`、`SmoothHeightCollapse.*`、NavPanel workspace/session styles、`themePayload.ts`、`themePayloadCompatibility.ts` | 删除不承担主题语义的全局布局或实现 key:`--input-*` 改读 canonical element/border/text/accent token,`--panel-bg` 改读 `--color-bg-primary`,button height、badge font size、user message padding、Git tight card padding 和 nav row action size/offset/gap 改为组件局部尺寸;Markdown spacing 不回到 root theme key,改由本地 Sass partial 作为 Markdown surface owner,供 renderer、FlowChat thinking、Mermaid/code-vars 复用;`SmoothHeightCollapse` 的 duration prop 改为 inline transition duration,不再投影到 Web root;generated widget static shell 同步移除该实现 key,但 iframe compatibility alias 保留 `--smooth-height-collapse-duration -> --motion-slow`,避免历史 widget CSS 失去动画时长。Markdown accent 不再是 root theme key,但保留 renderer 局部默认和 BaseToolCard 嵌入覆盖,避免工具卡片语义 accent 被默认值吞掉。该轮不合并相邻可见 surface/status 颜色,也不触碰 card/tool-card 跨组件布局 key,避免相邻区域语义被误合并。普通 app raw、unresolved、fallback-only、non-contract 仍为 0。static root contract 352 -> 320,low external usage key 144 -> 116。 | Phase 6 防回退约束: @@ -442,13 +455,24 @@ Phase 6 防回退约束: | `colorDomainNearPairs.nearTotal` | 9 | 9 | 控制 theme preset/runtime/token/editor/Mermaid 等专用域 near 队列规模 | | `colorScopes.appUi.uniqueColors` | 0 | 0 | 阻止普通组件 raw color 唯一色回涨 | | `colorScopes.appUi.occurrences` | 0 | 0 | 阻止普通组件 raw color 出现次数回涨 | -| `colorScopes.exception.uniqueColors` | 182 | 182 | 控制专用域/例外域总体规模;UI exception、syntax 和 language identity 已收敛,但 Mermaid/editor/terminal 仍按各自 owner 单独治理 | +| `colorScopes.token.occurrences` | 295 | 295 | 阻止 token 层重新写回已归并的派生色、扩展名色或 preview RGB 字面量 | +| `colorScopes.token.uniqueColors` | 184 | 184 | 控制 root/token 层唯一色数量,后续只允许在债务减少时下调 | +| `colorScopes.exception.uniqueColors` | 162 | 162 | 控制专用域/例外域总体规模;UI exception、syntax 和 language identity 已收敛,Mermaid status/pie fallback 已压缩且未接入 SCSS token 路径已退役,editor/terminal 仍按各自 owner 单独治理 | +| `cssVarDefinitions.staticContractDefinedUnique` | 320 | 320 | 控制静态 root contract key 总量,避免新增主题时需要维护不可扩展的大型 CSS var 表 | +| `cssVarDefinitions.staticContractExternalUsageUnique` | 320 | 320 | 跟踪真正被 root 外消费的 static contract key,防止删除 key 后遗漏调用点;generated widget payload 暴露给 iframe 的 key 也按外部消费计数 | +| `cssVarDefinitions.staticContractInternalOnlyUnique` | 0 | 0 | 暴露仅定义或内部派生的 root key;新增项必须删除、局部派生或证明是外部消费 contract | +| `cssVarDefinitions.staticContractLowExternalUsageUnique` | 116 | 116 | 暴露低外部消费 key,作为后续继续压缩 root contract 的候选队列 | +| `cssVarDefinitions.runtimeOnlyRequiredContractUnique` | 1 | 1 | 仅允许 `--window-control-close-hover-color` 作为 deprecated window close hover 覆盖,且由 baseline allowlist 锁定名称;默认路径不读取该 var,旧 custom theme 通过 `components.windowControls.close.hoverColor` 和 runtime attribute 激活 | | `colorDomainScopes.syntax.occurrences` | 16 | 16 | 阻止 Prism syntax palette 回到一 token class 一色的不可扩展模式,同时保留相邻 token 可读性边界 | | `colorDomainScopes.languageIdentity.uniqueColors` | 8 | 8 | 阻止 language/file identity 回到一语言一色或一扩展一色的不可扩展模式 | +| `colorDomainScopes.tokenContract.occurrences` | 94 | 94 | 控制 token contract 域 raw color 出现次数,防止 root 派生色回流 | +| `colorDomainScopes.tokenContract.uniqueColors` | 84 | 84 | 控制 token contract 域唯一色,确保新主题扩展不依赖额外静态色表 | | `colorDomainScopes.boundaryFallback.occurrences` | 18 | 18 | 防止 iframe/mini app/截图兜底色重新散写;导出 key 可保留语义,实际字面值必须回到 boundary fallback palette | +| `colorDomainScopes.mermaid.occurrences` | 53 | 53 | 控制 Mermaid 专用域 raw fallback 规模;status/pie 已压缩,未接入 SCSS token 路径已退役,节点/边/cluster/note 文本等相邻层级不能无证据合并 | +| `colorDomainScopes.mermaid.uniqueColors` | 48 | 48 | 控制 Mermaid 专用域唯一色数量;新增类别色或状态色必须先复用现有 compact fallback,确有相邻可读性需求才新增 | | `tokenAliasLiterals.occurrences` | 0 | 0 | 阻止重新出现可映射到 token 的 app literal | | `colorDomainScopes.appUi.occurrences` | 0 | 0 | 阻止未归类 app UI 色值回涨 | -| CSS var governance errors | 0 | 0 | 保持 unresolved、fallback-only、non-contract 和 dynamic family 错误为零 | +| CSS var governance errors | 0 | 0 | 保持 unresolved、fallback-only、non-contract 和 dynamic family 错误为零;唯一 runtime-only legacy var 单独由上一行跟踪 | | `compatibilityAliases.usedUnique` | 0 | 0 | 阻止产品代码重新通过旧 alias key 读取主题变量 | | `compatibilityAliases.occurrences` | 0 | 0 | 阻止历史 alias 调用点回涨 | | `compatibilityAliases.familyUsedUnique` | 0 | 0 | 阻止 `--radius-*`、`--spacing-*` 旧 family 重新成为内部读取面 | @@ -492,6 +516,11 @@ editor、syntax、terminal、language identity、boundary fallback 等专用域 清单:任何影响主题或 UI 色值的变更,都应确认是否触达以下 surface,并按 contract 补充 focused visual review、contrast review、boundary render review 或 mobile build review。 +本轮 single-surface helper 下沉的 focused review 清单:ChatInput 默认/胶囊/send/stop/ +disabled/focus,CubeLogo default/compact 的 light/dark,Snapshot fullscreen +pending/accepted/rejected/active tab/loading,Markdown light inline code/code block toolbar, +RichText file/directory/code/mermaid/image 与 widget/skill tag。 + | surface | 覆盖形态 | 重点风险 | | --- | --- | --- | | app-shell | desktop webview、web、desktop、narrow、dark/light/system | shell 邻近组件必须只读 canonical token,system theme 不能假设桌面专有行为 | diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index ab1ea275e8..6df78c6ce3 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -2,13 +2,13 @@ 本文只记录已完成事实摘要。后续执行路径以 [`core-decomposition-plan.md`](core-decomposition-plan.md) 为准;稳定架构目标以 -[`core-decomposition.md`](../architecture/core-decomposition.md) 和 +[`product-architecture.md`](../architecture/product-architecture.md) 和 [`agent-runtime-services-design.md`](../architecture/agent-runtime-services-design.md) 为准。 ## 1. 基础边界 -- 已建立 `product-full` 作为完整产品能力保护开关,产品入口显式启用完整能力。 +- 已建立 `product-full` 作为兼容入口的完整产品能力保护开关,产品入口显式启用当前兼容能力集合;它不是未来按产品形态拆分能力的唯一 source of truth。 - 已抽取 `bitfun-core-types`、`bitfun-events`、`bitfun-runtime-ports`、`bitfun-agent-stream` 等基础契约;LSP protocol DTO 和 plugin manifest DTO 已进入 `bitfun-core-types`。 - 已建立 `bitfun-services-core`、`bitfun-services-integrations`、`bitfun-agent-tools`、`tool-runtime`、`bitfun-tool-packs`、`bitfun-agent-runtime`、`bitfun-runtime-services`、`bitfun-harness`、`bitfun-product-domains`、`bitfun-product-capabilities` 等 owner crate。 - `src/crates` 已按 `interfaces / assembly / adapters / services / execution / contracts` 六层布局整理,DeepReview path classifier、boundary rules、Cargo workspace path 和根/层级 AGENTS 已同步。 @@ -17,11 +17,13 @@ - `services-core` 已承接 session layout、metadata store CRUD / index rebuild、metadata pagination、metadata construction / mutation、lineage / branch shaping、JSON file store、filesystem primitives、managed runtime command resolution / PATH merge、LSP plugin registry / extension matching / command-target mapping、diagnostic redaction、session usage/token usage 基础服务。 - `services-core` 已承接 workspace-runtime legacy session-store merge、metadata 冲突选择、index rebuild 和 legacy path copy/move fallback;core workspace-runtime 只保留路径计算、runtime layout ensure 和错误兼容映射。 +- `services-core` 已承接 LocalSystemAction 的稳定错误码投射;core Computer Use 系统动作路径只把这些 stable code 适配到既有 ControlHub 工具 envelope。 +- `services-core` 已承接 memory workspace Git baseline、diff collection 和 diff file rendering;core memory workspace 只保留业务文件生成、Phase2 diff 清理、兼容 API 路径和错误类别映射。 - `runtime-services` 已承接 typed runtime service assembly、capability availability、provider registry、capability validation、无副作用 capability marker ports 和 backend event delivery;core backend event system 只保留兼容 re-export。 - `bitfun-events` 已承接 backend event DTO、agentic event DTO、framework-neutral Agentic frontend event projection、AgenticEvent projection manifest、event version / aggregate / replay / retention facts、legacy WebSocket event allowlist 和 platform-neutral `EventEmitter` trait;Tauri/WebSocket transport 只负责 delivery。 -- `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、LAN IP/URL 探测、ngrok 进程/tunnel lifecycle、mobile-web relay upload manifest / incremental upload / fallback upload、IM bot provider-neutral config / persistence / file auto-push / locale / menu / state / command parsing、Weixin provider client、workspace search concrete owner、remote SSH/SFTP/PTY owner、Remote SSH disabled runtime surface、Remote SSH workspace/session identity helper、remote workspace-search disabled surface、DeepResearch report IO / display-map sidecar、MiniApp host dispatch / storage / worker / import IO、announcement remote fetch/cache、browser CDP endpoint HTTP probing / page creation、WebFetch / WebSearch concrete HTTP provider、debug-log HTTP ingest posting、review-platform HTTP transport,以及 MCP server registry、connection pool、catalog cache、reconnect retry state、runtime-only config overlay、local command resolution helper 和 lifecycle status policy。 +- `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、LAN IP/URL 探测、ngrok 进程/tunnel lifecycle、mobile-web relay upload manifest / incremental upload / fallback upload、IM bot provider-neutral config / persistence / file auto-push / locale / menu / state / command parsing、Weixin provider client、workspace search concrete owner、remote SSH/SFTP/PTY owner、Remote SSH disabled runtime surface、Remote SSH workspace/session identity helper、remote workspace-search disabled surface、DeepResearch report IO / display-map sidecar、MiniApp host dispatch / storage / worker / import IO、announcement remote fetch/cache、browser CDP endpoint HTTP probing / page creation、WebFetch / WebSearch concrete HTTP provider、debug-log HTTP ingest posting、review-platform provider service / token store / HTTP transport / Git provider integration,以及 MCP server registry、connection pool、catalog cache、reconnect retry state、runtime-only config overlay、local command resolution helper、lifecycle status policy 和 MCP OAuth credential vault / store / authorization bootstrap;core MCP auth 只保留产品 data-dir 注入、授权入口、错误映射和 deprecated 兼容 wrapper。 - `tool-contracts` 已承接 provider-neutral tool DTO、manifest/catalog/admission/result presentation、Computer Use DTO/input parser/screenshot payload、confirmation facts、truncation recovery presentation、runtime restriction policy、provider-entry materialization、materialized tool snapshot、provider identity、permission/effect filter、cancellation contract 和 stale-call guard;core 只保留 Computer Use 旧 public path re-export / compatibility shim、产品 Tool trait 适配与产品执行入口。 -- `tool-execution` 已承接 local / remote IO helper、Bash shell helper、batching plan、retry policy、state counting、tool state event payload shaping / result redaction、cancellation-state/token-store policy、background exec output capture、ExecCommand provider-neutral 呈现 / 输入默认值 / 结果 shape / shell metadata / shell argv / remote shell probe / remote env snapshot 解析、cache 与 capture policy / lifecycle facts / control facts / completion shape、prompt-safe tool context facts / custom-data materialization、Computer Use loop detection / screenshot hash / verification / retry policy,以及 File tool 的 provider-neutral 结果展示、写入 mode/status/line-count 规则、Edit guardrail 分类和 Delete success 文本;core 只保留 ToolResult 包装、权限、checkpoint、runtime handles、host adapter 调用、read-state adapter、remote FS 调用和旧工具入口。 +- `tool-execution` 已承接 local / remote IO helper、Bash shell helper、batching plan、retry policy、state counting、tool state event payload shaping / result redaction、cancellation-state/token-store policy、background exec output capture、ExecCommand provider-neutral 呈现 / 输入默认值 / 结果 shape / shell metadata / shell argv / remote shell probe / remote env snapshot 解析、cache 与 capture policy / lifecycle facts / control facts / completion shape、prompt-safe tool context facts / custom-data materialization、Computer Use loop detection / screenshot hash / verification / retry policy、WebFetch readable extraction / fallback / title / format facts、WebSearch Exa text result parsing,以及 File tool 的 provider-neutral 结果展示、写入 mode/status/line-count 规则、Edit guardrail 分类和 Delete success 文本;core 只保留 ToolResult 包装、权限、checkpoint、runtime handles、host adapter 调用、read-state adapter、remote FS 调用、Web tool network provider 调用和旧工具入口。 - `runtime-ports` / `terminal-core` / `services-integrations` 已承接 ExecCommand 会话执行端口和 concrete provider:`TerminalPort` 暴露本地命令执行、stdin 写入、会话控制和生命周期事件边界,`RemoteExecPort` 暴露远端 SSH 命令执行、bounded one-shot command、stdin、会话控制和生命周期事件边界;`TerminalRuntimePort` 复用原本地 `ExecProcessManager` 行为,`RemoteExecRuntimePort` 复用原 remote exec manager 与旧 SSH one-shot 行为,当前 desktop / CLI 产品入口和保留 server bootstrap 初始化路径通过 `CoreRuntimeServicesProvider` 构造 provider 并显式注入 `ConversationCoordinator` / 执行上下文 / `ToolRuntimeHandles`;core `ExecCommand` / `WriteStdin` / `ExecControl` 只消费端口,不再直接调用全局本地或远端进程 manager。 - `agent-runtime` 已承接 scheduler/background delivery 纯决策、dialog lifecycle port contracts、runtime event queue/router、session management/cancellation port contracts、session/config/summary facts、persisted session state sidecar / processing-state sanitization、session state facts / event-label projection、session state manager / event emission owner、dialog-turn id / stats facts、side-question runtime-only tracking、thread-goal facts、context profile / model capability policy、prompt markup / prompt / prompt-cache facts 与持久化写入决策、remote file delivery prompt facts、turn skill/agent snapshot DTO/diff/render/store、file-read session state / prior-read guardrail / freshness 决策、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation gate / wait channel state、user-question wait channel state、custom agent / mode / subagent schema、默认值、discovery/loading、markdown IO、validation、review 工具过滤、skill catalog/root specs、mode policy、selection/shadow/mode-info 规则、assistant payload rendering、post-call hook routing、DeepReview provider-neutral policy/queue/retry/diagnostics shaping 与 queue event payload shaping、DeepResearch citation renumber 与 report post-process gate,并建立不暴露 `bitfun-core` / `product-full` / concrete manager 的内部 SDK facade。SDK facade 已支持注入 fake runtime services、tool registry、harness registry、hook registry 和 agent registry。 - `harness` 已建立 descriptor、route plan 和 legacy provider registry。 @@ -38,7 +40,7 @@ ## 3. 已建立保护 - owner crate 不得依赖回 `bitfun-core`。 -- `product-full` 保持完整产品能力集合。 +- `product-full` 保持兼容入口的完整产品能力集合,产品形态差异仍以 Product Assembly 的 capability plan 表达。 - boundary check 覆盖 owner crate 禁止依赖、旧路径 facade-only、feature gate、six-layer path 解析、Product Assembly 收口、session/config/context fact owner、tool confirmation gate owner 和高风险 owner 回流。 - focused tests 覆盖当前 delivery profile 能力裁剪、ProductAssembler 缺失 service 报告、无直接 core 入口的空 capability plan、SDK fake provider / services / tool / harness / hook / workspace-scoped agent registry 闭环,以及 runtime hook 顺序、timeout、错误策略和重复 id 拦截。 - focused baseline 覆盖 tool manifest、GetToolSpec、execution admission、workspace search、remote workspace fallback、MCP config/catalog、prompt cache、custom agent / mode / subagent、thread-goal tools、AskUserQuestion、DeepReview policy、tool confirmation、session restore、MiniApp storage/builtin/import、function-agent Git、scheduled-job state 等路径。 @@ -55,3 +57,5 @@ - MiniApp larger workflow 的 UI asset / desktop scheduler / AI factory 调用仍属于产品 host adapter;可复用规则不得回流到 desktop 命令内重复实现。 - Agent Runtime SDK 已具备 v1 preview workspace 内公开边界、最小 fake-provider 闭环、runtime services / tool / harness / hook / workspace-scoped agent registry 注入基线、最小 feature 证明和外部 embedder 示例。若后续要独立发布为外部包,需要单独评审发布流程、crate packaging、semver 承诺和长期兼容策略。 - Skill registry 主体 owner 已收口到 `agent-runtime`:`bitfun-core` 保留本地/远端扫描、config/registry IO、缓存和加载错误映射;内置 skill 分组、root/slot/key 事实、mode default/override、visible resolution、shadow 标记、mode skill info 和加载后 assistant payload 由 runtime 统一给出。 +- Workspace runtime provider owner 已继续外移:本地 workspace FS/shell 实现由 `bitfun-services-core` 的 `workspace-runtime` feature 持有,远端 SSH workspace FS/shell adapter 由 `bitfun-services-integrations::remote_ssh` 持有;`bitfun-core::agentic::workspace` 保留 binding、session storage 解析和旧路径 re-export。 +- Review-platform 主体 owner 已从 `bitfun-core` 外移到 `services-integrations`:provider detection、remote discovery、token store、provider DTO mapping、pagination、CI log extraction 和 GitHub / GitLab / GitCode HTTP/Git 集成由 `review-platform` feature 持有;core 只保留旧 public path、产品 data-dir 注入和 remote SSH unsupported 分类。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 4382e0f140..c76d4e9bb7 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -1,15 +1,19 @@ # BitFun Core 拆解与运行时迁移计划 本文只维护后续执行计划。稳定目标以 -[`core-decomposition.md`](../architecture/core-decomposition.md)、 +[`product-architecture.md`](../architecture/product-architecture.md)、 [`agent-runtime-services-design.md`](../architecture/agent-runtime-services-design.md) 和 [`plugin-runtime-host-design.md`](../architecture/plugin-runtime-host-design.md) 为准;已完成事实归档在 [`core-decomposition-completed.md`](core-decomposition-completed.md)。 设计文档默认保持稳定,只有目标架构本身需要修正时才修改。 +本计划文件名继续保留 `core-decomposition`,因为它记录的是 `bitfun-core` 收敛和 owner 迁移的执行路径。 ## 1. 执行原则 - `bitfun-core` 最终收敛为 compatibility facade、`product-full` 组装边界和少量迁移期 adapter。 +- 迁移按概念 owner 判断:Product Surface、Product Assembly、Product Feature、Agent Kernel、Execution、Extension、Cross-platform Adapter、Stable Contracts。 +- 外部系统不是 owner 层级;OS、Git、MCP server、AI provider、remote host、browser runtime 和 plugin package 只在 + adapter I/O 边界出现。除 Product Assembly 外,调用方应依赖 port、descriptor 或 stable contract,而不是 concrete provider。 - 新抽象必须同步删除、迁移或显著简化旧 core 主体路径;纯 facade、纯 guard、纯文档或空接口不算完成。 - Product Assembly 是 composition root;除它以外,普通层级只能依赖稳定 contract、port、descriptor 或被注入的 typed part。 - 产品特性和内核能力分开:长程任务、调度、权限、上下文、session/workspace、memory、DFX、hook/event 属于 Agent Kernel; @@ -22,27 +26,47 @@ - workspace 已按六层物理目录展开:`interfaces -> assembly -> adapters -> services -> execution -> contracts`。 - Runtime Services、Agent Runtime、Tool Contracts、Tool Execution、Harness、Product Domains、Services Core、Services Integrations 等 owner crate 已建立。 - `bitfun-core --no-default-features` 已裁掉多批 concrete provider 和 direct provider 依赖;Desktop、CLI、ACP 仍通过 `bitfun-core/product-full` 获取完整产品能力。 +- Computer Use 系统动作错误码、memory workspace Git baseline / diff / render、MCP OAuth credential store、本地/远端 workspace runtime provider 已继续收口到 services owner;core 只保留既有工具 envelope、产品路径注入、授权入口、workspace binding/re-export 和 deprecated 兼容 wrapper。 - Agentic frontend event projection 和 AgenticEvent projection manifest 已进入 `bitfun-events`;Tauri/WebSocket transport 不再内联事件字段映射或 legacy event allowlist。 - Tool ABI 基础合同已进入 `tool-contracts`:materialized snapshot、provider identity、default permission/effect filter、cancellation contract 和 stale-call guard 由 owner crate 提供,core 只投射现有产品 Tool 元数据。 -- Terminal / ExecCommand、remote SSH concrete execution、workspace search、debug ingest、AI provider adapter runtime、browser CDP、WebFetch/WebSearch、review platform transport 等多批 owner 已迁出或收口到 port/provider。 +- Terminal / ExecCommand、remote SSH concrete execution、workspace search、debug ingest、AI provider adapter runtime、browser CDP、WebFetch/WebSearch、review platform provider service 等多批 owner 已迁出或收口到 port/provider。 - Boundary scripts 已覆盖核心 owner 防回流、six-layer path 解析、facade-only 文件、custom agent owner / custom subagent wrapper 保护和重点 feature gate。 ## 3. 目标差距 | 差距 | 影响 | 收敛要求 | |---|---|---| -| Plugin Runtime Host 仍缺少真实执行 Host 和生态 adapter | 插件能力只能表达 disabled / projection-only,不能加载或执行外部插件 | 在 UI Extension Contract 后落地受控 Host facade、effect / trust / diagnostics / deadline / epoch;生态 adapter 在 Host 边界稳定后接入 | +| 部分 concrete owner 仍在 core 或产品命令路径 | 层级依赖和平台差异仍可能回流 | 继续迁移剩余 process/session host adapter、SDK-facing concrete provider 选择和其他仍由 core 持有的 I/O owner | +| SDK readiness 仍未闭环 | 独立 Agent Runtime SDK 可能牵引 product-full 或 concrete provider | fake-provider smoke、minimal feature、cargo tree/metadata 对比和 API version 保护 | | UI Extension Contract 与产品形态矩阵仍需实现 | Desktop/Web/CLI/SDK/ACP 的插件 UI 行为可能不一致 | 建立 descriptor round-trip、fallback、unsupported/unavailable 和只读 state view | +| Plugin Runtime Host 仍缺少真实执行 Host 和生态 adapter | 插件能力只能表达 disabled / projection-only,不能加载或执行外部插件 | 在 UI Extension Contract 后落地受控 Host facade、effect / trust / diagnostics / deadline / epoch;生态 adapter 在 Host 边界稳定后接入 | | OpenCode compatibility adapter 仍缺少真实消费路径 | OpenCode 插件能力无法受控进入 BitFun | 插件 Host 边界稳定后再接入;具体生态 adapter、JS/TS runtime 和可写插件能力后置 | -| 部分 concrete owner 仍在 core 或产品命令路径 | 层级依赖和平台差异仍可能回流 | 继续迁移 Computer Use OS action、Git/process/session host adapter、MCP auth URL helper 等 | -| SDK readiness 仍未闭环 | 独立 Agent Runtime SDK 可能牵引 product-full 或 concrete provider | fake-provider smoke、minimal feature、cargo tree/metadata 对比和 API version 保护 | ## 4. 后续大型阶段 -### Stage D:UI Extension Contract 与产品形态矩阵 +### Stage D:剩余 Concrete Owner 与 SDK Readiness + +目标:继续把 concrete owner 从 `bitfun-core` / 产品命令路径收口到对应 owner crate,并验证独立 Agent Runtime SDK 边界不会牵引完整产品实现。 + +范围: + +- 继续迁移剩余 process/session host adapter、SDK-facing concrete provider 选择和其他仍由 core 持有的 I/O owner。 +- Product Assembly 负责选择 provider;Kernel、Execution、Extension、Product Feature 不直接依赖 platform concrete。 +- 建立 SDK minimal fake-provider smoke,确认 minimal feature 不牵引 Desktop、Tauri、Git provider、MCP client、AI HTTP client、remote SSH 或产品 UI。 + +准出: + +- 至少完成 2-3 个 concrete owner 的实际迁移,并同步删除或简化 core 旧主体路径。 +- `cargo check --workspace`、`cargo check -p bitfun-core --no-default-features`、SDK minimal smoke、cargo metadata/tree 对比和必要 product checks 通过。 + +### Stage E:UI Extension Contract 与产品形态矩阵 目标:为插件 UI contribution 提供声明式 descriptor,并明确不同交付形态的支持、禁用和降级行为。 +准入:本阶段不得只新增 descriptor、registry 或 matrix。任何 UI Extension Contract 代码都必须绑定至少一条既有 +实际消费方或旧路径迁移,例如输入框命令、settings entry、状态投影或插件候选 UI 的实际接入,并同步删除或显著简化旧 +实现;否则只允许保留在设计文档中。 + 范围: - 定义 slot、route、command/keymap、prompt augmentation、dialog/toast、settings entry、state view descriptor。 @@ -50,7 +74,7 @@ - Product Assembly 维护 UI contribution registry、capability matrix 和 unsupported/unavailable fallback。 - 建立 Desktop、Web、CLI、Server、Remote、ACP、SDK、Mobile Web 的插件能力矩阵。 -当前 UI Extension 形态矩阵: +Stage E 目标 UI Extension 形态矩阵: | 形态 | UI Extension 状态 | 降级要求 | |---|---|---| @@ -62,7 +86,7 @@ - UI descriptor round-trip、host fallback、unsupported/unavailable 和 product-shape focused tests 通过。 - Web、Desktop、CLI 不因 UI Extension Contract 引入互相依赖。 -### Stage E:Plugin Runtime Host 执行边界 +### Stage F:Plugin Runtime Host 执行边界 目标:在 disabled/projection-only 边界和 UI Extension Contract 之后,建立真实插件 Host 的受控执行边界,但仍不直接绑定 OpenCode、Claude Code 或 Codex 等具体生态实现。 @@ -79,7 +103,7 @@ - disabled、projection-only、unavailable、host failure、dispose 和 permission/effect focused tests 通过。 - 默认不开放可写 transform 或外部 JS/TS plugin runtime;这些能力需要单独安全评审。 -### Stage F:OpenCode Compatibility Adapter +### Stage G:OpenCode Compatibility Adapter 目标:在 Plugin Runtime Host、Tool ABI、Event Manifest 和 UI Extension Contract 可用后,实现受控 OpenCode 兼容适配。 @@ -95,21 +119,6 @@ - OpenCode adapter 不依赖 `bitfun-core/product-full`、full `RuntimeServices` bundle、UI implementation 或 concrete provider handle。 - adapter、permission/effect、event manifest、UI contribution 和 Desktop/CLI/Server/Remote/ACP/Web/Mobile Web/SDK product shape checks 通过。 -### Stage G:剩余 Concrete Owner 与 SDK Readiness - -目标:完成剩余 concrete owner 收口,并验证独立 Agent Runtime SDK 边界。 - -范围: - -- 继续迁移 Computer Use OS action、部分 Git/process/session host adapter、MCP auth URL helper 等剩余 concrete owner。 -- Product Assembly 负责选择 provider;Kernel、Execution、Extension、Product Feature 不直接依赖 platform concrete。 -- 建立 SDK minimal fake-provider smoke,确认 minimal feature 不牵引 Desktop、Tauri、Git provider、MCP client、AI HTTP client、remote SSH 或产品 UI。 - -准出: - -- 至少完成 2-3 个 concrete owner 的实际迁移,并同步删除或简化 core 旧主体路径。 -- `cargo check --workspace`、`cargo check -p bitfun-core --no-default-features`、SDK minimal smoke、cargo metadata/tree 对比和必要 product checks 通过。 - ## 5. 固定执行流程 1. 同步最新 `main`,检查主干新增的 CLI、tool、terminal、session、scheduler、remote、MiniApp、ACP、OpenCode、plugin、UI 或 product interface 变更。 @@ -130,8 +139,9 @@ | Agent Kernel / permission / event | `cargo test -p bitfun-agent-runtime`,`cargo check -p bitfun-core --no-default-features` | | Runtime Services / backend events | `cargo test -p bitfun-runtime-services`,backend event delivery focused tests | | Tool / MCP / terminal / sandbox | `cargo test -p bitfun-agent-tools`,`cargo test -p tool-runtime`,terminal / exec-command / MCP focused tests | -| Extension / OpenCode / ACP | plugin runtime host focused tests,OpenCode adapter focused tests,ACP permission / external tool focused tests | -| Product shape / SDK | SDK fake-provider smoke,Desktop / CLI / Server / Remote / ACP / Web / Mobile Web capability matrix checks,cargo tree / metadata 对比 | +| Harness / Product Domains | `cargo test -p bitfun-harness`,`cargo test -p bitfun-product-domains`,DeepReview / MiniApp focused tests | +| Extension / OpenCode / ACP | plugin runtime host focused tests,UI contribution descriptor tests,OpenCode adapter focused tests,ACP permission / external tool focused tests | +| Product shape / SDK | SDK fake-provider smoke,Desktop / CLI / Web / Server / Remote / ACP / SDK / Mobile Web capability matrix checks,cargo tree / metadata 对比 | | 大范围 owner 迁移 | `cargo check --workspace`,必要时补 `cargo test --workspace` | ## 7. 暂停条件 diff --git a/docs/sdlc-harness/features/opencode-compatibility.md b/docs/sdlc-harness/features/opencode-compatibility.md index f7531edc99..52bf53dea4 100644 --- a/docs/sdlc-harness/features/opencode-compatibility.md +++ b/docs/sdlc-harness/features/opencode-compatibility.md @@ -1,7 +1,7 @@ # BitFun 子模块设计:Plugin Runtime Host 与 OpenCode 兼容适配 > 上游文档:[design.md](../design.md)、 -> [core-decomposition.md](../../architecture/core-decomposition.md)、 +> [product-architecture.md](../../architecture/product-architecture.md)、 > [agent-runtime-services-design.md](../../architecture/agent-runtime-services-design.md)、 > [plugin-runtime-host-design.md](../../architecture/plugin-runtime-host-design.md) diff --git a/scripts/audit-theme-colors.mjs b/scripts/audit-theme-colors.mjs index 0bdc7c58ed..62a40eaa1f 100644 --- a/scripts/audit-theme-colors.mjs +++ b/scripts/audit-theme-colors.mjs @@ -784,6 +784,13 @@ function applyBaseline(report, options) { category: 'nonContractCssPrivate', reportField: 'nonContractCssPrivateVars', })); + baselineSummary.failures.push(...evaluateAllowlistCategory({ + report, + baseline, + baselineLabel, + category: 'runtimeOnlyRequiredContract', + reportField: 'runtimeOnlyRequiredContractVars', + })); return baselineSummary; } @@ -798,6 +805,9 @@ function audit(options) { relativePath: normalizePath(path.relative(cwd, file)), rootRelativePath: normalizePath(path.relative(root, file)), })); + const rootRelativePathByRelativePath = new Map( + fileEntries.map(entry => [entry.relativePath, entry.rootRelativePath]), + ); const ignoredTestFiles = fileEntries.filter(entry => isAuditTestFile(entry.relativePath)); const ignoredGeneratedFiles = fileEntries.filter(entry => ( !isAuditTestFile(entry.relativePath) @@ -820,6 +830,7 @@ function audit(options) { const varDefinitionCounts = new Map(); const varDefinitionKinds = new Map(); const varDefinitionFiles = new Map(); + const varUsageFileCounts = new Map(); const contractVarDefinitions = new Set(); const staticContractVarDefinitions = new Set(); const runtimeContractVarDefinitions = new Set(); @@ -869,7 +880,7 @@ function audit(options) { incrementMap(tokenColorCounts, color); } else if (exceptionFile) { incrementMap(exceptionColorCounts, color); - } else { + } else if (colorDomain === 'appUi') { componentColorOccurrences += 1; incrementMap(componentColorCounts, color); addToSetMap(componentColorFiles, color, relativePath); @@ -887,6 +898,9 @@ function audit(options) { for (const match of collectMatches(content, CSS_VAR_USAGE_PATTERN)) { incrementMap(varUsageCounts, match[1]); addToSetMap(varUsageFiles, match[1], relativePath); + const fileCounts = varUsageFileCounts.get(match[1]) ?? new Map(); + incrementMap(fileCounts, relativePath); + varUsageFileCounts.set(match[1], fileCounts); } for (const match of collectMatches(content, CSS_VAR_DEFINITION_PATTERN)) { @@ -1057,6 +1071,55 @@ function audit(options) { .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)); const runtimeOnlyRequiredContractVars = runtimeOnlyRequiredContractEntries .slice(0, REPORT_ROW_LIMIT); + const getRootRelativeFiles = files => Array.from(files) + .map(file => rootRelativePathByRelativePath.get(file) ?? file) + .sort() + .slice(0, 5); + const getUsageCountByPredicate = (name, predicate) => { + const fileCounts = varUsageFileCounts.get(name) ?? new Map(); + return Array.from(fileCounts.entries()) + .filter(([file]) => predicate(file)) + .reduce((total, [, count]) => total + count, 0); + }; + const staticContractUsageEntries = Array.from(staticContractVarDefinitions) + .map(key => { + const usageFiles = varUsageFiles.get(key) ?? new Set(); + const payloadFiles = generatedWidgetPayloadVarFiles.get(key) ?? new Set(); + const externalUsageFiles = new Set( + Array.from(usageFiles).filter(file => !isStaticContractVarDefinitionFile(file)), + ); + for (const file of payloadFiles) { + externalUsageFiles.add(file); + } + return { + key, + count: ( + getUsageCountByPredicate(key, file => !isStaticContractVarDefinitionFile(file)) + + (generatedWidgetPayloadVarCounts.get(key) ?? 0) + ), + internalUsageCount: getUsageCountByPredicate(key, file => isStaticContractVarDefinitionFile(file)), + definitionFiles: getRootRelativeFiles(varDefinitionFiles.get(key) ?? new Set()), + usageFiles: getRootRelativeFiles(externalUsageFiles), + externalUsageFileCount: externalUsageFiles.size, + }; + }) + .sort((a, b) => ( + b.externalUsageFileCount - a.externalUsageFileCount + || b.count - a.count + || a.key.localeCompare(b.key) + )); + const staticContractExternalUsageVars = staticContractUsageEntries + .filter(entry => entry.externalUsageFileCount > 0); + const staticContractInternalOnlyVars = staticContractUsageEntries + .filter(entry => entry.externalUsageFileCount === 0) + .sort((a, b) => b.internalUsageCount - a.internalUsageCount || a.key.localeCompare(b.key)); + const staticContractLowExternalUsageVars = staticContractExternalUsageVars + .filter(entry => entry.externalUsageFileCount <= 2) + .sort((a, b) => ( + a.externalUsageFileCount - b.externalUsageFileCount + || b.count - a.count + || a.key.localeCompare(b.key) + )); const nearPairs = buildNearColorPairs(componentColorCounts, componentColorFiles); const uniqueComponentColors = componentColorCounts.size; @@ -1440,10 +1503,15 @@ function audit(options) { fallbackOnlyUnique: fallbackOnlyEntries.length, unresolvedRequiredUnique: unresolvedRequiredEntries.length, runtimeOnlyRequiredContractUnique: runtimeOnlyRequiredContractEntries.length, + staticContractExternalUsageUnique: staticContractExternalUsageVars.length, + staticContractInternalOnlyUnique: staticContractInternalOnlyVars.length, + staticContractLowExternalUsageUnique: staticContractLowExternalUsageVars.length, nonContractCrossFileUnique: nonContractDefinedEntries.length, nonContractDynamicInputUnique: nonContractDynamicInputEntries.length, nonContractCssPrivateUnique: nonContractCssPrivateEntries.length, }, + staticContractInternalOnlyVars: staticContractInternalOnlyVars.slice(0, REPORT_ROW_LIMIT), + staticContractLowExternalUsageVars: staticContractLowExternalUsageVars.slice(0, REPORT_ROW_LIMIT), dynamicDefinedVars, dynamicFamilyUnexportedVars, unregisteredDynamicFamilies: unregisteredDynamicFamilyEntries, @@ -1757,6 +1825,9 @@ function printText(report) { `fallbackOnly=${report.cssVarDefinitions.fallbackOnlyUnique}, ` + `requiredMissing=${report.cssVarDefinitions.unresolvedRequiredUnique}, ` + `runtimeOnlyRequired=${report.cssVarDefinitions.runtimeOnlyRequiredContractUnique}, ` + + `staticContractExternal=${report.cssVarDefinitions.staticContractExternalUsageUnique}, ` + + `staticContractInternalOnly=${report.cssVarDefinitions.staticContractInternalOnlyUnique}, ` + + `staticContractLowExternal=${report.cssVarDefinitions.staticContractLowExternalUsageUnique}, ` + `dynamicFamilyUnexported=${report.cssVarDefinitions.dynamicFamilyUnexportedUnique}, ` + `nonContractCrossFile=${report.cssVarDefinitions.nonContractCrossFileUnique}, ` + `nonContractDynamicInputs=${report.cssVarDefinitions.nonContractDynamicInputUnique}, ` + @@ -1856,6 +1927,18 @@ function printText(report) { } } + console.log('\nStatic contract internal-only CSS vars (top):'); + if (report.staticContractInternalOnlyVars.length === 0) { + console.log(' none'); + } else { + for (const row of report.staticContractInternalOnlyVars.slice(0, 10)) { + console.log( + ` ${row.internalUsageCount.toString().padStart(5)} ${row.key} ` + + `definitions=${row.definitionFiles.join(', ')}` + ); + } + } + console.log(`\nIndistinguishable component color pairs (total=${report.nearPairs.indistinguishableTotal}, sample):`); if (report.nearPairs.indistinguishableTotal === 0) { console.log(' none'); diff --git a/scripts/audit-theme-colors.test.mjs b/scripts/audit-theme-colors.test.mjs index 35e0f61db5..91fb3ca84f 100644 --- a/scripts/audit-theme-colors.test.mjs +++ b/scripts/audit-theme-colors.test.mjs @@ -201,6 +201,21 @@ test('theme CSS var contract registry is explicit and non-overlapping', () => { } }); +test('FlowChat 4xl alias mirrors runtime typography without static root 4xl fallback', () => { + const tokens = readText(path.join(root, 'src/web-ui/src/component-library/styles/tokens.scss')); + + assert.match( + tokens, + /^\s*--flowchat-font-size-4xl:\s*var\(--font-size-4xl\);$/m, + 'FlowChat 4xl should mirror the runtime typography owner', + ); + assert.doesNotMatch( + tokens, + /^\s*--font-size-4xl:/m, + 'static root --font-size-4xl should not be reintroduced', + ); +}); + test('repository dynamic CSS var families match the registered contract', () => { for (const sourceRoot of SOURCE_OWNER_ROOTS) { const result = runAudit(['--root', sourceRoot, '--json', '--no-baseline']); @@ -460,6 +475,57 @@ test('theme color audit emits scoped machine-readable reports', (t) => { assert.equal(report.summary.baseline.enforced, false); }); +test('theme color audit reports static contract token external consumption', (t) => { + const { dir, sourceRoot } = createFixture({ + 'component-library/styles/tokens.scss': [ + ':root {', + ' --color-text-primary: #111111;', + ' --private-helper-rgb: 17, 17, 17;', + ' --derived-from-helper: rgb(var(--private-helper-rgb));', + ' --payload-export: #333333;', + ' --unused-export: #222222;', + '}', + '', + ].join('\n'), + 'app/App.scss': [ + '.app {', + ' color: var(--color-text-primary);', + ' background: var(--derived-from-helper);', + '}', + '', + ].join('\n'), + 'tools/generative-widget/themePayload.ts': [ + 'const WIDGET_THEME_VAR_GROUPS = {', + ' core: [', + " '--payload-export',", + ' ],', + '} as const;', + '', + ].join('\n'), + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const result = runAudit(['--root', sourceRoot, '--json', '--no-baseline']); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const report = JSON.parse(result.stdout); + assert.equal(report.cssVarDefinitions.staticContractExternalUsageUnique, 3); + assert.equal(report.cssVarDefinitions.staticContractInternalOnlyUnique, 2); + assert.deepEqual( + report.staticContractLowExternalUsageVars + .filter(row => row.key === '--payload-export') + .map(row => [row.key, row.count, row.externalUsageFileCount, row.usageFiles]), + [['--payload-export', 1, 1, ['tools/generative-widget/themePayload.ts']]], + ); + assert.deepEqual( + report.staticContractInternalOnlyVars.map(row => [row.key, row.definitionFiles, row.internalUsageCount]), + [ + ['--private-helper-rgb', ['component-library/styles/tokens.scss'], 1], + ['--unused-export', ['component-library/styles/tokens.scss'], 0], + ], + ); +}); + test('theme color audit reports deprecated surface-local token names', (t) => { const { dir, sourceRoot } = createFixture({ 'component-library/styles/tokens.scss': [ @@ -482,7 +548,7 @@ test('theme color audit reports deprecated surface-local token names', (t) => { ].join('\n'), 'tools/editor/meditor/components/TiptapEditor.scss': [ '.m-editor-tiptap {', - ' --m-editor-highlight-rgb: var(--markdown-editor-highlight-rgb);', + ' --m-editor-highlight-rgb: var(--private-markdown-editor-highlight-rgb);', ' background: rgba(var(--m-editor-highlight-rgb), 0.15);', '}', '', @@ -499,7 +565,7 @@ test('theme color audit reports deprecated surface-local token names', (t) => { assert.deepEqual( report.surfaceTokenRenames.active.map(row => [row.key, row.canonical, row.definitionCount, row.usageCount]), [ - ['--m-editor-highlight-rgb', '--markdown-editor-highlight-rgb', 1, 1], + ['--m-editor-highlight-rgb', '--private-markdown-editor-highlight-rgb', 1, 1], ['--primary-color', '--base-tool-card-accent-color', 1, 1], ['--operation-color', '--snapshot-card-operation-color', 1, 0], ], diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index 5886f452ae..e92cc65104 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -241,14 +241,17 @@ export const dependencyProfileRules = [ 'glob', 'globset', 'hostname', + 'htmd', 'image', 'include_dir', 'indexmap', + 'legible', 'local-ip-address', 'mac_address', 'md5', 'qrcode', 'rand', + 'readability-js', 'rmcp', 'russh', 'sse-stream', diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index b3ca6b64c2..98f4505ad4 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -55,7 +55,7 @@ export const optionalDependencyFeatureOwnerRules = [ ownerFeatures: ['mcp', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete'], }, { depName: 'bitfun-agent-runtime', ownerFeatures: ['deep-research'] }, - { depName: 'bitfun-product-domains', ownerFeatures: ['function-agents', 'miniapp-runtime'] }, + { depName: 'bitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'miniapp-runtime'] }, { depName: 'bitfun-runtime-ports', ownerFeatures: ['remote-connect'] }, { depName: 'bitfun-services-core', @@ -73,6 +73,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'mac_address', ownerFeatures: ['remote-connect'] }, { depName: 'md5', ownerFeatures: ['remote-connect'] }, { depName: 'notify', ownerFeatures: ['file-watch'] }, + { depName: 'oxc', ownerFeatures: ['canvas-runtime'] }, { depName: 'qrcode', ownerFeatures: ['remote-connect'] }, { depName: 'rand', ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh-concrete'] }, { depName: 'reqwest', ownerFeatures: ['announcement', 'browser-control', 'debug-log', 'mcp', 'miniapp-runtime', 'remote-connect', 'review-platform', 'web-tools'] }, @@ -83,7 +84,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'rustls', ownerFeatures: ['remote-connect'] }, { depName: 'rustls-native-certs', ownerFeatures: ['remote-connect'] }, { depName: 'schannel', ownerFeatures: ['remote-connect'] }, - { depName: 'sha2', ownerFeatures: ['remote-connect', 'remote-ssh'] }, + { depName: 'sha2', ownerFeatures: ['canvas-runtime', 'remote-connect', 'remote-ssh'] }, { depName: 'shellexpand', ownerFeatures: ['remote-ssh-concrete'] }, { depName: 'sse-stream', ownerFeatures: ['mcp'] }, { depName: 'ssh_config', ownerFeatures: ['remote-ssh-concrete', 'ssh_config'] }, @@ -91,8 +92,8 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'thiserror', ownerFeatures: ['browser-control', 'git', 'remote-ssh-concrete', 'review-platform', 'web-tools', 'workspace-search'] }, { depName: 'tokio-tungstenite', ownerFeatures: ['remote-connect'] }, { depName: 'tokio-util', ownerFeatures: ['remote-ssh'] }, - { depName: 'urlencoding', ownerFeatures: ['remote-connect'] }, - { depName: 'uuid', ownerFeatures: ['miniapp-runtime', 'remote-connect', 'remote-ssh-concrete'] }, + { depName: 'urlencoding', ownerFeatures: ['canvas-runtime', 'remote-connect'] }, + { depName: 'uuid', ownerFeatures: ['canvas-runtime', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete'] }, { depName: 'which', ownerFeatures: ['miniapp-runtime', 'remote-connect', 'workspace-search'] }, { depName: 'x25519-dalek', ownerFeatures: ['remote-connect'] }, ], @@ -161,6 +162,7 @@ export const ownerCrateFeatureAssemblyRules = [ 'computer-use', 'image-analysis', 'miniapp', + 'canvas', 'agent-control', ], }, @@ -170,6 +172,7 @@ export const ownerCrateFeatureAssemblyRules = [ requiredProductFullFeatures: [ 'announcement', 'browser-control', + 'canvas-runtime', 'debug-log', 'deep-research', 'file-watch', diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index b6d7ccc374..366454c282 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -141,6 +141,31 @@ export const forbiddenContentRules = [ message: 'core WebSearch tool must not own provider HTTP clients; use bitfun-services-integrations web provider', }, + { + regex: /strip_prefix\("Title: "\)/, + message: + 'core WebSearch tool must not own Exa text result parsing; use tool-runtime web_search helpers', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/implementations/web/readable.rs', + patterns: [ + { + regex: /\bhtmd::|\bHtmlToMarkdown\b/, + message: + 'core WebFetch readable facade must not own HTML-to-Markdown conversion; use tool-runtime web_readable helpers', + }, + { + regex: /\blegible::|\bparse_legible\b/, + message: + 'core WebFetch readable facade must not own legible extraction; use tool-runtime web_readable helpers', + }, + { + regex: /\breadability_js::|\bReadability::new\b/, + message: + 'core WebFetch readable facade must not own readability-js extraction; use tool-runtime web_readable helpers', + }, ], }, { @@ -161,6 +186,26 @@ export const forbiddenContentRules = [ message: 'core review platform service must not own concrete HTTP clients; use bitfun-services-integrations review platform HTTP transport', }, + { + regex: /\btokio::fs\b|\bstd::fs\b/, + message: + 'core review platform service must not own token or provider file IO; use bitfun-services-integrations review platform owner', + }, + { + regex: /\bprocess_manager::|\bCommand::new\(|\bexecute_git_command\b|\bgit\s+remote\b|\brev-parse\b/, + message: + 'core review platform service must not own Git probing; use bitfun-services-integrations review platform owner', + }, + { + regex: /\bserde_json::|\bjson!\b|\bValue\b/, + message: + 'core review platform service must not own provider DTO parsing; use bitfun-services-integrations review platform owner', + }, + { + regex: /\bstruct\s+(?:Github|Gitlab|Gitcode)|\bimpl\s+ReviewProvider\b|\btrait\s+ReviewProvider\b/, + message: + 'core review platform service must not own provider implementations; use bitfun-services-integrations review platform owner', + }, ], }, { diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 8e590840e9..46632c4a4a 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -3334,6 +3334,84 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/execution/tool-execution/Cargo.toml', + reason: + 'tool-runtime Web readable extraction must stay behind an explicit feature so minimal tool-runtime consumers do not pull HTML extractor dependencies', + patterns: [ + { + regex: /default = \[\]/, + message: 'tool-runtime default feature set must stay empty', + }, + { + regex: + /web-readable = \["dep:htmd", "dep:legible", "dep:readability-js", "dep:regex"\]/, + message: 'tool-runtime web-readable feature must own exactly the Web extractor deps', + }, + { + regex: /htmd = \{[^}]*optional = true[^}]*\}/, + message: 'htmd must stay optional under the web-readable owner feature', + }, + { + regex: /legible = \{[^}]*optional = true[^}]*\}/, + message: 'legible must stay optional under the web-readable owner feature', + }, + { + regex: /readability-js = \{[^}]*optional = true[^}]*\}/, + message: 'readability-js must stay optional under the web-readable owner feature', + }, + ], + }, + { + path: 'src/crates/execution/tool-execution/src/web_readable.rs', + reason: + 'tool-runtime must own provider-neutral WebFetch readable extraction and preserve existing fallback behavior', + patterns: [ + { + regex: /\bpub fn normalize_requested_format\b/, + message: 'missing WebFetch format normalization owner', + }, + { + regex: /\bpub fn extract_markdown_with_text_fallback\b/, + message: 'missing WebFetch readable extraction owner', + }, + { + regex: /\bfn attempt_legible\b/, + message: 'missing legible extraction attempt', + }, + { + regex: /\bfn attempt_readability_js\b/, + message: 'missing readability-js extraction fallback', + }, + { + regex: /\babsolutize_root_relative_markdown_uses_base_origin\b/, + message: 'missing root-relative markdown link behavior regression', + }, + ], + }, + { + path: 'src/crates/execution/tool-execution/src/web_search.rs', + reason: + 'tool-runtime must own provider-neutral WebSearch result parsing while core keeps product tool envelope assembly', + patterns: [ + { + regex: /\bpub struct WebSearchResult\b/, + message: 'missing typed WebSearch result DTO', + }, + { + regex: /\bpub fn parse_exa_text_results\b/, + message: 'missing Exa text result parser owner', + }, + { + regex: /\bparses_exa_text_blocks\b/, + message: 'missing Exa text parsing regression', + }, + { + regex: /\bfalls_back_for_unstructured_text\b/, + message: 'missing unstructured WebSearch result fallback regression', + }, + ], + }, { path: 'src/crates/execution/tool-execution/tests/tool_io_contracts.rs', reason: @@ -3446,8 +3524,12 @@ export const requiredContentRules = [ message: 'missing delegation policy parsing owner', }, { - regex: /\bpub fn primary_model_supports_image_understanding\b/, - message: 'missing model image-support policy owner', + regex: /\bpub struct PrimaryModelFacts\b/, + message: 'missing typed primary model facts owner', + }, + { + regex: /\bpub fn multimodal_tool_output_supported\b/, + message: 'missing primary model multimodal tool-output policy owner', }, { regex: /\bmaterializes_provider_neutral_tool_custom_data\b/, @@ -3577,6 +3659,17 @@ export const requiredContentRules = [ regex: /product-domains = \[[^\]]*"bitfun-services-integrations\/miniapp-runtime"[^\]]*\]/, message: 'core product-domain facade must enable the MiniApp service owner feature it imports', }, + { + regex: /canvas-runtime = \[[^\]]*"bitfun-services-integrations\/canvas-runtime"[^\]]*\]/, + message: + 'core canvas-runtime facade must enable the Canvas service owner feature it imports', + }, + { + regex: + /canvas-runtime = \[[\s\S]*"product-domains"[\s\S]*"bitfun-services-integrations\/canvas-runtime"[\s\S]*\]/, + message: + 'core canvas-runtime feature must explicitly aggregate product domains and the canvas service owner feature', + }, { regex: /bitfun-product-domains = \{ path = "\.\.\/\.\.\/contracts\/product-domains", default-features = false, optional = true \}/, @@ -7315,7 +7408,7 @@ export const requiredContentRules = [ message: 'missing Multitask runtime default model mapping', }, { - regex: /builtin_agent_spec\(\s*"GeneralPurpose",\s*SubAgent,\s*"fast"/, + regex: /builtin_agent_spec\(\s*"GeneralPurpose",\s*SubAgent,\s*"primary"/, message: 'missing GeneralPurpose runtime default model mapping', }, { diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 855d6ee30c..8021580ba6 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -544,7 +544,16 @@ export function runManifestParserSelfTest({ throw new Error('services-integrations default profile must forbid non-optional uuid'); } const coreProfile = dependencyProfileRules.find((rule) => rule.crateName === 'core'); - for (const dep of ['git2', 'rmcp', 'image', 'tool-runtime', 'bitfun-relay-server']) { + for (const dep of [ + 'git2', + 'rmcp', + 'image', + 'tool-runtime', + 'bitfun-relay-server', + 'htmd', + 'legible', + 'readability-js', + ]) { if (!coreProfile?.forbiddenNonOptionalDeps.includes(dep)) { throw new Error(`core no-default profile must forbid non-optional ${dep}`); } @@ -558,9 +567,12 @@ export function runManifestParserSelfTest({ const coreFullyMigratedDeps = new Set([ 'aes', 'hostname', + 'htmd', + 'legible', 'local-ip-address', 'mac_address', 'qrcode', + 'readability-js', 'x25519-dalek', ]); for (const dep of coreProfile?.forbiddenNonOptionalDeps ?? []) { @@ -637,6 +649,21 @@ export function runManifestParserSelfTest({ throw new Error(`core browser launcher facade boundary rule must forbid: ${contract}`); } } + const coreReviewPlatformFacadeRuleText = forbiddenRuleTextForPath( + 'src/crates/assembly/core/src/service/review_platform/mod.rs', + ); + for (const contract of [ + 'reqwest::', + 'tokio::fs', + 'process_manager::', + 'execute_git_command', + 'serde_json::', + 'ReviewProvider', + ]) { + if (!coreReviewPlatformFacadeRuleText.includes(contract)) { + throw new Error(`core review platform facade boundary rule must forbid: ${contract}`); + } + } const coreComputerUseSystemFacadeRuleText = forbiddenRuleTextForPath( 'src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs', ); @@ -3014,6 +3041,8 @@ export function runManifestParserSelfTest({ 'bitfun-product-domains = \\{ path = "\\.\\.\\/\\.\\.\\/contracts\\/product-domains", default-features = false, optional = true \\}', 'dep:bitfun-ai-adapters', 'ai-adapter-runtime', + 'canvas-runtime', + 'bitfun-services-integrations\\/canvas-runtime', 'bitfun-services-integrations\\/function-agents', 'bitfun-services-integrations\\/miniapp-runtime', 'dep:bitfun-product-capabilities', diff --git a/scripts/theme-color-governance-baseline.json b/scripts/theme-color-governance-baseline.json index 4162df32c6..2c00d49ebb 100644 --- a/scripts/theme-color-governance-baseline.json +++ b/scripts/theme-color-governance-baseline.json @@ -42,7 +42,7 @@ "max": 0 }, "surfaceTokenRenames.registeredUnique": { - "max": 9 + "max": 8 }, "surfaceTokenRenames.activeUnique": { "max": 0 @@ -87,7 +87,7 @@ "max": 0 }, "colorDomainContracts.registeredUnique": { - "max": 13 + "max": 14 }, "colorDomainContracts.missingRegisteredUnique": { "max": 0 @@ -104,17 +104,23 @@ "colorScopes.appUi.occurrences": { "max": 0 }, + "colorScopes.token.occurrences": { + "max": 295 + }, "colorScopes.token.uniqueColors": { - "max": 197 + "max": 184 }, "colorScopes.exception.uniqueColors": { - "max": 182 + "max": 162 + }, + "cssVarDefinitions.unresolvedUnique": { + "max": 0 }, "cssVarDefinitions.unresolvedRequiredUnique": { "max": 0 }, "cssVarDefinitions.runtimeOnlyRequiredContractUnique": { - "max": 0 + "max": 1 }, "cssVarDefinitions.fallbackOnlyUnique": { "max": 0 @@ -137,6 +143,18 @@ "cssVarDefinitions.staleRegisteredDynamicFamilyUnique": { "max": 0 }, + "cssVarDefinitions.staticContractDefinedUnique": { + "max": 320 + }, + "cssVarDefinitions.staticContractExternalUsageUnique": { + "max": 320 + }, + "cssVarDefinitions.staticContractInternalOnlyUnique": { + "max": 0 + }, + "cssVarDefinitions.staticContractLowExternalUsageUnique": { + "max": 116 + }, "tokenAliasLiterals.occurrences": { "max": 0 }, @@ -179,6 +197,12 @@ "colorDomainNearPairs.generatedWidget.nearTotal": { "max": 0 }, + "colorDomainNearPairs.bitfunCanvas.indistinguishableTotal": { + "max": 0 + }, + "colorDomainNearPairs.bitfunCanvas.nearTotal": { + "max": 0 + }, "colorDomainNearPairs.boundaryFallback.indistinguishableTotal": { "max": 0 }, @@ -240,16 +264,19 @@ "max": 0 }, "colorDomainScopes.themePreset.occurrences": { - "max": 170 + "max": 168 }, "colorDomainScopes.themePreset.uniqueColors": { - "max": 116 + "max": 115 }, "colorDomainScopes.themeRuntime.occurrences": { "max": 27 }, "colorDomainScopes.tokenContract.occurrences": { - "max": 108 + "max": 94 + }, + "colorDomainScopes.tokenContract.uniqueColors": { + "max": 84 }, "colorDomainScopes.generatedWidget.occurrences": { "max": 0 @@ -257,6 +284,12 @@ "colorDomainScopes.generatedWidget.uniqueColors": { "max": 0 }, + "colorDomainScopes.bitfunCanvas.occurrences": { + "max": 0 + }, + "colorDomainScopes.bitfunCanvas.uniqueColors": { + "max": 0 + }, "colorDomainScopes.boundaryFallback.occurrences": { "max": 18 }, @@ -297,10 +330,19 @@ "max": 0 }, "colorDomainScopes.mermaid.occurrences": { - "max": 82 + "max": 53 }, "colorDomainScopes.mermaid.uniqueColors": { - "max": 65 + "max": 48 } + }, + "allowlists": { + "runtimeOnlyRequiredContract": [ + { + "key": "--window-control-close-hover-color", + "owner": "ThemeService legacy custom-theme compatibility", + "reason": "Deprecated components.windowControls.close.hoverColor override; default close hover uses semantic error without this runtime var." + } + ] } } diff --git a/scripts/theme-css-var-contract.mjs b/scripts/theme-css-var-contract.mjs index 497017c87d..c65e63c345 100644 --- a/scripts/theme-css-var-contract.mjs +++ b/scripts/theme-css-var-contract.mjs @@ -21,6 +21,7 @@ export const CONTRACT_VAR_DEFINITION_PATH_PARTS = [ 'component-library/styles', 'infrastructure/theme', 'src/mobile-web/src/theme/presets', + 'tools/bitfun-canvas/runtime/styles', 'tools/generative-widget/themePayload.ts', ]; @@ -68,6 +69,11 @@ export const COLOR_DOMAIN_RULES = [ label: 'Generated widget', pathParts: ['tools/generative-widget'], }, + { + key: 'bitfunCanvas', + label: 'BitFun Canvas', + pathParts: ['tools/bitfun-canvas'], + }, { key: 'boundaryFallback', label: 'Boundary fallback', @@ -157,6 +163,12 @@ export const COLOR_DOMAIN_CONTRACTS = [ reason: 'Generated widgets run in an isolated iframe boundary and need an explicit payload instead of scraping host CSS variables.', mergePolicy: 'Keep payload variables canonical; keep legacy aliases in iframe fallback until widget consumers no longer read them.', }, + { + key: 'bitfunCanvas', + owner: 'src/web-ui/src/tools/bitfun-canvas', + reason: 'BitFun Canvas renders generated TSX inside a dedicated iframe runtime with an SDK palette that must stay isolated from app chrome tokens.', + mergePolicy: 'Keep Canvas iframe and SDK colors in the Canvas runtime contract; promote only reusable host chrome roles to shared app tokens.', + }, { key: 'boundaryFallback', owner: 'src/web-ui/src/shared/theme/themeBoundaryFallbacks.ts', @@ -271,27 +283,26 @@ export const SURFACE_TOKEN_RENAME_CONTRACTS = [ owner: 'src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss', reason: 'UserMessage failed-state line box should use readable Flow Chat surface names instead of an abbreviated local key family.', }, - { - key: '--tool-command-preview-empty-rgb', - canonical: '--tool-command-empty-rgb', - owner: 'src/web-ui/src/flow_chat/tool-cards/ToolCommandPreview.scss; src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss', - reason: 'Empty command color is shared by command preview and terminal command rendering, so it needs one tool-command token.', - }, { key: '--m-editor-highlight-rgb', - canonical: '--markdown-editor-highlight-rgb', + canonical: '--private-markdown-editor-highlight-rgb', owner: 'src/web-ui/src/tools/editor/meditor/components/TiptapEditor.scss', - reason: 'Markdown editor highlight color should use the shared markdown-editor namespace instead of the abbreviated meditor local key.', + reason: 'Markdown editor highlight color should use the component-private markdown-editor helper instead of the abbreviated meditor local key.', }, { key: '--m-editor-highlight-border-rgb', - canonical: '--markdown-editor-highlight-border-rgb', + canonical: '--private-markdown-editor-highlight-border-rgb', owner: 'src/web-ui/src/tools/editor/meditor/components/TiptapEditor.scss', - reason: 'Markdown editor highlight border color should use the shared markdown-editor namespace instead of the abbreviated meditor local key.', + reason: 'Markdown editor highlight border color should use the component-private markdown-editor helper instead of the abbreviated meditor local key.', }, ]; export const DYNAMIC_VAR_FAMILY_CONTRACTS = [ + { + prefix: '--bitfun-canvas-', + owner: 'src/web-ui/src/tools/bitfun-canvas/runtime/canvasRuntimeInstaller.ts; src/web-ui/src/tools/bitfun-canvas/runtime/styles/canvas-runtime.scss', + reason: 'BitFun Canvas iframe runtime receives host theme values through a scoped CSS variable family that must stay isolated from app root tokens.', + }, { prefix: '--blur-', owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts', @@ -342,11 +353,6 @@ export const DYNAMIC_VAR_FAMILY_CONTRACTS = [ owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts', reason: 'Theme runtime exports motion duration entries from active theme motion tokens.', }, - { - prefix: '--nav-font-size-', - owner: 'src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.ts', - reason: 'Font preference runtime exports navigation font-size aliases from the adjusted typography scale.', - }, { prefix: '--shadow-', owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts', diff --git a/src/apps/cli/src/diagnostics.rs b/src/apps/cli/src/diagnostics.rs new file mode 100644 index 0000000000..2133def0ca --- /dev/null +++ b/src/apps/cli/src/diagnostics.rs @@ -0,0 +1,96 @@ +//! Exec-mode exit diagnostics for automated runners. + +use std::path::Path; + +pub const EXIT_LINE_PREFIX: &str = "BITFUN_EXIT: "; +pub const DETAIL_MAX_LEN: usize = 500; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExitKind { + SessionCreateFailed, + SendMessageFailed, + DialogTurnFailed, + SystemError, + ExecError, + PatchWriteFailed, +} + +impl ExitKind { + pub fn as_str(self) -> &'static str { + match self { + Self::SessionCreateFailed => "session_create_failed", + Self::SendMessageFailed => "send_message_failed", + Self::DialogTurnFailed => "dialog_turn_failed", + Self::SystemError => "system_error", + Self::ExecError => "exec_error", + Self::PatchWriteFailed => "patch_write_failed", + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct ExitContext<'a> { + pub session_id: Option<&'a str>, + pub turn_id: Option<&'a str>, + pub agent_type: Option<&'a str>, + pub workspace: Option<&'a Path>, +} + +pub fn sanitize_exit_detail(detail: &str) -> String { + let collapsed = detail.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= DETAIL_MAX_LEN { + return collapsed; + } + let truncated: String = collapsed.chars().take(DETAIL_MAX_LEN).collect(); + format!("{truncated}...") +} + +pub fn format_exit_line(kind: ExitKind, detail: &str) -> String { + format!( + "{}{}: {}", + EXIT_LINE_PREFIX, + kind.as_str(), + sanitize_exit_detail(detail) + ) +} + +pub fn emit_exit_diagnostic(kind: ExitKind, detail: &str, ctx: &ExitContext<'_>) { + eprintln!("{}", format_exit_line(kind, detail)); + tracing::error!( + kind = kind.as_str(), + session_id = ctx.session_id.unwrap_or("-"), + turn_id = ctx.turn_id.unwrap_or("-"), + agent_type = ctx.agent_type.unwrap_or("-"), + workspace = ?ctx.workspace, + detail = %detail, + "exec exit diagnostic" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_exit_line_uses_stable_prefix_and_kind() { + let line = format_exit_line(ExitKind::DialogTurnFailed, "429 Too Many Requests"); + assert_eq!( + line, + "BITFUN_EXIT: dialog_turn_failed: 429 Too Many Requests" + ); + } + + #[test] + fn sanitize_exit_detail_collapses_whitespace_and_newlines() { + let detail = "line one\nline two\t\tline three"; + assert_eq!(sanitize_exit_detail(detail), "line one line two line three"); + } + + #[test] + fn sanitize_exit_detail_truncates_long_messages() { + let detail = "x".repeat(DETAIL_MAX_LEN + 10); + let sanitized = sanitize_exit_detail(&detail); + assert!(sanitized.ends_with("...")); + assert!(sanitized.chars().count() <= DETAIL_MAX_LEN + 3); + } +} diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 70b8817c88..5df3323792 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -10,6 +10,7 @@ mod agent; mod chat_state; mod commands; mod config; +mod diagnostics; mod logging; mod management; mod modes; diff --git a/src/apps/cli/src/modes/exec.rs b/src/apps/cli/src/modes/exec.rs index 98909c454f..76930ecce7 100644 --- a/src/apps/cli/src/modes/exec.rs +++ b/src/apps/cli/src/modes/exec.rs @@ -6,13 +6,20 @@ use anyhow::Result; use clap::ValueEnum; use serde_json::json; use std::collections::HashMap; +use std::io::Write; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; +use bitfun_core::agentic::core::SessionState; use bitfun_events::AgenticEvent; +use tokio::time::{sleep, Instant}; use crate::agent::{agentic_system::AgenticSystem, core_adapter::CoreAgentAdapter, Agent}; use crate::config::CliConfig; +use crate::diagnostics::{emit_exit_diagnostic, ExitContext, ExitKind}; + +const TOOL_START_INPUT_PREVIEW_CHARS: usize = 4_000; #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] pub enum ExecOutputFormat { @@ -71,6 +78,77 @@ impl ExecMode { } } + fn exit_context<'a>( + &'a self, + session_id: Option<&'a str>, + turn_id: Option<&'a str>, + ) -> ExitContext<'a> { + ExitContext { + session_id, + turn_id, + agent_type: Some(self.agent_type.as_str()), + workspace: self.workspace_path.as_deref(), + } + } + + fn workspace_display(&self) -> String { + self.workspace_path + .as_deref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| { + std::env::current_dir() + .map(|path| path.display().to_string()) + .unwrap_or_else(|_| ".".to_string()) + }) + } + + fn redact_large_inline_data(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(map) => { + if map.remove("data_url").is_some() { + map.insert("has_data_url".to_string(), serde_json::json!(true)); + } + for child in map.values_mut() { + Self::redact_large_inline_data(child); + } + } + serde_json::Value::Array(items) => { + for child in items { + Self::redact_large_inline_data(child); + } + } + _ => {} + } + } + + fn tool_input_preview(params: &serde_json::Value) -> String { + let mut redacted = params.clone(); + Self::redact_large_inline_data(&mut redacted); + let raw = + serde_json::to_string(&redacted).unwrap_or_else(|_| "".to_string()); + if raw.chars().count() <= TOOL_START_INPUT_PREVIEW_CHARS { + return raw; + } + + let preview: String = raw.chars().take(TOOL_START_INPUT_PREVIEW_CHARS).collect(); + format!("{preview}... [truncated]") + } + + fn print_tool_start_details(&self, tool_name: &str, tool_id: &str, params: &serde_json::Value) { + let started_at = chrono::Utc::now().to_rfc3339(); + let cwd = self.workspace_display(); + let input_preview = Self::tool_input_preview(params); + + self.print_text(|| { + println!("\nTool call: {}", tool_name); + println!(" Started at: {}", started_at); + println!(" Tool ID: {}", tool_id); + println!(" CWD: {}", cwd); + println!(" Input: {}", input_preview); + std::io::stdout().flush().ok(); + }); + } + fn get_git_diff(&self) -> Option { let workspace = self.workspace_path.as_ref()?; @@ -96,12 +174,21 @@ impl ExecMode { pub async fn run(&mut self) -> Result<()> { tracing::info!( - "Executing command, Agent: {}, Message: {}", - self.agent_type, - self.message + agent_type = %self.agent_type, + message_len = self.message.len(), + workspace = ?self.workspace_path, + "Executing command" ); - let session_id = self.prepare_session().await?; + let session_id = self.prepare_session().await.map_err(|error| { + emit_exit_diagnostic( + ExitKind::SessionCreateFailed, + &error.to_string(), + &self.exit_context(None, None), + ); + error + })?; + tracing::info!(session_id = %session_id, "Session ready"); let event_queue = self.agent.event_queue().clone(); self.emit(json!({ @@ -116,14 +203,24 @@ impl ExecMode { println!("Thinking..."); }); - let _turn_id = self + let turn_id = self .agent .send_message(self.message.clone(), &self.agent_type) - .await?; + .await + .map_err(|error| { + emit_exit_diagnostic( + ExitKind::SendMessageFailed, + &error.to_string(), + &self.exit_context(Some(&session_id), None), + ); + error + })?; + tracing::info!(session_id = %session_id, turn_id = %turn_id, "Message sent"); // Consume events from EventQueue until turn completes let mut total_tool_calls = 0usize; let mut subagent_parent_sessions: HashMap = HashMap::new(); + let mut terminal_outcome: Option> = None; loop { // Wait for events (efficient, uses Notify internally) @@ -155,15 +252,28 @@ impl ExecMode { use bitfun_events::ToolEventData; match tool_event { ToolEventData::Started { - tool_name, tool_id, .. + tool_name, + tool_id, + params, + .. } => { self.emit(json!({ "type": "subagent_tool_start", "session_id": session_id, "tool_id": tool_id, "tool_name": tool_name, + "input": params, }))?; - self.print_text(|| println!(" [subagent] {}", tool_name)); + self.print_text(|| { + let started_at = chrono::Utc::now().to_rfc3339(); + let input_preview = Self::tool_input_preview(params); + println!(" [subagent] {}", tool_name); + println!(" Started at: {}", started_at); + println!(" Tool ID: {}", tool_id); + println!(" CWD: {}", self.workspace_display()); + println!(" Input: {}", input_preview); + std::io::stdout().flush().ok(); + }); } ToolEventData::Completed { tool_name, @@ -217,6 +327,18 @@ impl ExecMode { } match event { + AgenticEvent::ModelRoundStarted { + model_id: Some(model_id), + .. + } + | AgenticEvent::ModelRoundCompleted { + model_id: Some(model_id), + .. + } + | AgenticEvent::TokenUsageUpdated { model_id, .. } => { + self.record_resolved_model_id(&session_id, model_id).await; + } + AgenticEvent::TextChunk { text, .. } => { self.emit(json!({ "type": "text", @@ -259,7 +381,7 @@ impl ExecMode { "tool_name": tool_name, "input": params, }))?; - self.print_text(|| println!("\nTool call: {}", tool_name)); + self.print_tool_start_details(tool_name, tool_id, params); total_tool_calls += 1; } ToolEventData::Progress { @@ -341,8 +463,8 @@ impl ExecMode { ); } }); - self.output_patch_if_needed(); - return Ok(()); + terminal_outcome = Some(Ok(())); + break; } AgenticEvent::DialogTurnFailed { error, .. } => { @@ -352,8 +474,14 @@ impl ExecMode { "message": error, }))?; self.print_text(|| eprintln!("\nExecution failed: {}", error)); - self.output_patch_if_needed(); - return Err(anyhow::anyhow!("Execution failed: {}", error)); + emit_exit_diagnostic( + ExitKind::DialogTurnFailed, + error, + &self.exit_context(Some(&session_id), Some(&turn_id)), + ); + terminal_outcome = + Some(Err(anyhow::anyhow!("Execution failed: {}", error))); + break; } AgenticEvent::DialogTurnCancelled { .. } => { @@ -364,8 +492,8 @@ impl ExecMode { "tool_calls": total_tool_calls, }))?; self.print_text(|| println!("\nExecution cancelled")); - self.output_patch_if_needed(); - return Ok(()); + terminal_outcome = Some(Ok(())); + break; } AgenticEvent::SystemError { error, .. } => { @@ -375,13 +503,47 @@ impl ExecMode { "message": error, }))?; self.print_text(|| eprintln!("\nSystem error: {}", error)); - self.output_patch_if_needed(); - return Err(anyhow::anyhow!("System error: {}", error)); + emit_exit_diagnostic( + ExitKind::SystemError, + error, + &self.exit_context(Some(&session_id), Some(&turn_id)), + ); + terminal_outcome = Some(Err(anyhow::anyhow!("System error: {}", error))); + break; } _ => {} } } + + if terminal_outcome.is_some() { + break; + } + } + + self.wait_for_turn_settlement(&session_id, &turn_id).await; + self.output_patch_if_needed(); + terminal_outcome.unwrap_or(Ok(())) + } + + async fn record_resolved_model_id(&self, session_id: &str, model_id: &str) { + let trimmed = model_id.trim(); + if trimmed.is_empty() || matches!(trimmed, "auto" | "default" | "primary" | "fast") { + return; + } + + if let Err(error) = self + .agent + .coordinator() + .update_session_model(session_id, trimmed) + .await + { + tracing::debug!( + "Failed to persist resolved CLI model id: session_id={}, model_id={}, error={}", + session_id, + trimmed, + error + ); } } @@ -495,7 +657,12 @@ impl ExecMode { if self.output_format != ExecOutputFormat::Text { if output_target != "-" && !patch.trim().is_empty() { - if let Err(e) = std::fs::write(output_target, &patch) { + if let Err(e) = write_patch_to_path(output_target, &patch) { + emit_exit_diagnostic( + ExitKind::PatchWriteFailed, + &e.to_string(), + &self.exit_context(None, None), + ); eprintln!("Failed to save patch: {}", e); } } @@ -510,12 +677,17 @@ impl ExecMode { println!("{}", patch); println!("---PATCH_END---"); } else { - match std::fs::write(output_target, &patch) { + match write_patch_to_path(output_target, &patch) { Ok(_) => { println!("Patch saved to: {}", output_target); println!("({} bytes)", patch.len()); } Err(e) => { + emit_exit_diagnostic( + ExitKind::PatchWriteFailed, + &e.to_string(), + &self.exit_context(None, None), + ); eprintln!("Failed to save patch: {}", e); println!("---PATCH_START---"); println!("{}", patch); @@ -536,4 +708,88 @@ impl ExecMode { } } } + + async fn wait_for_turn_settlement(&self, session_id: &str, turn_id: &str) { + let session_manager = self.agent.coordinator().get_session_manager().clone(); + let deadline = Instant::now() + Duration::from_secs(5); + + loop { + let Some(session) = session_manager.get_session(session_id) else { + return; + }; + + let still_processing = matches!( + &session.state, + SessionState::Processing { current_turn_id, .. } if current_turn_id == turn_id + ); + + if !still_processing { + return; + } + + if Instant::now() >= deadline { + tracing::warn!( + "Timed out waiting for exec turn settlement: session_id={}, turn_id={}", + session_id, + turn_id + ); + return; + } + + sleep(Duration::from_millis(50)).await; + } + } +} + +pub(crate) fn write_patch_to_path(output_target: &str, patch: &str) -> std::io::Result<()> { + use std::path::Path; + + let path = Path::new(output_target); + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + std::fs::write(path, patch) +} + +#[cfg(test)] +mod patch_tests { + use super::{write_patch_to_path, ExecMode, TOOL_START_INPUT_PREVIEW_CHARS}; + use serde_json::json; + + #[test] + fn write_patch_to_path_creates_nested_parent_directories() { + let temp = tempfile::tempdir().expect("tempdir"); + let patch_path = temp.path().join("parent/child/out.patch"); + write_patch_to_path(patch_path.to_str().expect("utf8 path"), "diff content") + .expect("write patch"); + + let written = std::fs::read_to_string(&patch_path).expect("read patch"); + assert_eq!(written, "diff content"); + } + + #[test] + fn tool_input_preview_redacts_data_urls() { + let preview = ExecMode::tool_input_preview(&json!({ + "image": { + "data_url": "data:image/png;base64,abc", + "name": "sample" + } + })); + + assert!(!preview.contains("data:image/png")); + assert!(preview.contains("\"has_data_url\":true")); + assert!(preview.contains("\"name\":\"sample\"")); + } + + #[test] + fn tool_input_preview_truncates_large_inputs() { + let preview = ExecMode::tool_input_preview(&json!({ + "content": "x".repeat(TOOL_START_INPUT_PREVIEW_CHARS + 100) + })); + + assert!(preview.ends_with("... [truncated]")); + assert!(preview.len() < TOOL_START_INPUT_PREVIEW_CHARS + 100); + } } diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 12f09c2b67..7dbb5b5c25 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -5,6 +5,7 @@ use std::path::Path; use crate::{ config::CliConfig, + diagnostics::{emit_exit_diagnostic, ExitContext, ExitKind}, modes::exec::{ExecMode, ExecOutputFormat, ExecSessionOptions}, ui::string_utils::truncate_str, ConfigAction, SessionAction, @@ -47,7 +48,20 @@ pub async fn handle_exec_command(config: CliConfig, args: ExecCommandArgs) -> Re let skip_confirmation = !args.confirm; let (agentic_system, original_skip_confirmation) = - crate::initialize_core_services(skip_confirmation).await?; + crate::initialize_core_services(skip_confirmation) + .await + .map_err(|error| { + emit_exit_diagnostic( + ExitKind::ExecError, + &error.to_string(), + &ExitContext { + agent_type: Some(args.agent.as_str()), + workspace: workspace_path_resolved.as_deref(), + ..Default::default() + }, + ); + error + })?; let mut exec_mode = ExecMode::new( config, diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index d80b643b52..bd125c36f0 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -20,6 +20,8 @@ serde_json = { workspace = true } [dependencies] # Internal crates bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = ["product-full"] } +bitfun-product-domains = { path = "../../crates/contracts/product-domains", default-features = false } +bitfun-services-integrations = { path = "../../crates/services/services-integrations", default-features = false, features = ["canvas-runtime"] } bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" } bitfun-transport = { path = "../../crates/adapters/transport", features = ["tauri-adapter"] } bitfun-webdriver = { path = "../../crates/adapters/webdriver" } @@ -57,6 +59,10 @@ ignore = { workspace = true } urlencoding = { workspace = true } reqwest = { workspace = true } zip = { workspace = true } +tar = { workspace = true } +flate2 = { workspace = true } +bzip2 = { workspace = true } +zstd = { workspace = true } thiserror = { workspace = true } futures = { workspace = true } async-trait = { workspace = true } @@ -117,3 +123,6 @@ devtools = ["tauri/devtools", "bitfun-webdriver/embedded"] [target.'cfg(all(target_os = "linux", not(target_env = "ohos")))'.dependencies] atspi = { workspace = true } leptess = { workspace = true } + +[target.'cfg(all(not(target_env = "ohos")))'.dependencies] +xz2 = { workspace = true } \ No newline at end of file diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 631b1dbd4b..eb47511a8d 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -23,6 +23,7 @@ use bitfun_core::agentic::deep_review_policy::{ }; use bitfun_core::agentic::goal_mode::{ThreadGoal, ThreadGoalStatus}; use bitfun_core::agentic::image_analysis::ImageContextData; +use bitfun_core::agentic::memories::{db::MemoryDatabase, workspace::reset_memory_workspace}; use bitfun_core::agentic::session::SessionViewRestoreTiming; use bitfun_core::agentic::tools::image_context::get_image_context; use bitfun_core::agentic::tools::implementations::exec_command::{ @@ -33,7 +34,7 @@ use bitfun_core::agentic::tools::implementations::exec_command::{ ReadBackgroundCommandOutputRequest as CoreReadBackgroundCommandOutputRequest, ReadBackgroundCommandOutputResponse, }; -use bitfun_core::service::session::{DialogTurnData, SessionRelationship}; +use bitfun_core::service::session::{DialogTurnData, SessionMemoryMode, SessionRelationship}; const SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET: usize = 512 * 1024; const SESSION_VIEW_TOOL_RESULT_STRING_CHAR_LIMIT: usize = 16 * 1024; @@ -178,6 +179,37 @@ pub struct GetSessionThreadGoalResponse { pub goal: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResetMemoryResponse { + pub success: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryPathsResponse { + pub memories_root_dir: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetSessionMemoryModeRequest { + pub session_id: String, + pub mode: String, + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetSessionMemoryModeResponse { + pub success: bool, + pub mode: SessionMemoryMode, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ClearSessionThreadGoalRequest { @@ -1088,6 +1120,73 @@ pub async fn get_session_thread_goal( result } +#[tauri::command] +pub async fn reset_memory(state: State<'_, AppState>) -> Result { + let path_manager = state.workspace_service.path_manager().clone(); + let db = MemoryDatabase::new(path_manager.clone()); + + db.reset_memory_state() + .await + .map_err(|error| format!("Failed to reset memory database: {}", error))?; + reset_memory_workspace(&path_manager.memories_root_dir()) + .await + .map_err(|error| format!("Failed to reset memory workspace: {}", error))?; + + Ok(ResetMemoryResponse { success: true }) +} + +#[tauri::command] +pub async fn get_memory_paths(state: State<'_, AppState>) -> Result { + let path_manager = state.workspace_service.path_manager().clone(); + let memories_root_dir = path_manager.memories_root_dir(); + tokio::fs::create_dir_all(&memories_root_dir) + .await + .map_err(|error| format!("Failed to create memory directory: {}", error))?; + Ok(MemoryPathsResponse { + memories_root_dir: memories_root_dir.to_string_lossy().to_string(), + }) +} + +#[tauri::command] +pub async fn set_session_memory_mode( + coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, + request: SetSessionMemoryModeRequest, +) -> Result { + let session_id = request.session_id.trim(); + if session_id.is_empty() { + return Err("session_id is required".to_string()); + } + let mode = match request.mode.trim().to_ascii_lowercase().as_str() { + "enabled" => SessionMemoryMode::Enabled, + "disabled" => SessionMemoryMode::Disabled, + "polluted" => { + return Err("polluted memory mode is internal and cannot be set directly".to_string()) + } + other => return Err(format!("unsupported memory mode: {other}")), + }; + let storage_path = resolve_thread_goal_storage_path( + coordinator.inner(), + app_state.inner(), + session_id, + request.workspace_path.as_deref(), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await?; + + coordinator + .get_session_manager() + .set_session_memory_mode(&storage_path, session_id, mode) + .await + .map_err(|error| error.to_string())?; + + Ok(SetSessionMemoryModeResponse { + success: true, + mode, + }) +} + #[tauri::command] pub async fn clear_session_thread_goal( coordinator: State<'_, Arc>, diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 723bbd7fd9..eac56b8b94 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -87,6 +87,8 @@ pub struct AppState { pub remote_terminal_manager: Arc>>, pub remote_workspace: Arc>>, pub active_searches: Arc>>>, + /// Cancellation flags for active file transfers (download/upload), keyed by transfer_id. + pub active_transfers: Arc>>>, pub announcement_scheduler: Arc, } @@ -310,6 +312,7 @@ impl AppState { remote_terminal_manager, remote_workspace, active_searches: Arc::new(Mutex::new(HashMap::new())), + active_transfers: Arc::new(Mutex::new(HashMap::new())), announcement_scheduler, }; diff --git a/src/apps/desktop/src/api/canvas_api.rs b/src/apps/desktop/src/api/canvas_api.rs new file mode 100644 index 0000000000..46d765dce0 --- /dev/null +++ b/src/apps/desktop/src/api/canvas_api.rs @@ -0,0 +1,243 @@ +//! Canvas artifact Tauri commands. + +use crate::api::app_state::AppState; +use crate::api::session_storage_path::desktop_effective_session_storage_path; +use bitfun_product_domains::canvas::{ + parse_canvas_artifact_ref, CanvasDiagnostic, CanvasDiagnosticCategory, + CanvasDiagnosticSeverity, CanvasRevision, CanvasSnapshot, CanvasState, CanvasStoragePort, +}; +use bitfun_services_integrations::canvas::CanvasService; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use tauri::State; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasStateRequest { + pub artifact_reference: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveCanvasStateRequest { + pub artifact_reference: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_revision_seen: Option, + #[serde(default)] + pub values: BTreeMap, + pub updated_at: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportCanvasRuntimeErrorRequest { + pub artifact_reference: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_revision_seen: Option, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stack: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasStateResponse { + pub state: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasArtifactResponse { + pub canvas: CanvasSnapshot, + pub artifact_reference: String, +} + +#[tauri::command] +pub async fn load_canvas_artifact( + state: State<'_, AppState>, + request: CanvasStateRequest, +) -> Result { + let reference = parse_canvas_artifact_ref(&request.artifact_reference) + .map_err(|error| format!("Invalid Canvas artifact reference: {:?}", error))?; + let service = canvas_service_for_request(&state, request_workspace(&request), &request).await?; + let snapshot = service + .load_snapshot(reference.session_id, reference.canvas_id) + .await + .map_err(|error| error.message)?; + Ok(CanvasArtifactResponse { + canvas: snapshot, + artifact_reference: request.artifact_reference, + }) +} + +#[tauri::command] +pub async fn load_canvas_state( + state: State<'_, AppState>, + request: CanvasStateRequest, +) -> Result { + let reference = parse_canvas_artifact_ref(&request.artifact_reference) + .map_err(|error| format!("Invalid Canvas artifact reference: {:?}", error))?; + let service = canvas_service_for_request(&state, request_workspace(&request), &request).await?; + let state = service + .load_state(reference.session_id, reference.canvas_id) + .await + .map_err(|error| error.message)?; + Ok(CanvasStateResponse { state }) +} + +#[tauri::command] +pub async fn save_canvas_state( + state: State<'_, AppState>, + request: SaveCanvasStateRequest, +) -> Result { + let reference = parse_canvas_artifact_ref(&request.artifact_reference) + .map_err(|error| format!("Invalid Canvas artifact reference: {:?}", error))?; + let service = canvas_service_for_save_request(&state, &request).await?; + let canvas_state = CanvasState { + canvas_id: reference.canvas_id, + source_revision_seen: request.source_revision_seen.map(CanvasRevision::new), + values: request.values, + updated_at: request.updated_at, + schema_version: bitfun_product_domains::canvas::CANVAS_CURRENT_STATE_SCHEMA_VERSION, + }; + let saved = service + .save_state(reference.session_id, canvas_state) + .await + .map_err(|error| error.message)?; + Ok(CanvasStateResponse { state: Some(saved) }) +} + +#[tauri::command] +pub async fn report_canvas_runtime_error( + state: State<'_, AppState>, + request: ReportCanvasRuntimeErrorRequest, +) -> Result { + let reference = parse_canvas_artifact_ref(&request.artifact_reference) + .map_err(|error| format!("Invalid Canvas artifact reference: {:?}", error))?; + let service = canvas_service_for_runtime_error_request(&state, &request).await?; + let message = if let Some(name) = request.name.as_deref().filter(|value| !value.is_empty()) { + format!("{}: {}", name, request.message) + } else { + request.message.clone() + }; + let mut diagnostic = CanvasDiagnostic { + severity: CanvasDiagnosticSeverity::Error, + category: CanvasDiagnosticCategory::Runtime, + message, + code: Some("canvas.runtime.error".to_string()), + line: None, + column: None, + suggested_fix: Some( + "Open the Canvas source and fix the runtime exception, then update the Canvas." + .to_string(), + ), + }; + if let Some(source_revision) = request.source_revision_seen.as_deref() { + diagnostic + .message + .push_str(&format!(" (source revision: {})", source_revision)); + } + if let Some(stack) = request.stack.as_deref().filter(|value| !value.is_empty()) { + diagnostic.message.push_str("\n"); + diagnostic.message.push_str(stack); + } + let snapshot = service + .report_runtime_diagnostic(reference.session_id, reference.canvas_id, diagnostic) + .await + .map_err(|error| error.message)?; + Ok(CanvasArtifactResponse { + canvas: snapshot, + artifact_reference: request.artifact_reference, + }) +} + +fn request_workspace(request: &CanvasStateRequest) -> Option<&str> { + request.workspace_path.as_deref() +} + +async fn canvas_service_for_runtime_error_request( + state: &AppState, + request: &ReportCanvasRuntimeErrorRequest, +) -> Result { + canvas_service_for_workspace( + state, + request.workspace_path.as_deref(), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await +} + +async fn canvas_service_for_save_request( + state: &AppState, + request: &SaveCanvasStateRequest, +) -> Result { + canvas_service_for_workspace( + state, + request.workspace_path.as_deref(), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await +} + +async fn canvas_service_for_request( + state: &AppState, + workspace_path: Option<&str>, + request: &CanvasStateRequest, +) -> Result { + canvas_service_for_workspace( + state, + workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await +} + +async fn canvas_service_for_workspace( + state: &AppState, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, +) -> Result { + let workspace = match workspace_path { + Some(path) if !path.trim().is_empty() => path.trim().to_string(), + _ => state + .workspace_path + .read() + .await + .as_ref() + .map(|path| path.to_string_lossy().to_string()) + .ok_or_else(|| "No active workspace is available for Canvas state".to_string())?, + }; + let sessions_dir = desktop_effective_session_storage_path( + state, + &workspace, + remote_connection_id, + remote_ssh_host, + ) + .await; + Ok(CanvasService::persistent(sessions_dir)) +} diff --git a/src/apps/desktop/src/api/clipboard_file_api.rs b/src/apps/desktop/src/api/clipboard_file_api.rs index 34d2b29aad..e6cc5083a6 100644 --- a/src/apps/desktop/src/api/clipboard_file_api.rs +++ b/src/apps/desktop/src/api/clipboard_file_api.rs @@ -22,6 +22,7 @@ pub struct PasteFilesRequest { pub struct PasteFilesResponse { pub success_count: usize, pub failed_files: Vec, + pub directory_count: usize, } #[derive(Debug, Serialize)] @@ -317,6 +318,7 @@ pub async fn paste_files(request: PasteFilesRequest) -> Result Result Result { success_count += 1; + if is_dir { + directory_count += 1; + } if request.is_cut { - if source.is_dir() { + if is_dir { if let Err(e) = std::fs::remove_dir_all(source) { log::warn!("Failed to remove source directory after cut: {}", e); } @@ -383,6 +389,7 @@ pub async fn paste_files(request: PasteFilesRequest) -> Result, } #[derive(Debug, Deserialize)] @@ -828,22 +831,30 @@ pub struct ExportLocalFileRequest { #[derive(Debug, Deserialize)] pub struct DeleteFileRequest { pub path: String, + #[serde(default, rename = "remoteConnectionId")] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] pub struct DeleteDirectoryRequest { pub path: String, pub recursive: Option, + #[serde(default, rename = "remoteConnectionId")] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] pub struct CreateFileRequest { pub path: String, + #[serde(default, rename = "remoteConnectionId")] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] pub struct CreateDirectoryRequest { pub path: String, + #[serde(default, rename = "remoteConnectionId")] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] @@ -3080,7 +3091,13 @@ pub async fn rename_file( state: State<'_, AppState>, request: RenameFileRequest, ) -> Result<(), String> { - rename_path(&state, &request.old_path, &request.new_path).await + rename_path( + &state, + &request.old_path, + &request.new_path, + request.remote_connection_id.as_deref(), + ) + .await } /// Copy a local file to another local path (binary-safe). Used for export and drag-upload into local workspaces. @@ -3107,7 +3124,12 @@ pub async fn delete_file( state: State<'_, AppState>, request: DeleteFileRequest, ) -> Result<(), String> { - delete_desktop_file(&state, &request.path).await + delete_desktop_file( + &state, + &request.path, + request.remote_connection_id.as_deref(), + ) + .await } #[tauri::command] @@ -3116,7 +3138,13 @@ pub async fn delete_directory( request: DeleteDirectoryRequest, ) -> Result<(), String> { let recursive = request.recursive.unwrap_or(false); - delete_desktop_directory(&state, &request.path, recursive).await + delete_desktop_directory( + &state, + &request.path, + recursive, + request.remote_connection_id.as_deref(), + ) + .await } #[tauri::command] @@ -3124,7 +3152,12 @@ pub async fn create_file( state: State<'_, AppState>, request: CreateFileRequest, ) -> Result<(), String> { - create_empty_file(&state, &request.path).await + create_empty_file( + &state, + &request.path, + request.remote_connection_id.as_deref(), + ) + .await } #[tauri::command] @@ -3132,7 +3165,437 @@ pub async fn create_directory( state: State<'_, AppState>, request: CreateDirectoryRequest, ) -> Result<(), String> { - create_desktop_directory(&state, &request.path).await + create_desktop_directory( + &state, + &request.path, + request.remote_connection_id.as_deref(), + ) + .await +} + +// === Compress / Decompress === + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompressPathRequest { + pub path: String, + #[serde(default)] + pub remote_connection_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DecompressPathRequest { + pub path: String, + #[serde(default)] + pub remote_connection_id: Option, +} + +/// Compress a local file or directory into a `.zip` archive placed in the same +/// parent directory. For remote workspaces, delegates to SSH command execution +/// (tries `zip`, falls back to `tar`). +#[tauri::command] +pub async fn compress_path( + state: State<'_, AppState>, + request: CompressPathRequest, +) -> Result { + let src = request.path; + let remote_cid = request.remote_connection_id; + + // Remote: execute compress command via SSH. + if let Some(cid) = &remote_cid { + let manager = state.get_ssh_manager_async().await?; + let parent = Path::new(&src) + .parent() + .ok_or_else(|| format!("Cannot determine parent directory of '{}'", src))? + .to_string_lossy() + .to_string(); + let base_name = Path::new(&src) + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| format!("Cannot determine file name of '{}'", src))? + .to_string(); + + // Try zip first, fall back to tar.gz. + // Escape single quotes in paths for shell safety. + let escaped_src = src.replace('\'', "'\\''"); + let escaped_parent = parent.replace('\'', "'\\''"); + let escaped_name = base_name.replace('\'', "'\\''"); + + let zip_out = format!("{}/{}.zip", parent, base_name); + let zip_shell_out = format!("{}/{}.zip", escaped_parent, escaped_name); + let zip_cmd = format!("zip -r -q '{}' '{}'", zip_shell_out, escaped_src); + + let (stdout, stderr, code) = manager + .execute_command(cid, &zip_cmd) + .await + .map_err(|e| e.to_string())?; + + if code == 0 { + return Ok(zip_out); + } + + // zip not available or failed — try tar. + let tar_out = format!("{}/{}.tar.gz", parent, base_name); + let tar_shell_out = format!("{}/{}.tar.gz", escaped_parent, escaped_name); + let tar_cmd = format!( + "tar -czf '{}' -C '{}' '{}'", + tar_shell_out, escaped_parent, escaped_name + ); + + let (stdout2, stderr2, code2) = manager + .execute_command(cid, &tar_cmd) + .await + .map_err(|e| e.to_string())?; + + if code2 == 0 { + return Ok(tar_out); + } + + let zip_err = if stderr.is_empty() { stdout } else { stderr }; + let tar_err = if stderr2.is_empty() { stdout2 } else { stderr2 }; + let zip_not_found = + zip_err.contains("command not found") || zip_err.contains("not installed"); + let tar_not_found = + tar_err.contains("command not found") || tar_err.contains("not installed"); + if zip_not_found && tar_not_found { + return Err("Remote server has neither 'zip' nor 'tar' installed. \ + Please install at least one of them." + .to_string()); + } + return Err(format!( + "Compression failed on the remote server.\nzip: {}\ntar: {}", + zip_err.trim(), + tar_err.trim() + )); + } + + // Local: use the `zip` crate to create a .zip archive. + let src_path = PathBuf::from(&src); + let parent = src_path + .parent() + .ok_or_else(|| format!("Cannot determine parent directory of '{}'", src))?; + let file_name = src_path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| format!("Cannot determine file name of '{}'", src))? + .to_string(); + let zip_path = parent.join(format!("{}.zip", file_name)); + + let zip_path_clone = zip_path.clone(); + let src_path_clone = src_path.clone(); + let file_name_clone = file_name.clone(); + tokio::task::spawn_blocking(move || { + let file = std::fs::File::create(&zip_path_clone) + .map_err(|e| format!("Failed to create '{}': {}", zip_path_clone.display(), e))?; + let mut zip_writer = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + if src_path_clone.is_dir() { + add_dir_to_zip(&mut zip_writer, &src_path_clone, &file_name_clone, options)?; + } else { + add_file_to_zip(&mut zip_writer, &src_path_clone, &file_name_clone, options)?; + } + + zip_writer + .finish() + .map_err(|e| format!("Failed to finalize zip archive: {}", e))?; + Ok::<(), String>(()) + }) + .await + .map_err(|e| e.to_string())??; + + Ok(zip_path.to_string_lossy().to_string()) +} + +/// Recursively add a directory tree to a zip archive. +fn add_dir_to_zip( + zip: &mut zip::ZipWriter, + dir: &Path, + archive_prefix: &str, + options: zip::write::SimpleFileOptions, +) -> Result<(), String> { + for entry in std::fs::read_dir(dir) + .map_err(|e| format!("Failed to read directory '{}': {}", dir.display(), e))? + { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + let archive_path = format!("{}/{}", archive_prefix, name); + + if path.is_dir() { + add_dir_to_zip(zip, &path, &archive_path, options)?; + } else if path.is_file() { + add_file_to_zip(zip, &path, &archive_path, options)?; + } + } + Ok(()) +} + +/// Add a single file to a zip archive. +fn add_file_to_zip( + zip: &mut zip::ZipWriter, + source_path: &Path, + archive_path: &str, + options: zip::write::SimpleFileOptions, +) -> Result<(), String> { + let mut file = std::fs::File::open(source_path) + .map_err(|e| format!("Failed to open '{}': {}", source_path.display(), e))?; + zip.start_file(archive_path.replace('\\', "/"), options) + .map_err(|e| format!("Failed to add '{}' to zip: {}", archive_path, e))?; + let mut buffer = [0u8; 16 * 1024]; + loop { + let n = file + .read(&mut buffer) + .map_err(|e| format!("Failed to read '{}': {}", source_path.display(), e))?; + if n == 0 { + break; + } + zip.write_all(&buffer[..n]) + .map_err(|e| format!("Failed to write to zip: {}", e))?; + } + Ok(()) +} + +/// Decompress an archive into a new folder named after the archive (without +/// extension) in the same parent directory. +/// +/// Supported formats: `.zip`, `.tar.gz`/`.tgz`, `.tar.bz2`/`.tbz2`, +/// `.tar.xz`/`.txz`, `.tar.zst`/`.tzst`, `.tar`. +/// For remote workspaces, delegates to SSH. +#[tauri::command] +pub async fn decompress_path( + state: State<'_, AppState>, + request: DecompressPathRequest, +) -> Result { + let src = request.path; + let remote_cid = request.remote_connection_id; + let src_path = Path::new(&src); + + let parent = src_path + .parent() + .ok_or_else(|| format!("Cannot determine parent directory of '{}'", src))? + .to_string_lossy() + .to_string(); + let file_name = src_path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| format!("Cannot determine file name of '{}'", src))? + .to_string(); + + // Determine the archive stem (file name without extension(s)). + let stem = archive_stem(&file_name); + let dest_dir_name = stem; + + // Remote: execute decompress command via SSH. + if let Some(cid) = &remote_cid { + let manager = state.get_ssh_manager_async().await?; + let escaped_src = src.replace('\'', "'\\''"); + let escaped_parent = parent.replace('\'', "'\\''"); + let escaped_dest_name = dest_dir_name.replace('\'', "'\\''"); + let escaped_dest = format!("{}/{}", escaped_parent, escaped_dest_name); + let dest_cmd = format!("mkdir -p '{}'", escaped_dest); + + let (_, _, _) = manager + .execute_command(cid, &dest_cmd) + .await + .map_err(|e| e.to_string())?; + + let lower = file_name.to_lowercase(); + let (flag, label) = if lower.ends_with(".zip") { + // zip uses a separate command, not tar. + let cmd = format!("unzip -o -q '{}' -d '{}'", escaped_src, escaped_dest); + let (stdout, stderr, code) = manager + .execute_command(cid, &cmd) + .await + .map_err(|e| e.to_string())?; + if code != 0 { + let err = if stderr.is_empty() { stdout } else { stderr }; + let trimmed = err.trim(); + if trimmed.contains("command not found") || trimmed.contains("not installed") { + return Err("Remote server does not have 'unzip' installed. \ + Please install it." + .to_string()); + } + return Err(format!("Extraction failed: {}", trimmed)); + } + return Ok(format!("{}/{}", parent, dest_dir_name)); + } else if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") { + ("-z", "tar.gz") + } else if lower.ends_with(".tar.bz2") || lower.ends_with(".tbz2") { + ("-j", "tar.bz2") + } else if lower.ends_with(".tar.xz") || lower.ends_with(".txz") { + ("-J", "tar.xz") + } else if lower.ends_with(".tar.zst") || lower.ends_with(".tzst") { + ("--zstd", "tar.zst") + } else if lower.ends_with(".tar") { + ("", "tar") + } else { + return Err(format!("Unsupported archive format: '{}'", file_name)); + }; + + let cmd = if flag.is_empty() { + format!("tar -xf '{}' -C '{}'", escaped_src, escaped_dest) + } else { + format!("tar {} -xf '{}' -C '{}'", flag, escaped_src, escaped_dest) + }; + let (stdout, stderr, code) = manager + .execute_command(cid, &cmd) + .await + .map_err(|e| e.to_string())?; + if code != 0 { + let err = if stderr.is_empty() { stdout } else { stderr }; + let trimmed = err.trim(); + if trimmed.contains("command not found") || trimmed.contains("not installed") { + return Err(format!( + "Remote server does not have the required tool for {} files. \ + Please install 'tar'.", + label + )); + } + return Err(format!("Extraction failed: {}", trimmed)); + } + + return Ok(format!("{}/{}", parent, dest_dir_name)); + } + + // Local decompression. + let dest_dir = PathBuf::from(&parent).join(&dest_dir_name); + let lower = file_name.to_lowercase(); + + let dest_dir_clone = dest_dir.clone(); + let src_clone = src.clone(); + let file_name_clone = file_name.clone(); + tokio::task::spawn_blocking(move || { + std::fs::create_dir_all(&dest_dir_clone) + .map_err(|e| format!("Failed to create '{}': {}", dest_dir_clone.display(), e))?; + + if lower.ends_with(".zip") { + let file = std::fs::File::open(&src_clone) + .map_err(|e| format!("Failed to open '{}': {}", src_clone, e))?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|e| format!("Failed to read zip '{}': {}", src_clone, e))?; + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .map_err(|e| format!("Failed to read zip entry {}: {}", i, e))?; + let entry_name = entry.name().to_string(); + let out_path = dest_dir_clone.join(&entry_name); + + // Security: prevent path traversal (zip-slip). + let canonical_dest = dest_dir_clone + .canonicalize() + .unwrap_or_else(|_| dest_dir_clone.clone()); + if !out_path.starts_with(&canonical_dest) { + log::warn!("Skipping zip entry with path traversal: {}", entry_name); + continue; + } + + if entry.is_dir() { + std::fs::create_dir_all(&out_path).map_err(|e| { + format!("Failed to create dir '{}': {}", out_path.display(), e) + })?; + } else { + if let Some(p) = out_path.parent() { + std::fs::create_dir_all(p) + .map_err(|e| format!("Failed to create parent dir: {}", e))?; + } + let mut out_file = std::fs::File::create(&out_path) + .map_err(|e| format!("Failed to create '{}': {}", out_path.display(), e))?; + std::io::copy(&mut entry, &mut out_file) + .map_err(|e| format!("Failed to extract '{}': {}", entry_name, e))?; + } + } + } else if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") { + let file = std::fs::File::open(&src_clone) + .map_err(|e| format!("Failed to open '{}': {}", src_clone, e))?; + let gz = flate2::read::GzDecoder::new(file); + let mut archive = tar::Archive::new(gz); + archive.set_overwrite(true); + archive + .unpack(&dest_dir_clone) + .map_err(|e| format!("Failed to extract tar.gz '{}': {}", src_clone, e))?; + } else if lower.ends_with(".tar.bz2") || lower.ends_with(".tbz2") { + let file = std::fs::File::open(&src_clone) + .map_err(|e| format!("Failed to open '{}': {}", src_clone, e))?; + let bz = bzip2::read::BzDecoder::new(file); + let mut archive = tar::Archive::new(bz); + archive.set_overwrite(true); + archive + .unpack(&dest_dir_clone) + .map_err(|e| format!("Failed to extract tar.bz2 '{}': {}", src_clone, e))?; + } else if lower.ends_with(".tar.xz") || lower.ends_with(".txz") { + #[cfg(not(target_env = "ohos"))] + { + let file = std::fs::File::open(&src_clone) + .map_err(|e| format!("Failed to open '{}': {}", src_clone, e))?; + let xz = xz2::read::XzDecoder::new(file); + let mut archive = tar::Archive::new(xz); + archive.set_overwrite(true); + archive + .unpack(&dest_dir_clone) + .map_err(|e| format!("Failed to extract tar.xz '{}': {}", src_clone, e))?; + } + #[cfg(target_env = "ohos")] + { + return Err("Unable support the platform".to_string()); + } + } else if lower.ends_with(".tar.zst") || lower.ends_with(".tzst") { + let file = std::fs::File::open(&src_clone) + .map_err(|e| format!("Failed to open '{}': {}", src_clone, e))?; + let zst = zstd::Decoder::new(file) + .map_err(|e| format!("Failed to init zstd decoder for '{}': {}", src_clone, e))?; + let mut archive = tar::Archive::new(zst); + archive.set_overwrite(true); + archive + .unpack(&dest_dir_clone) + .map_err(|e| format!("Failed to extract tar.zst '{}': {}", src_clone, e))?; + } else if lower.ends_with(".tar") { + let file = std::fs::File::open(&src_clone) + .map_err(|e| format!("Failed to open '{}': {}", src_clone, e))?; + let mut archive = tar::Archive::new(file); + archive.set_overwrite(true); + archive + .unpack(&dest_dir_clone) + .map_err(|e| format!("Failed to extract tar '{}': {}", src_clone, e))?; + } else { + return Err(format!("Unsupported archive format: '{}'", file_name_clone)); + } + + Ok::<(), String>(()) + }) + .await + .map_err(|e| e.to_string())??; + + Ok(dest_dir.to_string_lossy().to_string()) +} + +/// Determine the stem of an archive file name by stripping known extensions. +fn archive_stem(file_name: &str) -> String { + let lower = file_name.to_lowercase(); + // Double extensions (7 chars for .tar.gz / .tar.xz / etc., 6 for .tar.zst). + if lower.ends_with(".tar.gz") || lower.ends_with(".tar.xz") { + file_name[..file_name.len() - 7].to_string() + } else if lower.ends_with(".tar.bz2") { + file_name[..file_name.len() - 8].to_string() + } else if lower.ends_with(".tar.zst") { + file_name[..file_name.len() - 8].to_string() + // Short aliases (5 chars for .tbz2 / .txz, 5 for .tzst). + } else if lower.ends_with(".tgz") || lower.ends_with(".txz") { + file_name[..file_name.len() - 4].to_string() + } else if lower.ends_with(".tbz2") || lower.ends_with(".tzst") { + file_name[..file_name.len() - 5].to_string() + // Single extensions (4 chars). + } else if lower.ends_with(".tar") || lower.ends_with(".zip") { + file_name[..file_name.len() - 4].to_string() + } else { + // Unknown extension — strip the last extension if present. + match file_name.rfind('.') { + Some(pos) if pos > 0 => file_name[..pos].to_string(), + _ => file_name.to_string(), + } + } } #[derive(Debug, Deserialize)] @@ -3257,7 +3720,7 @@ pub async fn reveal_in_explorer( } else { let normalized_path = path_str.replace("/", "\\"); bitfun_core::util::process_manager::create_command("explorer") - .args(["/select,", &normalized_path]) + .arg(format!("/select,{}", normalized_path)) .spawn() .map_err(|e| format!("Failed to open explorer: {}", e))?; } @@ -3280,17 +3743,52 @@ pub async fn reveal_in_explorer( #[cfg(target_os = "linux")] { - let target = if is_directory { - path.to_path_buf() + if is_directory { + bitfun_core::util::process_manager::create_command("xdg-open") + .arg(&path_str) + .spawn() + .map_err(|e| format!("Failed to open file manager: {}", e))?; } else { - path.parent() - .ok_or_else(|| "Failed to get parent directory".to_string())? - .to_path_buf() - }; - bitfun_core::util::process_manager::create_command("xdg-open") - .arg(target) - .spawn() - .map_err(|e| format!("Failed to open file manager: {}", e))?; + // On Linux there is no cross-desktop standard to select a specific + // file in the file manager. Try the freedesktop FileManager1 D-Bus + // interface (supported by Nautilus, Dolphin, Nemo) to highlight the + // file; fall back to opening the parent directory with xdg-open. + // Encode each path segment so spaces and other special characters + // do not break the dbus-send array:string: syntax (which splits on + // spaces) and produce a valid file:// URI. + let encoded_path: String = path + .to_string_lossy() + .split('/') + .map(|s| urlencoding::encode(s).to_string()) + .collect::>() + .join("/"); + let file_uri = format!("file://{}", encoded_path); + let dbus_ok = match bitfun_core::util::process_manager::create_command("dbus-send") + .args([ + "--session", + "--print-reply", + "--dest=org.freedesktop.FileManager1", + "/org/freedesktop/FileManager1", + "org.freedesktop.FileManager1.ShowItems", + &format!("array:string:{}", file_uri), + "string:", + ]) + .spawn() + { + Ok(mut child) => child.wait().map(|s| s.success()).unwrap_or(false), + Err(_) => false, + }; + + if !dbus_ok { + let parent = path + .parent() + .ok_or_else(|| "Failed to get parent directory".to_string())?; + bitfun_core::util::process_manager::create_command("xdg-open") + .arg(parent) + .spawn() + .map_err(|e| format!("Failed to open file manager: {}", e))?; + } + } } Ok(()) diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 3672b79a57..7130185c3f 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -7,6 +7,7 @@ pub mod app_state; pub mod browser_api; pub mod browser_control_api; pub mod btw_api; +pub mod canvas_api; pub mod clipboard_file_api; pub mod commands; pub mod computer_use_api; diff --git a/src/apps/desktop/src/api/path_target.rs b/src/apps/desktop/src/api/path_target.rs index 66f68960fc..4a5648e40d 100644 --- a/src/apps/desktop/src/api/path_target.rs +++ b/src/apps/desktop/src/api/path_target.rs @@ -218,7 +218,9 @@ pub async fn read_text_file( raw_path: &str, preferred_remote_connection_id: Option<&str>, ) -> Result { - match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { + let target = + resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await?; + match &target { DesktopPathTarget::Local { resolved_path, .. } => app_state .filesystem_service .read_file(&resolved_path.to_string_lossy()) @@ -234,7 +236,7 @@ pub async fn read_text_file( .await .map_err(|e| format!("Remote file service not available: {}", e))?; let bytes = remote_fs - .read_file(&entry.connection_id, &requested_path) + .read_file(&entry.connection_id, requested_path) .await .map_err(|e| format!("Failed to read remote file: {}", e))?; String::from_utf8(bytes).map_err(|e| format!("File is not valid UTF-8: {}", e)) @@ -352,22 +354,28 @@ pub async fn rename_path( app_state: &AppState, old_path: &str, new_path: &str, + preferred_remote_connection_id: Option<&str>, ) -> Result<(), String> { - match resolve_desktop_path_target(app_state, old_path, None).await? { + match resolve_desktop_path_target(app_state, old_path, preferred_remote_connection_id).await? { DesktopPathTarget::Local { resolved_path: old_resolved_path, .. } => { - let new_resolved_path = - match resolve_desktop_path_target(app_state, new_path, None).await? { - DesktopPathTarget::Local { resolved_path, .. } => resolved_path, - DesktopPathTarget::Remote { .. } => { - return Err(format!( - "Cannot rename local path '{}' to remote destination '{}'", - old_path, new_path - )) - } - }; + let new_resolved_path = match resolve_desktop_path_target( + app_state, + new_path, + preferred_remote_connection_id, + ) + .await? + { + DesktopPathTarget::Local { resolved_path, .. } => resolved_path, + DesktopPathTarget::Remote { .. } => { + return Err(format!( + "Cannot rename local path '{}' to remote destination '{}'", + old_path, new_path + )) + } + }; app_state .filesystem_service @@ -391,8 +399,12 @@ pub async fn rename_path( } } -pub async fn delete_file(app_state: &AppState, raw_path: &str) -> Result<(), String> { - match resolve_desktop_path_target(app_state, raw_path, None).await? { +pub async fn delete_file( + app_state: &AppState, + raw_path: &str, + preferred_remote_connection_id: Option<&str>, +) -> Result<(), String> { + match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { DesktopPathTarget::Local { resolved_path, .. } => app_state .filesystem_service .delete_file(&resolved_path.to_string_lossy()) @@ -418,8 +430,9 @@ pub async fn delete_directory( app_state: &AppState, raw_path: &str, recursive: bool, + preferred_remote_connection_id: Option<&str>, ) -> Result<(), String> { - match resolve_desktop_path_target(app_state, raw_path, None).await? { + match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { DesktopPathTarget::Local { resolved_path, .. } => app_state .filesystem_service .delete_directory(&resolved_path.to_string_lossy(), recursive) @@ -440,7 +453,7 @@ pub async fn delete_directory( .map_err(|e| format!("Failed to delete remote directory: {}", e)) } else { remote_fs - .remove_dir_all(&entry.connection_id, &requested_path) + .remove_dir(&entry.connection_id, &requested_path) .await .map_err(|e| format!("Failed to delete remote directory: {}", e)) } @@ -448,8 +461,12 @@ pub async fn delete_directory( } } -pub async fn create_empty_file(app_state: &AppState, raw_path: &str) -> Result<(), String> { - match resolve_desktop_path_target(app_state, raw_path, None).await? { +pub async fn create_empty_file( + app_state: &AppState, + raw_path: &str, + preferred_remote_connection_id: Option<&str>, +) -> Result<(), String> { + match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { DesktopPathTarget::Local { resolved_path, .. } => { let options = FileOperationOptions::default(); app_state @@ -475,8 +492,12 @@ pub async fn create_empty_file(app_state: &AppState, raw_path: &str) -> Result<( } } -pub async fn create_directory(app_state: &AppState, raw_path: &str) -> Result<(), String> { - match resolve_desktop_path_target(app_state, raw_path, None).await? { +pub async fn create_directory( + app_state: &AppState, + raw_path: &str, + preferred_remote_connection_id: Option<&str>, +) -> Result<(), String> { + match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { DesktopPathTarget::Local { resolved_path, .. } => app_state .filesystem_service .create_directory(&resolved_path.to_string_lossy()) diff --git a/src/apps/desktop/src/api/ssh_api.rs b/src/apps/desktop/src/api/ssh_api.rs index ad105ccd34..19d6b5b5a4 100644 --- a/src/apps/desktop/src/api/ssh_api.rs +++ b/src/apps/desktop/src/api/ssh_api.rs @@ -2,8 +2,10 @@ //! //! Tauri commands for SSH connection management and remote file operations. +use serde::Serialize; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Instant; -use tauri::State; +use tauri::{Emitter, State}; use crate::api::app_state::SSHServiceError; use crate::startup_trace::DesktopStartupTrace; @@ -330,20 +332,101 @@ pub async fn remote_rename( .map_err(|e| e.to_string()) } -/// Read a remote file via SFTP and write it to a local path (binary-safe). +/// Payload emitted via `download_progress` events during a remote download. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadProgressPayload { + pub transfer_id: String, + pub downloaded: u64, + pub total: u64, +} + +/// Read a remote file or directory via SFTP and write it to a local path. +/// +/// If `remote_path` is a file, its bytes are read via SFTP and written to +/// `local_path` (binary-safe). If it is a directory, the directory tree is +/// recreated locally: subdirectories are created with `create_dir_all`, and +/// each file is read via SFTP and written to disk. +/// +/// Emits `download_progress` events with `{ transferId, downloaded, total }` +/// (bytes) during the SFTP read so the frontend can render a determinate +/// progress bar with speed display. The `transfer_id` lets the frontend +/// distinguish concurrent downloads and cancel individual transfers. Events +/// are throttled to at most one per 100 ms (plus a guaranteed final event) to +/// avoid flooding the webview. #[tauri::command] pub async fn remote_download_to_local_path( + app_handle: tauri::AppHandle, state: State<'_, AppState>, connection_id: String, remote_path: String, local_path: String, + transfer_id: String, ) -> Result<(), String> { let remote_fs = state.get_remote_file_service_async().await?; - let bytes = remote_fs - .read_file(&connection_id, &remote_path) + + // Register a cancellation flag for this transfer. + let cancel_flag = std::sync::Arc::new(AtomicBool::new(false)); + { + let mut map = state.active_transfers.lock().map_err(|e| e.to_string())?; + map.insert(transfer_id.clone(), cancel_flag.clone()); + } + + // Check if the remote path is a directory. + let is_dir = remote_fs + .is_dir(&connection_id, &remote_path) .await .map_err(|e| e.to_string())?; + if is_dir { + let dir_result = download_directory_from_remote( + &app_handle, + &state, + &connection_id, + &remote_path, + &local_path, + &transfer_id, + &cancel_flag, + ) + .await; + // Clean up the cancellation flag. + { + let mut map = state.active_transfers.lock().map_err(|e| e.to_string())?; + map.remove(&transfer_id); + } + return dir_result; + } + + // Regular file: read via SFTP with progress. + let mut last_emit = Instant::now(); + let result = remote_fs + .read_file_with_progress(&connection_id, &remote_path, &mut |downloaded, total| { + // Throttle: emit at most every 100 ms, plus the final 100% event. + let now = Instant::now(); + if downloaded >= total || now.duration_since(last_emit).as_millis() >= 100 { + let _ = app_handle.emit( + "download_progress", + DownloadProgressPayload { + transfer_id: transfer_id.clone(), + downloaded, + total, + }, + ); + last_emit = now; + } + // Return false to abort the read if cancelled. + !cancel_flag.load(Ordering::Relaxed) + }) + .await; + + // Clean up the cancellation flag. + { + let mut map = state.active_transfers.lock().map_err(|e| e.to_string())?; + map.remove(&transfer_id); + } + + let bytes = result.map_err(|e| e.to_string())?; + tokio::task::spawn_blocking(move || { let path = std::path::Path::new(&local_path); if let Some(parent) = path.parent() { @@ -357,24 +440,393 @@ pub async fn remote_download_to_local_path( .map_err(|e| e.to_string())? } -/// Read a local file and write it to the remote path via SFTP (binary-safe). +/// Recursively download a remote directory to a local path. +/// +/// Pre-scans the remote tree to determine total file size, then walks the tree +/// and downloads each file with chunked progress reporting. Emits cumulative +/// `download_progress` events so the frontend can show overall directory +/// download progress. +async fn download_directory_from_remote( + app_handle: &tauri::AppHandle, + state: &State<'_, AppState>, + connection_id: &str, + remote_dir: &str, + local_dir: &str, + transfer_id: &str, + cancel_flag: &std::sync::Arc, +) -> Result<(), String> { + let remote_fs = state.get_remote_file_service_async().await?; + + // Create the top-level local directory. + let local_dir_path = std::path::PathBuf::from(local_dir); + tokio::task::spawn_blocking(move || { + std::fs::create_dir_all(&local_dir_path).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + // Pre-scan the remote directory to determine total bytes for progress reporting. + let mut total_bytes: u64 = 0; + let mut scan_stack = vec![remote_dir.to_string()]; + while let Some(current) = scan_stack.pop() { + let entries = remote_fs + .read_dir(connection_id, ¤t) + .await + .map_err(|e| e.to_string())?; + for entry in entries { + if entry.is_dir { + scan_stack.push(entry.path); + } else if let Some(size) = entry.size { + total_bytes += size; + } + } + } + + let mut downloaded: u64 = 0; + let mut last_emit = Instant::now(); + + // Walk the remote directory tree. + let mut stack: Vec<(String, std::path::PathBuf)> = + vec![(remote_dir.to_string(), std::path::PathBuf::from(local_dir))]; + + while let Some((remote_current, local_current)) = stack.pop() { + if cancel_flag.load(Ordering::Relaxed) { + return Err("Transfer cancelled".to_string()); + } + + let entries = remote_fs + .read_dir(connection_id, &remote_current) + .await + .map_err(|e| e.to_string())?; + + for entry in entries { + let remote_child = entry.path; + let local_child = local_current.join(&entry.name); + + if entry.is_dir { + tokio::task::spawn_blocking({ + let local_child = local_child.clone(); + move || std::fs::create_dir_all(&local_child).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + stack.push((remote_child, local_child)); + } else { + let base_downloaded = downloaded; + let bytes = remote_fs + .read_file_with_progress(connection_id, &remote_child, &mut |read_bytes, _| { + let cumulative = base_downloaded + read_bytes; + let now = Instant::now(); + if cumulative >= total_bytes + || now.duration_since(last_emit).as_millis() >= 100 + { + let _ = app_handle.emit( + "download_progress", + DownloadProgressPayload { + transfer_id: transfer_id.to_string(), + downloaded: cumulative, + total: total_bytes, + }, + ); + last_emit = now; + } + !cancel_flag.load(Ordering::Relaxed) + }) + .await + .map_err(|e| e.to_string())?; + + let file_size = bytes.len() as u64; + let local_child_write = local_child.clone(); + tokio::task::spawn_blocking(move || { + if let Some(parent) = local_child_write.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + } + std::fs::write(&local_child_write, &bytes).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + downloaded += file_size; + } + } + } + + // Final progress event. + let _ = app_handle.emit( + "download_progress", + DownloadProgressPayload { + transfer_id: transfer_id.to_string(), + downloaded: total_bytes, + total: total_bytes, + }, + ); + + Ok(()) +} + +/// Result of uploading a local path to a remote server. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteUploadResult { + pub was_directory: bool, +} + +/// Payload emitted via `upload_progress` events during a remote upload. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UploadProgressPayload { + pub transfer_id: String, + pub uploaded: u64, + pub total: u64, +} + +/// Recursively scan a local directory and return the total size of all +/// regular files in bytes. Used for pre-scanning before directory upload +/// so that overall progress can be reported. +fn scan_directory_total_size(dir: &std::path::Path) -> u64 { + let mut total: u64 = 0; + let mut stack = vec![dir.to_path_buf()]; + while let Some(current) = stack.pop() { + if let Ok(entries) = std::fs::read_dir(¤t) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if let Ok(meta) = path.metadata() { + total += meta.len(); + } + } + } + } + total +} + +/// Upload a local file or directory tree to a remote path via SFTP. +/// +/// If `local_path` is a file, its bytes are written to `remote_path`. If it is +/// a directory, the directory tree is recreated on the remote side: +/// subdirectories are created with `create_dir_all`, and each file is read +/// locally and written via SFTP. +/// +/// Emits `upload_progress` events with `{ transferId, uploaded, total }` +/// (bytes) during the SFTP write so the frontend can render a determinate +/// progress bar with speed display. The `transfer_id` lets the frontend +/// distinguish concurrent uploads and cancel individual transfers. Events +/// are throttled to at most one per 100 ms (plus a guaranteed final event). #[tauri::command] pub async fn remote_upload_from_local_path( + app_handle: tauri::AppHandle, state: State<'_, AppState>, connection_id: String, local_path: String, remote_path: String, -) -> Result<(), String> { - let bytes = - tokio::task::spawn_blocking(move || std::fs::read(&local_path).map_err(|e| e.to_string())) - .await - .map_err(|e| e.to_string())??; + transfer_id: String, +) -> Result { + let local_path = std::path::Path::new(&local_path); + + // Register a cancellation flag for this transfer. + let cancel_flag = std::sync::Arc::new(AtomicBool::new(false)); + { + let mut map = state.active_transfers.lock().map_err(|e| e.to_string())?; + map.insert(transfer_id.clone(), cancel_flag.clone()); + } + + // A directory needs to be walked locally and recreated on the remote side. + if local_path.is_dir() { + let dir_result = upload_directory_to_remote( + &app_handle, + &state, + &connection_id, + local_path, + &remote_path, + &transfer_id, + &cancel_flag, + ) + .await; + // Clean up the cancellation flag. + { + let mut map = state.active_transfers.lock().map_err(|e| e.to_string())?; + map.remove(&transfer_id); + } + dir_result?; + return Ok(RemoteUploadResult { + was_directory: true, + }); + } + + // Regular file: read locally, write via SFTP with progress. + let local_path_owned = local_path.to_path_buf(); + let bytes = tokio::task::spawn_blocking(move || { + std::fs::read(&local_path_owned).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; let remote_fs = state.get_remote_file_service_async().await?; + let mut last_emit = Instant::now(); + let write_result = remote_fs + .write_file_with_progress( + &connection_id, + &remote_path, + &bytes, + &mut |written, total| { + let now = Instant::now(); + if written >= total || now.duration_since(last_emit).as_millis() >= 100 { + let _ = app_handle.emit( + "upload_progress", + UploadProgressPayload { + transfer_id: transfer_id.clone(), + uploaded: written, + total, + }, + ); + last_emit = now; + } + !cancel_flag.load(Ordering::Relaxed) + }, + ) + .await; + + // Clean up the cancellation flag. + { + let mut map = state.active_transfers.lock().map_err(|e| e.to_string())?; + map.remove(&transfer_id); + } + + write_result.map_err(|e| e.to_string())?; + + Ok(RemoteUploadResult { + was_directory: false, + }) +} + +/// Recursively upload a local directory to a remote path. +/// +/// Pre-scans the directory to determine total file size, then walks the tree +/// and uploads each file with chunked progress reporting. Emits cumulative +/// `upload_progress` events so the frontend can show overall directory upload +/// progress. +async fn upload_directory_to_remote( + app_handle: &tauri::AppHandle, + state: &State<'_, AppState>, + connection_id: &str, + local_dir: &std::path::Path, + remote_dir: &str, + transfer_id: &str, + cancel_flag: &std::sync::Arc, +) -> Result<(), String> { + let remote_fs = state.get_remote_file_service_async().await?; + + // Create the top-level remote directory. remote_fs - .write_file(&connection_id, &remote_path, &bytes) + .create_dir_all(connection_id, remote_dir) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + // Pre-scan the directory to determine total bytes for progress reporting. + let local_dir_owned = local_dir.to_path_buf(); + let total_bytes = + tokio::task::spawn_blocking(move || scan_directory_total_size(&local_dir_owned)) + .await + .map_err(|e| e.to_string())?; + + let mut uploaded: u64 = 0; + let mut last_emit = Instant::now(); + + // Walk the local directory tree. + let mut stack: Vec<(std::path::PathBuf, String)> = + vec![(local_dir.to_path_buf(), remote_dir.to_string())]; + + while let Some((local_current, remote_current)) = stack.pop() { + let entries = tokio::task::spawn_blocking(move || { + std::fs::read_dir(&local_current) + .map_err(|e| e.to_string()) + .and_then(|dir| { + dir.collect::, _>>() + .map_err(|e| e.to_string()) + }) + }) + .await + .map_err(|e| e.to_string())??; + + for entry in entries { + let entry_path = entry.path(); + let file_name = match entry.file_name().into_string() { + Ok(name) => name, + Err(_) => continue, + }; + let remote_child = if remote_current.ends_with('/') { + format!("{}{}", remote_current, file_name) + } else { + format!("{}/{}", remote_current, file_name) + }; + + if entry_path.is_dir() { + remote_fs + .create_dir_all(connection_id, &remote_child) + .await + .map_err(|e| e.to_string())?; + stack.push((entry_path, remote_child)); + } else { + let local_file = entry_path.clone(); + let bytes = tokio::task::spawn_blocking(move || { + std::fs::read(&local_file).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + let file_size = bytes.len() as u64; + let base_uploaded = uploaded; + remote_fs + .write_file_with_progress( + connection_id, + &remote_child, + &bytes, + &mut |written, _| { + let cumulative = base_uploaded + written; + let now = Instant::now(); + if cumulative >= total_bytes + || now.duration_since(last_emit).as_millis() >= 100 + { + let _ = app_handle.emit( + "upload_progress", + UploadProgressPayload { + transfer_id: transfer_id.to_string(), + uploaded: cumulative, + total: total_bytes, + }, + ); + last_emit = now; + } + !cancel_flag.load(Ordering::Relaxed) + }, + ) + .await + .map_err(|e| e.to_string())?; + uploaded += file_size; + } + } + } + + Ok(()) +} + +/// Cancel an in-progress file transfer by setting its cancellation flag. +/// +/// The transfer will abort at the next chunk boundary and the original +/// download/upload command will return an error. +#[tauri::command] +pub async fn cancel_transfer( + state: State<'_, AppState>, + transfer_id: String, +) -> Result<(), String> { + let map = state.active_transfers.lock().map_err(|e| e.to_string())?; + if let Some(flag) = map.get(&transfer_id) { + flag.store(true, Ordering::Relaxed); + } + Ok(()) } #[tauri::command] diff --git a/src/apps/desktop/src/api/storage_commands.rs b/src/apps/desktop/src/api/storage_commands.rs index fa12774157..cfbdf77fa3 100644 --- a/src/apps/desktop/src/api/storage_commands.rs +++ b/src/apps/desktop/src/api/storage_commands.rs @@ -55,7 +55,6 @@ pub async fn get_project_storage_paths( runtime_root: path_manager.project_runtime_root(&workspace_path), agents_dir: path_manager.project_agents_dir(&workspace_path), sessions_dir: path_manager.project_sessions_dir(&workspace_path), - memory_dir: path_manager.project_memory_dir(&workspace_path), plans_dir: path_manager.project_plans_dir(&workspace_path), }) } @@ -67,7 +66,6 @@ pub struct ProjectStoragePathsInfo { pub runtime_root: PathBuf, pub agents_dir: PathBuf, pub sessions_dir: PathBuf, - pub memory_dir: PathBuf, pub plans_dir: PathBuf, } diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 93a0b4e9d6..1c17ad0470 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -576,6 +576,39 @@ pub async fn _run() { #[cfg(not(target_env = "ohos"))] { + let workspace_startup_bootstrap_snapshot = { + let app_state: tauri::State<'_, api::app_state::AppState> = app.state(); + let startup_trace_state: tauri::State<'_, startup_trace::DesktopStartupTrace> = + app.state(); + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on( + prepare_workspace_startup_bootstrap_snapshot( + &app_state, + &app_handle, + &startup_trace_state, + ), + ) + }) + .and_then(|snapshot| { + serde_json::to_value(snapshot) + .map_err(|error| { + log::warn!( + "Failed to serialize workspace startup bootstrap snapshot, frontend will fall back to startup command: {}", + error + ); + error + }) + .ok() + }) + }; + let window_started = Instant::now(); + startup_trace.record_phase("main_window_create_start", "native_window"); + theme::create_main_window( + &app_handle, + &startup_trace_id, + &startup_trace, + workspace_startup_bootstrap_snapshot, + ); let window_duration_ms = elapsed_ms(window_started); startup_trace.record_step( "native_step_end", @@ -586,7 +619,7 @@ pub async fn _run() { log::debug!( "Desktop startup step completed: step=create_main_window, duration_ms={}", window_duration_ms - ); + ); let webdriver_started = Instant::now(); bitfun_webdriver::maybe_start(app_handle.clone()); startup_trace.record_elapsed_step( @@ -604,10 +637,10 @@ pub async fn _run() { ); startup_trace.record_phase("tauri_setup_window_phase_end", "native_setup"); log::debug!( - "Desktop startup timing: phase=tauri_setup_until_main_window_created, duration_ms={}, since_process_start_ms={}", - window_phase_duration_ms, - since_process_start_ms - ); + "Desktop startup timing: phase=tauri_setup_until_main_window_created, duration_ms={}, since_process_start_ms={}", + window_phase_duration_ms, + since_process_start_ms + ); } #[cfg(target_os = "macos")] @@ -800,6 +833,10 @@ pub async fn _run() { api::agentic_api::restore_session, api::agentic_api::restore_session_view, api::agentic_api::restore_session_with_turns, + api::agentic_api::reset_memory, + api::agentic_api::get_memory_paths, + api::agentic_api::set_session_memory_mode, + webdriver_bridge_result, get_startup_native_trace, api::agentic_api::list_sessions, api::agentic_api::confirm_tool_execution, @@ -870,6 +907,8 @@ pub async fn _run() { delete_directory, create_file, create_directory, + compress_path, + decompress_path, list_directory_files, start_file_watch, stop_file_watch, @@ -1139,7 +1178,6 @@ pub async fn _run() { install_update, api::system_api::open_html_file_in_browser, restart_app, - open_external_ohos, send_system_notification, api::system_api::quit_app, api::system_api::minimize_to_tray, @@ -1187,6 +1225,10 @@ pub async fn _run() { api::miniapp_api::miniapp_runtime_status, api::miniapp_api::miniapp_worker_call, api::miniapp_api::miniapp_host_call, + api::canvas_api::load_canvas_artifact, + api::canvas_api::load_canvas_state, + api::canvas_api::report_canvas_runtime_error, + api::canvas_api::save_canvas_state, api::miniapp_api::miniapp_worker_stop, api::miniapp_api::miniapp_worker_list_running, api::miniapp_api::miniapp_install_deps, @@ -1254,6 +1296,7 @@ pub async fn _run() { api::ssh_api::remote_rename, api::ssh_api::remote_download_to_local_path, api::ssh_api::remote_upload_from_local_path, + api::ssh_api::cancel_transfer, api::ssh_api::remote_execute, api::ssh_api::remote_open_workspace, api::ssh_api::remote_close_workspace, @@ -1276,6 +1319,7 @@ pub async fn _run() { window_start_dragging, close_window, set_theme_mode, + open_external_ohos, // Debug API (no-op stubs in release builds) api::debug_api::debug_devtools_available, diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index e50eb7bb37..672faac871 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -591,6 +591,14 @@ pub fn create_main_window( // Keep HTML5 drag-and-drop working inside the webview for desktop UI drag targets. builder = builder.disable_drag_drop_handler(); + // Block webview reloads: allow only the first navigation (initial load), + // cancel all subsequent navigations (F5 / Ctrl+R / location.reload()). + // The app uses state-driven routing, not browser navigation, so there are + // no legitimate full-page navigations after the initial load. + let first_navigation = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + builder = builder + .on_navigation(move |_| first_navigation.swap(false, std::sync::atomic::Ordering::SeqCst)); + #[cfg(target_os = "macos")] { builder = builder diff --git a/src/crates/assembly/core/AGENTS-CN.md b/src/crates/assembly/core/AGENTS-CN.md index e56ffb6c33..410ab13479 100644 --- a/src/crates/assembly/core/AGENTS-CN.md +++ b/src/crates/assembly/core/AGENTS-CN.md @@ -9,7 +9,7 @@ ## 定位 `bitfun-core` 是共享产品 runtime facade。它仍承载兼容路径和 `product-full` 组装边界,但新的拆解工作应优先遵循 -`docs/architecture/core-decomposition.md` 与 +`docs/architecture/product-architecture.md` 与 `docs/architecture/agent-runtime-services-design.md` 中定义的 owner crate 边界。 主要区域: @@ -52,7 +52,7 @@ SessionManager -> Session -> DialogTurn -> ModelRound 归属细节放在下列文件中,不要继续扩写本指南: -- `docs/architecture/core-decomposition.md` +- `docs/architecture/product-architecture.md` - `docs/architecture/agent-runtime-services-design.md` - `src/crates/execution/agent-runtime/AGENTS.md` - `src/crates/execution/tool-contracts/AGENTS.md` diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index ac3ae658b2..4aab048709 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -11,7 +11,7 @@ repository-wide rules and the nearest narrower guide when one exists. `bitfun-core` is the shared product runtime facade. It still owns compatibility paths and the `product-full` assembly boundary, but new decomposition work should -prefer the owner crates described in `docs/architecture/core-decomposition.md` +prefer the owner crates described in `docs/architecture/product-architecture.md` and `docs/architecture/agent-runtime-services-design.md`. Main areas: @@ -71,7 +71,7 @@ SessionManager -> Session -> DialogTurn -> ModelRound Use these files for ownership details instead of expanding this guide: -- `docs/architecture/core-decomposition.md` +- `docs/architecture/product-architecture.md` - `docs/architecture/agent-runtime-services-design.md` - `src/crates/execution/agent-runtime/AGENTS.md` - `src/crates/execution/tool-contracts/AGENTS.md` diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index c53716adf3..d1c9a8f230 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -37,6 +37,7 @@ base64 = { workspace = true } image = { workspace = true, optional = true } md5 = { workspace = true, optional = true } hex = { workspace = true } +rusqlite = { version = "0.32", features = ["bundled"] } dashmap = { workspace = true, optional = true } indexmap = { workspace = true, optional = true } htmd = "0.5.4" @@ -98,7 +99,10 @@ bitfun-agent-tools = { path = "../../execution/tool-contracts" } bitfun-tool-packs = { path = "../../execution/tool-provider-groups", default-features = false, optional = true } # Core service owner crate -bitfun-services-core = { path = "../../services/services-core", default-features = false, features = ["lsp"] } +bitfun-services-core = { path = "../../services/services-core", default-features = false, features = [ + "lsp", + "workspace-runtime", +] } # Integration service owner crate bitfun-services-integrations = { path = "../../services/services-integrations", default-features = false, features = ["remote-ssh"] } @@ -107,7 +111,9 @@ bitfun-services-integrations = { path = "../../services/services-integrations", bitfun-product-domains = { path = "../../contracts/product-domains", default-features = false, optional = true } # Tool runtime -tool-runtime = { path = "../../execution/tool-execution", optional = true } +tool-runtime = { path = "../../execution/tool-execution", default-features = false, optional = true, features = [ + "web-readable", +] } # terminal terminal-core = { path = "../../services/terminal" } @@ -163,6 +169,7 @@ schannel = "0.1" default = ["product-full"] product-full = [ "ai-adapter-runtime", + "canvas-runtime", "dep:chrono-tz", "dep:cron", "dep:dashmap", @@ -197,6 +204,10 @@ product-domains = [ "bitfun-services-integrations/miniapp-runtime", "bitfun-product-domains/product-full", ] +canvas-runtime = [ + "product-domains", + "bitfun-services-integrations/canvas-runtime", +] runtime-services = [] service-integrations = [ "dep:aes-gcm", @@ -223,5 +234,21 @@ ssh-remote = [ [dev-dependencies] tempfile = { workspace = true } +[[test]] +name = "context_profile" +required-features = ["product-full"] + +[[test]] +name = "git_contracts" +required-features = ["service-integrations"] + +[[test]] +name = "product_assembly" +required-features = ["product-full"] + +[[test]] +name = "remote_mcp_streamable_http" +required-features = ["service-integrations"] + [build-dependencies] sha2 = { workspace = true } diff --git a/src/crates/assembly/core/build.rs b/src/crates/assembly/core/build.rs index d018b1906f..415769719b 100644 --- a/src/crates/assembly/core/build.rs +++ b/src/crates/assembly/core/build.rs @@ -113,6 +113,7 @@ fn embed_agents_prompt_data() -> Result<(), Box> { println!("cargo:rerun-if-changed=src/agentic/agents/prompts"); println!("cargo:rerun-if-changed=src/agentic/prompts"); + println!("cargo:rerun-if-changed=src/agentic/memories/prompts"); // Get CARGO_MANIFEST_DIR (i.e. src/crates/assembly/core directory) let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?; @@ -151,6 +152,22 @@ fn embed_agents_prompt_data() -> Result<(), Box> { ); } + let memory_prompt_path = Path::new(&manifest_dir) + .join("src") + .join("agentic") + .join("memories") + .join("prompts"); + + if memory_prompt_path.exists() { + println!("Embedding memory prompts from: {:?}", memory_prompt_path); + read_prompts_recursive(&memory_prompt_path, &memory_prompt_path, &mut prompts)?; + } else { + eprintln!( + "Warning: memory prompts directory not found at {:?}", + memory_prompt_path + ); + } + if prompts.is_empty() { // Create empty embedded data create_empty_embedded_prompts(&manifest_dir)?; @@ -211,6 +228,9 @@ fn read_prompts_with_prefix( // Remove extension and add prefix as key let key = gen_key(&relative_path, prefix); + if prompts.contains_key(&key) { + return Err(format!("Duplicate embedded prompt key: {}", key).into()); + } prompts.insert(key, content); } } @@ -240,6 +260,9 @@ fn read_prompts_recursive( // Remove extension as key let key = gen_key(&relative_path, ""); + if prompts.contains_key(&key) { + return Err(format!("Duplicate embedded prompt key: {}", key).into()); + } prompts.insert(key, content); } } diff --git a/src/crates/assembly/core/builtin_skills/bitfun-canvas/SKILL.md b/src/crates/assembly/core/builtin_skills/bitfun-canvas/SKILL.md new file mode 100644 index 0000000000..45d8e6af71 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/bitfun-canvas/SKILL.md @@ -0,0 +1,108 @@ +--- +name: bitfun-canvas +description: >- + A BitFun Canvas is a live React app that the user can open beside the chat. + You MUST use a canvas when the agent produces a standalone analytical artifact + — quantitative analyses, billing investigations, security audits, architecture + reviews, data-heavy content, timelines, charts, tables, interactive + explorations, repeatable tools, or any response that benefits from visual + layout. Especially prefer a canvas when presenting structured tool or service + results where the data is the deliverable — render it in a rich canvas rather + than dumping it into a markdown table or code block. If you catch yourself + about to write a markdown table, stop and use a canvas instead. You MUST also + read this skill whenever you create, edit, or debug a BitFun Canvas artifact. +metadata: + surfaces: + - ide +--- +A canvas is a single TSX source artifact that BitFun compiles so the user can open it beside the chat. Follow the workflow below in order. + +## Workflow + +### 1. Decide whether to use a canvas + +The trigger is **user intent**, not response shape. Ask: would the user benefit from viewing this output as its **own standalone artifact**, separate from the chat? If the output is a means to an end (a drafted message, a code fix, a dashboard in another tool), skip the canvas. + +**Use a canvas when the agent produces new standalone analytical output:** +- Quantitative analyses and metrics breakdowns (e.g. "send 500 requests and tell me how many fail") +- Billing or account investigations that surface structured findings from database queries +- Security audits or architecture reviews with categorized findings +- Cross-system data analyses and overlap reports +- Structured data from tools or services where the data IS the deliverable +- Financial analyses, margin decompositions, usage trend reports +- Tables with more than a handful of rows that the user asked to see + +**Do NOT use a canvas when:** +- The user asks for work in a **specific tool** — "create a Datadog dashboard" means give them a Datadog dashboard, not a canvas +- The user has a **specific deliverable** — "draft a support response", "fix this code", "make this PR" +- The user is **working within an existing artifact** — improving an HTML dashboard, editing an existing file +- The user is doing **targeted debugging** or active development, even if structured findings emerge along the way +- Short factual answers, one-off file edits, or quick clarifying questions +- Tools are queried as an **intermediate step** for a different deliverable (e.g. querying Stripe to draft a support reply) + +### 2. Write the canvas + +**Location.** BitFun Canvases are session-scoped artifacts created with `CreateCanvas`. For a new canvas, always call `CreateCanvas` with a concise title and complete TSX source; do not stop after telling the user what the source would be or showing code in chat. For small targeted revisions, use `PatchCanvas` with exact unique text replacements. Use `ReadCanvas` first when you do not have the latest source in context. Use `UpdateCanvas` only for large rewrites that need a complete replacement TSX source. + +**File rules:** +- Exactly one TSX source per canvas. Never create helper files, style files, or supporting modules. +- Import **only** from `bitfun/canvas`. No relative imports, no npm packages, no Node built-ins. +- Default-export the top-level component. +- Embed all data inline. **No `fetch()`, no network calls.** +- Do not locally redeclare names imported from `bitfun/canvas` such as `Grid`, `Code`, `Stack`, `Text`, `Row`, or `Card`. Use the SDK component directly, or choose a distinct helper name such as `LayerGrid` or `InlineCodeText`. + +**Never render empty states.** A canvas exists to show real content. If a section, chart, table, or component has no data to display, **omit it** — do not render it with placeholder text ("Add header here", "TODO", "Example"), a "No data" message, an empty array, zeroed rows, or an empty chart frame. If the entire canvas would be empty because you don't have the underlying data, do not produce a canvas — tell the user what's missing and ask for it instead. + +**Label every plot.** Charts and tables must be self-describing — a reader looking at the canvas alone should know exactly what they're seeing. For every plot include: +- A title naming the **specific metric** (not "Metrics" — "API error rate by service"). +- **Axis labels with units** on both axes (e.g. "Date", "Latency (ms)"). +- A **legend** when more than one series is shown, with the exact series names from the source data. +- The **source and time range** in a small caption (e.g. "Source: Datadog · last 7 days"). If a value is a transformation (mean, p95, normalized, smoothed), say so in the label. + +**Component discovery:** prefer built-in `bitfun/canvas` components over hand-rolled markup. The full public surface (components, hooks, prop types, tokens) is declared in `sdk/index.d.ts` next to this skill and its sibling `.d.ts` files — read them when you need exact exports, prop shapes, or hook signatures rather than guessing. Referencing an export that does not exist is the most common runtime error. + +Apply the Canvas generation policy below as you write, and complete its pre-delivery self-check (section 6) before returning the canvas. + +## Design guidance + +Be creative. The SDK gives you expressive building blocks — use them in whatever combination best serves the content. But avoid slop: no gradients, no emojis, no box-shadows, no rainbow coloring. BitFun canvases are flat, minimal, and purposeful. + +### Visual hierarchy + +Not everything deserves equal treatment. Primary content gets more space, larger headings, and accent color. Supporting content stays compact. Squint test: blur your eyes — can you tell what matters? + +**Color.** All colors from `useHostTheme()` tokens — read its JSDoc in the SDK declarations for the return shape and usage pattern. No hardcoded hex. Use accent color deliberately, not on everything. + +### Slop patterns — forbidden + +These specific patterns produce low-quality output. If 2+ are present, redesign. + +- **Gradients** — no `linear-gradient`, `radial-gradient`, `background-clip: text`. +- **Emojis** — no emoji as icons, status indicators, bullets, or section markers. +- **Box shadows** — no `box-shadow`. Flat surfaces only. +- **Wall of identical cards** — every section wrapped in the same card style with no variation. Mix open sections with cards. +- **Rainbow coloring** — a different color on every element. Most elements are neutral; color is used sparingly with purpose. +- **Giant text** — font sizes above H1 (24px), or bold text stuffed in CardHeader. +- **Decorative borders** — colored borders on every element. Borders are structural (subtle stroke tokens), not decorative. + +### Pre-delivery self-check + +Before returning canvas code, verify: +1. Does the layout have visual hierarchy? One thing should stand out. +2. Is there variety in the composition? Not just a single column of uniform blocks. +3. Slop check: scan for the forbidden patterns above. + +## Introducing the canvas + +Whenever you mention a canvas to the user — one you created, updated, or want them to open — **always** include the `bitfun-canvas://...` artifact reference returned by the Canvas tool. Use the artifact title or a short descriptive label near the reference; do not refer to a canvas by name alone without the reference. + +When you create a canvas, add a short note in your chat response telling the user they can open it beside the chat, with that `bitfun-canvas://...` reference: + +- **First canvas** — include one sentence explaining what a canvas is. +- **Unsolicited canvas** — if the user didn't ask for a canvas, include one sentence explaining why you chose it over plain text. + +Both can apply at once; one or two sentences total is enough. Skip the intro for subsequent canvases unless you are mentioning that canvas again (still include the artifact reference). + +## Troubleshooting + +If a canvas appears blank or missing, first inspect the Canvas tool result diagnostics and runtime diagnostics. `CreateCanvas`, `PatchCanvas`, and `UpdateCanvas` save the source first, then compile it. If compilation or policy validation fails, the tool returns diagnostics and preserves the previous last-known-good compiled payload when one exists. Runtime errors are reported back to the host as Canvas diagnostics; open the source view, fix the exception, then call `PatchCanvas` for a small exact edit or `UpdateCanvas` for a full rewrite. diff --git a/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/callout-tone-icons.d.ts b/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/callout-tone-icons.d.ts new file mode 100644 index 0000000000..6888a2e27c --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/callout-tone-icons.d.ts @@ -0,0 +1,11 @@ +import type { JSX } from "react"; +type CalloutToneIconGlyph = "info" | "warning" | "circles-check" | "exclamation-circle"; +type CalloutToneForIcon = "info" | "success" | "warning" | "danger" | "neutral"; +/** Maps `Callout` tone to the same status icons used by `BitFun UI` toasts. */ +export declare const calloutToneIconGlyph: Record, CalloutToneIconGlyph>; +export declare function CalloutToneIcon({ tone, color }: { + tone: CalloutToneForIcon; + color: string; +}): JSX.Element; +export {}; +//# sourceMappingURL=callout-tone-icons.d.ts.map \ No newline at end of file diff --git a/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/canvas-tokens.d.ts b/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/canvas-tokens.d.ts new file mode 100644 index 0000000000..d8ebe80967 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/canvas-tokens.d.ts @@ -0,0 +1,98 @@ +/** + * Design-token contract for `bitfun/canvas`. + * + * Canvas uses host-provided BitFun semantic CSS variables instead of declaring + * an independent hardcoded color palette. Treat exported colors as token + * references or host-resolved strings; do not depend on concrete hex values. + */ +export interface CanvasPalette { + readonly foreground: string; + readonly foregroundSecondary: string; + readonly foregroundTertiary: string; + readonly foregroundQuaternary: string; + readonly editor: string; + readonly chrome: string; + readonly sidebar: string; + readonly elevated: string; + readonly fillPrimary: string; + readonly fillSecondary: string; + readonly fillTertiary: string; + readonly fillQuaternary: string; + readonly strokePrimary: string; + readonly strokeSecondary: string; + readonly strokeTertiary: string; + readonly strokeFocused: string; + readonly accent: string; + readonly buttonBackground: string; + readonly buttonForeground: string; + readonly buttonHoverBackground: string; + readonly link: string; + readonly diffInsertedLine: string; + readonly diffRemovedLine: string; + readonly diffStripAdded: string; + readonly diffStripRemoved: string; +} +export interface CanvasHostThemeOverrides { + readonly primary?: string; + readonly editorBackground?: string; + readonly editorForeground?: string; +} +export type Color = "gray" | "purple" | "green" | "yellow" | "cyan" | "pink" | "blue" | "orange"; +export type CategoryPalette = Readonly>; +export declare const canvasPaletteDark: CanvasPalette; +export declare const canvasPaletteLight: CanvasPalette; +export declare function applyWorkbenchSurfaces(palette: CanvasPalette, surfaces: Pick): CanvasPalette; +export declare function applyPrimaryColor(palette: CanvasPalette, primary: string): CanvasPalette; +export declare const categoryPaletteDark: CategoryPalette; +export declare const categoryPaletteLight: CategoryPalette; +/** Legacy `colorPalette` name kept for back-compat; prefer `useHostTheme().category`. */ +export declare const colorPalette: CategoryPalette; +export declare const usageColorSequence: readonly Color[]; +export declare const chartColorSequence: readonly string[]; +export interface CanvasTokens { + readonly bg: { + readonly editor: string; + readonly chrome: string; + readonly elevated: string; + }; + readonly text: { + readonly primary: string; + readonly secondary: string; + readonly tertiary: string; + readonly quaternary: string; + readonly link: string; + readonly onAccent: string; + }; + readonly stroke: { + readonly primary: string; + readonly secondary: string; + readonly tertiary: string; + readonly focused: string; + }; + readonly fill: { + readonly primary: string; + readonly secondary: string; + readonly tertiary: string; + readonly quaternary: string; + }; + readonly accent: { + readonly primary: string; + readonly control: string; + readonly controlHover: string; + }; + readonly diff: { + readonly insertedLine: string; + readonly removedLine: string; + readonly stripAdded: string; + readonly stripRemoved: string; + }; + readonly category: CategoryPalette; +} +/** Semantic colors for components. Spacing and radius live in `theme.ts`. */ +export declare const canvasTokens: CanvasTokens; +export declare const canvasTokensLight: CanvasTokens; +export declare function buildHostTokens(kind: string, overrides?: CanvasHostThemeOverrides): { + tokens: CanvasTokens; + palette: CanvasPalette; +}; +//# sourceMappingURL=canvas-tokens.d.ts.map diff --git a/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/chart-primitives.d.ts b/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/chart-primitives.d.ts new file mode 100644 index 0000000000..bcf6439628 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/chart-primitives.d.ts @@ -0,0 +1,272 @@ +/** + * Chart primitives for `bitfun/canvas` — multi-series, stacked, and pie charts + * rendered as pure inline SVG with zero external dependencies. + * + * Distilled from the portal-website Highcharts analytics charting layer. + */ +import type { CSSProperties, JSX } from "react"; +/** + * Semantic tone for a chart series or slice. Mirrors the tone vocabulary + * used by `Stat`, `Pill`, `Table`, and other SDK primitives so colors + * match across a canvas — e.g. a `Stat tone="success"` and a + * `ChartSeries tone="success"` render in the same green. + * + * Omit `tone` to let the chart auto-assign a distinct color from the + * chart palette; supply `tone` only when the value carries semantic + * meaning that should match other tonal elements on the page. + */ +export type ChartTone = "success" | "danger" | "warning" | "info" | "neutral"; +/** A single labeled value, used by `PieChart`. */ +export type ChartDataPoint = { + label: string; + /** Non-negative numeric value. */ + value: number; +}; +/** + * A named data series for `BarChart` and `LineChart`. + * The `data` array aligns by index with the parent component's `categories`. + * If `tone` is omitted, a color is auto-assigned from the chart palette. + */ +export type ChartSeries = { + name: string; + data: number[]; + tone?: ChartTone; +}; +/** + * A dashed marker line drawn across the plot at a fixed value — for targets, + * SLOs, budgets, means, or limits. Drawn horizontally on line / vertical-bar + * charts and vertically on `horizontal` bar charts; either way it marks the + * value axis and is folded into the auto domain so it stays on-canvas. + */ +export type ChartReferenceLine = { + /** Position on the value axis, in the same units as the series data. */ + value: number; + /** Short label drawn in a chip at the line's end. */ + label?: string; + /** Line color. Omit for a muted neutral; set to match other tonal elements. */ + tone?: ChartTone; +}; +/** + * Shared value-axis controls (the y-axis, or the x-axis on `horizontal` bar + * charts). By default the axis starts at zero; override to zoom into a tight + * range. On stacked / normalized bars these are ignored — those always start + * at zero. + */ +type ValueAxisProps = { + /** + * Start the value axis at zero. Defaults to `true`. Set `false` to + * auto-fit the axis to the data range — useful for tightly-clustered + * series (e.g. uptime 99.0–99.9%) that a zero baseline would flatten. + */ + beginAtZero?: boolean; + /** Explicit axis minimum. Overrides `beginAtZero`. */ + yMin?: number; + /** Explicit axis maximum. */ + yMax?: number; + /** Horizontal marker lines for targets / thresholds / means. */ + referenceLines?: ChartReferenceLine[]; +}; +export type BarChartProps = ValueAxisProps & { + /** Category labels along the independent axis. */ + categories: string[]; + /** One or more data series. Values align by index with `categories`. */ + series: ChartSeries[]; + height?: number; + /** Stack series on top of each other instead of grouping side-by-side. */ + stacked?: boolean; + /** Render horizontal bars instead of vertical columns. */ + horizontal?: boolean; + /** Show as 100% stacked (implies `stacked`). */ + normalized?: boolean; + /** Suffix for value labels (e.g. "%", " ms"). */ + valueSuffix?: string; + /** Prefix for value labels (e.g. "$"). Ignored in `normalized` mode. */ + valuePrefix?: string; + /** + * Print each bar's value as a label. Defaults to auto: on for a single + * series with ≤8 categories, off otherwise. Set `true` to force labels on + * (e.g. grouped multi-series), or `false` to force them off. No effect on + * `stacked` / `normalized` charts — use the hover tooltip there. + */ + showValues?: boolean; + style?: CSSProperties; +}; +export type LineChartProps = ValueAxisProps & { + categories: string[]; + series: ChartSeries[]; + height?: number; + /** Fill the area under each line with a soft tint. */ + fill?: boolean; + /** Suffix for value labels (e.g. "%", " ms"). */ + valueSuffix?: string; + /** Prefix for value labels (e.g. "$"). */ + valuePrefix?: string; + /** Print the value next to every data point (≤20 categories). */ + showValues?: boolean; + /** Draw a vertical guide through the cursor while hovering. Defaults to `true`. */ + showHoverGuide?: boolean; + style?: CSSProperties; +}; +export type PieChartProps = { + data: Array; + /** Diameter in px. Defaults to 200. */ + size?: number; + /** Render as a donut with the summed total shown in the hollow center. */ + donut?: boolean; + style?: CSSProperties; +}; +/** + * Multi-series bar/column chart with optional stacking and normalization. + * Distilled from the portal-website Highcharts analytics charts. + * + * Pass `categories` for x-axis labels and one or more `series` whose `data` + * arrays align by index. With a single series you get simple bars; with + * multiple series the default is grouped (side-by-side) — set `stacked` for + * stacked columns or `normalized` for 100%-stacked share-mode. + * + * Colors are auto-assigned from the chart palette. With a **single series**, + * each bar gets a different color by category (so a chart of 5 categories + * shows 5 colors out of the box). With **multiple series**, each series gets + * its own color. A legend appears when there are 2+ series. + * + * For semantic coloring, pass `tone` on a series — it maps to the same + * palette entries used by `Stat`, `Pill`, and `Table` so your chart matches + * tonal elements elsewhere on the page. + * + * @example + * ```tsx + * // Simple single-series + * + * + * // Stacked multi-series (like portal AI commit chart) + * + * + * // Semantic tones — "accepted" renders in the same green as + * // elsewhere on the page. + * + * + * // Mark a target with a reference line. Grouped/single bars also accept + * // `yMin` / `yMax` to frame the axis (stacked bars stay zero-based). + * + * ``` + */ +export declare function BarChart({ categories, series, height, stacked, horizontal, normalized, valueSuffix, valuePrefix, showValues, beginAtZero, yMin, yMax, referenceLines, style }: BarChartProps): JSX.Element; +/** + * Multi-series line chart with optional area fill. Distilled from the + * portal-website Highcharts analytics charts. + * + * Each series draws a polyline with dot markers at each data point. + * Set `fill` to shade the area under every line. Hover over any category + * column to see a tooltip with all series values and a vertical cursor guide. + * + * This is **not** a time-series component — it does not parse dates. + * Pass pre-formatted date strings as `categories` if plotting over time. + * + * Colors are auto-assigned from the chart palette. For semantic coloring, + * pass `tone` on a series — it maps to the same palette entries used by + * `Stat`, `Pill`, and `Table`. + * + * @example + * ```tsx + * // Single line + * + * + * // Multi-series with area fill + * + * + * // Semantic tones — "errors" renders in the same red as a + * // elsewhere on the page. + * + * + * // Zoom into a tight range and mark an SLO. `beginAtZero={false}` + * // auto-fits the axis; `referenceLines` draws the target. + * + * ``` + */ +export declare function LineChart({ categories, series, height, fill, valueSuffix, valuePrefix, showValues, showHoverGuide, beginAtZero, yMin, yMax, referenceLines, style }: LineChartProps): JSX.Element; +/** + * Pie (or donut) chart with hover highlighting. Distilled from the + * portal-website Highcharts analytics charts. + * + * Unlike `BarChart` and `LineChart`, `PieChart` takes a flat `data` array of + * `{ label, value }` points — each slice is its own category. Colors are + * auto-assigned from the chart palette; pass `tone` on a point to give a + * slice a semantic color that matches other tonal elements on the page. + * + * Hovering a slice expands it outward and dims the others; hovering a legend + * item does the same. A tooltip with value and percentage appears below the + * chart. Set `donut` for a hollow center that shows the summed total. + * + * **Do not** use for bar-style comparisons — use `BarChart` instead. + * + * @example + * ```tsx + * // Basic pie + * + * + * // Donut with semantic tones + * + * ``` + */ +export declare function PieChart({ data, size, donut, style }: PieChartProps): JSX.Element; +export {}; +//# sourceMappingURL=chart-primitives.d.ts.map \ No newline at end of file diff --git a/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/collapsible-section.d.ts b/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/collapsible-section.d.ts new file mode 100644 index 0000000000..87b0ed9099 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/collapsible-section.d.ts @@ -0,0 +1,67 @@ +/** + * Borderless disclosure row — chevron + structured header (title, optional + * leading slot, count, trailing slot) with a body that toggles open. Distilled + * from baby-glass `ContextTreeRow`'s lightweight list-row chrome (no card + * border, no background fill). + * + * For a bordered surface that collapses, use `` instead. + */ +import { type CSSProperties, type JSX, type ReactNode } from "react"; +export type CollapsibleSectionProps = { + /** Plain-text title rendered next to the disclosure chevron. */ + title: string; + /** + * Optional leading visual (e.g. ``) shown between the chevron and + * the title. + */ + leading?: ReactNode; + /** + * Optional small count rendered after the title (e.g. number of children). + */ + count?: number; + /** + * Optional trailing node, right-aligned (e.g. token readout, badge, button). + * Rendered with `t.text.tertiary` color hint via the wrapper; the slot can + * override. + */ + trailing?: ReactNode; + /** Body shown when expanded. */ + children?: ReactNode; + /** When true, the section starts expanded (uncontrolled). */ + defaultOpen?: boolean; + style?: CSSProperties; +}; +/** + * Borderless collapsible row with a structured header. Starts closed unless + * `defaultOpen` is set. + * + * Compose with `` in the `leading` slot for a colored category icon, + * and put a token readout / pill / button in `trailing`. Body content is + * indented under the row so nested `CollapsibleSection`s read as a tree. + * + * For a bordered, card-shaped collapsible surface, use `` + * instead — `CollapsibleSection` has no border or background and is meant to + * sit in a list of similar rows. + * + * @example + * ```tsx + * // Basic + * + * Messages go here. + * + * + * // With a colored category swatch + count + trailing token readout + * } + * trailing={12.3k} + * > + * + * Search results. + * + * + * ``` + */ +export declare function CollapsibleSection({ title, leading, count, trailing, children, defaultOpen, style }: CollapsibleSectionProps): JSX.Element; +//# sourceMappingURL=collapsible-section.d.ts.map \ No newline at end of file diff --git a/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/dag-layout.d.ts b/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/dag-layout.d.ts new file mode 100644 index 0000000000..722e1a01a8 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/dag-layout.d.ts @@ -0,0 +1,102 @@ +/** + * Pure layout math for directed acyclic graphs. Returns positioned node + * coordinates, edge anchor points, rank bounding boxes, and back-edge flags. + * Rendering is the caller's responsibility. + * + * Handles cycles gracefully: back-edges are detected via DFS, excluded from + * ranking, and flagged in the output so the caller can render them differently + * (e.g. dashed arcs). + */ +export type DAGLayoutOptions = { + /** Nodes to lay out. Only `id` is required. */ + nodes: Array<{ + id: string; + }>; + /** Directed edges. */ + edges: Array<{ + from: string; + to: string; + }>; + /** Flow direction. Default `"vertical"` (top-to-bottom). */ + direction?: "vertical" | "horizontal"; + /** Node box width in px. Default 160. */ + nodeWidth?: number; + /** Node box height in px. Default 40. */ + nodeHeight?: number; + /** Gap between ranks (layers) in px. Default 64. */ + rankGap?: number; + /** Gap between sibling nodes in the same rank in px. Default 48. */ + nodeGap?: number; + /** Padding around the bounding box in px. Default 24. */ + padding?: number; +}; +export type DAGLayoutNode = { + id: string; + /** Left edge of the node box. */ + x: number; + /** Top edge of the node box. */ + y: number; + /** Layer index (0 = root). */ + rank: number; + /** Position within the rank (0-indexed). */ + order: number; +}; +export type DAGLayoutEdge = { + from: string; + to: string; + /** Suggested source anchor point (center of the outgoing side). */ + sourceX: number; + sourceY: number; + /** Suggested target anchor point (center of the incoming side). */ + targetX: number; + targetY: number; + /** True when this edge was identified as a back-edge (part of a cycle). */ + isBackEdge: boolean; +}; +export type DAGLayoutRank = { + /** Rank index (0 = root). */ + rank: number; + /** Left edge of the rank bounding box. */ + x: number; + /** Top edge of the rank bounding box. */ + y: number; + /** Width of the rank bounding box. */ + width: number; + /** Height of the rank bounding box. */ + height: number; + /** Node ids in this rank, in order. */ + nodeIds: string[]; +}; +export type DAGLayoutResult = { + nodes: DAGLayoutNode[]; + edges: DAGLayoutEdge[]; + /** Bounding box per rank — useful for drawing layer bands. */ + ranks: DAGLayoutRank[]; + /** The direction used for this layout. */ + direction: "vertical" | "horizontal"; + /** Total width of the bounding box. */ + width: number; + /** Total height of the bounding box. */ + height: number; +}; +/** + * Compute a hierarchical layout for a directed graph. + * + * Returns node positions, edge anchor points, rank bounding boxes, and + * back-edge flags. The caller handles all rendering. + * + * @example + * ```ts + * const layout = computeDAGLayout({ + * nodes: [{ id: "a" }, { id: "b" }, { id: "c" }], + * edges: [{ from: "a", to: "b" }, { from: "b", to: "c" }], + * }); + * + * // layout.nodes[i].x / .y → position your own SVG/HTML elements + * // layout.edges[i].sourceX/Y, targetX/Y → draw lines between them + * // layout.edges[i].isBackEdge → style cycle edges differently + * // layout.ranks[i] → draw layer bands behind each rank + * ``` + */ +export declare function computeDAGLayout(options: DAGLayoutOptions): DAGLayoutResult; +//# sourceMappingURL=dag-layout.d.ts.map \ No newline at end of file diff --git a/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/diff-view.d.ts b/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/diff-view.d.ts new file mode 100644 index 0000000000..8457bcd8a6 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/diff-view.d.ts @@ -0,0 +1,130 @@ +/** + * Low-level diff primitives for the canvas SDK. + * + * The canvas diff surface is intentionally minimal — two components that + * compose with the generic `Card` / `CardHeader` / `CardBody` / `Pill` / + * `Text` primitives to build any diff layout an agent can imagine: + * + * - `DiffView` — a monospaced, syntax-highlighted unified diff renderer. + * Drops into any container (a `Card`, a table cell, a bare layout, + * nothing at all). No card chrome, no header, no path display. Pass + * `path` to auto-detect the language for highlighting, or `language` + * to override. + * + * - `DiffStats` — the canonical `+N` green / `-N` red glyph pair. Use + * it anywhere a small "added/deleted" summary makes sense — in a + * `CardHeader`'s `trailing` slot, next to a filename in a file tree, + * inside a status row, etc. + * + * For file-level metadata the preferred composition is to use the + * generic `Card` family: + * + * ```tsx + * + * }> + * src/utils.ts + * + * + * + * + * + * ``` + */ +import type { CSSProperties, JSX } from "react"; +export type DiffStatsProps = { + additions?: number; + deletions?: number; + style?: CSSProperties; +}; +/** + * Inline `+N` / `-N` glyph pair. Green additions, red deletions, with + * tabular numerals so columns of stats line up. Renders nothing when + * both counts are zero. + * + * @example + * ```tsx + * }> + * src/utils.ts + * + * + * + * Refactor pass + * + * + * ``` + */ +export declare function DiffStats({ additions, deletions, style }: DiffStatsProps): JSX.Element | null; +export type DiffLineType = "added" | "removed" | "unchanged"; +export type DiffLineData = { + type: DiffLineType; + content: string; + lineNumber?: number; +}; +export type DiffViewProps = { + lines: DiffLineData[]; + /** + * File path used to infer the syntax-highlighting language from the + * extension (e.g. `"src/utils.ts"` → `typescript`). The most ergonomic + * way to enable highlighting — pass the same path you show in the + * enclosing card header. Unknown extensions silently render as plain + * text. + * + * If both `path` and `language` are provided, `language` wins. + */ + path?: string; + /** + * Explicit language override for syntax highlighting (e.g. + * `"typescript"`, `"python"`, `"tsx"`). Use this when no file path is + * available, when the path's extension is misleading, or when the + * content is a snippet rather than a real file. Accepts common + * aliases (`ts`, `py`, `rs`, `md`, etc.). Unknown languages silently + * fall back to plain text. + * + * Highlighting is applied per line, so multi-line constructs (block + * comments, template literals) may not colorize perfectly across line + * boundaries. For typical diff-sized inputs this is fine. + */ + language?: string; + /** Show line numbers in the gutter. Default `true`. */ + showLineNumbers?: boolean; + /** Color line numbers green/red for added/removed lines. Default `true`. */ + coloredLineNumbers?: boolean; + /** Show a 3px accent strip on the left edge for changed lines. Default `true`. */ + showAccentStrip?: boolean; + style?: CSSProperties; +}; +/** + * Unified diff body renderer with monospaced type, colored line + * backgrounds, line-number gutter, accent strip, and optional Shiki + * syntax highlighting. + * + * `DiffView` does not provide any surrounding chrome — place it inside + * a `Card` + `CardBody` (with `padding: 0`) when you want the standard + * bordered "file diff" look, or drop it anywhere else if you want the + * bare renderer. + * + * Pass `path` to enable syntax highlighting from the file extension. + * + * @example + * ```tsx + * + * }> + * src/utils.ts + * + * + * + * + * + * ``` + */ +export declare function DiffView({ lines, path, language, showLineNumbers, coloredLineNumbers, showAccentStrip, style }: DiffViewProps): JSX.Element; +//# sourceMappingURL=diff-view.d.ts.map \ No newline at end of file diff --git a/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/form-primitives.d.ts b/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/form-primitives.d.ts new file mode 100644 index 0000000000..7a6be8ebb1 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/bitfun-canvas/sdk/form-primitives.d.ts @@ -0,0 +1,195 @@ +/** + * Form primitives for `bitfun/canvas`. Provides themed, controlled form controls + * for interactive canvas apps with persistent state. All `onChange` callbacks + * receive the **value directly** (not a DOM event), so they pair naturally + * with `useCanvasState`: + * + * ```tsx + * const [name, setName] = useCanvasState("name", ""); + * + * ``` + */ +import { type CSSProperties, type JSX, type ReactNode } from "react"; +export type TextInputProps = { + value?: string; + /** Called with the new string value on every keystroke. */ + onChange?: (value: string) => void; + placeholder?: string; + disabled?: boolean; + type?: "text" | "email" | "password" | "number" | "url" | "search"; + style?: CSSProperties; +}; +/** + * Single-line text input (28px height). Use for names, titles, search + * queries, and short text fields. + * + * `onChange` receives the **string value**, not a DOM event — this pairs + * directly with `useCanvasState` setters. + * + * @example + * ```tsx + * const [name, setName] = useCanvasState("name", ""); + * + * + * ``` + */ +export declare function TextInput({ value, onChange, placeholder, disabled, type, style }: TextInputProps): JSX.Element; +export type TextAreaProps = { + value?: string; + /** Called with the new string value on every keystroke. */ + onChange?: (value: string) => void; + placeholder?: string; + disabled?: boolean; + /** Minimum visible rows. Defaults to 3. */ + rows?: number; + style?: CSSProperties; +}; +/** + * Multi-line text input that auto-resizes to fit its content. + * Use for notes, descriptions, comments, and multi-line text fields. + * + * The textarea grows as the user types. Set `rows` for the minimum visible + * height (defaults to 3). Override with `style={{ height: "100px" }}` for a + * fixed size. + * + * @example + * ```tsx + * const [notes, setNotes] = useCanvasState("notes", ""); + * + *