From 04b8674439cf9a8b2e5e5ebd0b18f075abb595cd Mon Sep 17 00:00:00 2001 From: limityan Date: Tue, 4 Aug 2026 16:04:45 +0800 Subject: [PATCH] perf(server): narrow app-server feature closures Replace product-full with reviewed runtime owner features, preserve paused Server sources behind a source-check profile, and gate workspace startup side effects behind Runtime ownership. --- .github/workflows/ci.yml | 3 + Cargo.lock | 4 +- .../agent-runtime-deployment-design.md | 4 +- docs/architecture/product-architecture.md | 27 +- docs/performance/01-compile-performance.md | 43 +- scripts/check-core-boundaries.test.mjs | 189 +++++++ scripts/check-github-config.test.mjs | 5 + .../cargo-dependency-boundaries.mjs | 472 ++++++++++++------ scripts/core-boundaries/checker.mjs | 6 +- scripts/core-boundaries/self-test.mjs | 16 + src/apps/server/Cargo.toml | 37 +- src/apps/server/README.md | 67 ++- src/apps/server/src/ai_relay.rs | 52 +- src/apps/server/src/app_server.rs | 8 +- src/apps/server/src/bootstrap.rs | 311 ++++++++++-- src/apps/server/src/main.rs | 98 +--- src/apps/server/src/routes/dispatch.rs | 16 +- .../server/src/routes/external_sources.rs | 8 +- src/apps/server/src/routes/mod.rs | 9 +- src/apps/server/src/routes/websocket.rs | 4 +- src/apps/server/src/routes/ws_transport.rs | 85 +++- src/apps/server/src/rpc_dispatcher.rs | 15 +- .../src/agentic/coordination/coordinator.rs | 323 ++++++++++-- .../assembly/core/src/runtime_ownership.rs | 270 +++++++++- .../core/src/service/workspace/service.rs | 155 +++++- src/crates/interfaces/app-server/AGENTS-CN.md | 14 +- src/crates/interfaces/app-server/AGENTS.md | 18 +- src/crates/interfaces/app-server/Cargo.toml | 12 +- src/crates/interfaces/app-server/src/lib.rs | 15 +- .../interfaces/app-server/src/schema.rs | 11 +- .../interfaces/app-server/src/server.rs | 144 ++++-- .../services-core/src/runtime_ownership.rs | 91 +++- .../tests/runtime_ownership_contracts.rs | 31 ++ .../dispatch/dispatch.contract.test.ts | 2 +- 34 files changed, 2030 insertions(+), 535 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 028bbca96f..777cf8f1a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,6 +225,9 @@ jobs: # blocking unrelated work. - name: Run file watch contract tests if: runner.os != 'macOS' + # Fail with bounded diagnostics instead of occupying a runner for the + # workflow-wide timeout if a native file-watch operation stalls. + timeout-minutes: 5 run: cargo test --locked -p bitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts # Search tools resolve paths and symlinks directly, which also differs diff --git a/Cargo.lock b/Cargo.lock index e8b458d129..7a6ea0062a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1425,13 +1425,11 @@ dependencies = [ "bitfun-agent-runtime", "bitfun-app-server", "bitfun-core", - "bitfun-events", "chrono", "clap", - "dirs 6.0.0", - "futures", "futures-util", "log", + "reqwest", "serde", "serde_json", "tokio", diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index ea34089258..73fcaa1f3a 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -40,7 +40,7 @@ flowchart TB | ACP/SDK Host | 使用同一个 Runtime 事件入口的 session-scoped 订阅;各自协议和进程生命周期保持独立 | | Runtime ownership | Desktop、CLI、ACP、SDK Host 和现有 Server agent bootstrap 共用 Core owner;Embedded 取得共享锁,Shared TUI 取得独占锁,同一 workspace 上两种 deployment 互斥 | | Session 写入 | BitFun Runtime 的持久化 Session 由 `SessionManager` 管理;同一存储位置中的同一 Session 同时只允许一个本机进程写入,list/view 等只读操作不受影响 | -| 当前 HTTP Server | 只提供 health/info/WebSocket 外壳,未装配 Agent Runtime,因此不取得 workspace ownership;`bootstrap.rs` 仅保持 agent-enabled composition 的一致边界,不由当前入口启动 | +| 当前 HTTP Server | 产品演进暂停;当前本机入口通过 App Server 装配 Embedded Agent Runtime。显式 workspace 失败即停止;隐式历史只作建议并可降级到默认 Assistant workspace。workspace 准备与后台任务均晚于 ownership,远程执行仍不可用;这些边界不表示生产 Web 能力已完整交付 | | Shared local IPC | 未发布的本机协议已有 discovery、实例锁、严格握手、Session 控制权、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI adapter | | Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,删除未被控制的空闲非当前 Session,通过 `/fork` 从完整历史或选中提示词之前创建分支,重命名当前 Session,读取 transcript,通过 **View subagents** 只读查看当前根 Session 的子会话并定向取消子会话活动 Turn,切换当前 Session 的 Agent mode/model,通过 `/reload [skills|instructions]` 刷新声明式上下文,通过 `/compact` 或 `/summarize` 压缩当前 Session 上下文,在 Turn 空闲时通过 `/diff` 读取 Runtime 绑定工作区的只读差异,提交/取消 Turn,处理 Permission 和 UserInput;默认仍是 Embedded | | Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;Replay、Observer、通用 Controller transfer 和 Session archive 同样不在当前协议中 | @@ -130,7 +130,7 @@ flowchart TD | Desktop 打开多个 workspace | 首次 attach/write 时逐个取得并持有文件锁 | 不把窗口数、Session 数等同于 Runtime 进程数 | | 只读 list/view | 不加锁 | ownership 只管理 Runtime deployment,不扩大成读取权限 | | 已解析且带有效 `connection_id` 的 remote workspace | 本机不加锁 | 与 Session storage 的远端判据一致;`host` 提示本身不能绕过本地锁 | -| 当前只读 HTTP Server | 不创建 Core owner | 没有 Agent Runtime 就没有 ownership 可声明 | +| 暂停演进的 HTTP Server | 创建 Core owner;显式 workspace 经 ownership-aware open,历史 metadata 在 ownership 前不触发 workspace 准备 | 显式失败终止启动;历史恢复失败告警并通过同一门禁降级默认 Assistant workspace,默认也失败时终止;该安全门槛不等于生产完整度 | `CoreRuntimeOwnership` 只选择 deployment、产品 identity 并在进程存活期间持有锁;`services-core` 只负责 canonical key 和跨进程锁。二者都不选择 workspace、不启动 Runtime,也不替代 Session 单写、数据库事务、文件冲突控制或安全沙箱。 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 82d3690fcb..83c4083a9f 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -304,7 +304,7 @@ flowchart LR ### 2.4 Physical View · Level 0 -Physical View 展示当前可执行单元到设备、主机和存储的映射。Desktop、CLI、ACP 和 SDK Host 使用 Embedded Runtime;交互式 TUI 可以显式连接 Shared Runtime。当前 Web Server 和 Relay Server 都不承载 Agent Runtime。 +Physical View 展示当前可执行单元到设备、主机和存储的映射。Desktop、CLI、ACP 和 SDK Host 使用 Embedded Runtime;交互式 TUI 可以显式连接 Shared Runtime。暂停演进的 Web Server 当前也通过 App Server 装配 Embedded Runtime,但实现尚不完整,不能据此声明生产 Web 能力;Relay Server 不承载 Agent Runtime。 ```mermaid flowchart LR @@ -312,7 +312,7 @@ flowchart LR direction TB subgraph EmbeddedNodes["Embedded"] direction LR - DesktopApp["Desktop App"] ~~~ CLIApp["CLI App"] ~~~ ACPApp["ACP"] ~~~ SDKHost["SDK Host"] + DesktopApp["Desktop App"] ~~~ CLIApp["CLI App"] ~~~ ACPApp["ACP"] ~~~ SDKHost["SDK Host"] ~~~ PausedWebServer["Paused Web Server"] end SharedRuntime["Shared Runtime"] WorkspaceData["Workspace Data"] @@ -325,8 +325,6 @@ flowchart LR MobileClient["Mobile Client"] end - WebServer["Web Server"] - subgraph RelayHost["Relay Node"] direction TB RelayServer["Relay Server"] @@ -337,7 +335,7 @@ flowchart LR AIProviders["AI Providers"] RemoteHosts["Remote Hosts"] - WebClient -->|WebSocket| WebServer + WebClient -->|WebSocket| PausedWebServer MobileClient -->|HTTPS| RelayServer DesktopApp <-->|WebSocket| RelayServer CLIApp <-->|WebSocket| RelayServer @@ -353,7 +351,7 @@ flowchart LR DesktopApp -->|SSH| RemoteHosts classDef unit fill:#ffffff,stroke:#737373,stroke-width:1.3px,color:#171717; - class DesktopApp,CLIApp,ACPApp,SDKHost,SharedRuntime,WorkspaceData,ToolProcesses,WebClient,MobileClient,WebServer,RelayServer,RelayDB,AssetStore,AIProviders,RemoteHosts unit; + class DesktopApp,CLIApp,ACPApp,SDKHost,PausedWebServer,SharedRuntime,WorkspaceData,ToolProcesses,WebClient,MobileClient,RelayServer,RelayDB,AssetStore,AIProviders,RemoteHosts unit; style LocalHost fill:#ffffff,stroke:#737373; style EmbeddedNodes fill:#ffffff,stroke:#a3a3a3; style UserDevice fill:#ffffff,stroke:#a3a3a3; @@ -369,7 +367,7 @@ flowchart LR | Shared Runtime | 私有本机 IPC;当前只有交互式 TUI consumer | | ACP | Embedded Agent Runtime、ACP 协议生命周期 | | SDK Host | 私有跨进程 adapter;公开 SDK 产品尚未交付 | -| Web Server | Health、Info、WebSocket 外壳;不包含 Agent Runtime | +| Paused Web Server | 当前提供 Health、Info、WebSocket 与 App Server,并装配 Embedded Agent Runtime;产品演进暂停,现有实现不代表生产完整度 | | Relay Server | WebSocket/HTTP bridge、账户与同步;不包含 Agent Runtime | ### 2.5 Scenarios (+1) · Level 0 @@ -727,15 +725,23 @@ flowchart TB CLI["CLI / TUI"] --> CliClosure["Core owner feature closure"] ACP["ACP"] --> Parts["Runtime Parts"] SDKHost["SDK Host"] --> Parts - ServerBootstrap["Server agent bootstrap · dormant"] --> Full + ServerBootstrap["Paused Web Server current host"] --> ServerClosure["agent-runtime + ssh-remote"] Full --> Coordinator["ConversationCoordinator"] CliClosure --> Coordinator Parts --> Coordinator + ServerClosure --> Coordinator Ownership["CoreRuntimeOwnership"] -. "first-party composition injects once" .-> Coordinator ``` -当前公开 HTTP Server 不调用 agent bootstrap,因此不创建 Runtime 或 workspace ownership;图中的 Server 节点只记录已有 agent-enabled composition 边界,不能据此宣称 Server Agent API 已交付。 +暂停演进的 HTTP Server 当前会调用 agent bootstrap 并装配 Embedded Runtime。启动边界如下: + +- 显式 `--workspace` 必须是已存在且可规范化(canonicalize)的绝对本机路径。它是权威操作员输入;参数校验、打开或 ownership 失败均终止启动,不允许静默切换目录。 +- 无显式参数时只加载历史 metadata,不在 ownership 前准备 workspace。本机历史打开失败会告警,并通过同一门禁降级到默认 Assistant workspace;默认目录的首次创建也位于可回滚 ownership claim 内,默认 workspace 失败时才终止启动。 +- 当前 Host 没有 SSH manager,因此不尝试恢复 Remote 历史。新默认目录不存在时会原位使用并正确分类旧 Assistant 默认目录;目录迁移仍由正常产品启动流程持有。 +- Server 选择 `ssh-remote` 并注入 `RemoteExecPort`,但没有装配全局 remote-workspace/SSH manager,远程执行仍返回不可用。 + +这些是当前实现缺口,不表示产品已弃用,也不表示生产 Server、Remote Connect 或 Desktop 完整能力已经交付。恢复 Web Server 演进时必须通过 App Server 和现有 owner 补齐这些边界,不能恢复平行的 WebSocket command path。 当前 Peer 运行连接: @@ -756,7 +762,8 @@ flowchart LR | Desktop | 使用 `product-full`;显示外部来源、审批、冲突、诊断和 Host 能力 | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | | CLI / TUI | 使用显式 Core owner feature closure(`agent-runtime`、`canvas-runtime`、`external-sources`、`plugin-runtime`、`ssh-remote`);提供 `/extensions`、统一 `/hooks`(旧 `/hooks_external` 为别名)、`/tools` 和 `/agents`;Claude Code/Codex 命令 Hook 可经显式审阅复制为原生层 | 保持现有 CLI capability plan,但不自动继承 Desktop 后续加入 `product-full` 的能力;生态解析仍在适配器,不启动第二套 Agent Runtime;OpenCode Hook 仍只静态发现;远程能力未接入时不回退本机 | | ACP | 使用 `DeliveryProfile::Acp`、Runtime Parts,以及 `agent-runtime`/`canvas-runtime`/`external-sources`/`ssh-remote` Core owner feature | load 成功后才发布活动状态;close 排空后再卸载;完整历史、Canvas 工具物化、兼容指令来源和配置仍由 Core/ACP 管理 | -| Peer / Server | Server 提供 control/catalog;Peer Host 执行真实工作区操作;当前 HTTP Server 不装配 Agent Runtime | 控制端不替远端发现或执行;旧 Host 明确降级,SSH Remote 未接入时返回不支持;只读 Server 不声明 Runtime ownership | +| Peer | Peer Host 执行真实工作区操作 | 控制端不替远端发现或执行;SSH Remote 未接入时返回不支持 | +| Paused HTTP Server | 当前装配 Embedded Runtime 和 App Server;Core feature closure 为 `agent-runtime,ssh-remote` | 产品演进暂停;显式本机 workspace 失败即终止,历史 metadata 不在 ownership 前准备;本机历史恢复失败或 Remote 历史不可执行时降级默认 Assistant workspace;SSH manager 未装配,不能声明远程执行能力或产品完整度 | | Web / Mobile Web | 依赖现有后端入口 | 不持有插件执行单元,也不能据空 profile 宣称独立能力 | | HarmonyOS 手机 Remote | phone-only ArkTS 远程入口 | 不等于 HarmonyOS PC 本地 Runtime、CLI/TUI 或 GUI | diff --git a/docs/performance/01-compile-performance.md b/docs/performance/01-compile-performance.md index b5e036b186..087dd941d2 100644 --- a/docs/performance/01-compile-performance.md +++ b/docs/performance/01-compile-performance.md @@ -2,7 +2,7 @@ > 最近核实:2026-08-04 > -> 快照基线:`gcwing/main@061024fb2` 加权限规划 owner 迁移 +> 快照基线:`gcwing/main@7f9bcf3a8` > > 稳定规则:[Rust 构建与依赖边界](../architecture/rust-build-dependency-boundaries.md) @@ -13,10 +13,10 @@ | 结论 | 说明 | |---|---| -| 本轮收益是测试隔离,不是产品构建瘦身 | 权限纯策略测试从 Core 约 449 节点的闭包迁到 Agent Runtime 约 78 节点的闭包;产品依赖图不变 | +| App Server / Server 已退出 `product-full` | normal/build 闭包分别从 545 降到 421、从 565 降到 497;这是产品入口构建图收敛,不据此宣称固定耗时收益 | | 不再用 `product-full` 解决 focused test | Core 权限编排测试当前最小闭包是 `agent-runtime,canvas-runtime`;纯策略直接在 Agent Runtime 验证 | | 不新增 CI 或测试入口 | 继续使用现有 test target 和 CI job;治理 PR 不复制同一闭包的验证 | -| 下一优先级是 App Server / Server | 先核实真实生产调用链,再收敛其 Core `product-full` 边界;收益不足则停止 | +| Server 保留 SSH 接口边界 | App Server 只选 `agent-runtime`;Server 为 bootstrap 注入的 `RemoteExecPort` 额外选择 `ssh-remote`。当前验证 host 未装配 SSH manager,不能据此宣称远程执行可用 | | 依赖多版本不能按数量批量清理 | 只处理仓库能控制、行为等价且能缩小真实构建图的版本路径 | 权限 owner 的长期边界和功能不变量见 @@ -55,17 +55,20 @@ | Core `agent-runtime` check | 约 391 个节点 | 窄 owner feature 可独立编译 | | Core 权限编排测试 | `agent-runtime,canvas-runtime`,约 449 个节点 | 保留真实 scope、Hook、请求生命周期和 Tool 执行 | | Core `product-full` test | 约 516 个节点 | 仅用于确实需要完整产品装配的兼容路径 | +| App Server normal/build | 545 → 421 个节点 | 精确选择 `agent-runtime`,减少 124 个节点(约 22.8%) | +| Paused Web Server normal/build | 565 → 497 个节点 | 默认入口选择 `agent-runtime,ssh-remote`,减少 68 个节点(约 12.0%);非默认 source-check 为 544 个节点,不进入默认模块图 | | Agent Runtime integration target | 5 个显式 target | 已完成收敛;平台和进程边界继续独立 | 节点数来自同一 Windows 环境下的 `cargo tree --locked` 相对统计,不是实际耗时,也不是跨平台阈值。 -权限纯策略路径理论上少进入约 371 个节点;产品构建闭包没有变化。 +权限纯策略路径理论上少进入约 371 个节点。App Server / Server 数字包含入口自身,使用同一 +Windows 环境和 normal/build edge 口径;没有混入全 workspace 的 feature union。 ### 3.2 依赖与 feature | 状态 | 范围 | 处理结论 | |---|---|---| | 已稳定 | 根 `Cargo.lock`、Reqwest Rustls 单栈、Desktop 直接 `image 0.25`、workspace Tokio 最小基线 | 不重复治理 | -| 下一步核实 | App Server / Server 的 Core `product-full` | 按生产 construction path 收敛,不先写 feature 清单 | +| 已完成 | App Server / Server 的 Core `product-full` | App Server 固定 `agent-runtime`;Server 固定 `agent-runtime,ssh-remote`;边界检查防止回退 | | 可独立治理 | Installer 的 Reqwest 0.12、独立 lockfile、疑似无消费者的 `tokio/full` | 保持 Installer 独立 workspace,不顺手合并 | | 等待上游 | `screenshots 0.8.10 -> image 0.24.9` | 只有受维护且行为等价的上游替代出现后再处理 | | 明确保留 | `portable-pty 0.8/0.9` | 非 OHOS 与 OHOS 的平台兼容选择,不为去重破坏 | @@ -100,25 +103,17 @@ ## 5. 后续顺序 -### R1:收敛 App Server / Server 的 `product-full` 边界 - -这是下一 PR 的推荐范围,也是唯一需要优先设计的核心入口改造。 - -先回答: - -- App Server 与 Server 的真实 construction、command 和 schema 路径分别消费哪些 Core owner? -- Server 对未实现能力应保持什么 typed unsupported 行为? -- 哪些能力由 Server 直接消费,哪些只是经 App Server 间接带入? - -实现边界: - -- 只替换一个端到端 capability slice 的依赖路径,不一次迁移全部 Core 调用; -- 优先显式选择已有 owner feature,或消费现有 Runtime SDK/service port; -- 不复制 Session、Tool、Permission、Hook、Event 状态,不建立第二 Runtime; -- 未迁移能力保留现有兼容路径或明确 unsupported,禁止静默本机回退。 - -验收必须覆盖 Server WebSocket/App Server round-trip、权限、取消、事件与恢复语义,并对比 App Server、 -Server 的 normal/build/test closure。若构建图收益不足或行为等价无法证明,则不删除兼容边界。 +### R1:App Server / Server `product-full` 边界(已完成) + +- 真实入口是 Server bootstrap 构造一套 Embedded Agent Runtime,再由 `/ws` 直接 serve App Server; +- App Server 的当前 schema 只消费 Runtime 以及 git/config/i18n 路径,因此固定为 `agent-runtime`; +- Server 的 bootstrap 还注入 `RemoteExecPort`,因此显式保留 `ssh-remote`;当前未装配 SSH manager, + 远程执行仍明确不可用; +- 未注册的 dispatch/external-source 旧模块及其第二套 SSH 状态退出默认编译图;非默认 source-check + feature 只保护暂停源码的编译与既有单测。external-source 请求继续返回 typed `host_capability_unavailable`, + 没有静默本机回退; +- 默认解析闭包没有新增激活依赖;4 个 optional dependency 只服务于 source-check。没有新增 test target + 或 CI job,也没有复制 Runtime owner。 ### 后续队列 diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index f22dfae6bc..e9c7494428 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -416,6 +416,195 @@ test('explicit product entrypoint bitfun-core feature selections pass', () => { ); }); +test('Server package features must remain limited to the reviewed source-health profile', () => { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const sourceCheckMembers = [ + 'bitfun-core/dispatch-store', + 'bitfun-core/external-sources', + 'dep:base64', + 'dep:chrono', + 'dep:reqwest', + 'dep:serde_json', + ]; + const serverWithFeatures = (features) => ({ + ...packageAt('bitfun-server', 'src/apps/server/Cargo.toml', [ + pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: ['agent-runtime', 'ssh-remote'], + }), + ]), + features, + }); + const violationsFor = (features) => findProductEntrypointCoreFeatureViolations( + [serverWithFeatures(features), core], + { root: TEST_ROOT, crateLayoutRules }, + ); + const reviewedFeatures = { + default: [], + 'paused-web-server-source-check': sourceCheckMembers, + }; + + assert.deepEqual(violationsFor(reviewedFeatures), []); + assert.match( + violationsFor({ ...reviewedFeatures, accidental_profile: ['dep:private-toggle'] })[0].message, + /unreviewed package feature.*accidental_profile/, + ); + assert.match( + violationsFor({ ...reviewedFeatures, default: ['paused-web-server-source-check'] })[0].message, + /default features must remain empty/, + ); + assert.match( + violationsFor({ + ...reviewedFeatures, + 'paused-web-server-source-check': sourceCheckMembers.slice(1), + })[0].message, + /exact reviewed source-health closure/, + ); + assert.match( + violationsFor({ + ...reviewedFeatures, + 'paused-web-server-source-check': [...sourceCheckMembers, 'bitfun-core/product-full'], + })[0].message, + /exact reviewed source-health closure/, + ); +}); + +test('Server default closure rejects source-check Core owners hidden behind an unconditional dependency', () => { + const core = { + ...packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'), + features: { + 'agent-runtime': [], + 'ssh-remote': [], + 'dispatch-store': [], + 'external-sources': [], + }, + }; + const bridge = packageAt('server-bridge', 'src/crates/assembly/server-bridge/Cargo.toml', [ + pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: ['external-sources'], + }), + ]); + const server = { + ...packageAt('bitfun-server', 'src/apps/server/Cargo.toml', [ + pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: ['agent-runtime', 'ssh-remote'], + }), + pathDependency('src/crates/assembly/server-bridge', { name: 'server-bridge' }), + ]), + features: { + default: [], + 'paused-web-server-source-check': [ + 'bitfun-core/dispatch-store', + 'bitfun-core/external-sources', + 'dep:base64', + 'dep:chrono', + 'dep:reqwest', + 'dep:serde_json', + ], + }, + }; + + const violations = findProductEntrypointCoreFeatureViolations( + [server, bridge, core], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.ok(violations.some((violation) => + /Server default dependency closure.*server-bridge.*bitfun-core\/external-sources/ + .test(violation.message))); +}); + +test('App Server package features keep default empty and ts exact', () => { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const appServerWithFeatures = (features) => ({ + ...packageAt('bitfun-app-server', 'src/crates/interfaces/app-server/Cargo.toml', [ + pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: ['agent-runtime'], + }), + ]), + features, + }); + const violationsFor = (features) => findProductEntrypointCoreFeatureViolations( + [appServerWithFeatures(features), core], + { root: TEST_ROOT, crateLayoutRules }, + ); + const reviewedFeatures = { + default: [], + ts: ['dep:ts-rs', 'bitfun-core/ts'], + }; + + assert.deepEqual(violationsFor(reviewedFeatures), []); + assert.match( + violationsFor({ ...reviewedFeatures, accidental_profile: ['bitfun-core/product-full'] })[0] + .message, + /unreviewed package feature.*accidental_profile/, + ); + assert.match( + violationsFor({ ...reviewedFeatures, default: ['ts'] })[0].message, + /default features must remain empty/, + ); + assert.match( + violationsFor({ ...reviewedFeatures, ts: ['bitfun-core/ts'] })[0].message, + /exact reviewed TypeScript closure/, + ); + assert.match( + violationsFor({ + ...reviewedFeatures, + ts: [...reviewedFeatures.ts, 'bitfun-core/product-full'], + })[0].message, + /exact reviewed TypeScript closure/, + ); +}); + +test('App Server default closure rejects ts hidden behind an unconditional dependency', () => { + const core = { + ...packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'), + features: { + 'agent-runtime': [], + ts: [], + }, + }; + const bridge = packageAt('app-server-bridge', 'src/crates/interfaces/app-server-bridge/Cargo.toml', [ + pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: ['ts'], + }), + ]); + const appServer = { + ...packageAt('bitfun-app-server', 'src/crates/interfaces/app-server/Cargo.toml', [ + pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: ['agent-runtime'], + }), + pathDependency('src/crates/interfaces/app-server-bridge', { + name: 'app-server-bridge', + }), + ]), + features: { + default: [], + ts: ['dep:ts-rs', 'bitfun-core/ts'], + }, + }; + + const violations = findProductEntrypointCoreFeatureViolations( + [appServer, bridge, core], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.ok(violations.some((violation) => + /App Server default dependency closure.*app-server-bridge.*bitfun-core\/ts/ + .test(violation.message))); +}); + test('ACP Core capability closure must retain its Canvas owner', () => { const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); const acp = packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml', [ diff --git a/scripts/check-github-config.test.mjs b/scripts/check-github-config.test.mjs index 15fc7cd0d1..f4d262f896 100644 --- a/scripts/check-github-config.test.mjs +++ b/scripts/check-github-config.test.mjs @@ -262,6 +262,11 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => commandByStep.get('Run file watch contract tests'), 'cargo test --locked -p bitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts', ); + assert.equal( + rustJob.steps.find((step) => step.name === 'Run file watch contract tests') + ?.['timeout-minutes'], + 5, + ); assert.equal( commandByStep.get('Run search tool tests'), 'cargo test --locked -p tool-runtime --lib search::', diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 2287d11cd2..6101854940 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -22,6 +22,24 @@ const ALLOWED_TARGET_LAYERS = new Map([ ['contracts', new Set(['contracts'])], ]); +export const reviewedCoreFeatureClosures = new Map([ + ['bitfun-cli', [ + 'agent-runtime', + 'canvas-runtime', + 'external-sources', + 'plugin-runtime', + 'ssh-remote', + ]], + ['bitfun-acp', [ + 'agent-runtime', + 'canvas-runtime', + 'external-sources', + 'ssh-remote', + ]], + ['bitfun-app-server', ['agent-runtime']], + ['bitfun-server', ['agent-runtime', 'ssh-remote']], +]); + function normalizedPath(path) { const normalized = resolve(path).replace(/\\/g, '/'); return process.platform === 'win32' ? normalized.toLowerCase() : normalized; @@ -258,6 +276,8 @@ const REQWEST_PACKAGE_PROFILES = new Map([ allowedPackageFeatureRefs: new Set(['reqwest/rustls-tls']), }], ['bitfun-core', { dependencyFeatures: REQWEST_TRANSPORT_FEATURES, optional: true }], + // Used only by bitfun-server's non-default paused source-health profile. + ['bitfun-server', { dependencyFeatures: REQWEST_TRANSPORT_FEATURES, optional: true }], ['bitfun-services-integrations', { dependencyFeatures: REQWEST_TRANSPORT_FEATURES, optional: true, @@ -770,27 +790,20 @@ export function findProductEntrypointCoreFeatureViolations( packages, { root, crateLayoutRules }, ) { - const reviewedCoreFeatureClosures = new Map([ - ['bitfun-cli', [ - 'agent-runtime', - 'canvas-runtime', - 'external-sources', - 'plugin-runtime', - 'ssh-remote', - ]], - ['bitfun-acp', [ - 'agent-runtime', - 'canvas-runtime', - 'external-sources', - 'ssh-remote', - ]], - ]); - const acpActiveCoreFeatures = [ + const appServerTsFeature = 'ts'; + const expectedAppServerTsMembers = ['dep:ts-rs', 'bitfun-core/ts']; + const serverSourceCheckFeature = 'paused-web-server-source-check'; + const expectedServerSourceCheckMembers = [ + 'bitfun-core/dispatch-store', + 'bitfun-core/external-sources', + 'dep:base64', + 'dep:chrono', + 'dep:reqwest', + 'dep:serde_json', + ]; + const agentRuntimeActiveCoreFeatures = [ 'agent-runtime', 'ai-adapter-runtime', - 'canvas-runtime', - 'external-sources', - 'file-watch', 'filesystem', 'git', 'lsp', @@ -802,21 +815,119 @@ export function findProductEntrypointCoreFeatureViolations( 'remote-workspace', 'review-platform', 'runtime-services', - 'ssh-remote', 'terminal', 'tool-packs', 'workspace-runtime', + ]; + const acpActiveCoreFeatures = [ + ...agentRuntimeActiveCoreFeatures, + 'canvas-runtime', + 'external-sources', + 'file-watch', + 'ssh-remote', 'workspace-watch', ]; const reviewedActiveCoreFeatureClosures = new Map([ ['bitfun-cli', [...acpActiveCoreFeatures, 'plugin-runtime']], ['bitfun-acp', acpActiveCoreFeatures], + ['bitfun-app-server', [...agentRuntimeActiveCoreFeatures, 'ts']], + ['bitfun-server', [ + ...agentRuntimeActiveCoreFeatures, + 'dispatch-store', + 'external-sources', + 'file-watch', + 'ssh-remote', + 'workspace-watch', + ]], ]); const packageByManifest = new Map( packages.map((pkg) => [normalizedPath(pkg.manifest_path), pkg]), ); const violations = []; + const appServerPackage = packages.find((pkg) => pkg.name === 'bitfun-app-server'); + if (appServerPackage) { + const reviewedFeatureNames = new Set(['default', appServerTsFeature]); + const unexpectedFeatureNames = Object.keys(appServerPackage.features ?? {}) + .filter((featureName) => !reviewedFeatureNames.has(featureName)) + .sort(); + if (unexpectedFeatureNames.length > 0) { + violations.push({ + path: appServerPackage.manifest_path, + line: 1, + message: `bitfun-app-server has unreviewed package feature(s): ${unexpectedFeatureNames.join(', ')}`, + }); + } + const defaultFeatures = appServerPackage.features?.default ?? []; + if (defaultFeatures.length !== 0) { + violations.push({ + path: appServerPackage.manifest_path, + line: 1, + message: 'bitfun-app-server default features must remain empty', + }); + } + const actualMembers = new Set( + appServerPackage.features?.[appServerTsFeature] ?? [], + ); + const expectedMembers = new Set(expectedAppServerTsMembers); + const missing = expectedAppServerTsMembers.filter( + (member) => !actualMembers.has(member), + ); + const unexpected = [...actualMembers].filter( + (member) => !expectedMembers.has(member), + ); + if (missing.length > 0 || unexpected.length > 0) { + violations.push({ + path: appServerPackage.manifest_path, + line: 1, + message: `${appServerTsFeature} must keep its exact reviewed TypeScript closure`, + }); + } + } + + const serverPackage = packages.find((pkg) => pkg.name === 'bitfun-server'); + if (serverPackage) { + const reviewedFeatureNames = new Set([ + 'default', + serverSourceCheckFeature, + ]); + const unexpectedFeatureNames = Object.keys(serverPackage.features ?? {}) + .filter((featureName) => !reviewedFeatureNames.has(featureName)) + .sort(); + if (unexpectedFeatureNames.length > 0) { + violations.push({ + path: serverPackage.manifest_path, + line: 1, + message: `bitfun-server has unreviewed package feature(s): ${unexpectedFeatureNames.join(', ')}`, + }); + } + const defaultFeatures = serverPackage.features?.default ?? []; + if (defaultFeatures.length !== 0) { + violations.push({ + path: serverPackage.manifest_path, + line: 1, + message: 'bitfun-server default features must remain empty', + }); + } + const actualMembers = new Set( + serverPackage.features?.[serverSourceCheckFeature] ?? [], + ); + const expectedMembers = new Set(expectedServerSourceCheckMembers); + const missing = expectedServerSourceCheckMembers.filter( + (member) => !actualMembers.has(member), + ); + const unexpected = [...actualMembers].filter( + (member) => !expectedMembers.has(member), + ); + if (missing.length > 0 || unexpected.length > 0) { + violations.push({ + path: serverPackage.manifest_path, + line: 1, + message: `${serverSourceCheckFeature} must keep its exact reviewed source-health closure`, + }); + } + } + for (const sourcePackage of packages) { const sourceLayer = layerForManifest(sourcePackage.manifest_path, { root, @@ -881,158 +992,213 @@ export function findProductEntrypointCoreFeatureViolations( 'product-full', 'announcement', 'debug-log', - 'dispatch-store', ]; const reportedUnexpectedFeatures = new Set(); - for (const [rootName, reviewedClosure] of reviewedCoreFeatureClosures) { + for (const [rootName] of reviewedCoreFeatureClosures) { const rootPackage = packages.find((pkg) => pkg.name === rootName); if (!rootPackage) { continue; } - const allowedCoreFeatures = new Set( - reviewedActiveCoreFeatureClosures.get(rootName) ?? [], - ); - const rootSelectedFeatures = Object.keys(rootPackage.features ?? {}) + const rootLabel = new Map([ + ['bitfun-cli', 'CLI'], + ['bitfun-acp', 'ACP'], + ['bitfun-app-server', 'App Server'], + ['bitfun-server', 'Server'], + ]).get(rootName) ?? rootName; + + const strongestSelectedFeatures = Object.keys(rootPackage.features ?? {}) .filter((feature) => feature !== 'default'); - const rootLabel = rootName === 'bitfun-cli' ? 'CLI' : 'ACP'; - - const packageStates = new Map(); - const pending = []; - const queued = new Set(); - - const mergePackageState = ( - pkg, - dependencyKindContext, - selectedFeatures, - useDefaultFeatures, - packagePath, - ) => { - const key = [ - normalizedPath(pkg.manifest_path), - dependencyKindContext, - ].join('|'); - let state = packageStates.get(key); - if (!state) { - state = { - pkg, - dependencyKindContext, - selectedFeatures: new Set(), - useDefaultFeatures: false, - featureState: { active: new Set(), references: new Set() }, - packagePath, - initialized: false, - }; - packageStates.set(key, state); - } + let profiles; + if (rootName === 'bitfun-server') { + profiles = [ + { + label: ' default', + selectedFeatures: [], + allowedCoreFeatures: new Set([ + ...agentRuntimeActiveCoreFeatures, + 'ssh-remote', + ]), + }, + { + label: ` ${serverSourceCheckFeature}`, + selectedFeatures: [serverSourceCheckFeature], + allowedCoreFeatures: new Set( + reviewedActiveCoreFeatureClosures.get(rootName) ?? [], + ), + }, + ]; + } else if (rootName === 'bitfun-app-server') { + profiles = [ + { + label: ' default', + selectedFeatures: [], + allowedCoreFeatures: new Set(agentRuntimeActiveCoreFeatures), + }, + { + label: ` ${appServerTsFeature}`, + selectedFeatures: [appServerTsFeature], + allowedCoreFeatures: new Set( + reviewedActiveCoreFeatureClosures.get(rootName) ?? [], + ), + }, + ]; + } else { + profiles = [{ + label: '', + selectedFeatures: strongestSelectedFeatures, + allowedCoreFeatures: new Set( + reviewedActiveCoreFeatureClosures.get(rootName) ?? [], + ), + }]; + } - let changed = false; - for (const feature of selectedFeatures) { - if (!state.selectedFeatures.has(feature)) { - state.selectedFeatures.add(feature); - changed = true; - } - } - if (useDefaultFeatures && !state.useDefaultFeatures) { - state.useDefaultFeatures = true; - changed = true; - } - if (!changed && state.initialized) { - return state; - } + for (const profile of profiles) { + const { + label: profileLabel, + selectedFeatures: rootSelectedFeatures, + allowedCoreFeatures, + } = profile; - state.featureState = expandedLocalFeatures( - pkg.features ?? {}, - state.selectedFeatures, - state.useDefaultFeatures, - ); - state.initialized = true; - if (!queued.has(key)) { - pending.push(key); - queued.add(key); - } - return state; - }; - - // This is an architecture declaration check, not a target simulator. - // Cargo target cfg facts are multi-valued and evolve with rustc. Treating - // every declared target edge as reachable prevents a platform-only path - // from hiding an unreviewed Core owner. Products that genuinely need - // different owners must express that difference through package/module - // boundaries. Cargo features are additive, so all root features form the - // strongest buildable profile. - mergePackageState( - rootPackage, - 'normal', - rootSelectedFeatures, - true, - [rootPackage.name], - ); + const packageStates = new Map(); + const pending = []; + const queued = new Set(); - while (pending.length > 0) { - const stateKey = pending.shift(); - queued.delete(stateKey); - const { - pkg: sourcePackage, + const mergePackageState = ( + pkg, dependencyKindContext, - featureState, + selectedFeatures, + useDefaultFeatures, packagePath, - } = packageStates.get(stateKey); - - for (const dependency of sourcePackage.dependencies ?? []) { - const kind = dependency.kind ?? 'normal'; - if ( - !dependency.path - || (kind !== 'normal' && kind !== 'build') - || repositoryPath(root, dependency.path) === null - ) { - continue; + ) => { + const key = [ + normalizedPath(pkg.manifest_path), + dependencyKindContext, + ].join('|'); + let state = packageStates.get(key); + if (!state) { + state = { + pkg, + dependencyKindContext, + selectedFeatures: new Set(), + useDefaultFeatures: false, + featureState: { active: new Set(), references: new Set() }, + packagePath, + initialized: false, + }; + packageStates.set(key, state); } - const activation = dependencyActivation(dependency, featureState); - if (!activation) { - continue; + + let changed = false; + for (const feature of selectedFeatures) { + if (!state.selectedFeatures.has(feature)) { + state.selectedFeatures.add(feature); + changed = true; + } } - const targetPackage = packageByManifest.get( - normalizedPath(join(dependency.path, 'Cargo.toml')), - ); - if (!targetPackage) { - continue; + if (useDefaultFeatures && !state.useDefaultFeatures) { + state.useDefaultFeatures = true; + changed = true; + } + if (!changed && state.initialized) { + return state; } - const targetDependencyKindContext = - dependencyKindContext === 'build' - || kind === 'build' - || isProcMacroPackage(targetPackage) - ? 'build' - : 'normal'; - const targetPath = [...packagePath, targetPackage.name]; - const targetState = mergePackageState( - targetPackage, - targetDependencyKindContext, - activation.features, - activation.useDefaultFeatures, - targetPath, + + state.featureState = expandedLocalFeatures( + pkg.features ?? {}, + state.selectedFeatures, + state.useDefaultFeatures, ); + state.initialized = true; + if (!queued.has(key)) { + pending.push(key); + queued.add(key); + } + return state; + }; + + // This is an architecture declaration check, not a target simulator. + // Cargo target cfg facts are multi-valued and evolve with rustc, so all + // declared target edges remain reachable. Root feature profiles stay + // separate when their accepted Core owners intentionally differ. + mergePackageState( + rootPackage, + 'normal', + rootSelectedFeatures, + true, + [rootPackage.name], + ); - if (targetPackage.name === 'bitfun-core') { - const activeCoreFeatures = targetState.featureState.active; - const unexpected = forbiddenCoreFeatures.find((feature) => - activeCoreFeatures.has(feature)) - ?? [...activeCoreFeatures] - .filter((feature) => !allowedCoreFeatures.has(feature)) - .sort()[0]; - const reportKey = [rootName, targetDependencyKindContext, unexpected].join('|'); - if (unexpected && !reportedUnexpectedFeatures.has(reportKey)) { - reportedUnexpectedFeatures.add(reportKey); - violations.push({ - path: sourcePackage.manifest_path, - line: 1, - message: `${rootLabel} dependency closure must not enable ${unexpected}: ${[ - ...packagePath, - `${targetPackage.name}/${unexpected}`, - ].join(' -> ')}`, - }); + while (pending.length > 0) { + const stateKey = pending.shift(); + queued.delete(stateKey); + const { + pkg: sourcePackage, + dependencyKindContext, + featureState, + packagePath, + } = packageStates.get(stateKey); + + for (const dependency of sourcePackage.dependencies ?? []) { + const kind = dependency.kind ?? 'normal'; + if ( + !dependency.path + || (kind !== 'normal' && kind !== 'build') + || repositoryPath(root, dependency.path) === null + ) { + continue; + } + const activation = dependencyActivation(dependency, featureState); + if (!activation) { + continue; + } + const targetPackage = packageByManifest.get( + normalizedPath(join(dependency.path, 'Cargo.toml')), + ); + if (!targetPackage) { + continue; + } + const targetDependencyKindContext = + dependencyKindContext === 'build' + || kind === 'build' + || isProcMacroPackage(targetPackage) + ? 'build' + : 'normal'; + const targetPath = [...packagePath, targetPackage.name]; + const targetState = mergePackageState( + targetPackage, + targetDependencyKindContext, + activation.features, + activation.useDefaultFeatures, + targetPath, + ); + + if (targetPackage.name === 'bitfun-core') { + const activeCoreFeatures = targetState.featureState.active; + const unexpected = forbiddenCoreFeatures.find((feature) => + activeCoreFeatures.has(feature)) + ?? [...activeCoreFeatures] + .filter((feature) => !allowedCoreFeatures.has(feature)) + .sort()[0]; + const reportKey = [ + rootName, + profileLabel, + targetDependencyKindContext, + unexpected, + ].join('|'); + if (unexpected && !reportedUnexpectedFeatures.has(reportKey)) { + reportedUnexpectedFeatures.add(reportKey); + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rootLabel}${profileLabel} dependency closure must not enable ${unexpected}: ${[ + ...packagePath, + `${targetPackage.name}/${unexpected}`, + ].join(' -> ')}`, + }); + } + continue; } - continue; } } } diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs index 942fedad31..7f51fbe46c 100644 --- a/scripts/core-boundaries/checker.mjs +++ b/scripts/core-boundaries/checker.mjs @@ -34,7 +34,10 @@ import { unexpectedDependencyOwnerFeatures, unexpectedReachableLocalFeatures, } from './manifest-feature-helpers.mjs'; -import { checkCargoDependencyBoundariesSafely } from './cargo-dependency-boundaries.mjs'; +import { + checkCargoDependencyBoundariesSafely, + reviewedCoreFeatureClosures, +} from './cargo-dependency-boundaries.mjs'; import { agentRuntimeIntegrationTestTargets, checkAgentRuntimeIntegrationTestTopology, @@ -1091,6 +1094,7 @@ export function runCoreBoundaryCheck() { ownerCrateFeatureAssemblyRules, parseManifestFeatures, optionalDependencyFeatureOwnerRules, + reviewedCoreFeatureClosures, lightweightBoundaryRules, dependencyProfileRules, forbiddenManifestDependencyRules, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index a29845408f..a56077ab69 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -12,6 +12,7 @@ export function runManifestParserSelfTest({ ownerCrateFeatureAssemblyRules, parseManifestFeatures, optionalDependencyFeatureOwnerRules, + reviewedCoreFeatureClosures, lightweightBoundaryRules, dependencyProfileRules, forbiddenManifestDependencyRules, @@ -228,6 +229,21 @@ export function runManifestParserSelfTest({ throw new Error('forbidden dependency checks must reject Cargo package aliases'); } + for (const [packageName, expectedClosure] of [ + ['bitfun-app-server', ['agent-runtime']], + ['bitfun-server', ['agent-runtime', 'ssh-remote']], + ]) { + const reviewedClosure = reviewedCoreFeatureClosures?.get(packageName); + if ( + reviewedClosure?.length !== expectedClosure.length + || expectedClosure.some((feature) => !reviewedClosure.includes(feature)) + ) { + throw new Error( + `${packageName} must keep its reviewed Core feature closure`, + ); + } + } + for (const featureName of [ 'agent-runtime', 'announcement', diff --git a/src/apps/server/Cargo.toml b/src/apps/server/Cargo.toml index 187c3a5b66..b51f4aafc6 100644 --- a/src/apps/server/Cargo.toml +++ b/src/apps/server/Cargo.toml @@ -9,15 +9,33 @@ description = "BitFun Server - Web-based AI Code Assistant" name = "bitfun-server" path = "src/main.rs" +[features] +default = [] +# Compile and test the paused source references without registering their +# handlers or changing the default Web Server runtime. This is a source-health +# contract, not a product capability or compatibility feature. +paused-web-server-source-check = [ + "bitfun-core/dispatch-store", + "bitfun-core/external-sources", + "dep:base64", + "dep:chrono", + "dep:reqwest", + "dep:serde_json", +] + [dependencies] -bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = ["product-full"] } +# The embedded Agent Runtime needs the narrow runtime owner set. The bootstrap +# also injects the concrete RemoteExecPort boundary, so keep ssh-remote even +# though the paused Web Server's current host does not initialize the required +# SSH manager. Dispatch/external-source product capabilities are preserved as +# inactive source and are not part of the default host module graph. +bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = ["agent-runtime", "ssh-remote"] } -# App-server surface: an in-process JSON-RPC server/client pair over an -# in-memory channel transport. The websocket handler routes agent kernel RPCs -# through this client so agent interfaces uniformly go through app-server. +# App Server surface. Each accepted WebSocket is adapted directly into a +# JSON-RPC `Lines` transport and handed to `BitfunAppServer::serve`; there is no +# shared in-process client in the current Server host. bitfun-app-server = { path = "../../crates/interfaces/app-server" } bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } -bitfun-events = { path = "../../crates/contracts/events" } agent-client-protocol = { workspace = true } @@ -28,16 +46,15 @@ tower-http = { workspace = true } # Inherited from workspace tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "sync"] } serde = { workspace = true } -serde_json = { workspace = true } +serde_json = { workspace = true, optional = true } anyhow = { workspace = true } clap = { workspace = true } -base64 = { workspace = true } +base64 = { workspace = true, optional = true } +chrono = { workspace = true, optional = true } +reqwest = { workspace = true, optional = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } futures-util = { workspace = true } -futures = { workspace = true } -chrono = { workspace = true } -dirs = { workspace = true } log = { workspace = true } [lints] diff --git a/src/apps/server/README.md b/src/apps/server/README.md index 347238dd95..b9967ca160 100644 --- a/src/apps/server/README.md +++ b/src/apps/server/README.md @@ -1,14 +1,63 @@ -# BitFun Server (Web App Backend) +# BitFun Web Server (development paused) -This directory contains the `bitfun-server` application, which serves the web backend runtime for BitFun. +`bitfun-server` is BitFun's Web Server product surface. Product development is +currently paused; the product is not deprecated or retired. The current partial +host is local and loopback-only, embeds the existing Agent Runtime, and exposes: -> **Deprecated:** This Web Server was already deprecated before the current App Server refactor. Changes made here -> during that refactor are intended to validate protocol and host boundaries; they do not promise feature completeness, -> Desktop parity, backward compatibility, or production readiness. +- `GET /health` and `GET /api/v1/health`; +- `GET /api/v1/info`; +- `GET /ws`, which serves the App Server JSON-RPC surface after exact browser + origin validation. -If you are looking for **Remote Connect self-hosted relay deployment**, use: +The current implementation is not yet a production Web backend and does not +provide Desktop parity. Dispatch and external-source handlers are preserved for +a later App Server migration but are not registered on the current WebSocket +path. External-source method names keep the App Server's typed +`host_capability_unavailable` response instead of falling back locally. +The embedded Runtime retains the `RemoteExecPort` interface selected by its +bootstrap. The current host does not initialize the required global SSH manager, +so remote execution remains unavailable; the retained port is not an end-to-end +remote capability. Resuming Web Server development must connect these +capabilities through App Server and the existing lower-layer owners rather than +restore a parallel WebSocket command path. -- [Relay Server README](../relay-server/README.md) -- [deploy.sh](../relay-server/deploy.sh) +`--workspace` requires an absolute, canonicalizable local path and is treated as +an authoritative operator request; validation, open, or Runtime ownership +failure stops startup instead of silently selecting another workspace. Without +that argument, persisted history is only an advisory startup hint. Server loads +the history metadata without preparing the restored workspace. A local history +entry gets an ownership-aware open; an unusable local entry or a Remote entry +(the current host has no SSH manager) logs a warning and falls back to the +default Assistant workspace. The default workspace uses the same ownership +boundary, including its first directory creation; failure there still stops +startup. This safety gate does not make the paused product production-complete. +The deferred host does not migrate legacy +Assistant directories before ownership; when the current default directory is +absent it keeps using the legacy default in place, classified as an Assistant +workspace. Normal product startup remains the owner of the directory migration. -`src/apps/server` and `src/apps/relay-server` are different components. `src/apps/server` is the main web app backend, while `src/apps/relay-server` is the relay service used by Remote Connect. +The repository preserves four inactive Server source references: + +- `src/ai_relay.rs`, an unregistered AI API proxy draft that is distinct from + Remote Connect Relay; +- `src/rpc_dispatcher.rs`, the pre-App-Server dispatcher reference; +- `src/routes/dispatch.rs` and `src/routes/external_sources.rs`, the former + host-local handlers awaiting App Server migration. + +These sources are not in the default Rust module graph and do not represent +delivered runtime capabilities. The non-default +`paused-web-server-source-check` feature only keeps them and their existing +unit tests compilable; it does not register a route, add a CLI option, or +initialize an SSH manager. + +For Remote Connect self-hosted relay deployment, use the +[Relay Server README](../relay-server/README.md). The relay service and this +paused Web Server are different products. + +## Verification + +```bash +cargo check --locked -p bitfun-server +cargo test --locked -p bitfun-server +cargo test --locked -p bitfun-server --features paused-web-server-source-check +``` diff --git a/src/apps/server/src/ai_relay.rs b/src/apps/server/src/ai_relay.rs index 134a38307a..a8501c9fd5 100644 --- a/src/apps/server/src/ai_relay.rs +++ b/src/apps/server/src/ai_relay.rs @@ -1,4 +1,9 @@ -//! AI Relay - Lightweight HTTP proxy for AI API requests +//! Inactive AI API relay draft for the paused Web Server. +//! +//! This module is preserved as source material and compiled only by the +//! non-default source-health profile. It is not registered by the current +//! Server runtime, and the command-line flags below are not available. It is +//! separate from the supported Relay Server and SSH remote connection paths. //! //! When running BitFun Server on a remote machine that cannot directly access //! AI APIs (due to network restrictions), AI Relay acts as a local proxy: @@ -11,7 +16,7 @@ //! └─────────────┘ └─────────────┘ //! ``` //! -//! Usage: +//! Historical intended usage (not currently wired): //! 1. Start AI Relay on local machine: `bitfun-server --ai-relay --port 9090` //! 2. SSH to remote with reverse tunnel: `ssh -R 9090:localhost:9090 user@remote` //! 3. Configure remote BitFun to use proxy: `proxy_url = "http://localhost:9090"` @@ -28,12 +33,12 @@ use std::sync::Arc; /// AI Relay state #[derive(Clone)] -pub struct RelayState { +pub(crate) struct RelayState { pub client: Client, } /// Create the AI Relay router -pub fn create_relay_router() -> Router { +pub(crate) fn create_relay_router() -> Router { let client = Client::builder() .timeout(std::time::Duration::from_secs(300)) .build() @@ -41,9 +46,7 @@ pub fn create_relay_router() -> Router { let state = Arc::new(RelayState { client }); - Router::new() - .fallback(handle_proxy) - .with_state(state) + Router::new().fallback(handle_proxy).with_state(state) } /// Handle all incoming requests and proxy them @@ -57,11 +60,7 @@ async fn handle_proxy( // Reconstruct the target URL let target_url = reconstruct_target_url(&uri)?; - log::info!( - "AI Relay: proxying {} {}", - req.method(), - target_url - ); + log::info!("AI Relay: proxying {} {}", req.method(), target_url); // Remove hop-by-hop headers let mut headers = req.headers().clone(); @@ -70,7 +69,7 @@ async fn handle_proxy( // Build the proxied request let method = reqwest::Method::from_bytes(req.method().as_str().as_bytes()) .unwrap_or(reqwest::Method::GET); - + let mut builder = state.client.request(method, &target_url); // Add headers @@ -95,10 +94,7 @@ async fn handle_proxy( .await .map_err(|e| ProxyError::UpstreamError(format!("Failed to connect to upstream: {}", e)))?; - log::info!( - "AI Relay: received response status {}", - response.status() - ); + log::info!("AI Relay: received response status {}", response.status()); // Build response let status = response.status(); @@ -132,9 +128,9 @@ fn reconstruct_target_url(uri: &Uri) -> Result { let path = uri.path(); // Skip the leading slash and extract scheme - let path = path.strip_prefix('/').ok_or_else(|| { - ProxyError::InvalidRequest("Invalid path format".to_string()) - })?; + let path = path + .strip_prefix('/') + .ok_or_else(|| ProxyError::InvalidRequest("Invalid path format".to_string()))?; let parts: Vec<&str> = path.splitn(3, '/').collect(); if parts.is_empty() { @@ -144,9 +140,9 @@ fn reconstruct_target_url(uri: &Uri) -> Result { } let scheme = *parts.get(0).unwrap_or(&"https"); - let host = parts.get(1).ok_or_else(|| { - ProxyError::InvalidRequest("Missing host in path".to_string()) - })?; + let host = parts + .get(1) + .ok_or_else(|| ProxyError::InvalidRequest("Missing host in path".to_string()))?; let rest = parts.get(2).unwrap_or(&""); // Build the target URL @@ -185,7 +181,7 @@ fn remove_hop_by_hop_headers(headers: &mut HeaderMap) { /// Proxy errors #[derive(Debug)] -pub enum ProxyError { +pub(crate) enum ProxyError { InvalidRequest(String), RequestError(String), UpstreamError(String), @@ -198,9 +194,7 @@ impl IntoResponse for ProxyError { ProxyError::InvalidRequest(msg) => (StatusCode::BAD_REQUEST, msg), ProxyError::RequestError(msg) => (StatusCode::BAD_REQUEST, msg), ProxyError::UpstreamError(msg) => (StatusCode::BAD_GATEWAY, msg), - ProxyError::ResponseError(msg) => { - (StatusCode::INTERNAL_SERVER_ERROR, msg) - } + ProxyError::ResponseError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), }; let body = serde_json::json!({ @@ -223,9 +217,7 @@ mod tests { assert_eq!(result, "https://api.openai.com/v1/chat/completions"); // Test with query string - let uri: Uri = "/https/api.openai.com/v1/models?limit=10" - .parse() - .unwrap(); + let uri: Uri = "/https/api.openai.com/v1/models?limit=10".parse().unwrap(); let result = reconstruct_target_url(&uri).unwrap(); assert_eq!(result, "https://api.openai.com/v1/models?limit=10"); diff --git a/src/apps/server/src/app_server.rs b/src/apps/server/src/app_server.rs index 6fc25d0ca9..0fb7aa911e 100644 --- a/src/apps/server/src/app_server.rs +++ b/src/apps/server/src/app_server.rs @@ -1,11 +1,11 @@ //! Server-host app-server wiring: build the in-process `BitfunAppServer` from //! the product-assembled [`AgentRuntime`] and return a cloneable handle. //! -//! The containing Web Server was already deprecated before this refactor. -//! This wiring exists to validate the App Server boundary and is not required -//! to provide complete legacy Web/Desktop behavior or production compatibility. +//! The containing Web Server is paused, not deprecated. This current partial +//! wiring validates the App Server boundary and protects its live protocol +//! behavior, but it is not yet a production-complete Web/Desktop surface. //! -//! Under browser-direct ACP-over-WS (Step 2) the server host no longer pairs +//! Under browser-direct App Server over WebSocket, the host does not pair //! the app-server with an in-process client over `in_memory_pair`. Instead each //! WebSocket connection is handed straight to [`BitfunAppServer::serve`] via the //! [`crate::routes::ws_transport`] `Lines` adapter, so the browser connects diff --git a/src/apps/server/src/bootstrap.rs b/src/apps/server/src/bootstrap.rs index 94817d50be..31c5c9147b 100644 --- a/src/apps/server/src/bootstrap.rs +++ b/src/apps/server/src/bootstrap.rs @@ -6,9 +6,87 @@ use bitfun_core::agentic::*; use bitfun_core::infrastructure::ai::AIClientFactory; use bitfun_core::infrastructure::try_get_path_manager_arc; use bitfun_core::service::{config, filesystem, mcp, token_usage, workspace}; +use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; +#[derive(Debug, PartialEq, Eq)] +enum StartupWorkspacePlan { + Explicit(PathBuf), + RestoreThenDefault { + restored: Option, + default: PathBuf, + }, +} + +fn startup_workspace_plan( + explicit: Option, + restored: Option, + default: PathBuf, +) -> StartupWorkspacePlan { + match explicit { + Some(path) => StartupWorkspacePlan::Explicit(path), + None => StartupWorkspacePlan::RestoreThenDefault { restored, default }, + } +} + +fn restorable_local_workspace_path( + workspace_kind: workspace::WorkspaceKind, + root_path: PathBuf, +) -> Option { + (workspace_kind != workspace::WorkspaceKind::Remote).then_some(root_path) +} + +fn default_assistant_workspace_path( + path_manager: &bitfun_core::infrastructure::PathManager, +) -> PathBuf { + prefer_current_assistant_workspace( + path_manager.default_assistant_workspace_dir(None), + path_manager.legacy_default_assistant_workspace_dir(None), + ) +} + +fn prefer_current_assistant_workspace(current: PathBuf, legacy: PathBuf) -> PathBuf { + if !current.exists() && legacy.is_dir() { + legacy + } else { + current + } +} + +async fn open_owned_workspace( + coordinator: &coordination::ConversationCoordinator, + workspace_service: &workspace::WorkspaceService, + path: PathBuf, + snapshot_log_context: &str, +) -> anyhow::Result { + let path_label = path.display().to_string(); + coordinator + .open_local_workspace_with_runtime_ownership(workspace_service, path, snapshot_log_context) + .await + .map_err(|error| anyhow::anyhow!("Failed to open Server workspace '{path_label}': {error}")) +} + +async fn open_default_assistant_workspace( + coordinator: &coordination::ConversationCoordinator, + workspace_service: &workspace::WorkspaceService, + path: PathBuf, +) -> anyhow::Result { + let path_label = path.display().to_string(); + coordinator + .create_and_open_managed_local_workspace_with_runtime_ownership( + workspace_service, + path, + "default Assistant Server bootstrap", + ) + .await + .map_err(|error| { + anyhow::anyhow!( + "Failed to open default Assistant Server workspace '{path_label}': {error}" + ) + }) +} + /// Shared application state for the server (mirrors Desktop's AppState). /// /// Several fields are stored to keep the corresponding services alive (they @@ -34,7 +112,9 @@ pub(crate) struct ServerAppState { /// Initialize all core services and return the shared server state. /// -/// The optional `workspace` path, when provided, is opened automatically. +/// Opens an explicit `workspace` as an authoritative request. Without one, +/// history is advisory and falls back to the default Assistant workspace when +/// its ownership-aware open fails. pub(crate) async fn initialize(workspace: Option) -> anyhow::Result> { log::info!("Initializing BitFun server core services"); @@ -141,20 +221,6 @@ pub(crate) async fn initialize(workspace: Option) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result { + open_owned_workspace( + coordinator.as_ref(), workspace_service.as_ref(), path, - None, - None, - "server bootstrap", + "explicit Server bootstrap", ) - .await - { - Ok(info) => { - log::info!( - "Workspace opened: name={}, path={}", - info.name, - info.root_path.display() - ); - Some(info.root_path) - } - Err(e) => { - log::error!("Failed to open workspace '{}': {}", ws_path, e); - None - } + .await? } - } else { - // Try to restore last workspace - workspace_service - .get_current_workspace() + StartupWorkspacePlan::RestoreThenDefault { restored, default } => match restored { + Some(path) => match open_owned_workspace( + coordinator.as_ref(), + workspace_service.as_ref(), + path, + "restored Server bootstrap", + ) .await - .map(|w| w.root_path) + { + Ok(info) => info, + Err(error) => { + log::warn!( + "Failed to restore Server workspace; falling back to the default Assistant workspace: {}", + error + ); + open_default_assistant_workspace( + coordinator.as_ref(), + workspace_service.as_ref(), + default, + ) + .await? + } + }, + None => { + open_default_assistant_workspace( + coordinator.as_ref(), + workspace_service.as_ref(), + default, + ) + .await? + } + }, }; + log::info!( + "Workspace opened: name={}, path={}", + workspace_info.name, + workspace_info.root_path.display() + ); + let initial_workspace_path = Some(workspace_info.root_path); + + // Construction loads and reconciles persisted jobs, so create, register, + // and start Cron only after the Server has an owned workspace. + let cron_service = bitfun_core::service::cron::CronService::new( + path_manager.clone(), + coordinator.clone(), + scheduler.clone(), + ) + .await?; + bitfun_core::service::cron::set_global_cron_service(cron_service.clone()); + let cron_subscriber = Arc::new(bitfun_core::service::cron::CronEventSubscriber::new( + cron_service.clone(), + )); + event_router.subscribe_internal("cron_jobs".to_string(), cron_subscriber); + cron_service.start(); // LSP if let Err(e) = bitfun_core::service::lsp::initialize_global_lsp_manager().await { @@ -243,3 +362,109 @@ pub(crate) async fn initialize(workspace: Option) -> anyhow::Result, ssh_manager: Arc, @@ -41,12 +47,10 @@ pub(crate) struct DispatchHostState { /// Application state #[derive(Clone)] pub struct AppState { - // NOTE(Step 2a): only read by the external_sources dispatch path, which is - // temporarily dead under browser-direct ACP-over-WS. Kept for the follow-up - // that brings external_sources onto the app-server schema. - #[allow(dead_code)] + #[cfg(feature = "paused-web-server-source-check")] external_workspace_root: Option, allowed_browser_origins: Arc>, + #[cfg(feature = "paused-web-server-source-check")] dispatch_host: Option>, } @@ -56,7 +60,7 @@ const DEFAULT_ALLOWED_BROWSER_ORIGINS: [&str; 2] = #[derive(Debug, Parser)] #[command(name = "bitfun-server")] struct ServerArgs { - /// Project workspace owned by this Server Host. + /// Initial local project workspace requested for this Server Host. #[arg(long, value_name = "PATH")] workspace: Option, @@ -92,7 +96,7 @@ async fn main() -> Result<()> { tracing::info!("BitFun Server v{}", env!("CARGO_PKG_VERSION")); let args = ServerArgs::parse(); - let external_workspace_root = args + let workspace_root = args .workspace .map(|path| { if !path.is_absolute() { @@ -106,10 +110,10 @@ async fn main() -> Result<()> { // Initialize the full agentic stack (coordinator, scheduler, token usage, // MCP/config/filesystem services, event queue). This binding is held alive // for the lifetime of the server so its services outlive every websocket - // connection; the app-server client and spawned tasks hold their own Arc - // clones of the coordinator, scheduler, and event queue. + // connection; per-WebSocket App Server serve tasks and Runtime services hold + // their own Arc clones of the coordinator, scheduler, and event queue. let server_state = bootstrap::initialize( - external_workspace_root + workspace_root .as_ref() .map(|path| path.to_string_lossy().into_owned()), ) @@ -118,7 +122,7 @@ async fn main() -> Result<()> { // Build the agent runtime the same way the Desktop session application does, // then build an in-process `BitfunAppServer` for it. Each WebSocket // connection is handed straight to `BitfunAppServer::serve` over a WS-bridged - // `Lines` transport (browser-direct ACP-over-WS, Step 2), so the browser + // `Lines` transport, so the browser // connects directly to the in-process app-server over native JSON-RPC — no // shared in-process client, no custom WS envelope. let agent_runtime = @@ -159,31 +163,12 @@ async fn main() -> Result<()> { }) .collect::>>()?; - // This is a narrow controller/observer capability. It deliberately does - // not initialize the Server Host's dormant Agent Runtime: authoritative - // sessions and execution stay inside the target-side `bitfun dispatch` - // worker. - let path_manager = Arc::new(bitfun_core::infrastructure::PathManager::new()?); - let ssh_data_dir = dirs::data_local_dir() - .ok_or_else(|| anyhow::anyhow!("Could not resolve the local data directory"))? - .join("BitFun") - .join("ssh"); - let ssh_manager = Arc::new(bitfun_core::service::remote_ssh::SSHConnectionManager::new( - ssh_data_dir, - )); - if let Err(error) = ssh_manager.load_saved_connections().await { - tracing::warn!(error = %error, "Failed to load saved SSH connections"); - } - if let Err(error) = ssh_manager.load_known_hosts().await { - tracing::warn!(error = %error, "Failed to load SSH known hosts"); - } let app_state = AppState { - external_workspace_root, + #[cfg(feature = "paused-web-server-source-check")] + external_workspace_root: workspace_root.clone(), allowed_browser_origins: Arc::new(allowed_browser_origins), - dispatch_host: Some(Arc::new(DispatchHostState { - path_manager, - ssh_manager, - })), + #[cfg(feature = "paused-web-server-source-check")] + dispatch_host: None, }; let app = Router::new() @@ -265,39 +250,4 @@ mod tests { assert!(normalize_browser_origin(invalid).is_err(), "{invalid}"); } } - - #[test] - fn agent_bootstrap_reuses_core_ownership_without_activating_the_http_shell() { - let bootstrap = include_str!("bootstrap.rs"); - assert!(bootstrap.contains("CoreRuntimeOwnership::embedded")); - let coordinator = bootstrap - .split("ConversationCoordinator::new") - .nth(1) - .and_then(|source| source.split(");").next()) - .expect("Server agent bootstrap Coordinator assembly"); - assert!(coordinator.contains("runtime_ownership")); - assert!(bootstrap.contains("open_workspace_with_runtime_ownership")); - assert!(!bootstrap.contains("initialize_snapshot_manager_for_workspace")); - - let rpc = include_str!("rpc_dispatcher.rs"); - let delete = rpc - .split("\"delete_session\" =>") - .nth(1) - .and_then(|source| source.split("\"start_dialog_turn\" =>").next()) - .expect("Server delete RPC"); - assert!(delete.contains("ensure_workspace_runtime_ownership")); - - let main_source = include_str!("main.rs") - .split("#[cfg(test)]") - .next() - .expect("Server production entrypoint"); - assert!( - !main_source.contains("bootstrap::initialize"), - "the current read-only HTTP shell must not silently start an Agent Runtime" - ); - assert!( - main_source.contains("DispatchHostState"), - "the lightweight Server Host should expose dispatch without booting an Agent Runtime" - ); - } } diff --git a/src/apps/server/src/routes/dispatch.rs b/src/apps/server/src/routes/dispatch.rs index 0885447772..93208d8aab 100644 --- a/src/apps/server/src/routes/dispatch.rs +++ b/src/apps/server/src/routes/dispatch.rs @@ -1,5 +1,10 @@ //! Narrow detached-dispatch capability for the lightweight Server Host. //! +//! This source is preserved while the Web Server product is paused, but it is +//! not registered as a current HTTP or App Server route. A future reactivation +//! must expose the capability through the App Server protocol instead of +//! reviving the removed parallel WebSocket command path. +//! //! This route owns no Agent Runtime and no target session. It only exposes the //! same platform-neutral controller used by Desktop, backed by saved SSH //! profiles and the observer-only outbound index. @@ -10,12 +15,11 @@ use bitfun_core::external_sources::{ use bitfun_core::service::dispatch::{ answer_dispatch, append_dispatch, cancel_dispatch, cancel_dispatch_cli_install, get_dispatch_status, list_dispatch_jobs, list_dispatch_targets, poll_dispatch_cli_install, - probe_dispatch_target, start_dispatch_cli_install, submit_dispatch, - sync_dispatch_model_config, sync_dispatch_result, DispatchAnswerRequest, - DispatchAppendRequest, DispatchConnectionRequest, DispatchInstallPollRequest, - DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, - DispatchListTargetsRequest, DispatchProbeTargetRequest, DispatchStatusRequest, - DispatchSubmitRequest, DispatchSyncResultRequest, OutboundDispatchStore, + probe_dispatch_target, start_dispatch_cli_install, submit_dispatch, sync_dispatch_model_config, + sync_dispatch_result, DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, + DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, + DispatchListJobsRequest, DispatchListTargetsRequest, DispatchProbeTargetRequest, + DispatchStatusRequest, DispatchSubmitRequest, DispatchSyncResultRequest, OutboundDispatchStore, }; use serde::de::DeserializeOwned; diff --git a/src/apps/server/src/routes/external_sources.rs b/src/apps/server/src/routes/external_sources.rs index 093801e18c..d8f8600780 100644 --- a/src/apps/server/src/routes/external_sources.rs +++ b/src/apps/server/src/routes/external_sources.rs @@ -6,10 +6,10 @@ use std::path::PathBuf; use crate::AppState; -// NOTE(Step 2a): these host-local external-source dispatch helpers were wired -// through the old `websocket.rs::handle_command` path. Under browser-direct -// ACP-over-WS the browser connects straight to the in-process app-server, so -// `external_sources` commands now hit the ACP `method_not_found` fallback +// These host-local external-source dispatch helpers were wired through the old +// `websocket.rs::handle_command` path. Under browser-direct App Server over +// WebSocket the browser connects straight to the in-process app-server, so +// `external_sources` commands now hit the App Server `method_not_found` fallback // (the desktop/Server Host external-source surface is temporarily unavailable // in web mode -- tracked for a later batch that brings them onto the app-server // schema). Kept here so the host capability plumbing stays intact for that diff --git a/src/apps/server/src/routes/mod.rs b/src/apps/server/src/routes/mod.rs index 4376b8744d..6590fe7794 100644 --- a/src/apps/server/src/routes/mod.rs +++ b/src/apps/server/src/routes/mod.rs @@ -1,8 +1,11 @@ +//! HTTP and WebSocket routes exposed by the paused Web Server's current host. + pub(crate) mod api; +#[cfg(feature = "paused-web-server-source-check")] +#[allow(dead_code)] pub(crate) mod dispatch; +#[cfg(feature = "paused-web-server-source-check")] +#[allow(dead_code)] pub(crate) mod external_sources; -/// Routes module -/// -/// Contains all HTTP and WebSocket routes pub(crate) mod websocket; pub(crate) mod ws_transport; diff --git a/src/apps/server/src/routes/websocket.rs b/src/apps/server/src/routes/websocket.rs index e4c2629348..12e9238a4f 100644 --- a/src/apps/server/src/routes/websocket.rs +++ b/src/apps/server/src/routes/websocket.rs @@ -1,6 +1,6 @@ //! WebSocket handler. //! -//! Under browser-direct ACP-over-WS (Step 2), the browser speaks raw JSON-RPC +//! Under browser-direct App Server over WebSocket, the browser speaks raw JSON-RPC //! 2.0 over the WebSocket. Each connection is handed straight to //! [`bitfun_app_server::BitfunAppServer::serve`] via the [`super::ws_transport`] //! `Lines` adapter -- no custom `{type:"request"|...}` envelope, no @@ -103,10 +103,12 @@ mod tests { fn state_with_allowed_origins(origins: &[&str]) -> AppState { AppState { + #[cfg(feature = "paused-web-server-source-check")] external_workspace_root: None, allowed_browser_origins: std::sync::Arc::new( origins.iter().map(|origin| (*origin).to_string()).collect(), ), + #[cfg(feature = "paused-web-server-source-check")] dispatch_host: None, } } diff --git a/src/apps/server/src/routes/ws_transport.rs b/src/apps/server/src/routes/ws_transport.rs index 81573a4a05..cc17e0c176 100644 --- a/src/apps/server/src/routes/ws_transport.rs +++ b/src/apps/server/src/routes/ws_transport.rs @@ -1,12 +1,13 @@ //! Axum WebSocket -> `agent_client_protocol::Lines` transport bridge. //! //! The browser speaks raw JSON-RPC 2.0 over WebSocket (one message per WS text -//! frame). ACP's [`agent_client_protocol::Lines`] already implements +//! frame). [`agent_client_protocol::Lines`] already implements //! [`agent_client_protocol::ConnectTo`] for any `futures::Sink` + `futures::Stream>` pair. This module //! adapts an axum `WebSocket` (after `split()`) into exactly that pair: outgoing //! wraps the `SplitSink` (each `String` -> `Message::Text`), incoming wraps the -//! `SplitStream` (each `Message::Text` -> `Ok(String)`, everything else -> `Err`). +//! `SplitStream` (each `Message::Text` -> `Ok(String)`, control frames ignored, +//! binary frames rejected, and close frames ending the stream). //! //! The returned `Lines` is handed to [`bitfun_app_server::BitfunAppServer::serve`] //! per WebSocket connection, so the browser connects directly to the in-process @@ -19,16 +20,16 @@ use std::task::{Context, Poll}; use agent_client_protocol::Lines; use axum::extract::ws::{Message, WebSocket}; -use futures::{Sink, Stream}; use futures_util::stream::{SplitSink, SplitStream}; -use futures_util::{SinkExt, StreamExt}; +use futures_util::{Sink, SinkExt, Stream, StreamExt}; -/// Bridge an axum WebSocket into an ACP `Lines` transport for +/// Bridge an axum WebSocket into an agent-client-protocol `Lines` transport for /// `BitfunAppServer::serve(lines)`. /// /// The WebSocket is split; the outgoing half becomes the `Lines` sink (one /// `String` per WS text frame), the incoming half becomes the stream (text -/// frames only; binary/control frames surface as a stream end or `io::Error`). +/// frames only; binary frames surface as `io::Error`, ping/pong are ignored, +/// and close frames end the stream). pub(crate) fn ws_lines(socket: WebSocket) -> Lines { let (sink, stream) = socket.split(); Lines::new(WSSink { sink }, WSStream { stream }) @@ -76,34 +77,66 @@ impl Sink for WSSink { } /// Incoming adapter: `futures::Stream>` from axum -/// `Message::Text`. Binary frames surface as `io::Error`; control frames close -/// the stream (axum handles ping/pong internally). -pub(crate) struct WSStream { - stream: SplitStream, +/// `Message::Text`. Binary frames surface as `io::Error`; ping/pong are ignored +/// and only close frames terminate the JSON-RPC stream. +pub(crate) struct WSStream> { + stream: S, } -impl Unpin for WSStream {} +impl Unpin for WSStream {} -impl Stream for WSStream { +impl Stream for WSStream +where + S: Stream> + Unpin, +{ type Item = io::Result; fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let this = self.get_mut(); - match Stream::poll_next(Pin::new(&mut this.stream), cx) { - Poll::Ready(None) => Poll::Ready(None), - Poll::Ready(Some(Err(_))) => Poll::Ready(Some(Err(io::Error::new( - io::ErrorKind::ConnectionAborted, - "ws recv failed", - )))), - Poll::Ready(Some(Ok(Message::Text(text)))) => Poll::Ready(Some(Ok(text.to_string()))), - Poll::Ready(Some(Ok(Message::Binary(_)))) => Poll::Ready(Some(Err(io::Error::new( - io::ErrorKind::InvalidData, - "binary ws frames not supported", - )))), - Poll::Ready(Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Close(_)))) => { - Poll::Ready(None) + loop { + match Stream::poll_next(Pin::new(&mut this.stream), cx) { + Poll::Ready(None) => return Poll::Ready(None), + Poll::Ready(Some(Err(_))) => { + return Poll::Ready(Some(Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + "ws recv failed", + )))) + } + Poll::Ready(Some(Ok(Message::Text(text)))) => { + return Poll::Ready(Some(Ok(text.to_string()))) + } + Poll::Ready(Some(Ok(Message::Binary(_)))) => { + return Poll::Ready(Some(Err(io::Error::new( + io::ErrorKind::InvalidData, + "binary ws frames not supported", + )))) + } + Poll::Ready(Some(Ok(Message::Ping(_) | Message::Pong(_)))) => continue, + Poll::Ready(Some(Ok(Message::Close(_)))) => return Poll::Ready(None), + Poll::Pending => return Poll::Pending, } - Poll::Pending => Poll::Pending, } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn ping_and_pong_do_not_end_the_json_rpc_stream() { + let frames = futures_util::stream::iter(vec![ + Ok::<_, axum::Error>(Message::Ping(Vec::new().into())), + Ok::<_, axum::Error>(Message::Pong(Vec::new().into())), + Ok::<_, axum::Error>(Message::Text("request".into())), + Ok::<_, axum::Error>(Message::Close(None)), + ]); + let mut incoming = WSStream { stream: frames }; + + assert_eq!( + incoming.next().await.transpose().expect("valid text frame"), + Some("request".to_string()) + ); + assert!(incoming.next().await.is_none()); + } +} diff --git a/src/apps/server/src/rpc_dispatcher.rs b/src/apps/server/src/rpc_dispatcher.rs index 5d1416326c..ce379ec6f1 100644 --- a/src/apps/server/src/rpc_dispatcher.rs +++ b/src/apps/server/src/rpc_dispatcher.rs @@ -1,8 +1,10 @@ -//! WebSocket RPC command dispatcher. +//! Inactive legacy WebSocket RPC command dispatcher. //! -//! Maps Tauri command names (used by the frontend `api.invoke()`) to -//! server-side handler functions. Each handler receives the raw JSON -//! `params` and returns a JSON `result`. +//! This source is preserved while the Web Server product is paused and compiled +//! only by the non-default source-health profile. It is not connected to the +//! current runtime. Future command exposure must use the App Server protocol and +//! current capability owners instead of restoring this parallel Tauri-shaped +//! dispatch path wholesale. use crate::bootstrap::ServerAppState; use anyhow::{anyhow, Result}; @@ -25,7 +27,7 @@ use std::time::Duration; /// `"open_workspace"`, `"terminal_create"`), so the frontend's /// `api.invoke(name, args)` works identically over both Tauri IPC and /// WebSocket. -pub async fn dispatch( +pub(crate) async fn dispatch( method: &str, params: serde_json::Value, state: &Arc, @@ -430,9 +432,12 @@ pub async fn dispatch( turn_id, agent_type, workspace_path, + None, + None, DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopUi), None, None, + None, ) .await .map_err(|e| anyhow!("{}", e))?; diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 96ceb20ab5..6355140348 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -2137,6 +2137,22 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet path.display() ))); } + if known_remote.is_none() && !path.is_dir() { + return Err(BitFunError::service(format!( + "Workspace path is not a directory: {}", + path.display() + ))); + } + if known_remote.is_none() { + return self + .open_local_workspace_with_runtime_ownership( + workspace_service, + path, + snapshot_log_context, + ) + .await; + } + // Caller-provided remote facts only select a known workspace. They are // not authority to bypass the local Runtime ownership lease. let resolved_connection_id = known_remote @@ -2150,34 +2166,115 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .and_then(|value| value.as_str()) .map(ToOwned::to_owned) }); - if let Some(connection_id) = resolved_connection_id.as_deref() { - self.ensure_verified_remote_workspace_runtime_ownership( - &path, - connection_id, - resolved_ssh_host.as_deref(), - )?; - } else { - self.ensure_runtime_ownership(&path, None, None)?; - } + let connection_id = resolved_connection_id + .as_deref() + .ok_or_else(|| BitFunError::service("Known remote workspace has no connection id"))?; + self.ensure_verified_remote_workspace_runtime_ownership( + &path, + connection_id, + resolved_ssh_host.as_deref(), + )?; let info = workspace_service .open_workspace_after_known_resolution(path, known_remote) .await?; - if info.workspace_kind != WorkspaceKind::Remote { - if let Err(error) = crate::service::snapshot::initialize_snapshot_manager_for_workspace( - info.root_path.clone(), - None, - ) - .await - { - error!( - "Failed to initialize snapshot after {}: {}", - snapshot_log_context, error - ); - } + Self::initialize_snapshot_after_workspace_open(&info, snapshot_log_context).await; + Ok(info) + } + + /// Opens an authoritative local path without consulting remote history. + /// This is the entrypoint for product hosts whose workspace source is known + /// to be local (CLI arguments, defaults, or local history records). + pub async fn open_local_workspace_with_runtime_ownership( + &self, + workspace_service: &WorkspaceService, + path: PathBuf, + snapshot_log_context: &str, + ) -> BitFunResult { + if !path.exists() { + return Err(BitFunError::service(format!( + "Workspace path does not exist: {}", + path.display() + ))); + } + if !path.is_dir() { + return Err(BitFunError::service(format!( + "Workspace path is not a directory: {}", + path.display() + ))); } + let ownership = self + .runtime_ownership + .begin_local_workspace_open(&path) + .map_err(|error| BitFunError::Service(self.runtime_ownership.error_message(&error)))?; + ownership + .validate_workspace_path(&path) + .map_err(|error| BitFunError::Service(self.runtime_ownership.error_message(&error)))?; + let info = workspace_service.open_workspace(path).await?; + ownership + .commit() + .map_err(|error| BitFunError::Service(self.runtime_ownership.error_message(&error)))?; + Self::initialize_snapshot_after_workspace_open(&info, snapshot_log_context).await; Ok(info) } + /// Creates and opens a product-managed local workspace while holding its + /// Runtime ownership claim across the first directory allocation. + pub async fn create_and_open_managed_local_workspace_with_runtime_ownership( + &self, + workspace_service: &WorkspaceService, + path: PathBuf, + snapshot_log_context: &str, + ) -> BitFunResult { + if path.exists() { + return self + .open_local_workspace_with_runtime_ownership( + workspace_service, + path, + snapshot_log_context, + ) + .await; + } + let ownership = self + .runtime_ownership + .begin_managed_local_workspace_creation(&path) + .map_err(|error| BitFunError::Service(self.runtime_ownership.error_message(&error)))?; + tokio::fs::create_dir_all(&path).await.map_err(|error| { + BitFunError::service(format!( + "Failed to create managed workspace '{}': {error}", + path.display() + )) + })?; + ownership + .validate_workspace_path(&path) + .map_err(|error| BitFunError::Service(self.runtime_ownership.error_message(&error)))?; + let info = workspace_service.open_workspace(path).await?; + ownership + .commit() + .map_err(|error| BitFunError::Service(self.runtime_ownership.error_message(&error)))?; + Self::initialize_snapshot_after_workspace_open(&info, snapshot_log_context).await; + Ok(info) + } + + async fn initialize_snapshot_after_workspace_open( + info: &WorkspaceInfo, + snapshot_log_context: &str, + ) { + if info.workspace_kind == WorkspaceKind::Remote { + return; + } + if let Err(error) = crate::service::snapshot::initialize_snapshot_manager_for_workspace( + info.root_path.clone(), + None, + ) + .await + { + error!( + "Failed to initialize snapshot after {}: {}", + snapshot_log_context, error + ); + } + } + /// Ensures ownership from the loaded session binding, or from a local /// fallback workspace before a session is restored. pub fn ensure_session_runtime_ownership( @@ -14540,24 +14637,53 @@ mod tests { #[test] fn workspace_open_owner_gates_before_open_and_guards_snapshot_by_kind() { let source = include_str!("coordinator.rs"); - let helper = source + let remote_helper = source .split("pub async fn open_workspace_with_runtime_ownership") .nth(1) .and_then(|source| { source - .split("pub fn ensure_session_runtime_ownership") + .split("pub async fn open_local_workspace_with_runtime_ownership") .next() }) - .expect("workspace open owner"); - let ownership_gate = helper - .find("ensure_runtime_ownership") - .expect("workspace ownership gate"); - let workspace_open = helper + .expect("remote-aware workspace open owner"); + let remote_ownership_gate = remote_helper + .find("ensure_verified_remote_workspace_runtime_ownership") + .expect("verified remote ownership gate"); + let remote_workspace_open = remote_helper .find("open_workspace_after_known_resolution") - .expect("workspace open call"); - assert!(ownership_gate < workspace_open); - assert!(helper.contains("WorkspaceKind::Remote")); - assert!(helper.contains("initialize_snapshot_manager_for_workspace")); + .expect("remote workspace open call"); + assert!(remote_ownership_gate < remote_workspace_open); + + let local_helper = source + .split("pub async fn open_local_workspace_with_runtime_ownership") + .nth(1) + .and_then(|source| { + source + .split("async fn initialize_snapshot_after_workspace_open") + .next() + }) + .expect("local workspace open owner"); + let local_ownership_gate = local_helper + .find("begin_local_workspace_open") + .expect("provisional local ownership gate"); + let local_workspace_open = local_helper + .find("workspace_service.open_workspace") + .expect("local workspace open call"); + let ownership_commit = local_helper.find(".commit()").expect("ownership commit"); + assert!(local_ownership_gate < local_workspace_open); + assert!(local_workspace_open < ownership_commit); + + let snapshot_helper = source + .split("async fn initialize_snapshot_after_workspace_open") + .nth(1) + .and_then(|source| { + source + .split("pub fn ensure_session_runtime_ownership") + .next() + }) + .expect("snapshot initialization owner"); + assert!(snapshot_helper.contains("WorkspaceKind::Remote")); + assert!(snapshot_helper.contains("initialize_snapshot_manager_for_workspace")); let bot_router = include_str!("../../service/remote_connect/bot/command_router.rs"); assert!(bot_router.contains("open_workspace_with_runtime_ownership")); @@ -14612,6 +14738,139 @@ mod tests { assert_eq!(opened.remote_ssh_connection_id(), Some("conn-known-remote")); } + #[tokio::test] + async fn local_workspace_open_does_not_reclassify_a_same_path_remote_record() { + let root = tempfile::tempdir().expect("test root"); + let workspace = tempfile::tempdir().expect("local workspace"); + let path_manager = Arc::new(PathManager::with_user_root_for_tests( + root.path().join("user-root"), + )); + let workspace_service = + crate::service::workspace::WorkspaceService::new_for_test_path_manager(path_manager) + .await; + workspace_service + .track_workspace_activity( + workspace.path().to_path_buf(), + crate::service::workspace::WorkspaceCreateOptions { + workspace_kind: WorkspaceKind::Remote, + remote_connection_id: Some("same-path-remote".to_string()), + remote_ssh_host: Some("remote-host".to_string()), + ..Default::default() + }, + crate::service::workspace::WorkspaceActivityMode::RefreshMetadata, + ) + .await + .expect("remember same-path remote workspace"); + + let ownership_root = root.path().join("ownership"); + let key = bitfun_services_core::runtime_ownership::RuntimeOwnershipKey::for_workspace( + workspace.path(), + "bitfun", + ) + .expect("ownership key"); + let _shared = + bitfun_services_core::runtime_ownership::WorkspaceRuntimeOwnership::try_acquire( + &ownership_root, + &key, + bitfun_services_core::runtime_ownership::RuntimeDeployment::Shared, + ) + .expect("shared owner"); + let owner = Arc::new(CoreRuntimeOwnership::embedded_with_facts( + ownership_root, + "bitfun".to_string(), + "test", + )); + let (coordinator, _) = test_coordinator_with_config_and_ownership(100, false, owner); + + let error = coordinator + .open_local_workspace_with_runtime_ownership( + &workspace_service, + workspace.path().to_path_buf(), + "authoritative local test", + ) + .await + .expect_err("same-path remote history must not bypass local ownership"); + + assert!(error.to_string().contains("ownership")); + } + + #[tokio::test] + async fn workspace_open_rejects_non_directory_before_acquiring_ownership() { + let root = tempfile::tempdir().expect("test root"); + let workspace_file = root.path().join("workspace-file"); + std::fs::write(&workspace_file, "not a workspace").expect("workspace file"); + let path_manager = Arc::new(PathManager::with_user_root_for_tests( + root.path().join("user-root"), + )); + let workspace_service = + crate::service::workspace::WorkspaceService::new_for_test_path_manager( + path_manager.clone(), + ) + .await; + let owner = Arc::new(CoreRuntimeOwnership::embedded( + path_manager.as_ref(), + "test", + )); + let (coordinator, _) = test_coordinator_with_config_and_ownership(100, false, owner); + + let error = coordinator + .open_workspace_with_runtime_ownership( + &workspace_service, + workspace_file.clone(), + None, + None, + "non-directory test", + ) + .await + .expect_err("a workspace must be a directory"); + + assert!(error.to_string().contains("not a directory")); + assert!( + !CoreRuntimeOwnership::runtime_owner_present(path_manager.as_ref(), &workspace_file,) + .expect("ownership state should remain readable"), + "invalid workspace input must fail before it leaves a process lease" + ); + } + + #[tokio::test] + async fn managed_workspace_is_created_and_opened_under_runtime_ownership() { + let root = tempfile::tempdir().expect("test root"); + let path_manager = Arc::new(PathManager::with_user_root_for_tests( + root.path().join("user-root"), + )); + let workspace_service = + crate::service::workspace::WorkspaceService::new_for_test_path_manager( + path_manager.clone(), + ) + .await; + let owner = Arc::new(CoreRuntimeOwnership::embedded( + path_manager.as_ref(), + "test", + )); + let (coordinator, _) = test_coordinator_with_config_and_ownership(100, false, owner); + let workspace = root.path().join("managed").join("workspace"); + + let opened = coordinator + .create_and_open_managed_local_workspace_with_runtime_ownership( + &workspace_service, + workspace.clone(), + "managed workspace test", + ) + .await + .expect("managed workspace should open"); + + assert_eq!( + opened.root_path, + dunce::canonicalize(&workspace).expect("canonical managed workspace") + ); + assert!(opened.root_path.is_dir()); + assert!(CoreRuntimeOwnership::runtime_owner_present( + path_manager.as_ref(), + &opened.root_path + ) + .expect("ownership state should remain readable")); + } + #[tokio::test] async fn unverified_remote_hint_cannot_bypass_local_workspace_ownership() { let ownership_root = tempfile::tempdir().expect("ownership root"); diff --git a/src/crates/assembly/core/src/runtime_ownership.rs b/src/crates/assembly/core/src/runtime_ownership.rs index efc3210a1c..f3713cade2 100644 --- a/src/crates/assembly/core/src/runtime_ownership.rs +++ b/src/crates/assembly/core/src/runtime_ownership.rs @@ -20,7 +20,7 @@ const DEFAULT_PRODUCT_IDENTITY: &str = "bitfun"; enum CoreRuntimeOwnershipDeployment { Embedded { - leases: Mutex>, + leases: Mutex>, }, Shared { key: RuntimeOwnershipKey, @@ -28,6 +28,74 @@ enum CoreRuntimeOwnershipDeployment { }, } +struct EmbeddedWorkspaceLease { + _lease: WorkspaceRuntimeOwnership, + committed: bool, + provisional_claims: usize, +} + +/// A local workspace lease held while the workspace owner performs its open. +/// Dropping an uncommitted claim rolls back only a lease acquired for failed +/// in-flight opens; an already committed process lease remains process-bound. +pub(crate) struct ProvisionalLocalWorkspaceOwnership<'a> { + owner: &'a CoreRuntimeOwnership, + key: RuntimeOwnershipKey, + provisional: bool, +} + +impl ProvisionalLocalWorkspaceOwnership<'_> { + pub(crate) fn commit(mut self) -> Result<(), CoreRuntimeOwnershipError> { + if !self.provisional { + return Ok(()); + } + let CoreRuntimeOwnershipDeployment::Embedded { leases } = &self.owner.deployment else { + return Ok(()); + }; + let mut leases = leases + .lock() + .map_err(|_| CoreRuntimeOwnershipError::OwnershipStateUnavailable)?; + let lease = leases + .get_mut(&self.key) + .ok_or(CoreRuntimeOwnershipError::OwnershipStateUnavailable)?; + lease.provisional_claims = lease.provisional_claims.saturating_sub(1); + lease.committed = true; + self.provisional = false; + Ok(()) + } + + pub(crate) fn validate_workspace_path( + &self, + workspace: &Path, + ) -> Result<(), CoreRuntimeOwnershipError> { + let actual = RuntimeOwnershipKey::for_workspace(workspace, &self.owner.product_identity)?; + if actual != self.key { + return Err(CoreRuntimeOwnershipError::WorkspaceIdentityChangedDuringOpen); + } + Ok(()) + } +} + +impl Drop for ProvisionalLocalWorkspaceOwnership<'_> { + fn drop(&mut self) { + if !self.provisional { + return; + } + let CoreRuntimeOwnershipDeployment::Embedded { leases } = &self.owner.deployment else { + return; + }; + let Ok(mut leases) = leases.lock() else { + return; + }; + let should_remove = leases.get_mut(&self.key).is_some_and(|lease| { + lease.provisional_claims = lease.provisional_claims.saturating_sub(1); + !lease.committed && lease.provisional_claims == 0 + }); + if should_remove { + leases.remove(&self.key); + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct VerifiedRemoteRuntimeScope { workspace_path: String, @@ -181,7 +249,8 @@ impl CoreRuntimeOwnership { let mut leases = leases .lock() .map_err(|_| CoreRuntimeOwnershipError::OwnershipStateUnavailable)?; - if leases.contains_key(&key) { + if let Some(lease) = leases.get_mut(&key) { + lease.committed = true; return Ok(()); } let lease = WorkspaceRuntimeOwnership::try_acquire( @@ -198,7 +267,14 @@ impl CoreRuntimeOwnership { ); })?; log_acquired(self.entrypoint, RuntimeDeployment::Embedded, &key); - leases.insert(key, lease); + leases.insert( + key, + EmbeddedWorkspaceLease { + _lease: lease, + committed: true, + provisional_claims: 0, + }, + ); Ok(()) } CoreRuntimeOwnershipDeployment::Shared { @@ -214,6 +290,97 @@ impl CoreRuntimeOwnership { } } + /// Acquires a reversible local ownership claim for an in-flight workspace + /// open. The caller commits only after the Workspace owner accepts the + /// path; otherwise `Drop` releases a newly acquired process lease. + pub(crate) fn begin_local_workspace_open( + &self, + workspace: &Path, + ) -> Result, CoreRuntimeOwnershipError> { + let key = RuntimeOwnershipKey::for_workspace(workspace, &self.product_identity)?; + self.begin_local_workspace_with_key(key) + } + + /// Acquires the same reversible claim for a product-managed workspace that + /// has not been created yet. This prevents directory allocation from + /// preceding Runtime ownership. + pub(crate) fn begin_managed_local_workspace_creation( + &self, + workspace: &Path, + ) -> Result, CoreRuntimeOwnershipError> { + let key = RuntimeOwnershipKey::for_workspace_candidate(workspace, &self.product_identity)?; + self.begin_local_workspace_with_key(key) + } + + fn begin_local_workspace_with_key( + &self, + key: RuntimeOwnershipKey, + ) -> Result, CoreRuntimeOwnershipError> { + match &self.deployment { + CoreRuntimeOwnershipDeployment::Embedded { leases } => { + let mut leases = leases + .lock() + .map_err(|_| CoreRuntimeOwnershipError::OwnershipStateUnavailable)?; + if let Some(lease) = leases.get_mut(&key) { + if lease.committed { + return Ok(ProvisionalLocalWorkspaceOwnership { + owner: self, + key, + provisional: false, + }); + } + lease.provisional_claims += 1; + return Ok(ProvisionalLocalWorkspaceOwnership { + owner: self, + key, + provisional: true, + }); + } + let lease = WorkspaceRuntimeOwnership::try_acquire( + &self.ownership_root, + &key, + RuntimeDeployment::Embedded, + ) + .inspect_err(|error| { + log_acquisition_failure( + self.entrypoint, + RuntimeDeployment::Embedded, + &key, + error, + ); + })?; + log_acquired(self.entrypoint, RuntimeDeployment::Embedded, &key); + leases.insert( + key.clone(), + EmbeddedWorkspaceLease { + _lease: lease, + committed: false, + provisional_claims: 1, + }, + ); + Ok(ProvisionalLocalWorkspaceOwnership { + owner: self, + key, + provisional: true, + }) + } + CoreRuntimeOwnershipDeployment::Shared { + key: shared_key, .. + } if shared_key == &key => Ok(ProvisionalLocalWorkspaceOwnership { + owner: self, + key, + provisional: false, + }), + CoreRuntimeOwnershipDeployment::Shared { .. } => { + warn!( + "Shared Agent Runtime rejected a second local workspace: entrypoint={}, error_code=shared_runtime_workspace_mismatch", + self.entrypoint + ); + Err(CoreRuntimeOwnershipError::SharedRuntimeWorkspaceMismatch) + } + } + } + /// Tests whether another local Runtime currently owns this workspace. pub fn runtime_owner_present( path_manager: &PathManager, @@ -280,6 +447,8 @@ pub enum CoreRuntimeOwnershipError { Primitive(#[from] RuntimeOwnershipError), #[error("runtime ownership state is unavailable")] OwnershipStateUnavailable, + #[error("workspace identity changed while Runtime ownership was being acquired")] + WorkspaceIdentityChangedDuringOpen, #[error("Shared Agent Runtime is limited to its startup workspace")] SharedRuntimeWorkspaceMismatch, #[error("remote workspace binding was not verified by the Workspace owner")] @@ -291,6 +460,7 @@ impl CoreRuntimeOwnershipError { match self { Self::Primitive(error) => error.code(), Self::OwnershipStateUnavailable => "ownership_state_unavailable", + Self::WorkspaceIdentityChangedDuringOpen => "workspace_identity_changed_during_open", Self::SharedRuntimeWorkspaceMismatch => "shared_runtime_workspace_mismatch", Self::UnverifiedRemoteWorkspaceScope => "unverified_remote_workspace_scope", } @@ -382,3 +552,97 @@ fn log_acquisition_failure( fn key_prefix(key: &RuntimeOwnershipKey) -> &str { key.as_str().get(..12).unwrap_or(key.as_str()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uncommitted_embedded_workspace_claim_is_released() { + let ownership_root = tempfile::tempdir().expect("ownership root"); + let workspace = tempfile::tempdir().expect("workspace"); + let owner = CoreRuntimeOwnership::embedded_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "test", + ); + + let claim = owner + .begin_local_workspace_open(workspace.path()) + .expect("provisional owner"); + drop(claim); + + let key = + RuntimeOwnershipKey::for_workspace(workspace.path(), "bitfun").expect("ownership key"); + WorkspaceRuntimeOwnership::try_acquire( + ownership_root.path(), + &key, + RuntimeDeployment::Shared, + ) + .expect("a failed open must not retain its provisional lease"); + } + + #[test] + fn one_successful_concurrent_claim_commits_the_process_lease() { + let ownership_root = tempfile::tempdir().expect("ownership root"); + let workspace = tempfile::tempdir().expect("workspace"); + let owner = CoreRuntimeOwnership::embedded_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "test", + ); + + let failed_open = owner + .begin_local_workspace_open(workspace.path()) + .expect("first provisional owner"); + let successful_open = owner + .begin_local_workspace_open(workspace.path()) + .expect("second provisional owner"); + successful_open.commit().expect("commit successful open"); + drop(failed_open); + + let key = + RuntimeOwnershipKey::for_workspace(workspace.path(), "bitfun").expect("ownership key"); + assert!(matches!( + WorkspaceRuntimeOwnership::try_acquire( + ownership_root.path(), + &key, + RuntimeDeployment::Shared, + ), + Err(RuntimeOwnershipError::OwnershipUnavailable { .. }) + )); + } + + #[test] + fn managed_workspace_creation_is_claimed_before_the_directory_exists() { + let ownership_root = tempfile::tempdir().expect("ownership root"); + let managed_root = tempfile::tempdir().expect("managed root"); + let workspace = managed_root + .path() + .join("personal_assistant") + .join("workspace"); + let owner = CoreRuntimeOwnership::embedded_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "test", + ); + + let claim = owner + .begin_managed_local_workspace_creation(&workspace) + .expect("claim managed workspace before creation"); + std::fs::create_dir_all(&workspace).expect("create managed workspace"); + claim + .validate_workspace_path(&workspace) + .expect("candidate key must match the created workspace"); + + let key = RuntimeOwnershipKey::for_workspace(&workspace, "bitfun").expect("ownership key"); + assert!(matches!( + WorkspaceRuntimeOwnership::try_acquire( + ownership_root.path(), + &key, + RuntimeDeployment::Shared, + ), + Err(RuntimeOwnershipError::OwnershipUnavailable { .. }) + )); + } +} diff --git a/src/crates/assembly/core/src/service/workspace/service.rs b/src/crates/assembly/core/src/service/workspace/service.rs index b37de9e080..cbdf644bc9 100644 --- a/src/crates/assembly/core/src/service/workspace/service.rs +++ b/src/crates/assembly/core/src/service/workspace/service.rs @@ -242,11 +242,29 @@ impl WorkspaceService { /// Creates a new workspace service. pub async fn new() -> BitFunResult { let config = WorkspaceManagerConfig::default(); - Self::with_config(config).await + Self::with_config_and_workspace_preparation(config, true).await + } + + /// Creates a workspace service that restores persisted metadata without + /// preparing any restored workspace. + /// + /// Runtime hosts that enforce workspace ownership use this constructor, + /// then perform an ownership-aware open before workspace-scoped startup + /// effects such as `.gitignore` updates or Runtime data migration. + pub async fn new_with_deferred_workspace_preparation() -> BitFunResult { + let config = WorkspaceManagerConfig::default(); + Self::with_config_and_workspace_preparation(config, false).await } /// Creates a workspace service with a custom configuration. pub async fn with_config(config: WorkspaceManagerConfig) -> BitFunResult { + Self::with_config_and_workspace_preparation(config, true).await + } + + async fn with_config_and_workspace_preparation( + config: WorkspaceManagerConfig, + prepare_workspaces: bool, + ) -> BitFunResult { let path_manager = try_get_path_manager_arc()?; let runtime_service = try_get_workspace_runtime_service_arc()?; @@ -270,19 +288,26 @@ impl WorkspaceService { runtime_service, }; - if let Err(e) = service.load_workspace_history_only().await { + let history_result = if prepare_workspaces { + service.load_workspace_history_only().await + } else { + service.load_workspace_history_metadata_only().await + }; + if let Err(e) = history_result { warn!("Failed to load workspace history on startup: {}", e); } - if let Err(e) = service.remap_legacy_assistant_workspace_records().await { - warn!( - "Failed to remap legacy assistant workspace records on startup: {}", - e - ); - } + if prepare_workspaces { + if let Err(e) = service.remap_legacy_assistant_workspace_records().await { + warn!( + "Failed to remap legacy assistant workspace records on startup: {}", + e + ); + } - if let Err(e) = service.ensure_assistant_workspaces().await { - warn!("Failed to ensure assistant workspaces on startup: {}", e); + if let Err(e) = service.ensure_assistant_workspaces().await { + warn!("Failed to ensure assistant workspaces on startup: {}", e); + } } Ok(service) @@ -1726,8 +1751,20 @@ impl WorkspaceService { Ok(()) } - /// Loads workspace history only without restoring the current workspace (used on startup). + /// Loads persisted history and prepares restored workspaces for the default + /// single-host startup path. async fn load_workspace_history_only(&self) -> BitFunResult<()> { + self.load_workspace_history(true).await + } + + /// Loads persisted workspace metadata without workspace-scoped startup + /// preparation. Runtime hosts use the restored current selection only as + /// an advisory candidate until they acquire ownership and open it. + async fn load_workspace_history_metadata_only(&self) -> BitFunResult<()> { + self.load_workspace_history(false).await + } + + async fn load_workspace_history(&self, prepare_workspaces: bool) -> BitFunResult<()> { let workspace_data: Option = self .persistence .load_json("workspace_data") @@ -1825,8 +1862,10 @@ impl WorkspaceService { self.save_workspace_data().await?; } - self.prepare_startup_restored_workspaces(workspaces_to_restore) - .await; + if prepare_workspaces { + self.prepare_startup_restored_workspaces(workspaces_to_restore) + .await; + } Ok(()) } @@ -2135,7 +2174,10 @@ impl WorkspaceService { return options; } - if let Some(descriptor) = self.assistant_descriptor_from_path(path) { + if let Some(descriptor) = self + .assistant_descriptor_from_path(path) + .or_else(|| self.legacy_assistant_descriptor_from_path(path)) + { options.workspace_kind = WorkspaceKind::Assistant; if options.assistant_id.is_none() { options.assistant_id = descriptor.assistant_id; @@ -2282,6 +2324,7 @@ impl WorkspaceService { /// Returns whether a path is a managed assistant workspace. pub fn is_assistant_workspace_path(&self, path: &Path) -> bool { self.assistant_descriptor_from_path(path).is_some() + || self.legacy_assistant_descriptor_from_path(path).is_some() } /// Clears all persisted data. @@ -2470,6 +2513,30 @@ mod tests { assert_eq!(gitignore, "target/\n"); } + #[tokio::test] + async fn legacy_assistant_path_is_classified_without_startup_migration() { + let env = TestEnvironment::new(); + let service = build_test_workspace_service(env.path_manager.clone()).await; + let legacy = env + .path_manager + .legacy_default_assistant_workspace_dir(None); + std::fs::create_dir_all(&legacy).expect("legacy assistant workspace"); + + let options = service + .normalize_workspace_options_for_path(&legacy, WorkspaceCreateOptions::default()); + + assert_eq!(options.workspace_kind, WorkspaceKind::Assistant); + assert_eq!(options.assistant_id, None); + assert!(service.is_assistant_workspace_path(&legacy)); + assert!(legacy.is_dir()); + assert!( + !env.path_manager + .default_assistant_workspace_dir(None) + .exists(), + "classification must not migrate workspace data before ownership" + ); + } + #[tokio::test] async fn load_workspace_history_only_ensures_all_opened_local_workspaces() { let env = TestEnvironment::new(); @@ -2589,6 +2656,66 @@ mod tests { ); } + #[tokio::test] + async fn metadata_only_history_load_defers_workspace_side_effects_until_ownership() { + let env = TestEnvironment::new(); + let service = build_test_workspace_service(env.path_manager.clone()).await; + let workspace_root = env.create_workspace_dir("deferred-workspace"); + let gitignore_path = workspace_root.join(".gitignore"); + std::fs::write(&gitignore_path, "target/\n").expect("gitignore should be seeded"); + + let workspace = WorkspaceInfo::new( + workspace_root.clone(), + WorkspaceOpenOptions { + auto_set_current: false, + ..Default::default() + }, + ) + .await + .expect("workspace should initialize"); + let workspace_data = WorkspacePersistenceData { + workspaces: HashMap::from([(workspace.id.clone(), workspace.clone())]), + opened_workspace_ids: vec![workspace.id.clone()], + current_workspace_id: Some(workspace.id.clone()), + recent_workspaces: vec![workspace.id.clone()], + recent_assistant_workspaces: Vec::new(), + saved_at: chrono::Utc::now(), + }; + service + .persistence + .save_json("workspace_data", &workspace_data, StorageOptions::default()) + .await + .expect("workspace data should save"); + + let runtime = service + .runtime_service + .context_for_local_workspace(&workspace_root); + assert!(!runtime.runtime_root.exists()); + + service + .load_workspace_history_metadata_only() + .await + .expect("workspace history metadata should restore"); + + assert_eq!( + service + .get_current_workspace() + .await + .map(|current| current.id), + Some(workspace.id), + "metadata restore should preserve the advisory current workspace selection" + ); + assert_eq!( + std::fs::read_to_string(gitignore_path).expect("gitignore should be readable"), + "target/\n", + "metadata restore must not prepare the workspace before Runtime ownership" + ); + assert!( + !runtime.runtime_root.exists(), + "metadata restore must not create or migrate Runtime data before ownership" + ); + } + #[tokio::test] async fn track_workspace_activity_registers_without_opening_workspace() { let env = TestEnvironment::new(); diff --git a/src/crates/interfaces/app-server/AGENTS-CN.md b/src/crates/interfaces/app-server/AGENTS-CN.md index 18ec43ecb0..9328f87cbc 100644 --- a/src/crates/interfaces/app-server/AGENTS-CN.md +++ b/src/crates/interfaces/app-server/AGENTS-CN.md @@ -31,15 +31,11 @@ JSON-RPC server/client 脚手架。role/transport 层不绑定 schema;使用 `bitfun_app_server::client::connect`。 - Transport 构造器必须固定 `ByteStreams::new(outgoing, incoming)` 方向;不要暴露 易出错的 swap API。 -- 本 crate 在 option C 下拥有**完整后端契约**:app-server schema 是前端面对的单一 - JSON-RPC 接口,覆盖 agent kernel 操作(委托给 `bitfun-agent-runtime` SDK)和 - host 服务(git/mcp/config/cron/snapshot/fs/workspace/...)。为覆盖 host 服务,它 - 直接依赖 `assembly/core`(`bitfun-core`,`features = "product-full"`)— 与 - `bitfun-acp` 已有的模式相同(`bitfun-acp/Cargo.toml`)。Product assembly 构造 - `AgentRuntime` 和 host 服务单例并通过 `BitfunAppRuntime` 注入两者;host 服务的 - schema handler 调用 `bitfun_core::service::*`,与 Desktop host 相同(静态/全局 - 访问器),因此 `BitfunAppRuntime` 不需要按服务持有 host-services 字段。不要将本 - crate 描述为 host 服务操作的 Core 无关;agent-kernel handler 仍由 SDK facade 支持。 +- 具体 schema 只覆盖已经交付且有消费者的能力。当前包括 Agent Runtime 的 + Session/Turn/Permission 操作与事件投递,以及 Web client 已使用的 git/config/i18n + host-service 方法。Core 依赖必须保持精确的 `agent-runtime` feature 闭包。新增 + host-service 家族必须先明确 schema owner 并复审 feature 边界;不得恢复 + `product-full`,也不得把尚未实现的后端超集描述为当前能力。 - Handler 将 runtime 调用卸载到后台任务或立即返回;不要在 handler 回调内调用 `SentRequest::block_task`(`jsonrpc.rs` 中的上游 `DEADLOCK` 注释)。通过 `responder.respond_with_result` 回复。 diff --git a/src/crates/interfaces/app-server/AGENTS.md b/src/crates/interfaces/app-server/AGENTS.md index 2e852a693d..3b04d4cc93 100644 --- a/src/crates/interfaces/app-server/AGENTS.md +++ b/src/crates/interfaces/app-server/AGENTS.md @@ -36,18 +36,12 @@ role, unlike `bitfun-acp` which uses the built-in ACP `Agent` role. on the client half. - Transport constructors must pin `ByteStreams::new(outgoing, incoming)` direction; never expose a swap-prone API. -- This crate owns the **full backend contract** under option C: the app-server - schema is the single JSON-RPC surface the frontend faces, covering both agent - kernel operations (delegated to `bitfun-agent-runtime` SDK) and host services - (git/mcp/config/cron/snapshot/fs/workspace/...). To cover host services it - depends directly on `assembly/core` (`bitfun-core`, `features = "product-full"`) - -- the same pattern `bitfun-acp` already follows (`bitfun-acp/Cargo.toml`). - Product assembly constructs the `AgentRuntime` and the host service singletons - and injects both via `BitfunAppRuntime`; schema handlers for host services call - `bitfun_core::service::*` the same way the Desktop host does (static/global - accessors), so `BitfunAppRuntime` does not need a host-services field per - service. Do not describe this crate as Core-independent for host-service - operations; the agent-kernel handlers remain backed by the SDK facade only. +- Keep the concrete schema bounded to delivered consumers. It currently exposes + Agent Runtime session/turn/permission operations and event delivery, plus the + git/config/i18n host-service methods already used by the Web client. Its Core + dependency must keep the exact `agent-runtime` feature closure. A new + host-service family needs a reviewed schema owner and feature boundary; do not + restore `product-full` or describe an unimplemented backend superset as current. - Handlers offload runtime calls to background tasks or return immediately; do not call `SentRequest::block_task` inside a handler callback (upstream `DEADLOCK` note in `jsonrpc.rs`). Reply through `responder.respond_with_result`. diff --git a/src/crates/interfaces/app-server/Cargo.toml b/src/crates/interfaces/app-server/Cargo.toml index ee7594c4d6..dbb63a4677 100644 --- a/src/crates/interfaces/app-server/Cargo.toml +++ b/src/crates/interfaces/app-server/Cargo.toml @@ -10,14 +10,10 @@ name = "bitfun_app_server" [dependencies] agent-client-protocol = { workspace = true } -# Host services (git/mcp/config/cron/snapshot/fs/workspace/...) live in -# `bitfun-core`. The app-server surface owns the full backend contract under -# option C, so it depends on core directly -- mirroring `bitfun-acp`, which -# already does the same (`bitfun-acp/Cargo.toml`). Product assembly constructs -# the `AgentRuntime` and the host service singletons and injects both via -# `BitfunAppRuntime`; the schema handlers call `bitfun_core::service::*` the -# same way the Desktop host does (static/global accessors, no extra injection). -bitfun-core = { path = "../../assembly/core", default-features = false, features = ["product-full"] } +# The current schema exposes Agent Runtime operations plus the git/config/i18n +# services already owned by the runtime closure. Keep this exact feature set; +# adding a new host-service family requires a schema owner and feature review. +bitfun-core = { path = "../../assembly/core", default-features = false, features = ["agent-runtime"] } bitfun-agent-runtime = { path = "../../execution/agent-runtime" } bitfun-events = { path = "../../contracts/events" } tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } diff --git a/src/crates/interfaces/app-server/src/lib.rs b/src/crates/interfaces/app-server/src/lib.rs index 865f092dff..e2ceaebdf7 100644 --- a/src/crates/interfaces/app-server/src/lib.rs +++ b/src/crates/interfaces/app-server/src/lib.rs @@ -45,13 +45,10 @@ //! avoid implying a stable public SDK. They will be replaced by a proper //! versioned event envelope and connection protocol in a follow-up. -// Lifted from the default 128: the `AppServer` builder chains one -// `ChainedHandler` layer per registered request handler, and with the -// agent-kernel + permission + git + config surface all on one builder the -// monomorphized handler tower overflows the default recursion limit when the -// `agent_kernel` integration test instantiates the full `BitfunAppServer::serve` -// connection. Raise it so the chain keeps compiling as more host-service groups -// land under option C. +// Lifted from the default 128: the current bounded request groups already form +// a deep `ChainedHandler` type when `BitfunAppServer::serve` is instantiated by +// the integration tests. This is an implementation limit for the delivered +// surface, not a commitment to absorb every backend API into this crate. #![recursion_limit = "256"] pub mod agent; @@ -75,8 +72,8 @@ pub use server::BitfunAppServer; /// Convenience prelude for consumers building an app-server connection. pub mod prelude { pub use crate::{ - agent, client, schema, server, transport, AppClient, AppServer, - BitfunAppRuntime, BitfunAppServer, + agent, client, schema, server, transport, AppClient, AppServer, BitfunAppRuntime, + BitfunAppServer, }; pub use agent_client_protocol::{ Builder, ConnectionTo, Dispatch, Handled, JsonRpcNotification, JsonRpcRequest, diff --git a/src/crates/interfaces/app-server/src/schema.rs b/src/crates/interfaces/app-server/src/schema.rs index c6b8418b69..89e5b4ed51 100644 --- a/src/crates/interfaces/app-server/src/schema.rs +++ b/src/crates/interfaces/app-server/src/schema.rs @@ -431,7 +431,7 @@ pub struct SessionEventNotification(pub AgenticEventEnvelope); /// to the browser by the server's `serve` main loop. Carrying the projected /// `event` name and `payload` lets the browser `listen(event)` dispatch on the /// same names it uses today, with zero call-site change. This is the -/// browser-facing event surface under browser-direct ACP-over-WS (Step 2). +/// browser-facing event surface under direct App Server JSON-RPC over WebSocket. #[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[notification(method = "agent/frontendEvent")] @@ -444,8 +444,8 @@ pub struct FrontendEventNotification { // Git service surface --------------------------------------------------------- // -// Under option C the app-server schema owns the full backend contract, not just -// agent-kernel ops. These `git/*` messages expose the read-only `GitService` +// The current App Server consumer needs this bounded read-only Git projection +// alongside agent-kernel operations. These `git/*` messages expose `GitService` // operations (`bitfun_core::service::git::GitService`, which re-exports // `bitfun_services_integrations::git::GitService`). The handlers call the // static `GitService::xxx(&path)` associated functions the same way the @@ -529,9 +529,8 @@ pub struct GitBranchesRequest { // Config service surface ----------------------------------------------------- // -// Read-only `ConfigService` / agent-profile canonicalizer operations. Under -// option C these live on the app-server surface alongside the agent-kernel -// and `git/*` groups. The handlers call the global config singletons the same +// Read-only `ConfigService` / agent-profile canonicalizer operations required +// by the current App Server consumer. The handlers call global config singletons the same // way the Desktop host does -- `bitfun_core::service::config::get_global_config_service` // (an `Arc` initialized by the host's bootstrap) and the static // `mode_config_canonicalizer::get_agent_profile_views` -- so no service diff --git a/src/crates/interfaces/app-server/src/server.rs b/src/crates/interfaces/app-server/src/server.rs index 9bf9dde941..ac3c869605 100644 --- a/src/crates/interfaces/app-server/src/server.rs +++ b/src/crates/interfaces/app-server/src/server.rs @@ -24,19 +24,50 @@ use bitfun_agent_runtime::sdk::PermissionRequestEvent; use bitfun_core::service::git::GitService; use bitfun_events::project_agentic_frontend_event; -/// Method-name substrings for the external-source operations that the old Server -/// Host dispatched via `routes/external_sources.rs`. Under browser-direct ACP -/// these are not yet on the app-server schema; the dispatch fallback returns a -/// typed "not available in web mode" error so the frontend gets a clear signal -/// rather than a bare `method_not_found`. -const EXTERNAL_SOURCE_METHOD_MARKERS: &[&str] = &[ - "external_source", - "external_tool", - "external_subagent", - "external_mcp", - "external_integration", +/// Complete command set routed through the Web UI external-source error +/// contract, including its external Hook facade. These methods are not on the +/// paused Server Host's App Server schema yet, so the fallback preserves the +/// domain error contract instead of relying on name substrings. +const EXTERNAL_SOURCE_METHODS: &[&str] = &[ + "plan_external_mcp_import_command", + "apply_external_mcp_import_command", + "get_external_source_snapshot", + "get_external_source_control_snapshot", + "reveal_external_source_location", + "get_workspace_reference_snapshot", + "expand_external_prompt_command_command", + "get_native_prompt_command_conflicts_command", + "set_native_prompt_command_conflict_choice_command", + "apply_external_source_control_action_command", + "set_external_source_enabled_command", + "set_external_source_conflict_choice_command", + "set_external_tool_target_decision_command", + "set_external_tool_conflict_choice_command", + "set_external_subagent_activation_command", + "set_external_subagent_model_binding_command", + "choose_external_subagent_conflict_command", + "set_external_mcp_server_decision_command", + "choose_external_mcp_conflict_command", + "update_external_integration_policy_command", + "get_external_hook_catalog", + "get_external_hook_import_snapshot", + "plan_external_hook_import_command", + "apply_external_hook_import_command", + "mutate_external_hook_import_command", ]; +fn unsupported_external_source_error(method: &str) -> Option { + EXTERNAL_SOURCE_METHODS + .contains(&method) + .then(|| { + Error::method_not_found().data(serde_json::json!({ + "code": "host_capability_unavailable", + "detail": "External source operations are not available from the paused Server Host. Use an authenticated Desktop or Peer Host.", + "retryable": false + })) + }) +} + use crate::agent::{ bitfun_error, config_get_error, git_service_error, runtime_call, BitfunAppRuntime, }; @@ -50,18 +81,18 @@ use crate::schema::{ GetConfigsResponse, GetModelConfigsMessage, GetModelConfigsResponse, GitBranchesRequest, GitGetBranchesMessage, GitGetBranchesResponse, GitGetStatusMessage, GitGetStatusResponse, GitIsRepositoryMessage, GitIsRepositoryResponse, GitRepositoryPathRequest, + I18nGetConfigMessage, I18nGetConfigResponse, I18nGetCurrentLanguageMessage, + I18nGetCurrentLanguageResponse, I18nGetSupportedLanguagesMessage, + I18nGetSupportedLanguagesResponse, I18nLocaleMetadata, I18nSetConfigMessage, + I18nSetConfigResponse, I18nSetLanguageMessage, I18nSetLanguageResponse, ListPendingPermissionRequestsMessage, ListPendingPermissionRequestsResponse, ListProjectPermissionAuditMessage, ListProjectPermissionAuditResponse, ListProjectPermissionGrantsMessage, ListProjectPermissionGrantsResponse, ListSessionsMessage, ListSessionsResponse, RemoveProjectPermissionGrantMessage, RemoveProjectPermissionGrantResponse, RespondPermissionBatchMessage, RespondPermissionBatchResponse, RespondPermissionMessage, RespondPermissionResponse, - RunMessage, RunResponse, SetConfigMessage, SetConfigResponse, - SubmitDialogTurnMessage, SubmitDialogTurnResponse, SubmitTurnMessage, SubmitTurnResponse, - I18nGetCurrentLanguageMessage, I18nGetCurrentLanguageResponse, I18nGetConfigMessage, - I18nGetConfigResponse, I18nGetSupportedLanguagesMessage, I18nGetSupportedLanguagesResponse, - I18nLocaleMetadata, I18nSetConfigMessage, I18nSetConfigResponse, I18nSetLanguageMessage, - I18nSetLanguageResponse, + RunMessage, RunResponse, SetConfigMessage, SetConfigResponse, SubmitDialogTurnMessage, + SubmitDialogTurnResponse, SubmitTurnMessage, SubmitTurnResponse, }; /// BitFun agent kernel server over the generic app-server role. @@ -592,18 +623,8 @@ impl BitfunAppServer { Dispatch::Request(req, _) => req.method().to_string(), _ => String::new(), }; - let is_external_source = EXTERNAL_SOURCE_METHOD_MARKERS - .iter() - .any(|marker| method.contains(marker)); - let error = if is_external_source { - Error::method_not_found().data(serde_json::json!({ - "capability": "external_sources", - "reason": "not_available_in_web_mode", - "message": "External source operations are not yet available in web mode. Use the desktop host." - })) - } else { - Error::method_not_found() - }; + let error = unsupported_external_source_error(&method) + .unwrap_or_else(Error::method_not_found); message.respond_with_error(error, cx) }, agent_client_protocol::on_receive_dispatch!(), @@ -652,7 +673,7 @@ impl BitfunAppServer { // today, and push it as a `agent/frontendEvent` // notification. The browser's WS adapter dispatches on // `params.event`, so its existing `listen(...)` call - // sites stay unchanged under browser-direct ACP. + // sites stay unchanged with browser-direct App Server. if let Some(projected) = project_agentic_frontend_event(envelope.event) { @@ -691,7 +712,7 @@ impl BitfunAppServer { // `app.emit("permission://event")`), and push it as a // `agent/frontendEvent` notification. The payload is the // serialized `PermissionRequestEvent` (the same shape - // `client.rs` projected to before Step 2). + // earlier in-process client path projected). if let Ok(payload) = serde_json::to_value(&event) { let notification = FrontendEventNotification { event: "permission://event".to_string(), @@ -731,3 +752,64 @@ impl BitfunAppServer { .await } } + +#[cfg(test)] +mod tests { + use super::unsupported_external_source_error; + use agent_client_protocol::Error; + + #[test] + fn external_source_methods_keep_the_typed_web_mode_error() { + for method in [ + "plan_external_mcp_import_command", + "apply_external_mcp_import_command", + "get_external_source_snapshot", + "get_external_source_control_snapshot", + "reveal_external_source_location", + "get_workspace_reference_snapshot", + "expand_external_prompt_command_command", + "get_native_prompt_command_conflicts_command", + "set_native_prompt_command_conflict_choice_command", + "apply_external_source_control_action_command", + "set_external_source_enabled_command", + "set_external_source_conflict_choice_command", + "set_external_tool_target_decision_command", + "set_external_tool_conflict_choice_command", + "set_external_subagent_activation_command", + "set_external_subagent_model_binding_command", + "choose_external_subagent_conflict_command", + "set_external_mcp_server_decision_command", + "choose_external_mcp_conflict_command", + "update_external_integration_policy_command", + "get_external_hook_catalog", + "get_external_hook_import_snapshot", + "plan_external_hook_import_command", + "apply_external_hook_import_command", + "mutate_external_hook_import_command", + ] { + let error = unsupported_external_source_error(method) + .unwrap_or_else(|| panic!("{method} should be recognized")); + + assert_eq!(error.code, Error::method_not_found().code); + assert_eq!( + error.data.as_ref().and_then(|data| data.get("code")), + Some(&serde_json::json!("host_capability_unavailable")) + ); + assert_eq!( + error.data.as_ref().and_then(|data| data.get("retryable")), + Some(&serde_json::json!(false)) + ); + assert!(error + .data + .as_ref() + .and_then(|data| data.get("detail")) + .is_some()); + } + } + + #[test] + fn unrelated_unknown_methods_keep_the_generic_method_not_found_path() { + assert!(unsupported_external_source_error("unknown_method").is_none()); + assert!(unsupported_external_source_error("get_external_source_snapshot_debug").is_none()); + } +} diff --git a/src/crates/services/services-core/src/runtime_ownership.rs b/src/crates/services/services-core/src/runtime_ownership.rs index 52bc3a229a..70c001dbdd 100644 --- a/src/crates/services/services-core/src/runtime_ownership.rs +++ b/src/crates/services/services-core/src/runtime_ownership.rs @@ -40,17 +40,104 @@ impl RuntimeOwnershipKey { } })?; + Ok(Self::from_canonical_workspace( + &canonical_workspace, + product_identity, + )) + } + + /// Creates the key for a managed absolute workspace path that may not exist + /// yet. The deepest existing ancestor is canonicalized and the normalized + /// missing suffix is appended, so the key matches [`Self::for_workspace`] + /// after the caller creates the directory. + pub fn for_workspace_candidate( + workspace_root: &Path, + product_identity: &str, + ) -> Result { + validate_product_identity(product_identity)?; + if workspace_root.exists() { + return Self::for_workspace(workspace_root, product_identity); + } + if !workspace_root.is_absolute() + || workspace_root.components().any(|component| { + matches!( + component, + std::path::Component::CurDir | std::path::Component::ParentDir + ) + }) + { + return Err(RuntimeOwnershipError::CanonicalizeWorkspace { + path: workspace_root.to_path_buf(), + source: std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "managed workspace candidate must be an absolute normalized path", + ), + }); + } + + let mut existing_ancestor = workspace_root; + let mut missing_suffix = Vec::new(); + loop { + match std::fs::symlink_metadata(existing_ancestor) { + Ok(_) => break, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let name = existing_ancestor.file_name().ok_or_else(|| { + RuntimeOwnershipError::CanonicalizeWorkspace { + path: workspace_root.to_path_buf(), + source: std::io::Error::new( + std::io::ErrorKind::NotFound, + "managed workspace candidate has no existing ancestor", + ), + } + })?; + missing_suffix.push(name.to_os_string()); + existing_ancestor = existing_ancestor.parent().ok_or_else(|| { + RuntimeOwnershipError::CanonicalizeWorkspace { + path: workspace_root.to_path_buf(), + source: std::io::Error::new( + std::io::ErrorKind::NotFound, + "managed workspace candidate has no existing ancestor", + ), + } + })?; + } + Err(source) => { + return Err(RuntimeOwnershipError::CanonicalizeWorkspace { + path: workspace_root.to_path_buf(), + source, + }); + } + } + } + + let mut canonical_workspace = dunce::canonicalize(existing_ancestor).map_err(|source| { + RuntimeOwnershipError::CanonicalizeWorkspace { + path: workspace_root.to_path_buf(), + source, + } + })?; + for component in missing_suffix.into_iter().rev() { + canonical_workspace.push(component); + } + + Ok(Self::from_canonical_workspace( + &canonical_workspace, + product_identity, + )) + } + + fn from_canonical_workspace(canonical_workspace: &Path, product_identity: &str) -> Self { let mut hasher = Sha256::new(); hasher.update(b"bitfun-runtime-ownership-v2\0"); hasher.update(product_identity.as_bytes()); hasher.update(b"\0"); - hash_canonical_path(&mut hasher, &canonical_workspace); + hash_canonical_path(&mut hasher, canonical_workspace); let digest = hasher.finalize(); let mut encoded = String::with_capacity(digest.len() * 2); for byte in digest { write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); } - Ok(Self(encoded)) + Self(encoded) } pub fn as_str(&self) -> &str { diff --git a/src/crates/services/services-core/tests/runtime_ownership_contracts.rs b/src/crates/services/services-core/tests/runtime_ownership_contracts.rs index cc688da181..7c6a5ba2ab 100644 --- a/src/crates/services/services-core/tests/runtime_ownership_contracts.rs +++ b/src/crates/services/services-core/tests/runtime_ownership_contracts.rs @@ -71,6 +71,37 @@ fn ownership_key_is_stable_and_scoped_by_workspace_and_product() { assert!(first.as_str().bytes().all(|byte| byte.is_ascii_hexdigit())); } +#[test] +fn managed_workspace_candidate_keeps_the_same_key_after_creation() { + let root = tempdir().expect("managed workspace root"); + let ownership_root = tempdir().expect("ownership root"); + let workspace = root.path().join("personal_assistant").join("workspace"); + + let candidate_key = RuntimeOwnershipKey::for_workspace_candidate(&workspace, "bitfun") + .expect("candidate ownership key"); + let embedded = WorkspaceRuntimeOwnership::try_acquire( + ownership_root.path(), + &candidate_key, + RuntimeDeployment::Embedded, + ) + .expect("claim candidate before directory creation"); + + std::fs::create_dir_all(&workspace).expect("create managed workspace"); + let opened_key = RuntimeOwnershipKey::for_workspace(&workspace, "bitfun") + .expect("canonical key after creation"); + + assert_eq!(candidate_key, opened_key); + assert!(matches!( + WorkspaceRuntimeOwnership::try_acquire( + ownership_root.path(), + &opened_key, + RuntimeDeployment::Shared, + ), + Err(RuntimeOwnershipError::OwnershipUnavailable { .. }) + )); + drop(embedded); +} + #[cfg(unix)] #[test] fn ownership_key_preserves_non_utf8_workspace_bytes() { diff --git a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts index 409d6043fa..0fc8724d08 100644 --- a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts +++ b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts @@ -64,7 +64,7 @@ describe('dispatch controller-only routing contract', () => { ...tables.map(table => table.source), read('./dispatchApi.ts'), read('./types.ts'), - read('../../../../../src/apps/server/src/routes/dispatch.rs'), + read('../../../../../src/apps/desktop/src/api/dispatch_api.rs'), ]; for (const source of sources) { expect(source).not.toContain('dispatch_install_cli_source_start');