diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index 7db5e322d7..d7fb2c3eef 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -11,29 +11,35 @@ Agent Runtime 的模块职责见 [`agent-runtime-services-design.md`](agent-runt BitFun 只有一套 Agent Runtime 行为。`Embedded` 和 `Shared` 只描述同一套 Runtime 的物理部署方式,不是两套实现。 ```mermaid -flowchart LR +flowchart TB subgraph "产品入口" - GUI["GUI / TUI"] - CLI["Headless CLI"] - SDK["Agent SDK"] + GUI["Desktop GUI"] + TUI["TUI / Headless CLI"] + ACP["ACP"] + SDK["Agent SDK · SDK Host"] + Server["Server agent bootstrap"] end - GUI --> Adapter["first-party adapter"] - CLI --> Adapter - SDK --> SDKAdapter["SDK Host adapter"] + GUI --> Adapter["同级 first-party adapters"] + TUI --> Adapter + ACP --> Adapter + SDK --> Adapter + Server --> Adapter Adapter --> API["Agent Runtime API"] - SDKAdapter --> API - API --> Owners["Session / Tool / Permission / MCP owners"] + API --> Coordinator["ConversationCoordinator"] + Coordinator --> Owners["Session / Tool / Permission / MCP owners"] + Coordinator -. "local attach / mutation" .-> Ownership["CoreRuntimeOwnership"] ``` 当前代码状态必须和目标设计分开阅读: | 范围 | 当前状态 | |---|---| -| Embedded Desktop GUI | 继续使用现有 Desktop 事件投影和 Tauri adapter;本设计没有改变其依赖或生命周期 | +| Embedded Desktop GUI | 继续使用 Desktop 事件投影和 Tauri adapter;按实际打开的本机 workspace 延迟取得并持有 Embedded ownership,不增加后台进程 | | Embedded TUI/Headless CLI/Peer Host | Session、Turn、Permission 和事件订阅统一通过同一个 Rust Runtime SDK(当前 preview);CLI crate 只保留第一方 adapter 和各形态自己的展示/断流策略 | | ACP/SDK Host | 使用同一个 Runtime 事件入口的 session-scoped 订阅;各自协议和进程生命周期保持独立 | -| Runtime ownership | CLI 的 Embedded deployment 取得共享锁;Shared TUI deployment 取得独占锁,二者在同一 workspace 互斥;其他产品入口尚未接入该锁 | +| Runtime ownership | Desktop、CLI、ACP、SDK Host 和现有 Server agent bootstrap 共用 Core owner;Embedded 取得共享锁,Shared TUI 取得独占锁,同一 workspace 上两种 deployment 互斥 | +| 当前 HTTP Server | 只提供 health/info/WebSocket 外壳,未装配 Agent Runtime,因此不取得 workspace ownership;`bootstrap.rs` 仅保持 agent-enabled composition 的一致边界,不由当前入口启动 | | Shared local IPC | 未发布的本机协议已有 discovery、实例锁、严格握手、Session 控制租约、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI adapter | | Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,读取 transcript,提交/取消 Turn,处理 Permission 和 UserInput;默认仍是 Embedded | | Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;Replay、Observer、Controller transfer、Session delete/fork 同样不在当前协议中 | @@ -95,19 +101,37 @@ flowchart LR ### 4.1 Runtime ownership -`services-core::runtime_ownership` 提供进程级 RAII 文件锁: +ownership 分成“产品决策”和“文件锁原语”两层;入口不再各自拼 key、目录或锁模式: ```mermaid -flowchart LR - E1["Embedded A"] -->|"shared lock"| Key["workspace + product ownership key"] - E2["Embedded B"] -->|"shared lock"| Key - S["Shared deployment"] -->|"exclusive lock"| Key +flowchart TB + Entrypoints["Desktop · CLI · ACP · SDK Host · Server bootstrap"] + Entrypoints --> Core["CoreRuntimeOwnership
deployment · product identity · process leases"] + Core --> Primitive["services-core::runtime_ownership
canonical key · RAII file lock"] + Primitive --> E["Embedded · shared lock"] + Primitive --> S["Shared · exclusive lock"] +``` + +```mermaid +flowchart TD + Op["Session operation"] --> Read{"read-only view/list?"} + Read -->|"yes"| NoLock["不取得 ownership"] + Read -->|"no · attach/mutate/turn"| Remote{"structured remote facts?"} + Remote -->|"yes"| RemoteHost["由目标 execution host 负责"] + Remote -->|"no"| Gate["Coordinator → CoreRuntimeOwnership"] + Gate --> Lease["按 canonical workspace 保留进程期 lease"] ``` -- 多个 Embedded 进程可继续并存。 -- 在当前 CLI 边界内,Shared TUI 与 Embedded CLI Runtime 互斥;多个 Embedded CLI 进程仍可并存。 -- CLI 每次初始化 Runtime 时都调用该原语;Desktop、SDK Host、Server 等入口尚未接入,也不会被误报为已共享或已互斥。 -- 该锁不选择 workspace、不启动 Runtime、不缓存实例,也不替代 Session 写入权或文件冲突控制。 +| 场景 | 行为 | 原因 | +|---|---|---| +| 多个 Embedded 进程访问同一 workspace | 共享锁允许并存 | 保持单实例、CI 和隔离测试的既有成本模型 | +| Shared 与任一 Embedded 访问同一 workspace | 后启动者返回稳定错误码和启动建议 | 防止同一 workspace 同时存在两种 Runtime deployment | +| Desktop 打开多个 workspace | 首次 attach/write 时逐个取得并保留 lease | 不把窗口数、Session 数等同于 Runtime 进程数 | +| 只读 list/view | 不加锁 | ownership 只管理 Runtime deployment,不扩大成读取权限 | +| 已解析且带有效 `connection_id` 的 remote workspace | 本机不加锁 | 与 Session storage 的远端判据一致;`host` 提示本身不能绕过本地锁 | +| 当前只读 HTTP Server | 不创建 Core owner | 没有 Agent Runtime 就没有 ownership 可声明 | + +`CoreRuntimeOwnership` 只选择 deployment、产品 identity 并保留进程期 lease;`services-core` 只负责 canonical key 和跨进程锁。二者都不选择 workspace、不启动 Runtime,也不替代 Session 单写、数据库事务、文件冲突控制或安全沙箱。 ### 4.2 私有本机 IPC @@ -163,18 +187,23 @@ sequenceDiagram ## 5. 产品入口保持同级 ```mermaid -flowchart LR - GUI["GUI"] --> GA["GUI adapter"] - TUI["TUI"] --> TA["TUI adapter"] - CLI["Headless CLI"] --> CA["CLI adapter"] - SDK["Agent SDK"] --> SA["SDK Host adapter"] - ACP["ACP"] --> AA["ACP adapter"] - - GA --> API["Agent Runtime API"] - TA --> API - CA --> API - SA --> API - AA --> API +flowchart TB + GUI["GUI adapter"] --> API["Agent Runtime API"] + TUI["TUI adapter"] --> API + CLI["Headless CLI adapter"] --> API + SDK["SDK Host adapter"] --> API + ACP["ACP adapter"] --> API + Server["Server adapter · when assembled"] --> API + API --> Coordinator["ConversationCoordinator"] + Coordinator --> Behavior["single behavior owners"] + + GUI -. "composition" .-> Ownership["CoreRuntimeOwnership"] + TUI -. "Embedded / opt-in Shared" .-> Ownership + CLI -. "Embedded" .-> Ownership + SDK -. "Embedded" .-> Ownership + ACP -. "Embedded" .-> Ownership + Server -. "only when Runtime is assembled" .-> Ownership + Ownership -. "injected once" .-> Coordinator ``` - CLI 不依赖 SDK Host,GUI/TUI 也不依赖公开 SDK package。 @@ -238,7 +267,7 @@ Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理 - 只有一套 Agent Runtime 业务实现;部署差异不能产生第二套 Session、Tool、Permission 或 MCP owner。 - Client、窗口、Session 或 workspace 数量不会自动等量增加 Runtime 或 Plugin Host 进程。 - 私有 IPC 不成为公开 SDK、Remote、Peer、HTTP 或浏览器协议。 -- 默认 GUI/TUI/Headless CLI 保持 Embedded;只有交互式 TUI 的显式 `--shared` 选择 Shared,当前互斥范围也只覆盖 CLI deployment。 +- 默认 GUI/TUI/Headless CLI、ACP 与 SDK Host 保持 Embedded;只有交互式 TUI 的显式 `--shared` 选择 Shared。互斥按 `workspace + product` 生效,不再按入口名称缩窄。 - Account/session cloud sync 仍使用既有 Core compatibility 边界,不属于 Shared Runtime 支持。 - Remote workspace 的文件、凭据、进程和 Runtime 位于目标执行域,禁止静默回落本机。 - 未经真实 consumer 验证的接口不进入 wire;当前 wire 只包含表中列出的 Shared TUI 操作。 diff --git a/docs/architecture/agent-sdk-product-architecture.md b/docs/architecture/agent-sdk-product-architecture.md index 8696771329..54f03fb519 100644 --- a/docs/architecture/agent-sdk-product-architecture.md +++ b/docs/architecture/agent-sdk-product-architecture.md @@ -171,27 +171,29 @@ Python SDK、TypeScript SDK、managed Host 和连接预启动 Host 不是四种 ### 4.1 产品入口 ```mermaid -flowchart LR +flowchart TB GUI["GUI / TUI"] --> UIA["UI adapter"] CLI["bitfun exec"] --> CLIA["CLI adapter"] SDK["Agent SDK"] --> SDKA["SDK Host"] + ACP["ACP"] --> ACPA["ACP adapter"] + Server["Server / Remote"] --> RemoteA["Server / Remote adapter"] UIA --> API["Runtime API"] CLIA --> API SDKA --> API - API --> Runtime["Agent Runtime owners"] + ACPA --> API["Runtime API"] + RemoteA --> API + API --> Coordinator["ConversationCoordinator"] + Coordinator --> Runtime["Agent Runtime owners"] + + Composition["first-party composition roots"] -. "inject once" .-> Ownership["CoreRuntimeOwnership"] + Ownership -. "local attach / mutation gate" .-> Coordinator ``` ### 4.2 互操作入口 -```mermaid -flowchart LR - ACP["ACP"] --> ACPA["ACP adapter"] - Remote["Server / Remote"] --> RemoteA["Remote adapter"] - ACPA --> API["Runtime API"] - RemoteA --> API -``` +上图中的 ACP、Server/Remote 和 SDK Host 都是同级 adapter;虚线只表示第一方进程装配 ownership,不表示某个入口依赖另一个入口。 -以上两图固定四条架构结论: +上图固定四条架构结论: - GUI/TUI/CLI 同样使用 Query、MCP、Permission 和 Hook,但它们直接经过各自 adapter 调用共享 Runtime API, 不依赖 Python/TypeScript SDK,也不依赖 SDK Host。 @@ -203,8 +205,9 @@ flowchart LR 一次性 Headless CLI 继续 Embedded;公开 SDK 默认连接私有 SDK Host。Shared Agent Runtime process 和 SDK Host 都是 Rust 产品进程, 与运行第三方 JS/TS 的 Node/Bun Plugin Host 不同;三者不能共享名称或业务归属。 -当前代码只具备 Shared deployment 的本机 IPC、身份、握手、Health 和 ownership 基础;没有 GUI/TUI/Remote consumer, -也没有 Shared Session/Turn 协议。图中 Shared deployment 是目标架构,不是已交付产品能力。 +当前代码已经交付显式启用的 Shared TUI 最小切片,包含本机 IPC、身份、握手、Session/Turn、Permission/UserInput、 +ownership 和生命周期治理;GUI、Headless CLI、ACP、SDK Host、Server/Remote 仍没有 Shared consumer。该图中的多入口逻辑复用是 +当前事实,除 Shared TUI 外的跨进程 Shared deployment 仍是目标架构。 ### 4.3 各形态能做什么 @@ -242,6 +245,7 @@ flowchart LR | `bitfun-sdk-host` | 独立组装入口,选择 SDK profile | 依赖 CLI crate;成为第二个 Server 或 Runtime | | SDK Host adapter | 协议、能力协商、连接/Query 资源清理责任和 DTO 转换 | stdin/stdout 入口、Agent 业务状态、Tool/MCP 注册表 | | Python/TypeScript SDK | 管理或连接匹配 Host,提供一致公开 API | 要求用户安装 `bitfun` CLI;暴露内部 wire DTO | +| `CoreRuntimeOwnership` | 第一方 Rust 入口选择 Embedded/Shared,并把本机 workspace lease 注入 Coordinator | 进入公开 SDK/wire;成为 Session 单写或 Server 路由 owner | ### 5.2 一次 Query 的运行时序 @@ -468,6 +472,7 @@ CLI 和 SDK 共享能力事实,但不是上下层关系: 因此: - CLI 不默认依赖 SDK Host,也不通过 SDK package 运行。 +- CLI、ACP、Desktop 与 SDK Host 只共享 Core ownership 和 Runtime 行为 owner;共享这些内部 owner 不构成产品依赖,也不新增第二种 SDK。 - 一次性 `bitfun exec` 默认使用 Embedded Runtime;只有恢复或控制 Shared Agent Runtime 中的共享 Session 时,才使用第一方 client adapter attach,且不经过 SDK Host。 - SDK 不解析 CLI `stream-json` 作为正式双向协议。 @@ -481,6 +486,10 @@ CLI 和 SDK 共享能力事实,但不是上下层关系: ```mermaid flowchart LR Runtime["Runtime domain contracts"] --> HostSchema["SDK Host schema\nstable + experimental"] + Runtime --> SessionCreate["AgentSessionCreateResult\nshared session-create facts"] + SessionCreate --> HostSchema + SessionCreate --> CLIProjection + SessionCreate --> UIProjection HostSchema --> TSClient["generated internal TS wire client"] HostSchema --> PyClient["generated internal Python wire client"] TSClient --> TSApi["curated TypeScript public API"] @@ -494,6 +503,11 @@ flowchart LR Fixtures --> CLIProjection ``` +会话创建是这条规则的当前实例:`AgentSessionCreateResult` 由 Session owner 生成并携带规范化的 +workspace 与 execution-target 事实;Desktop 的 `CreateSessionResponse` 只是该类型的宿主命名,SDK Host 的 +`SessionCreateResult` 则保留 `agent`、`lifetime` 等协议字段并从同一结果转换。adapter 可以改变 wire 形状, +但不能重新计算或持有第二份 Session 创建事实。 + 生成的 wire 类型保持 SDK 内部;公开 API 必须经过人工策划,不能把协议 DTO 原样暴露给用户。 ### 10.2 防止持续迭代造成不一致 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index efb9fbb7a3..ac4d77bdd1 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -375,12 +375,20 @@ flowchart LR 当前本机入口组装: ```mermaid -flowchart LR +flowchart TB Desktop["Desktop"] --> Full["product-full"] CLI["CLI / TUI"] --> Full ACP["ACP"] --> Parts["Runtime Parts"] + SDKHost["SDK Host"] --> Parts + ServerBootstrap["Server agent bootstrap · dormant"] --> Full + + Full --> Coordinator["ConversationCoordinator"] + Parts --> Coordinator + Ownership["CoreRuntimeOwnership"] -. "first-party composition injects once" .-> Coordinator ``` +当前公开 HTTP Server 不调用 agent bootstrap,因此不创建 Runtime 或 workspace ownership;图中的 Server 节点只记录已有 agent-enabled composition 边界,不能据此宣称 Server Agent API 已交付。 + 当前 Peer 运行连接: ```mermaid @@ -400,7 +408,7 @@ flowchart LR | Desktop | 使用 `product-full`;显示外部来源、审批、冲突、诊断和 Host 能力 | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | | CLI / TUI | 使用 `product-full`;提供 `/extensions`、`/hooks_external`、`/tools` 和 `/agents` | 不解析生态文件,不启动第二套 Agent Runtime;远程能力未接入时不回退本机 | | ACP | 使用 `DeliveryProfile::Acp` 和 Runtime Parts | load 成功后才发布活动状态;close 排空后再卸载;完整历史和配置仍由 Core/ACP 管理 | -| Peer / Server | Server 提供 control/catalog;Peer Host 执行真实工作区操作 | 控制端不替远端发现或执行;旧 Host 明确降级,SSH Remote 未接入时返回不支持 | +| Peer / Server | Server 提供 control/catalog;Peer Host 执行真实工作区操作;当前 HTTP Server 不装配 Agent Runtime | 控制端不替远端发现或执行;旧 Host 明确降级,SSH Remote 未接入时返回不支持;只读 Server 不声明 Runtime ownership | | Web / Mobile Web | 依赖现有后端入口 | 不持有插件执行单元,也不能据空 profile 宣称独立能力 | | HarmonyOS 手机 Remote | phone-only ArkTS 远程入口 | 不等于 HarmonyOS PC 本地 Runtime、CLI/TUI 或 GUI | diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 8ffbf0acdf..06ecbc3d55 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -1482,8 +1482,9 @@ export const requiredContentRules = [ 'the standalone SDK Host must inject its selected delivery profile into the Core tool owner before agentic system construction', patterns: [ { - regex: /\binit_agentic_system_for_profile\b/, - message: 'SDK Host runtime must initialize Core with its selected delivery profile', + regex: /\binit_agentic_system_for_profile_with_runtime_ownership\b/, + message: + 'SDK Host runtime must initialize Core with its selected delivery profile and Runtime ownership owner', }, { regex: /\bselect_agentic_system_profile\b/, diff --git a/src/apps/cli/src/agent/agentic_system.rs b/src/apps/cli/src/agent/agentic_system.rs index 27713295ce..5c4cf3702c 100644 --- a/src/apps/cli/src/agent/agentic_system.rs +++ b/src/apps/cli/src/agent/agentic_system.rs @@ -2,6 +2,8 @@ use anyhow::{Context, Result}; use bitfun_core::product_assembly::DeliveryProfile; use bitfun_core::product_runtime::CoreRuntimeServicesProvider; +use bitfun_core::runtime_ownership::CoreRuntimeOwnership; +use std::sync::Arc; pub(crate) use bitfun_core::agentic::system::AgenticSystem; @@ -10,8 +12,15 @@ pub(crate) fn select_agentic_system_profile(profile: DeliveryProfile) -> Result< .context("Failed to select agentic system delivery profile") } -pub(crate) async fn init_agentic_system(profile: DeliveryProfile) -> Result { - let system = bitfun_core::agentic::system::init_agentic_system_for_profile(profile) +pub(crate) async fn init_agentic_system( + profile: DeliveryProfile, + runtime_ownership: Arc, +) -> Result { + let system = + bitfun_core::agentic::system::init_agentic_system_for_profile_with_runtime_ownership( + profile, + runtime_ownership, + ) .await .context("Failed to initialize agentic system")?; system diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 68fde2f3bf..0b7146e1ff 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -652,7 +652,26 @@ async fn initialize_core_services_for_deployment( .await .map_err(|error| anyhow!("Failed to initialize global config service: {error}"))?; tracing::info!("Global config service initialized"); - let runtime_ownership = shared_runtime::acquire_ownership(workspace_root, deployment)?; + let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() + .map_err(|error| anyhow!(error.to_string()))?; + let entrypoint = match (deployment, bootstrap_profile) { + ( + bitfun_services_core::runtime_ownership::RuntimeDeployment::Embedded, + BootstrapProfile::Interactive, + ) => "cli-interactive", + (bitfun_services_core::runtime_ownership::RuntimeDeployment::Embedded, _) => "cli-headless", + (bitfun_services_core::runtime_ownership::RuntimeDeployment::Shared, _) => { + "shared-tui-runtime" + } + }; + let runtime_ownership = bitfun_core::runtime_ownership::CoreRuntimeOwnership::fixed_workspace( + path_manager.as_ref(), + entrypoint, + workspace_root, + deployment, + ) + .map_err(|error| anyhow!(error.startup_message(deployment, entrypoint)))?; + let runtime_ownership = std::sync::Arc::new(runtime_ownership); let config_service = bitfun_core::service::config::get_global_config_service() .await @@ -667,6 +686,7 @@ async fn initialize_core_services_for_deployment( let agentic_system = agent::agentic_system::init_agentic_system( bitfun_core::product_assembly::DeliveryProfile::Cli, + runtime_ownership, ) .await .map_err(|error| anyhow!("Failed to initialize agentic system: {error}"))?; @@ -676,7 +696,6 @@ async fn initialize_core_services_for_deployment( agentic_system, workspace_root, approval_policy, - runtime_ownership, )?); debug_assert!(runtime .product() diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs index 5ba6460f92..4f8426d717 100644 --- a/src/apps/cli/src/peer_host/commands/session.rs +++ b/src/apps/cli/src/peer_host/commands/session.rs @@ -32,13 +32,32 @@ fn session_storage_request(request: &Value) -> Result Result { + let scope = session_storage_request(request)?; + state + .compatibility + .ensure_workspace_runtime_ownership(&scope) + .map_err(|error| format!("Agent Runtime ownership is unavailable: {error}"))?; + Ok(scope) +} + pub(super) async fn resolved_session_storage_path( state: &PeerHostState, request: &Value, +) -> Result { + resolved_session_storage_scope(state, session_storage_request(request)?).await +} + +pub(super) async fn resolved_session_storage_scope( + state: &PeerHostState, + scope: SessionStoragePathRequest, ) -> Result { state .compatibility - .resolve_persisted_session_storage_path(session_storage_request(request)?) + .resolve_persisted_session_storage_path(scope) .await .map_err(|error| format!("Failed to resolve session storage path: {error}")) } @@ -366,7 +385,8 @@ pub(crate) async fn touch_session_activity( ) -> Result { let request = request_value(args); let session_id = validated_session_id(request)?; - let workspace_path = resolved_session_storage_path(state, request).await?; + let scope = ensure_session_workspace_runtime_ownership(state, request)?; + let workspace_path = resolved_session_storage_scope(state, scope).await?; let _mutation = state .compatibility .begin_persisted_session_mutation(&workspace_path, &session_id) @@ -435,6 +455,7 @@ pub(crate) async fn ensure_coordinator_session( ) -> Result { let request = request_value(args); let session_id = validated_session_id(request)?; + let scope = ensure_session_workspace_runtime_ownership(state, request)?; if state .compatibility .is_session_loaded_in_memory(&session_id) @@ -442,7 +463,7 @@ pub(crate) async fn ensure_coordinator_session( { return Ok(Value::Null); } - let storage = resolved_session_storage_path(state, request).await?; + let storage = resolved_session_storage_scope(state, scope).await?; let include_internal = optional_bool(request, "includeInternal").unwrap_or(false); state @@ -517,7 +538,8 @@ pub(crate) async fn save_session_turn( args: &Value, ) -> Result { let request = request_value(args); - let workspace_path = resolved_session_storage_path(state, request).await?; + let scope = ensure_session_workspace_runtime_ownership(state, request)?; + let workspace_path = resolved_session_storage_scope(state, scope).await?; let turn_data = request .get("turnData") .or_else(|| request.get("turn_data")) @@ -557,6 +579,45 @@ mod tests { ProcessingPhase, Session as CoreSession, SessionConfig, SessionState as CoreSessionState, }; + #[test] + fn peer_attach_and_raw_mutations_reuse_core_runtime_ownership() { + let session_source = include_str!("session.rs"); + for mutation in [ + "pub(crate) async fn touch_session_activity", + "pub(crate) async fn ensure_coordinator_session", + "pub(crate) async fn save_session_turn", + ] { + let body = session_source + .split_once(mutation) + .unwrap_or_else(|| panic!("missing Peer mutation: {mutation}")) + .1 + .split_once("pub(crate) async fn") + .unwrap_or_else(|| panic!("missing Peer mutation boundary: {mutation}")) + .0; + assert!(body.contains("ensure_session_workspace_runtime_ownership")); + } + + let workspace_source = include_str!("workspace.rs"); + let open = workspace_source + .split_once("pub(crate) async fn open_workspace") + .expect("Peer workspace open") + .1 + .split_once("pub(crate) async fn reload_config") + .expect("Peer workspace open boundary") + .0; + assert!(open.contains("ensure_workspace_runtime_ownership")); + + let snapshot_source = include_str!("snapshot.rs"); + let rollback = snapshot_source + .split_once("pub(crate) async fn rollback_to_turn") + .expect("Peer rollback") + .1 + .split_once("#[cfg(test)]") + .expect("Peer rollback boundary") + .0; + assert!(rollback.contains("ensure_session_workspace_runtime_ownership")); + } + #[test] fn basic_restore_keeps_peer_host_session_shape() { let value = restored_session_to_json(AgentSessionRestoreResult { diff --git a/src/apps/cli/src/peer_host/commands/snapshot.rs b/src/apps/cli/src/peer_host/commands/snapshot.rs index 48d942a2f0..d37d27e6a0 100644 --- a/src/apps/cli/src/peer_host/commands/snapshot.rs +++ b/src/apps/cli/src/peer_host/commands/snapshot.rs @@ -17,7 +17,7 @@ use crate::peer_host::args::{ use crate::peer_host::fanout::fanout_peer_device_event; use crate::peer_host::state::PeerHostState; -use super::session::resolved_session_storage_path; +use super::session::{ensure_session_workspace_runtime_ownership, resolved_session_storage_scope}; pub(super) async fn require_local_snapshot_workspace( request: &Value, @@ -168,7 +168,8 @@ pub(crate) async fn rollback_to_turn(state: &PeerHostState, args: &Value) -> Res bitfun_agent_runtime::session_control::validate_session_id(&session_id)?; require_local_snapshot_workspace(request, &workspace_path).await?; let workspace = PathBuf::from(&workspace_path); - let session_storage_path = resolved_session_storage_path(state, request).await?; + let scope = ensure_session_workspace_runtime_ownership(state, request)?; + let session_storage_path = resolved_session_storage_scope(state, scope).await?; if delete_turns { state .compatibility diff --git a/src/apps/cli/src/peer_host/commands/workspace.rs b/src/apps/cli/src/peer_host/commands/workspace.rs index f02b929b1e..09608fc8e8 100644 --- a/src/apps/cli/src/peer_host/commands/workspace.rs +++ b/src/apps/cli/src/peer_host/commands/workspace.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; +use bitfun_runtime_ports::SessionStoragePathRequest; use serde_json::{json, Value}; use crate::peer_host::args::{get_string, request_value}; @@ -51,6 +52,14 @@ pub(crate) async fn get_current_workspace(state: &PeerHostState) -> Result Result { let request = request_value(args); let path = get_string(request, "path")?; + state + .compatibility + .ensure_workspace_runtime_ownership(&SessionStoragePathRequest { + workspace_path: PathBuf::from(&path), + remote_connection_id: None, + remote_ssh_host: None, + }) + .map_err(|error| format!("Agent Runtime ownership is unavailable: {error}"))?; let info = state .workspace_service .open_workspace(PathBuf::from(path)) diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 48604f7eb1..d5e01b520c 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -807,6 +807,7 @@ pub(crate) fn handle_health_command() -> Result<()> { pub(crate) async fn serve_acp_stdio() -> Result<()> { crate::setup_workspace(); + let workspace_root = std::env::current_dir().context("Failed to resolve ACP workspace")?; crate::agent::agentic_system::select_agentic_system_profile( bitfun_core::product_assembly::DeliveryProfile::Acp, @@ -824,14 +825,25 @@ pub(crate) async fn serve_acp_stdio() -> Result<()> { crate::initialize_terminal_service().await; + let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let deployment = bitfun_services_core::runtime_ownership::RuntimeDeployment::Embedded; + let runtime_ownership = bitfun_core::runtime_ownership::CoreRuntimeOwnership::fixed_workspace( + path_manager.as_ref(), + "acp", + &workspace_root, + deployment, + ) + .map_err(|error| anyhow::anyhow!(error.startup_message(deployment, "acp")))?; + let agentic_system = crate::agent::agentic_system::init_agentic_system( bitfun_core::product_assembly::DeliveryProfile::Acp, + std::sync::Arc::new(runtime_ownership), ) .await .context("Failed to initialize agentic system")?; tracing::info!("Agentic system initialized"); - let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let runtime = crate::runtime::AcpRuntimeContext::build(agentic_system, workspace_root)?; let (agent_runtime, compatibility) = runtime.parts(); bitfun_acp::BitfunAcpRuntime::serve_stdio(agent_runtime, compatibility).await?; diff --git a/src/apps/cli/src/runtime/mod.rs b/src/apps/cli/src/runtime/mod.rs index 45693f9b60..2ceec6f876 100644 --- a/src/apps/cli/src/runtime/mod.rs +++ b/src/apps/cli/src/runtime/mod.rs @@ -12,7 +12,6 @@ use bitfun_core::product_runtime::{ use bitfun_core::runtime_ports::PluginRuntimeAvailability; use bitfun_runtime_ports::LocalWorkspaceSnapshotPort; use bitfun_runtime_services::RuntimeServices; -use bitfun_services_core::runtime_ownership::WorkspaceRuntimeOwnership; use crate::product_assembly::{assemble_acp_runtime_parts, assemble_cli_runtime_parts}; @@ -58,7 +57,6 @@ pub(crate) struct CliRuntimeContext { services: RuntimeServices, product: CliProductRuntimeState, approval_policy: CliApprovalPolicy, - _runtime_ownership: Arc, } impl CliRuntimeContext { @@ -66,7 +64,6 @@ impl CliRuntimeContext { agentic_system: AgenticSystem, workspace_root: impl AsRef, approval_policy: CliApprovalPolicy, - runtime_ownership: WorkspaceRuntimeOwnership, ) -> Result { let scheduler = ensure_product_dialog_scheduler(&agentic_system); let (workspace_root, services) = @@ -120,7 +117,6 @@ impl CliRuntimeContext { services, product, approval_policy, - _runtime_ownership: Arc::new(runtime_ownership), }) } diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 41e79160c9..fba5fcbf99 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -10,10 +10,9 @@ use bitfun_agent_runtime_ipc::{ RuntimeIpcRequestHandler, RuntimeIpcServer, RuntimeIpcServerConfig, RuntimeIpcStreamInvalidationReason, PROTOCOL_VERSION, }; +use bitfun_core::runtime_ownership::CoreRuntimeOwnership; use bitfun_events::{AgenticEvent, ToolEventData}; -use bitfun_services_core::runtime_ownership::{ - RuntimeDeployment, RuntimeOwnershipError, RuntimeOwnershipKey, WorkspaceRuntimeOwnership, -}; +use bitfun_services_core::runtime_ownership::RuntimeDeployment; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; @@ -677,7 +676,16 @@ pub(crate) async fn connect_or_start(workspace: &Path) -> Result last_connect_error = Some(error), } if let Some(status) = child.try_wait().context("poll Shared Runtime startup")? { - if !runtime_owner_present(workspace)? { + if embedded_runtime_owner_present(workspace)? { + return Err(anyhow!( + "Agent Runtime ownership failed (runtime_ownership_unavailable): an Embedded Runtime owns this workspace; close it before starting Shared TUI ({status})" + )); + } + if runtime_owner_present(workspace)? { + // Another Shared child may still be initializing and has not + // published discovery yet. Keep connecting until the normal + // bounded startup timeout instead of mislabeling it Embedded. + } else { if respawned { return Err(anyhow!( "Shared Runtime exited before becoming ready ({status})" @@ -690,7 +698,7 @@ pub(crate) async fn connect_or_start(workspace: &Path) -> Result= STARTUP_TIMEOUT { let owner_guidance = if runtime_owner_present(workspace)? { - "; another local Runtime still owns this workspace, so close its clients and wait up to 30 seconds" + "; Agent Runtime ownership failed (runtime_ownership_unavailable): another local Runtime still owns this workspace, so close its clients and wait up to 30 seconds" } else { "" }; @@ -708,16 +716,13 @@ pub(crate) async fn connect_or_start(workspace: &Path) -> Result Result { - let key = RuntimeOwnershipKey::for_workspace(workspace, product_identity())?; - match WorkspaceRuntimeOwnership::try_acquire( - &ownership_root()?, - &key, - RuntimeDeployment::Shared, - ) { - Ok(_) => Ok(false), - Err(RuntimeOwnershipError::OwnershipUnavailable { .. }) => Ok(true), - Err(error) => Err(error.into()), - } + CoreRuntimeOwnership::runtime_owner_present(path_manager()?.as_ref(), workspace) + .map_err(anyhow::Error::from) +} + +fn embedded_runtime_owner_present(workspace: &Path) -> Result { + CoreRuntimeOwnership::embedded_runtime_owner_present(path_manager()?.as_ref(), workspace) + .map_err(anyhow::Error::from) } fn require_interactive_tui(client: RuntimeIpcClient) -> Result { @@ -739,25 +744,6 @@ async fn prepare_client_environment() -> Result<()> { .map_err(|error| anyhow!("Failed to initialize Shared TUI configuration: {error}")) } -pub(crate) fn acquire_ownership( - workspace: &Path, - deployment: RuntimeDeployment, -) -> Result { - let key = RuntimeOwnershipKey::for_workspace(workspace, product_identity()) - .context("resolve Runtime ownership key")?; - WorkspaceRuntimeOwnership::try_acquire(&ownership_root()?, &key, deployment).map_err(|error| { - let guidance = match deployment { - RuntimeDeployment::Embedded => { - "A Shared TUI Runtime owns this CLI workspace; use `bitfun chat --shared`, or close its clients and wait up to 30 seconds" - } - RuntimeDeployment::Shared => { - "An Embedded CLI process owns this workspace; close it before using `--shared`" - } - }; - anyhow!("{guidance}: {error}") - }) -} - async fn connect_existing( store: &DiscoveryStore, runtime_root: &Path, @@ -851,7 +837,7 @@ fn instance_identity(workspace: &Path) -> Result { let user_root = path_manager()?.user_data_dir(); RuntimeInstanceIdentity::for_workspace( workspace, - product_identity(), + CoreRuntimeOwnership::distribution_identity(), RELEASE_CHANNEL, &user_root.to_string_lossy(), PROTOCOL_VERSION, @@ -859,10 +845,6 @@ fn instance_identity(workspace: &Path) -> Result { .context("resolve Shared Runtime identity") } -fn product_identity() -> &'static str { - option_env!("BITFUN_PRODUCT_BINARY_NAME").unwrap_or("bitfun") -} - fn ipc_root() -> Result { Ok(path_manager()? .user_data_dir() @@ -870,13 +852,6 @@ fn ipc_root() -> Result { .join(format!("ipc-v{PROTOCOL_VERSION}"))) } -fn ownership_root() -> Result { - Ok(path_manager()? - .user_data_dir() - .join("agent-runtime") - .join("ownership")) -} - fn path_manager() -> Result> { bitfun_core::infrastructure::try_get_path_manager_arc() .map_err(|error| anyhow!(error.to_string())) @@ -989,6 +964,23 @@ mod tests { .is_err()); } + #[test] + fn exited_shared_child_reports_embedded_owner_without_waiting_for_timeout() { + let source = include_str!("shared_runtime.rs"); + let exited_child = source + .split_once("if let Some(status) = child.try_wait()") + .expect("Shared Runtime child exit branch") + .1 + .split_once("if started.elapsed() >= STARTUP_TIMEOUT") + .expect("startup timeout boundary") + .0; + + assert!(exited_child.contains("embedded_runtime_owner_present")); + assert!(exited_child.contains("runtime_ownership_unavailable")); + assert!(exited_child.contains("Embedded Runtime owns this workspace")); + assert!(exited_child.contains("return Err")); + } + fn delegated_permission(session_id: &str, parent_session_id: &str) -> PermissionRequest { PermissionRequest { request_id: "permission-1".to_string(), diff --git a/src/apps/cli/tests/product_assembly_cli.rs b/src/apps/cli/tests/product_assembly_cli.rs index 6ddba35906..c63d7cb6a6 100644 --- a/src/apps/cli/tests/product_assembly_cli.rs +++ b/src/apps/cli/tests/product_assembly_cli.rs @@ -370,3 +370,34 @@ fn interactive_tui_agent_operations_stay_behind_cli_runtime_client() { "interactive composition changes must preserve product-aware CLI identity and MCP import" ); } + +#[test] +fn runtime_ownership_policy_is_assembled_once_in_core() { + const SHARED_RUNTIME: &str = include_str!("../src/shared_runtime.rs"); + const CLI_RUNTIME: &str = include_str!("../src/runtime/mod.rs"); + const CLI_MAIN: &str = include_str!("../src/main.rs"); + const AGENTIC_SYSTEM: &str = include_str!("../src/agent/agentic_system.rs"); + + for private_policy in [ + "RuntimeOwnershipKey::for_workspace", + "WorkspaceRuntimeOwnership::try_acquire", + "fn ownership_root", + "fn product_identity", + "pub(crate) fn acquire_ownership", + ] { + assert!( + !SHARED_RUNTIME.contains(private_policy), + "CLI must not duplicate Core ownership policy: {private_policy}" + ); + } + assert!( + !CLI_RUNTIME.contains("WorkspaceRuntimeOwnership") + && !CLI_RUNTIME.contains("_runtime_ownership"), + "Coordinator must retain the Core owner; CliRuntimeContext must not keep a second guard" + ); + assert!( + CLI_MAIN.contains("CoreRuntimeOwnership") + && AGENTIC_SYSTEM.contains("init_agentic_system_for_profile_with_runtime_ownership"), + "CLI must select a deployment and inject the single Core owner" + ); +} diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 6e1ee50c14..def58f79ae 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -16,9 +16,9 @@ use crate::runtime::{ use crate::startup_trace::DesktopStartupTrace; use bitfun_agent_runtime::deep_review::sanitize_focused_review_public_metadata; use bitfun_agent_runtime::sdk::{ - AgentDialogTurnRequest, AgentInputAttachment, AgentSessionModelUpdateRequest, - AgentSubmissionSource, AgentTurnCancellationRequest, PermissionAuditRecord, PermissionGrant, - PermissionGrantKey, PermissionReply, PermissionRequest, + AgentDialogTurnRequest, AgentInputAttachment, AgentSessionCreateResult, + AgentSessionModelUpdateRequest, AgentSubmissionSource, AgentTurnCancellationRequest, + PermissionAuditRecord, PermissionGrant, PermissionGrantKey, PermissionReply, PermissionRequest, }; use bitfun_core::agentic::agents::AgentSource; use bitfun_core::agentic::coordination::{ @@ -145,21 +145,7 @@ pub struct SessionConfigDTO { pub remote_ssh_host: Option, } -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateSessionResponse { - pub session_id: String, - pub session_name: String, - pub agent_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub workspace_path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub workspace_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub project_workspace_path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub execution_target: Option, -} +pub type CreateSessionResponse = AgentSessionCreateResult; fn existing_session_create_response( request: &CreateSessionRequest, @@ -204,15 +190,16 @@ fn existing_session_create_response( )); } - Ok(CreateSessionResponse { - session_id: metadata.session_id.clone(), - session_name: metadata.session_name.clone(), - agent_type: metadata.agent_type.clone(), - workspace_path: metadata.workspace_path.clone(), - workspace_id: None, - project_workspace_path: metadata.project_workspace_path.clone(), - execution_target: metadata.execution_target.clone(), - }) + let mut response = AgentSessionCreateResult::new( + metadata.session_id.clone(), + metadata.session_name.clone(), + metadata.agent_type.clone(), + ); + response.workspace_path = metadata.workspace_path.clone(); + response.workspace_id = request.workspace_id.clone(); + response.project_workspace_path = metadata.project_workspace_path.clone(); + response.execution_target = metadata.execution_target.clone(); + Ok(response) } fn is_idempotent_review_create(request: &CreateSessionRequest) -> bool { @@ -1248,6 +1235,7 @@ pub struct GenerateSessionTitleRequest { pub async fn create_session( coordinator: State<'_, Arc>, app_state: State<'_, AppState>, + runtime: State<'_, DesktopRuntimeContext>, mut request: CreateSessionRequest, ) -> Result { fn norm_conn(s: Option) -> Option { @@ -1267,6 +1255,18 @@ pub async fn create_session( .and_then(|c| norm_conn(c.remote_ssh_host.clone())) }); + if remote_conn.is_some() { + runtime + .session_application() + .ensure_workspace_runtime_ownership(desktop_session_scope( + request.workspace_path.clone(), + remote_conn.clone(), + remote_ssh_host.clone(), + )) + .await + .map_err(|error| error.to_string())?; + } + let source_workspace_path = request.workspace_path.clone(); let is_idempotent_managed_create = matches!( request.execution_target.as_ref(), @@ -1442,9 +1442,7 @@ pub async fn create_session( "Session ID {session_id} already exists with a different worktree target" )); } - let mut response = existing_session_create_response(&request, &metadata)?; - response.workspace_id = request.workspace_id.clone(); - return Ok(response); + return existing_session_create_response(&request, &metadata); } } @@ -1484,6 +1482,13 @@ pub async fn create_session( repaired = true; } if repaired { + coordinator + .ensure_workspace_runtime_ownership( + Path::new(&project_workspace_path), + remote_conn.as_deref(), + remote_ssh_host.as_deref(), + ) + .map_err(|error| error.to_string())?; let relationship = request.relationship.clone(); let deep_review_run_manifest = request.deep_review_run_manifest.clone(); let review_target_evidence = request.review_target_evidence.clone(); @@ -1631,15 +1636,7 @@ pub async fn create_session( .map_err(|e| format!("Failed to persist Review target evidence: {}", e))?; } - Ok(CreateSessionResponse { - session_id: session.session_id, - session_name: session.session_name, - agent_type: session.agent_type, - workspace_path: session.config.workspace_path, - workspace_id: session.config.workspace_id, - project_workspace_path: session.config.project_workspace_path, - execution_target: session.config.execution_target, - }) + Ok(session.into()) } #[tauri::command] @@ -1840,6 +1837,13 @@ pub async fn compact_session( .ok_or_else(|| { "workspace_path is required when the session is not loaded".to_string() })?; + coordinator + .ensure_workspace_runtime_ownership( + Path::new(workspace_path), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .map_err(|error| error.to_string())?; let effective = desktop_effective_session_storage_path( &app_state, workspace_path, @@ -1888,6 +1892,13 @@ pub async fn activate_session_goal( .ok_or_else(|| { "workspace_path is required when the session is not loaded".to_string() })?; + coordinator + .ensure_workspace_runtime_ownership( + Path::new(workspace_path), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .map_err(|error| error.to_string())?; let effective = desktop_effective_session_storage_path( &app_state, workspace_path, @@ -1938,6 +1949,13 @@ async fn ensure_session_for_thread_goal( .ok_or_else(|| { "workspace_path is required when the session is not loaded".to_string() })?; + coordinator + .ensure_workspace_runtime_ownership( + Path::new(workspace_path), + remote_connection_id, + remote_ssh_host, + ) + .map_err(|error| error.to_string())?; let effective = desktop_effective_session_storage_path( app_state, workspace_path, @@ -2076,6 +2094,31 @@ pub async fn set_session_memory_mode( } other => return Err(format!("unsupported memory mode: {other}")), }; + if coordinator + .get_session_manager() + .get_session(session_id) + .is_some() + { + coordinator + .ensure_session_runtime_ownership(session_id, None) + .map_err(|error| error.to_string())?; + } else { + let workspace_path = request + .workspace_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "workspace_path is required when the session is not loaded".to_string() + })?; + coordinator + .ensure_workspace_runtime_ownership( + Path::new(workspace_path), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .map_err(|error| error.to_string())?; + } let storage_path = resolve_thread_goal_storage_path( coordinator.inner(), app_state.inner(), @@ -2224,6 +2267,13 @@ pub async fn run_init_agents_md( .ok_or_else(|| { "workspace_path is required when the session is not loaded".to_string() })?; + coordinator + .ensure_workspace_runtime_ownership( + Path::new(workspace_path), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .map_err(|error| error.to_string())?; let effective = desktop_effective_session_storage_path( &app_state, workspace_path, @@ -3366,7 +3416,8 @@ mod tests { #[test] fn existing_create_session_retry_returns_the_matching_session() { - let request = idempotent_create_request(); + let mut request = idempotent_create_request(); + request.workspace_id = Some("workspace-1".to_string()); let mut metadata = SessionMetadata::new( "review_child_request-1".to_string(), "Review fixes".to_string(), @@ -3378,11 +3429,13 @@ mod tests { relationship.parent_dialog_turn_id = Some("turn-2".to_string()); relationship.parent_turn_index = Some(2); - let response = existing_session_create_response(&request, &metadata) - .expect("matching retry should reuse the session"); + let response: AgentSessionCreateResult = + existing_session_create_response(&request, &metadata) + .expect("matching retry should reuse the session"); assert_eq!(response.session_id, "review_child_request-1"); assert_eq!(response.agent_type, "CodeReview"); + assert_eq!(response.workspace_id.as_deref(), Some("workspace-1")); } #[test] diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 4805b69669..040a18c0eb 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -2,6 +2,7 @@ use crate::api::session_storage_path::desktop_effective_session_storage_path; use crate::embedded_relay_host::DesktopEmbeddedRelayHost; +use bitfun_core::agentic::coordination::{get_global_coordinator, ConversationCoordinator}; use bitfun_core::agentic::persistence::PersistenceManager; use bitfun_core::agentic::tools::account_login_capability::set_account_login_available; use bitfun_core::agentic::tools::page_deploy_host::set_page_deploy_handler; @@ -16,6 +17,8 @@ use bitfun_core::service::remote_connect::{ PairingState, RemoteConnectConfig, RemoteConnectService, }; use bitfun_core::service::session::{DialogTurnData, SessionMetadata}; +use bitfun_core::service::workspace::{get_global_workspace_service, WorkspaceKind}; +use bitfun_core::service::workspace_runtime::WorkspaceRuntimeService; use bitfun_services_integrations::remote_connect::account::{ error_indicates_expired_token, validate_relay_base_url, }; @@ -3160,6 +3163,7 @@ pub async fn account_export_all_sessions( #[tauri::command] pub async fn account_import_remote_sessions( workspace_path: String, + coordinator: State<'_, Arc>, app_state: State<'_, crate::api::app_state::AppState>, path_manager: State<'_, Arc>, ) -> Result, String> { @@ -3167,6 +3171,10 @@ pub async fn account_import_remote_sessions( let _sync_guard = lock_account_sync(generation).await?; let (acct_session, relay_url) = read_account_context().await?; + coordinator + .ensure_workspace_runtime_ownership(std::path::Path::new(&workspace_path), None, None) + .map_err(|error| error.to_string())?; + let storage_path = desktop_effective_session_storage_path(&app_state, &workspace_path, None, None).await; @@ -3233,6 +3241,7 @@ pub async fn account_import_remote_sessions( pub async fn account_fetch_session_turns( session_id: String, workspace_path: String, + coordinator: State<'_, Arc>, app_state: State<'_, crate::api::app_state::AppState>, path_manager: State<'_, Arc>, ) -> Result { @@ -3244,6 +3253,10 @@ pub async fn account_fetch_session_turns( return Ok(false); } + coordinator + .ensure_workspace_runtime_ownership(std::path::Path::new(&workspace_path), None, None) + .map_err(|error| error.to_string())?; + let storage_path = desktop_effective_session_storage_path(&app_state, &workspace_path, None, None).await; let manager = PersistenceManager::new(path_manager.inner().clone()) @@ -4306,28 +4319,23 @@ async fn import_session_bundle(bundle_json: &str, account_generation: u64) -> an let path_manager = std::sync::Arc::new(bitfun_core::infrastructure::PathManager::new()?); let manager = PersistenceManager::new(path_manager.clone())?; - - // Find the first workspace sessions dir that exists - let projects_root = path_manager.projects_root(); - let entries = std::fs::read_dir(&projects_root)?; - let mut target_dir: Option = None; - for entry in entries.flatten() { - if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { - continue; - } - let sessions = entry.path().join("sessions"); - if sessions.is_dir() { - target_dir = Some(sessions); - break; - } + let workspace = get_global_workspace_service() + .ok_or_else(|| anyhow::anyhow!("workspace service is unavailable"))? + .get_current_workspace() + .await + .ok_or_else(|| anyhow::anyhow!("no active workspace is available for session import"))?; + if workspace.workspace_kind == WorkspaceKind::Remote { + return Err(anyhow::anyhow!( + "session import requires an active local workspace" + )); } - - // If no workspace sessions dir exists, create one under a "synced" workspace - let target_dir = target_dir.unwrap_or_else(|| { - let dir = projects_root.join("synced").join("sessions"); - let _ = std::fs::create_dir_all(&dir); - dir - }); + get_global_coordinator() + .ok_or_else(|| anyhow::anyhow!("Agent Runtime coordinator is unavailable"))? + .ensure_workspace_runtime_ownership(&workspace.root_path, None, None) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let target_dir = WorkspaceRuntimeService::new(path_manager.clone()) + .context_for_local_workspace(&workspace.root_path) + .sessions_dir; let mut metadata: SessionMetadata = serde_json::from_value(bundle.metadata.clone())?; if metadata.session_id != bundle.session_id { diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 119a4ab489..512b57a564 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -346,7 +346,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ("editor_ai_stream", RemoteWorkspacePolicy::LegacyUnaudited), ( "ensure_assistant_bootstrap", - RemoteWorkspacePolicy::LegacyUnaudited, + RemoteWorkspacePolicy::RemoteUnsupported, ), ( "ensure_coordinator_session", diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index c65f7b3ca3..018edc08e1 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -432,7 +432,17 @@ pub async fn save_session_turn( request: SaveSessionTurnRequest, app_state: State<'_, AppState>, path_manager: State<'_, Arc>, + runtime: State<'_, DesktopRuntimeContext>, ) -> Result<(), String> { + runtime + .session_application() + .ensure_workspace_runtime_ownership(desktop_session_scope( + request.workspace_path.clone(), + request.remote_connection_id.clone(), + request.remote_ssh_host.clone(), + )) + .await + .map_err(desktop_session_error)?; let workspace_path = desktop_effective_session_storage_path( &app_state, &request.workspace_path, @@ -674,7 +684,17 @@ pub async fn archive_all_sessions( request: ArchiveAllSessionsRequest, app_state: State<'_, AppState>, path_manager: State<'_, Arc>, + runtime: State<'_, DesktopRuntimeContext>, ) -> Result { + runtime + .session_application() + .ensure_workspace_runtime_ownership(desktop_session_scope( + request.workspace_path.clone(), + request.remote_connection_id.clone(), + request.remote_ssh_host.clone(), + )) + .await + .map_err(desktop_session_error)?; let workspace_path = desktop_effective_session_storage_path( &app_state, &request.workspace_path, @@ -732,7 +752,17 @@ pub async fn delete_all_archived_sessions( request: DeleteAllArchivedSessionsRequest, app_state: State<'_, AppState>, path_manager: State<'_, Arc>, + runtime: State<'_, DesktopRuntimeContext>, ) -> Result { + runtime + .session_application() + .ensure_workspace_runtime_ownership(desktop_session_scope( + request.workspace_path.clone(), + request.remote_connection_id.clone(), + request.remote_ssh_host.clone(), + )) + .await + .map_err(desktop_session_error)?; let workspace_path = desktop_effective_session_storage_path( &app_state, &request.workspace_path, diff --git a/src/apps/desktop/src/api/snapshot_service.rs b/src/apps/desktop/src/api/snapshot_service.rs index dbec2514a4..6cfff7981b 100644 --- a/src/apps/desktop/src/api/snapshot_service.rs +++ b/src/apps/desktop/src/api/snapshot_service.rs @@ -4,7 +4,8 @@ use bitfun_core::infrastructure::try_get_path_manager_arc; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_core::service::snapshot::{ ensure_snapshot_manager_for_workspace, get_snapshot_manager_for_workspace, - initialize_snapshot_manager_for_workspace, OperationType, SnapshotConfig, SnapshotManager, + initialize_snapshot_manager_for_workspace, open_snapshot_manager_for_view, OperationType, + SnapshotConfig, SnapshotManager, }; use bitfun_runtime_ports::{ LocalWorkspaceSnapshotPort, LocalWorkspaceSnapshotSessionRequest, @@ -16,13 +17,50 @@ use std::collections::HashSet; use std::{path::PathBuf, sync::Arc, time::Duration}; use tauri::{AppHandle, Emitter, State}; -use crate::runtime::DesktopRuntimeContext; +use crate::runtime::{DesktopRuntimeContext, DesktopSessionScopeRequest}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SnapshotRemoteScope { + #[serde(default, alias = "remoteConnectionId")] + pub remote_connection_id: Option, + #[serde(default, alias = "remoteSshHost")] + pub remote_ssh_host: Option, +} + +impl SnapshotRemoteScope { + fn declares_remote(&self) -> bool { + self.remote_connection_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || self + .remote_ssh_host + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + } +} + +async fn ensure_local_runtime_ownership( + runtime: &DesktopRuntimeContext, + workspace_path: &str, +) -> Result<(), String> { + runtime + .session_application() + .ensure_workspace_runtime_ownership(DesktopSessionScopeRequest { + workspace_path: workspace_path.to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .map_err(|error| error.to_string()) +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SnapshotInitRequest { #[serde(alias = "workspacePath")] pub workspace_path: String, pub config: Option, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -39,6 +77,8 @@ pub struct RecordFileChangeRequest { pub tool_name: String, #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -50,6 +90,8 @@ pub struct RollbackSessionRequest { pub delete_session: bool, // Whether to also delete the session (default false) #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -62,6 +104,8 @@ pub struct RollbackTurnRequest { pub delete_turns: bool, #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -70,6 +114,8 @@ pub struct AcceptSessionRequest { pub session_id: String, #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -80,6 +126,8 @@ pub struct AcceptFileRequest { pub file_path: String, #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -88,6 +136,8 @@ pub struct GetSessionFilesRequest { pub session_id: String, #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -96,6 +146,8 @@ pub struct GetSessionTurnsRequest { pub session_id: String, #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -106,6 +158,8 @@ pub struct GetTurnFilesRequest { pub turn_index: usize, #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -119,6 +173,8 @@ pub struct GetFileDiffRequest { pub operation_id: Option, #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -127,6 +183,8 @@ pub struct GetBaselineSnapshotDiffRequest { pub file_path: String, #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -137,6 +195,8 @@ pub struct GetOperationDiffRequest { pub operationId: Option, #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -145,6 +205,8 @@ pub struct GetSessionFileDiffStatsRequest { pub filePath: String, #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -153,6 +215,8 @@ pub struct GetOperationSummaryRequest { pub operationId: String, #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -161,6 +225,8 @@ pub struct GetSessionStatsRequest { pub session_id: String, #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -169,32 +235,40 @@ pub struct GetFileChangeHistoryRequest { pub file_path: String, #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetAllModifiedFilesRequest { #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SnapshotWorkspaceRequest { #[serde(alias = "workspacePath")] pub workspace_path: String, + #[serde(flatten)] + pub remote_scope: SnapshotRemoteScope, } #[tauri::command] pub async fn initialize_snapshot( app_handle: AppHandle, + runtime: State<'_, DesktopRuntimeContext>, request: SnapshotInitRequest, ) -> Result { // Remote workspaces don't support snapshot system - if is_remote_path(&request.workspace_path).await { + if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { return Ok(serde_json::json!({ "success": true, "message": "Snapshot system skipped for remote workspace" })); } + ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; let workspace_dir = PathBuf::from(&request.workspace_path); @@ -371,17 +445,43 @@ async fn ensure_snapshot_manager_ready_for( Ok(manager) } -async fn ensure_snapshot_manager_ready( +async fn ensure_local_snapshot_mutation_path( workspace_path: &str, + remote_scope: &SnapshotRemoteScope, +) -> Result<(), String> { + if remote_scope.declares_remote() || is_remote_path(workspace_path).await { + return Err(format!( + "Snapshot system not supported for remote workspace: {}", + workspace_path + )); + } + Ok(()) +} + +async fn snapshot_manager_for_view( + workspace_path: &str, + remote_scope: &SnapshotRemoteScope, ) -> Result, String> { - ensure_snapshot_manager_ready_for(workspace_path, "unspecified").await + if remote_scope.declares_remote() || is_remote_path(workspace_path).await { + return Err(format!( + "Snapshot view unavailable (snapshot_remote_workspace_unavailable): remote workspace {} has no local snapshot runtime", + workspace_path + )); + } + let workspace_dir = resolve_workspace_dir(workspace_path).await?; + open_snapshot_manager_for_view(&workspace_dir) + .await + .map_err(|error| format!("Failed to open snapshot view: {error}")) } #[tauri::command] pub async fn record_file_change( app_handle: AppHandle, + runtime: State<'_, DesktopRuntimeContext>, request: RecordFileChangeRequest, ) -> Result { + ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; + ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; let manager = ensure_snapshot_manager_ready_for(&request.workspace_path, "record_file_change").await?; @@ -425,12 +525,12 @@ pub async fn record_file_change( #[tauri::command] pub async fn rollback_session( app_handle: AppHandle, + runtime: State<'_, DesktopRuntimeContext>, request: RollbackSessionRequest, ) -> Result, String> { // Remote workspaces have no local snapshots — nothing to roll back - if is_remote_path(&request.workspace_path).await { - return Ok(vec![]); - } + ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; + ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; let manager = ensure_snapshot_manager_ready_for(&request.workspace_path, "rollback_session").await?; @@ -464,9 +564,8 @@ pub async fn rollback_to_turn( request: RollbackTurnRequest, ) -> Result, String> { // Remote workspaces have no local snapshots — nothing to roll back - if is_remote_path(&request.workspace_path).await { - return Ok(vec![]); - } + ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; + ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; let workspace_path = resolve_workspace_dir(&request.workspace_path).await?; { @@ -625,8 +724,11 @@ pub async fn rollback_to_turn( #[tauri::command] pub async fn accept_session( app_handle: AppHandle, + runtime: State<'_, DesktopRuntimeContext>, request: AcceptSessionRequest, ) -> Result { + ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; + ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; let manager = ensure_snapshot_manager_ready_for(&request.workspace_path, "accept_session").await?; @@ -651,9 +753,12 @@ pub async fn accept_session( #[tauri::command] pub async fn accept_file( app_handle: AppHandle, + runtime: State<'_, DesktopRuntimeContext>, request: AcceptFileRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; + ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; + let manager = ensure_snapshot_manager_ready_for(&request.workspace_path, "accept_file").await?; manager .accept_file(&request.session_id, &request.file_path) @@ -677,9 +782,12 @@ pub async fn accept_file( #[tauri::command] pub async fn reject_file( app_handle: AppHandle, + runtime: State<'_, DesktopRuntimeContext>, request: AcceptFileRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; + ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; + let manager = ensure_snapshot_manager_ready_for(&request.workspace_path, "reject_file").await?; let restored_files = manager .reject_file(&request.session_id, &request.file_path) @@ -711,7 +819,7 @@ pub async fn get_session_files( runtime: State<'_, DesktopRuntimeContext>, request: GetSessionFilesRequest, ) -> Result, String> { - if is_remote_path(&request.workspace_path).await { + if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { return Ok(vec![]); } let workspace_path = resolve_workspace_dir(&request.workspace_path).await?; @@ -732,6 +840,10 @@ pub async fn get_session_turns( ) -> Result, String> { use bitfun_core::agentic::persistence::PersistenceManager; + if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { + return Ok(vec![]); + } + let workspace_path = PathBuf::from(&request.workspace_path); if let Ok(path_manager) = try_get_path_manager_arc() { match PersistenceManager::new(path_manager) { @@ -762,7 +874,7 @@ pub async fn get_session_turns( } } - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let turns = manager .get_session_turns(&request.session_id) @@ -774,7 +886,10 @@ pub async fn get_session_turns( #[tauri::command] pub async fn get_turn_files(request: GetTurnFilesRequest) -> Result, String> { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { + return Ok(vec![]); + } + let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let files = manager .get_turn_files(&request.session_id, request.turn_index) @@ -789,7 +904,7 @@ pub async fn get_turn_files(request: GetTurnFilesRequest) -> Result, #[tauri::command] pub async fn get_file_diff(request: GetFileDiffRequest) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let diff = manager .get_file_diff( @@ -807,7 +922,7 @@ pub async fn get_file_diff(request: GetFileDiffRequest) -> Result Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let diff = manager .get_file_diff( @@ -849,9 +964,7 @@ pub async fn get_operation_diff( pub async fn get_session_file_diff_stats( request: GetSessionFileDiffStatsRequest, ) -> Result { - let manager = - ensure_snapshot_manager_ready_for(&request.workspace_path, "get_session_file_diff_stats") - .await?; + let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let stats = manager .get_session_file_diff_stats(&request.sessionId, &request.filePath) @@ -865,8 +978,7 @@ pub async fn get_session_file_diff_stats( pub async fn get_operation_summary( request: GetOperationSummaryRequest, ) -> Result { - let manager = - ensure_snapshot_manager_ready_for(&request.workspace_path, "get_operation_summary").await?; + let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let summary = manager .get_operation_summary(&request.sessionId, &request.operationId) @@ -890,7 +1002,10 @@ pub async fn get_operation_summary( pub async fn get_session_operations( request: GetSessionFilesRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { + return Ok(serde_json::Value::Array(Vec::new())); + } + let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let session = manager .get_session(&request.session_id) @@ -933,9 +1048,13 @@ pub async fn get_session_operations( #[tauri::command] pub async fn accept_operation( app_handle: AppHandle, + runtime: State<'_, DesktopRuntimeContext>, request: GetOperationSummaryRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; + ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "accept_operation").await?; let summary = manager .get_operation_summary(&request.sessionId, &request.operationId) @@ -969,9 +1088,13 @@ pub async fn accept_operation( #[tauri::command] pub async fn reject_operation( app_handle: AppHandle, + runtime: State<'_, DesktopRuntimeContext>, request: GetOperationSummaryRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?; + ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "reject_operation").await?; let summary = manager .get_operation_summary(&request.sessionId, &request.operationId) @@ -1013,7 +1136,7 @@ pub async fn get_session_stats( runtime: State<'_, DesktopRuntimeContext>, request: GetSessionStatsRequest, ) -> Result { - if is_remote_path(&request.workspace_path).await { + if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { return Ok(serde_json::json!({ "session_id": request.session_id, "total_files": 0, @@ -1036,7 +1159,7 @@ pub async fn get_session_stats( pub async fn get_snapshot_system_stats( request: SnapshotWorkspaceRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let stats = manager .get_system_stats() @@ -1050,7 +1173,10 @@ pub async fn get_snapshot_system_stats( pub async fn get_snapshot_sessions( request: SnapshotWorkspaceRequest, ) -> Result, String> { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { + return Ok(vec![]); + } + let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; manager .list_sessions() @@ -1062,7 +1188,7 @@ pub async fn get_snapshot_sessions( pub async fn check_git_isolation( request: SnapshotWorkspaceRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let is_isolated = manager .check_git_isolation() @@ -1079,7 +1205,10 @@ pub async fn check_git_isolation( pub async fn get_file_change_history( request: GetFileChangeHistoryRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { + return Ok(serde_json::Value::Array(Vec::new())); + } + let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let file_path = PathBuf::from(&request.file_path); let changes = manager @@ -1094,7 +1223,10 @@ pub async fn get_file_change_history( pub async fn get_all_modified_files( request: GetAllModifiedFilesRequest, ) -> Result, String> { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + if request.remote_scope.declares_remote() || is_remote_path(&request.workspace_path).await { + return Ok(vec![]); + } + let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let files = manager .get_all_modified_files() @@ -1111,7 +1243,7 @@ pub async fn get_all_modified_files( pub async fn get_baseline_snapshot_diff( request: GetBaselineSnapshotDiffRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = snapshot_manager_for_view(&request.workspace_path, &request.remote_scope).await?; let file_path = PathBuf::from(&request.file_path); @@ -1153,10 +1285,143 @@ mod tests { }; use super::{ + ensure_local_snapshot_mutation_path, get_snapshot_manager_for_workspace, local_snapshot_command_error, local_snapshot_session_files, local_snapshot_session_stats, - rollback_local_workspace_files, + rollback_local_workspace_files, snapshot_manager_for_view, RollbackTurnRequest, + SnapshotRemoteScope, }; + #[test] + fn snapshot_mutation_dto_preserves_structured_remote_facts() { + let request: RollbackTurnRequest = serde_json::from_value(serde_json::json!({ + "sessionId": "remote-session", + "turnIndex": 2, + "workspacePath": "/srv/project", + "remoteConnectionId": "ssh:user@example.com:22", + "remoteSshHost": "example.com" + })) + .expect("deserialize snapshot mutation scope"); + + assert_eq!( + request.remote_scope.remote_connection_id.as_deref(), + Some("ssh:user@example.com:22") + ); + assert_eq!( + request.remote_scope.remote_ssh_host.as_deref(), + Some("example.com") + ); + } + + #[tokio::test] + async fn remote_snapshot_mutation_is_rejected_before_writer_initialization() { + let workspace = tempfile::tempdir().expect("create workspace"); + let workspace_path = workspace.path().to_string_lossy().to_string(); + let remote = + bitfun_core::service::remote_ssh::workspace_state::init_remote_workspace_manager(); + remote + .register_remote_workspace( + workspace_path.clone(), + "snapshot-remote-test".to_string(), + "Snapshot remote test".to_string(), + "snapshot-test-host".to_string(), + ) + .await; + + let error = ensure_local_snapshot_mutation_path(&workspace_path, &Default::default()) + .await + .expect_err("remote mutation must fail closed"); + + assert!(error.contains("not supported for remote workspace")); + assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); + assert_eq!( + std::fs::read_dir(workspace.path()) + .expect("workspace remains readable") + .count(), + 0 + ); + remote + .unregister_remote_workspace("snapshot-remote-test", &workspace_path) + .await; + + let disconnected_scope = SnapshotRemoteScope { + remote_connection_id: Some("snapshot-test-connection".to_string()), + remote_ssh_host: Some("snapshot-test-host".to_string()), + }; + let disconnected_error = + ensure_local_snapshot_mutation_path(&workspace_path, &disconnected_scope) + .await + .expect_err("structured session facts remain remote after registry removal"); + assert!(disconnected_error.contains("not supported for remote workspace")); + assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); + } + + #[test] + fn rollback_commands_reject_remote_workspaces_before_local_side_effects() { + let source = include_str!("snapshot_service.rs"); + let rollback_session = source + .split_once("pub async fn rollback_session") + .expect("rollback_session remains present") + .1 + .split_once("pub async fn rollback_to_turn") + .expect("rollback_to_turn remains present") + .0; + let rollback_to_turn = source + .split_once("pub async fn rollback_to_turn") + .expect("rollback_to_turn remains present") + .1 + .split_once("pub async fn accept_session") + .expect("accept_session remains present") + .0; + + let assert_remote_guard_precedes = |body: &str, side_effect: &str| { + let guard = body + .find("ensure_local_snapshot_mutation_path") + .expect("remote mutation guard remains present"); + let effect = body + .find(side_effect) + .unwrap_or_else(|| panic!("expected side effect remains present: {side_effect}")); + assert!(guard < effect, "remote guard must precede {side_effect}"); + }; + + assert_remote_guard_precedes(rollback_session, "ensure_local_runtime_ownership"); + assert_remote_guard_precedes(rollback_session, "ensure_snapshot_manager_ready_for"); + assert_remote_guard_precedes(rollback_to_turn, "ensure_local_runtime_ownership"); + assert_remote_guard_precedes(rollback_to_turn, "cancel_active_turn_for_session"); + } + + #[tokio::test] + async fn snapshot_view_does_not_initialize_a_writer() { + let workspace = tempfile::tempdir().expect("create workspace"); + assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); + + snapshot_manager_for_view( + &workspace.path().to_string_lossy(), + &SnapshotRemoteScope::default(), + ) + .await + .expect("an empty read-only view remains available"); + assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); + } + + #[tokio::test] + async fn snapshot_view_rejects_structured_remote_scope_after_registry_disconnect() { + let workspace = tempfile::tempdir().expect("create colliding local workspace"); + let scope = SnapshotRemoteScope { + remote_connection_id: Some("connection-1".to_string()), + remote_ssh_host: Some("host-1".to_string()), + }; + + let error = match snapshot_manager_for_view(&workspace.path().to_string_lossy(), &scope) + .await + { + Ok(_) => panic!("structured remote scope must not read the colliding local Snapshot"), + Err(error) => error, + }; + + assert!(error.contains("snapshot_remote_workspace_unavailable")); + assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); + } + #[derive(Default)] struct RecordingSnapshotPort { file_calls: AtomicUsize, diff --git a/src/apps/desktop/src/api/workspace_activation.rs b/src/apps/desktop/src/api/workspace_activation.rs index 3d946361d9..1fcff53acd 100644 --- a/src/apps/desktop/src/api/workspace_activation.rs +++ b/src/apps/desktop/src/api/workspace_activation.rs @@ -1,5 +1,4 @@ use crate::api::app_state::AppState; -use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_core::service::search::workspace_search_runtime_available; use bitfun_core::service::workspace::{WorkspaceInfo, WorkspaceKind}; use log::{debug, info, warn}; @@ -32,32 +31,6 @@ async fn warm_workspace_background_services( ) { let started_at = Instant::now(); let target_path = workspace_info.root_path.clone(); - let root_str = target_path.to_string_lossy().to_string(); - let skip_local_snapshot = workspace_info.workspace_kind == WorkspaceKind::Remote - || is_remote_path(root_str.trim()).await; - - if !skip_local_snapshot && is_workspace_active(&workspace_path, &target_path).await { - let snapshot_started_at = Instant::now(); - if let Err(error) = - bitfun_core::service::snapshot::initialize_snapshot_manager_for_workspace( - target_path.clone(), - None, - ) - .await - { - warn!( - "Failed to initialize snapshot system during workspace warmup: path={}, error={}", - target_path.display(), - error - ); - } else { - debug!( - "Workspace snapshot warmup completed: path={}, elapsed_ms={}", - target_path.display(), - snapshot_started_at.elapsed().as_millis() - ); - } - } if is_workspace_active(&workspace_path, &target_path).await { let subagents_started_at = Instant::now(); diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index c0a34328ec..6e242f3ae5 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1849,12 +1849,19 @@ async fn init_agentic_system() -> anyhow::Result<( exec_config, )); + let runtime_ownership = Arc::new( + bitfun_core::runtime_ownership::CoreRuntimeOwnership::embedded( + path_manager.as_ref(), + "desktop", + ), + ); let coordinator = Arc::new(coordination::ConversationCoordinator::new( session_manager.clone(), execution_engine, tool_pipeline, event_queue.clone(), event_router.clone(), + runtime_ownership, )); coordinator.set_terminal_port( bitfun_core::product_runtime::CoreRuntimeServicesProvider::terminal_port(), diff --git a/src/apps/desktop/src/runtime/mod.rs b/src/apps/desktop/src/runtime/mod.rs index 0f7f3e0c37..f5e8899dfb 100644 --- a/src/apps/desktop/src/runtime/mod.rs +++ b/src/apps/desktop/src/runtime/mod.rs @@ -178,13 +178,13 @@ mod tests { 3, "only file listing, typed stats, and workspace rollback use the local owner port" ); - assert!(snapshot_commands.contains("is_remote_path(&request.workspace_path).await")); + assert!(snapshot_commands.contains("ensure_local_snapshot_mutation_path")); let rollback_source = &snapshot_commands[snapshot_commands .find("pub async fn rollback_to_turn") .expect("rollback command must exist")..]; let remote_guard = rollback_source - .find("if is_remote_path(&request.workspace_path).await") + .find("ensure_local_snapshot_mutation_path") .expect("remote rollback guard must remain host-owned"); let cancellation = rollback_source .find("cancel_active_turn_for_session") @@ -225,4 +225,140 @@ mod tests { assert!(!runtime_source.contains(&runtime_services)); assert!(!runtime_source.contains(&desktop_services_provider)); } + + #[test] + fn desktop_session_writes_reuse_the_coordinator_ownership_owner() { + let application = include_str!("session_application.rs"); + let app_entrypoint = include_str!("../lib.rs"); + let agentic_api = include_str!("../api/agentic_api.rs"); + let remote_connect_api = include_str!("../api/remote_connect_api.rs"); + let snapshot_api = include_str!("../api/snapshot_service.rs"); + let workspace_activation = include_str!("../api/workspace_activation.rs"); + + assert!( + !workspace_activation.contains("initialize_snapshot_manager_for_workspace"), + "read-only workspace activation must not attach the snapshot Runtime" + ); + + assert!( + app_entrypoint.contains("CoreRuntimeOwnership::embedded"), + "Desktop composition must inject one lazy multi-workspace Core owner" + ); + assert!( + application + .matches(".ensure_workspace_runtime_ownership(") + .count() + == 1, + "Desktop application must delegate ownership to one Coordinator gate" + ); + assert!( + application + .matches("self.ensure_runtime_ownership(&scope)") + .count() + >= 6, + "Desktop attach and mutation paths must reuse one application helper" + ); + assert!( + !application.contains("RuntimeOwnershipKey") + && !application.contains("WorkspaceRuntimeOwnership"), + "Desktop application must not duplicate ownership primitives" + ); + + let create_session = agentic_api + .split_once("pub async fn create_session") + .expect("create_session") + .1 + .split_once("pub async fn update_session_model") + .expect("create_session boundary") + .0; + assert!( + create_session.contains("session_application()") + && create_session.contains("ensure_workspace_runtime_ownership"), + "Desktop session creation must validate remote facts through the shared application scope resolver" + ); + + let view = application + .split_once("pub(crate) async fn restore_session_view") + .expect("view restore") + .1 + .split_once("pub(crate) async fn restore_session_with_turns") + .expect("view restore boundary") + .0; + assert!( + !view.contains("ensure_workspace_runtime_ownership"), + "read-only view restore must remain available without acquiring runtime ownership" + ); + + for (mutation, end) in [ + ("if is_idempotent_review_create", "let config = request"), + ( + "pub async fn set_session_memory_mode", + "pub async fn clear_session_thread_goal", + ), + ] { + let source = agentic_api + .split_once(mutation) + .unwrap_or_else(|| panic!("missing Desktop mutation: {mutation}")) + .1 + .split_once(end) + .unwrap_or_else(|| panic!("missing Desktop mutation boundary: {end}")) + .0; + assert!( + source.contains("ensure_workspace_runtime_ownership") + || source.contains("ensure_session_runtime_ownership"), + "Desktop mutation {mutation} must pass through the Core ownership owner" + ); + } + + for (mutation, end) in [ + ( + "pub async fn account_import_remote_sessions", + "pub async fn account_fetch_session_turns", + ), + ( + "pub async fn account_fetch_session_turns", + "pub async fn account_execute_on_device", + ), + ( + "async fn import_session_bundle", + "async fn pull_and_reconcile", + ), + ] { + let source = remote_connect_api + .split_once(mutation) + .unwrap_or_else(|| panic!("missing relay mutation: {mutation}")) + .1 + .split_once(end) + .unwrap_or_else(|| panic!("missing relay mutation boundary: {end}")) + .0; + assert!( + source.contains("ensure_workspace_runtime_ownership"), + "relay mutation {mutation} must pass through the Core ownership owner" + ); + } + + for mutation in [ + "pub async fn initialize_snapshot", + "pub async fn record_file_change", + "pub async fn rollback_session", + "pub async fn rollback_to_turn", + "pub async fn accept_session", + "pub async fn accept_file", + "pub async fn reject_file", + "pub async fn accept_operation", + "pub async fn reject_operation", + ] { + let source = snapshot_api + .split_once(mutation) + .unwrap_or_else(|| panic!("missing snapshot mutation: {mutation}")) + .1 + .split_once("#[tauri::command]") + .unwrap_or_else(|| panic!("missing snapshot mutation boundary: {mutation}")) + .0; + assert!( + source.contains("ensure_local_runtime_ownership"), + "snapshot mutation {mutation} must acquire ownership before side effects" + ); + } + } } diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index fdf64317bb..18298f7199 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -4,7 +4,7 @@ //! Rich Desktop persistence views remain on Core's compatibility facade while //! stable lifecycle operations use the Agent Runtime SDK. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; @@ -98,6 +98,7 @@ struct ResolvedDesktopSessionScope { remote_connection_id: Option, requested_remote_ssh_host: Option, resolved_remote_ssh_host: Option, + remote_binding_verified: bool, } #[derive(Clone)] @@ -110,15 +111,22 @@ impl DesktopSessionScopeResolver { async fn resolve(&self, request: DesktopSessionScopeRequest) -> ResolvedDesktopSessionScope { let remote_connection_id = normalized_optional(request.remote_connection_id.as_deref()); let requested_remote_ssh_host = normalized_optional(request.remote_ssh_host.as_deref()); - let mut registered_remote_ssh_host = None; - if requested_remote_ssh_host.is_none() { + let registered_remote_ssh_host = if let Some(connection_id) = remote_connection_id.as_deref() { - registered_remote_ssh_host = self - .workspace_service + self.workspace_service .remote_ssh_host_for_remote_workspace(connection_id, &request.workspace_path) - .await; - } - } + .await + } else { + None + }; + let remote_binding_verified = remote_connection_id.is_some() + && registered_remote_ssh_host + .as_deref() + .is_some_and(|registered| { + requested_remote_ssh_host + .as_deref() + .map_or(true, |requested| requested.eq_ignore_ascii_case(registered)) + }); let mut saved_remote_ssh_host = None; if requested_remote_ssh_host.is_none() && registered_remote_ssh_host.is_none() { if let Some(connection_id) = remote_connection_id.as_deref() { @@ -148,6 +156,7 @@ impl DesktopSessionScopeResolver { remote_connection_id, requested_remote_ssh_host, resolved_remote_ssh_host, + remote_binding_verified, } } } @@ -178,6 +187,7 @@ pub(crate) trait DesktopSessionHostEffects: Send + Sync { #[derive(Clone)] pub(crate) struct DesktopSessionApplication { + coordinator: Arc, agent_runtime: AgentRuntime, compatibility: CoreAgentRuntimeCompatibility, scope_resolver: DesktopSessionScopeResolver, @@ -198,9 +208,10 @@ impl DesktopSessionApplication { scheduler.clone(), token_usage_service, )?; - let compatibility = CoreAgentRuntimeCompatibility::build(coordinator, scheduler); + let compatibility = CoreAgentRuntimeCompatibility::build(coordinator.clone(), scheduler); Ok(Self { + coordinator, agent_runtime, compatibility, scope_resolver: DesktopSessionScopeResolver { @@ -226,6 +237,38 @@ impl DesktopSessionApplication { scope.effective_storage_path.clone() } + fn ensure_runtime_ownership( + &self, + scope: &ResolvedDesktopSessionScope, + ) -> DesktopSessionApplicationResult<()> { + let result = if scope.remote_binding_verified { + self.coordinator + .ensure_verified_remote_workspace_runtime_ownership( + Path::new(&scope.workspace_path), + scope + .remote_connection_id + .as_deref() + .expect("verified Remote scope has a connection id"), + scope.resolved_remote_ssh_host.as_deref(), + ) + } else { + self.coordinator.ensure_workspace_runtime_ownership( + Path::new(&scope.workspace_path), + scope.remote_connection_id.as_deref(), + scope.resolved_remote_ssh_host.as_deref(), + ) + }; + result.map_err(|error| DesktopSessionApplicationError::Core(error.to_string())) + } + + pub(crate) async fn ensure_workspace_runtime_ownership( + &self, + request: DesktopSessionScopeRequest, + ) -> DesktopSessionApplicationResult<()> { + let scope = self.resolved_scope(request).await; + self.ensure_runtime_ownership(&scope) + } + pub(crate) async fn list_persisted_sessions( &self, request: DesktopSessionScopeRequest, @@ -296,6 +339,7 @@ impl DesktopSessionApplication { session_id: &str, ) -> DesktopSessionApplicationResult<()> { let scope = self.resolved_scope(request).await; + self.ensure_runtime_ownership(&scope)?; let storage_path = self.storage_path(&scope); self.compatibility .touch_persisted_session(&storage_path, session_id) @@ -316,6 +360,7 @@ impl DesktopSessionApplication { } let workspace_path = request.workspace_path.clone(); let scope = self.resolved_scope(request).await; + self.ensure_runtime_ownership(&scope)?; let storage_path = self.storage_path(&scope); let session_id = incoming.session_id.clone(); self.compatibility @@ -361,10 +406,11 @@ impl DesktopSessionApplication { source_turn_id: String, ) -> DesktopSessionApplicationResult { let scope = self.resolved_scope(request).await; + self.ensure_runtime_ownership(&scope)?; let result = self .agent_runtime .fork_session_at_turn(AgentSessionForkAtTurnRequest { - workspace_path: scope.effective_storage_path.to_string_lossy().into_owned(), + workspace_path: scope.workspace_path.clone(), source_session_id, source_turn_id, remote_connection_id: scope.remote_connection_id, @@ -386,9 +432,10 @@ impl DesktopSessionApplication { archived: bool, ) -> DesktopSessionApplicationResult<()> { let scope = self.resolved_scope(request).await; + self.ensure_runtime_ownership(&scope)?; self.agent_runtime .set_session_archived(AgentSessionArchiveStateRequest { - workspace_path: scope.effective_storage_path.to_string_lossy().into_owned(), + workspace_path: scope.workspace_path.clone(), session_id, archived, remote_connection_id: scope.remote_connection_id, @@ -404,6 +451,7 @@ impl DesktopSessionApplication { session_id: String, ) -> DesktopSessionApplicationResult<()> { let scope = self.resolved_scope(request).await; + self.ensure_runtime_ownership(&scope)?; delete_session_with_host_effects( &self.agent_runtime, self.host_effects.as_ref(), @@ -422,6 +470,7 @@ impl DesktopSessionApplication { let normalized_title = title.trim().to_string(); if let Some(request) = request { let scope = self.resolved_scope(request).await; + self.ensure_runtime_ownership(&scope)?; if !self .compatibility .is_session_loaded_in_memory(&session_id) @@ -437,7 +486,7 @@ impl DesktopSessionApplication { } self.agent_runtime .rename_session(AgentSessionRenameRequest { - workspace_path: scope.effective_storage_path.to_string_lossy().into_owned(), + workspace_path: scope.workspace_path.clone(), session_id: session_id.clone(), session_name: title, remote_connection_id: scope.remote_connection_id, @@ -487,6 +536,7 @@ impl DesktopSessionApplication { )); } let scope = self.resolved_scope(request).await; + self.ensure_runtime_ownership(&scope)?; let storage_path = self.storage_path(&scope); self.compatibility .ensure_session_loaded_from_storage_path(&storage_path, session_id, include_internal) @@ -501,6 +551,7 @@ impl DesktopSessionApplication { include_internal: bool, ) -> DesktopSessionApplicationResult { let scope = self.resolved_scope(request).await; + self.ensure_runtime_ownership(&scope)?; let storage_path = self.storage_path(&scope); self.compatibility .restore_session_from_storage_path(&storage_path, session_id, include_internal) @@ -561,6 +612,7 @@ impl DesktopSessionApplication { { let path_started_at = Instant::now(); let scope = self.resolved_scope(request).await; + self.ensure_runtime_ownership(&scope)?; let storage_path = self.storage_path(&scope); let resolve_storage_path_duration_ms = path_started_at.elapsed().as_millis().min(u64::MAX as u128) as u64; @@ -587,7 +639,7 @@ async fn delete_session_with_host_effects( host_effects.release_session(&session_id).await; agent_runtime .delete_session(AgentSessionDeleteRequest { - workspace_path: scope.effective_storage_path.to_string_lossy().into_owned(), + workspace_path: scope.workspace_path.clone(), session_id: session_id.clone(), remote_connection_id: scope.remote_connection_id, remote_ssh_host: scope.resolved_remote_ssh_host, @@ -657,6 +709,7 @@ mod tests { struct RecordingDeletePort { events: Arc>>, + workspace_path: Arc>>, fail_delete: bool, } @@ -668,11 +721,11 @@ mod tests { &self, request: AgentSessionCreateRequest, ) -> PortResult { - Ok(AgentSessionCreateResult { - session_id: "unused".to_string(), - session_name: request.session_name, - agent_type: request.agent_type, - }) + Ok(AgentSessionCreateResult::new( + "unused", + request.session_name, + request.agent_type, + )) } async fn submit_message( @@ -702,8 +755,9 @@ mod tests { Ok(Vec::new()) } - async fn delete_session(&self, _request: AgentSessionDeleteRequest) -> PortResult<()> { + async fn delete_session(&self, request: AgentSessionDeleteRequest) -> PortResult<()> { self.events.lock().unwrap().push("durable_delete"); + *self.workspace_path.lock().unwrap() = Some(request.workspace_path); if self.fail_delete { return Err(PortError::new(PortErrorKind::Backend, "delete failed")); } @@ -742,17 +796,20 @@ mod tests { remote_connection_id: None, requested_remote_ssh_host: None, resolved_remote_ssh_host: None, + remote_binding_verified: false, } } fn delete_test_runtime( events: Arc>>, + workspace_path: Arc>>, fail_delete: bool, ) -> AgentRuntime { AgentRuntimeBuilder::new() .with_submission_port(Arc::new(NoopSubmissionPort)) .with_session_management_port(Arc::new(RecordingDeletePort { events, + workspace_path, fail_delete, })) .build() @@ -901,7 +958,8 @@ mod tests { #[tokio::test] async fn delete_orders_host_release_durable_delete_and_relay_tombstone() { let events = Arc::new(Mutex::new(Vec::new())); - let runtime = delete_test_runtime(events.clone(), false); + let workspace_path = Arc::new(Mutex::new(None)); + let runtime = delete_test_runtime(events.clone(), workspace_path.clone(), false); let host_effects = RecordingHostEffects { events: events.clone(), }; @@ -919,12 +977,16 @@ mod tests { events.lock().unwrap().as_slice(), ["release", "durable_delete", "relay_delete"] ); + assert_eq!( + workspace_path.lock().unwrap().as_deref(), + Some("D:/workspace/project") + ); } #[tokio::test] async fn delete_failure_does_not_publish_relay_tombstone() { let events = Arc::new(Mutex::new(Vec::new())); - let runtime = delete_test_runtime(events.clone(), true); + let runtime = delete_test_runtime(events.clone(), Arc::new(Mutex::new(None)), true); let host_effects = RecordingHostEffects { events: events.clone(), }; diff --git a/src/apps/sdk-host/src/runtime.rs b/src/apps/sdk-host/src/runtime.rs index 9a38ad5171..f9da5654ac 100644 --- a/src/apps/sdk-host/src/runtime.rs +++ b/src/apps/sdk-host/src/runtime.rs @@ -8,6 +8,8 @@ use bitfun_core::product_runtime::{ build_local_runtime_services, ensure_product_dialog_scheduler, CoreProductAgentRuntime, CoreProductEventQueueOwner, CoreRuntimeServicesProvider, }; +use bitfun_core::runtime_ownership::{CoreRuntimeOwnership, RuntimeDeployment}; +use std::sync::Arc; const RUNTIME_EVENT_BUFFER: usize = 256; const DELIVERY_PROFILE: DeliveryProfile = DeliveryProfile::Sdk; @@ -27,16 +29,28 @@ impl SdkHostRuntime { pub(crate) async fn build(workspace_root: impl AsRef) -> Result { let (workspace_root, services) = build_local_runtime_services(workspace_root, RUNTIME_EVENT_BUFFER)?; + let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let deployment = RuntimeDeployment::Embedded; + let runtime_ownership = CoreRuntimeOwnership::fixed_workspace( + path_manager.as_ref(), + "sdk-host", + &workspace_root, + deployment, + ) + .map_err(|error| anyhow::anyhow!(error.startup_message(deployment, "sdk-host")))?; - // The SDK Host keeps its own product identity. The SDK and CLI profiles - // currently select the same assembly-plan ceiling from shared facts. - // The Host's effective wire capability set remains a strict subset. + // SDK Host keeps its own delivery profile while sharing the product-wide + // workspace ownership identity with every first-party entrypoint. let parts = ProductAssembler::new() .assemble(ProductAssemblyInput::new(DELIVERY_PROFILE, services)) .context("Failed to assemble SDK Host product runtime")?; - let agentic_system = system::init_agentic_system_for_profile(parts.plan().profile()) - .await - .context("Failed to initialize agentic system")?; + let agentic_system = system::init_agentic_system_for_profile_with_runtime_ownership( + parts.plan().profile(), + Arc::new(runtime_ownership), + ) + .await + .context("Failed to initialize agentic system")?; bind_core_execution_ports(&agentic_system); let scheduler = ensure_product_dialog_scheduler(&agentic_system); let (services, harness_registry, _disabled_plugin_runtime) = parts.into_runtime_parts(); diff --git a/src/apps/sdk-host/tests/process_initialization.rs b/src/apps/sdk-host/tests/process_initialization.rs index 2cc267da9e..8a5300f7d3 100644 --- a/src/apps/sdk-host/tests/process_initialization.rs +++ b/src/apps/sdk-host/tests/process_initialization.rs @@ -28,3 +28,18 @@ fn sdk_host_process_keeps_cleanup_warnings_on_stderr() { assert!(entrypoint.contains(".with_max_level(tracing::Level::WARN)")); assert!(entrypoint.contains(".with_writer(std::io::stderr)")); } + +#[test] +fn sdk_host_injects_core_ownership_before_runtime_initialization() { + let runtime = include_str!("../src/runtime.rs"); + let ownership = runtime + .find("CoreRuntimeOwnership::fixed_workspace") + .expect("SDK Host Core ownership assembly"); + let initialize = runtime + .find("init_agentic_system_for_profile_with_runtime_ownership") + .expect("SDK Host ownership-aware AgenticSystem initialization"); + + assert!(ownership < initialize); + assert!(runtime.contains("RuntimeDeployment::Embedded")); + assert!(!runtime.contains("WorkspaceRuntimeOwnership")); +} diff --git a/src/apps/sdk-host/tests/stdio_transport.rs b/src/apps/sdk-host/tests/stdio_transport.rs index f072c107f5..b30a8c5625 100644 --- a/src/apps/sdk-host/tests/stdio_transport.rs +++ b/src/apps/sdk-host/tests/stdio_transport.rs @@ -16,6 +16,19 @@ use tokio::time::{timeout, Duration}; struct MinimalOwner; +fn created_session_result( + session_id: impl Into, + request: AgentSessionCreateRequest, +) -> AgentSessionCreateResult { + let mut result = + AgentSessionCreateResult::new(session_id, request.session_name, request.agent_type); + result.workspace_path = request.workspace_path; + result.workspace_id = Some("workspace-fixture".to_string()); + result.project_workspace_path = request.project_workspace_path; + result.execution_target = request.execution_target; + result +} + struct BlockingCreateOwner { calls: AtomicUsize, deleted: AtomicUsize, @@ -38,11 +51,7 @@ impl AgentSubmissionPort for MinimalOwner { &self, request: AgentSessionCreateRequest, ) -> PortResult { - Ok(AgentSessionCreateResult { - session_id: "unused".to_string(), - session_name: request.session_name, - agent_type: request.agent_type, - }) + Ok(created_session_result("unused", request)) } async fn create_session_with_id( @@ -50,11 +59,7 @@ impl AgentSubmissionPort for MinimalOwner { session_id: String, request: AgentSessionCreateRequest, ) -> PortResult { - Ok(AgentSessionCreateResult { - session_id, - session_name: request.session_name, - agent_type: request.agent_type, - }) + Ok(created_session_result(session_id, request)) } async fn create_transient_session_with_id( @@ -99,11 +104,7 @@ impl AgentSubmissionPort for BlockingCreateOwner { if self.calls.fetch_add(1, Ordering::AcqRel) == 0 { self.release.notified().await; } - Ok(AgentSessionCreateResult { - session_id: "session-blocking".to_string(), - session_name: request.session_name, - agent_type: request.agent_type, - }) + Ok(created_session_result("session-blocking", request)) } async fn create_session_with_id( @@ -114,11 +115,7 @@ impl AgentSubmissionPort for BlockingCreateOwner { if self.calls.fetch_add(1, Ordering::AcqRel) == 0 { self.release.notified().await; } - Ok(AgentSessionCreateResult { - session_id, - session_name: request.session_name, - agent_type: request.agent_type, - }) + Ok(created_session_result(session_id, request)) } async fn create_transient_session_with_id( @@ -362,6 +359,8 @@ async fn transport_accepts_input_while_an_owner_call_is_pending_and_bounds_reque let created: serde_json::Value = serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); assert_eq!(created["id"], 2); + assert_eq!(created["result"]["workspacePath"], "D:/workspace/project"); + assert_eq!(created["result"]["workspaceId"], "workspace-fixture"); client_write .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"session/create\",\"params\":{}}\n") .await diff --git a/src/apps/server/src/bootstrap.rs b/src/apps/server/src/bootstrap.rs index 379727cfaf..2b41c1404f 100644 --- a/src/apps/server/src/bootstrap.rs +++ b/src/apps/server/src/bootstrap.rs @@ -55,6 +55,12 @@ pub async fn initialize(workspace: Option) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result { log::info!( "Workspace opened: name={}, path={}", info.name, info.root_path.display() ); - - // Initialize snapshot for workspace - if let Err(e) = - bitfun_core::service::snapshot::initialize_snapshot_manager_for_workspace( - info.root_path.clone(), - None, - ) - .await - { - log::warn!("Failed to initialize snapshot system: {}", e); - } - Some(info.root_path) } Err(e) => { diff --git a/src/apps/server/src/main.rs b/src/apps/server/src/main.rs index c6603f0cb5..037fdc6199 100644 --- a/src/apps/server/src/main.rs +++ b/src/apps/server/src/main.rs @@ -175,4 +175,35 @@ 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" + ); + } } diff --git a/src/apps/server/src/rpc_dispatcher.rs b/src/apps/server/src/rpc_dispatcher.rs index 2335103fd9..084f16a342 100644 --- a/src/apps/server/src/rpc_dispatcher.rs +++ b/src/apps/server/src/rpc_dispatcher.rs @@ -388,6 +388,14 @@ pub async fn dispatch( let request = extract_request(¶ms)?; let session_id = get_string(&request, "sessionId")?; let workspace_path = get_string(&request, "workspacePath")?; + state + .coordinator + .ensure_workspace_runtime_ownership( + std::path::Path::new(&workspace_path), + None, + None, + ) + .map_err(|e| anyhow!("{}", e))?; state .coordinator .delete_session(&PathBuf::from(workspace_path), &session_id) diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs index 8dbba23c91..74f8d8890c 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs @@ -126,16 +126,14 @@ impl RuntimeIpcRequestHandler for CreateRaceHandler { operation: RuntimeIpcOperation, ) -> Result { match operation { - RuntimeIpcOperation::CreateSession { request: _ } => { + RuntimeIpcOperation::CreateSession { request } => { self.create_started.notify_one(); self.allow_create.notified().await; - Ok(RuntimeIpcOperationResult::SessionCreated { - session: AgentSessionCreateResult { - session_id: "session-a".to_string(), - session_name: "Created session".to_string(), - agent_type: "agentic".to_string(), - }, - }) + let mut session = + AgentSessionCreateResult::new("session-a", "Created session", "agentic"); + session.workspace_path = request.workspace_path; + session.workspace_id = Some("workspace-fixture".to_string()); + Ok(RuntimeIpcOperationResult::SessionCreated { session }) } RuntimeIpcOperation::RestoreSession { request } => Ok(restored(&request.session_id)), _ => Ok(RuntimeIpcOperationResult::Unit), @@ -350,6 +348,7 @@ async fn generated_session_is_claimed_before_another_connection_can_restore_it() let mut creator = server.connect("creator").await; let mut restorer = server.connect("restorer").await; let workspace_path = server.workspace.path().to_string_lossy().to_string(); + let expected_workspace_path = workspace_path.clone(); let create_task = tokio::spawn(async move { request( @@ -380,13 +379,19 @@ async fn generated_session_is_claimed_before_another_connection_can_restore_it() "restore must wait until create has claimed its generated Session" ); allow_create.notify_one(); - assert!(matches!( - create_task.await.expect("create task"), + match create_task.await.expect("create task") { RuntimeIpcFrame::Response { - result: RuntimeIpcOperationResult::SessionCreated { .. }, + result: RuntimeIpcOperationResult::SessionCreated { session }, .. + } => { + assert_eq!( + session.workspace_path.as_deref(), + Some(expected_workspace_path.as_str()) + ); + assert_eq!(session.workspace_id.as_deref(), Some("workspace-fixture")); } - )); + other => panic!("unexpected create response: {other:?}"), + } assert!(matches!( restore_task.await.expect("restore task"), RuntimeIpcFrame::Error { error, .. } diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index cfab5d0c39..4aab1a7f07 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -98,8 +98,9 @@ bitfun-tool-packs = { path = "../../execution/tool-provider-groups", default-fea bitfun-services-core = { path = "../../services/services-core", default-features = false, features = [ "lsp", "markdown", - "workspace-runtime", "permission", + "runtime-ownership", + "workspace-runtime", ] } # Integration service owner crate diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 1c3ec4c382..879d71a35b 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -50,6 +50,7 @@ use crate::agentic::tools::{ use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; use crate::native_hooks::{self, NativeHookSessionFacts}; +use crate::runtime_ownership::CoreRuntimeOwnership; use crate::service::bootstrap::{ ensure_workspace_persona_files_for_prompt, is_workspace_bootstrap_pending, }; @@ -62,7 +63,8 @@ use crate::service::session::{ SessionMemoryMode, SessionRelationship, SessionRelationshipKind, SessionStatus, }; use crate::service::workspace::{ - get_global_workspace_service, WorkspaceActivityMode, WorkspaceCreateOptions, WorkspaceKind, + get_global_workspace_service, WorkspaceActivityMode, WorkspaceCreateOptions, WorkspaceInfo, + WorkspaceKind, WorkspaceService, }; use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::errors::{BitFunError, BitFunResult}; @@ -79,8 +81,8 @@ use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; use bitfun_runtime_ports::{ AgentSessionWorkspaceBinding, AgentThreadGoalDeliveryKind, AgentThreadGoalDeliveryRequest, DelegationPolicy, PermissionDelegationContext, PermissionRuntimeCeiling, RemoteExecPort, - SessionStoragePathRequest, SessionStorePort, SubagentContextMode, TerminalPort, ThreadGoal, - ThreadGoalContinuationPlan, ThreadGoalStatus, + SessionStoragePathRequest, SessionStoragePathResolution, SessionStorePort, SubagentContextMode, + TerminalPort, ThreadGoal, ThreadGoalContinuationPlan, ThreadGoalStatus, }; use dashmap::DashMap; use log::{debug, error, info, warn}; @@ -870,6 +872,7 @@ impl SubagentTimeoutHandle { /// Conversation coordinator pub struct ConversationCoordinator { session_manager: Arc, + runtime_ownership: Arc, execution_engine: Arc, tool_pipeline: Arc, event_queue: Arc, @@ -1581,6 +1584,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet tool_pipeline: Arc, event_queue: Arc, event_router: Arc, + runtime_ownership: Arc, ) -> Self { let coordination_database_file = session_manager .path_manager() @@ -1592,6 +1596,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet event_queue, event_router, coordination_database_file, + runtime_ownership, ) } @@ -1602,6 +1607,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet event_queue: Arc, event_router: Arc, coordination_database_file: PathBuf, + runtime_ownership: Arc, ) -> Self { let coordination_store = Arc::new(CoordinationStore::new(coordination_database_file)); let background_subagent_outcomes = Arc::new(BackgroundSubagentOutcomeStore::new( @@ -1610,6 +1616,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet )); Self { session_manager, + runtime_ownership, execution_engine, tool_pipeline, event_queue, @@ -1630,6 +1637,130 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } } + fn ensure_runtime_ownership( + &self, + workspace_path: &Path, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + ) -> BitFunResult<()> { + self.runtime_ownership + .ensure_workspace_scope(workspace_path, remote_connection_id, remote_ssh_host) + .map_err(|error| BitFunError::Service(self.runtime_ownership.error_message(&error))) + } + + /// Ensures that this process may attach or mutate one workspace Runtime. + pub fn ensure_workspace_runtime_ownership( + &self, + workspace_path: &Path, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + ) -> BitFunResult<()> { + self.ensure_runtime_ownership(workspace_path, remote_connection_id, remote_ssh_host) + } + + /// Accepts a Remote scope only after the Workspace owner has matched its + /// path and connection identity against persisted Workspace facts. + pub fn ensure_verified_remote_workspace_runtime_ownership( + &self, + workspace_path: &Path, + remote_connection_id: &str, + remote_ssh_host: Option<&str>, + ) -> BitFunResult<()> { + self.runtime_ownership + .register_verified_remote_scope(workspace_path, remote_connection_id, remote_ssh_host) + .map_err(|error| BitFunError::Service(self.runtime_ownership.error_message(&error)))?; + self.ensure_runtime_ownership(workspace_path, Some(remote_connection_id), remote_ssh_host) + } + + /// Gates workspace attachment before opening it, then prepares local + /// Snapshot ownership without treating remote workspaces as local paths. + pub async fn open_workspace_with_runtime_ownership( + &self, + workspace_service: &WorkspaceService, + path: PathBuf, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + snapshot_log_context: &str, + ) -> BitFunResult { + let known_remote = workspace_service + .find_known_remote_workspace_for_path( + &path.to_string_lossy(), + remote_connection_id, + remote_ssh_host, + ) + .await; + if known_remote.is_none() && !path.exists() { + return Err(BitFunError::service(format!( + "Workspace path does not exist locally and is not a known remote SSH workspace: {}. Open it once from the desktop SSH remote UI so BitFun can remember the connection, then try again.", + path.display() + ))); + } + // 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 + .as_ref() + .and_then(WorkspaceInfo::remote_ssh_connection_id) + .map(ToOwned::to_owned); + let resolved_ssh_host = known_remote.as_ref().and_then(|workspace| { + workspace + .metadata + .get("sshHost") + .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 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 + ); + } + } + Ok(info) + } + + /// Ensures ownership from the loaded session binding, or from a local + /// fallback workspace before a session is restored. + pub fn ensure_session_runtime_ownership( + &self, + session_id: &str, + fallback_workspace: Option<&Path>, + ) -> BitFunResult<()> { + if let Some(session) = self.session_manager.get_session(session_id) { + let workspace_path = session.config.workspace_path.as_deref().ok_or_else(|| { + BitFunError::Validation(format!("Session workspace_path is missing: {session_id}")) + })?; + return self.ensure_runtime_ownership( + Path::new(workspace_path), + session.config.remote_connection_id.as_deref(), + session.config.remote_ssh_host.as_deref(), + ); + } + match fallback_workspace { + Some(workspace_path) => self.ensure_runtime_ownership(workspace_path, None, None), + None => Err(BitFunError::NotFound(format!( + "Session not found: {session_id}" + ))), + } + } + pub fn thread_goal_runtime(&self) -> Arc { Arc::clone(&self.thread_goal_runtime) } @@ -1767,6 +1898,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } pub async fn update_session_model(&self, session_id: &str, model_id: &str) -> BitFunResult<()> { + self.ensure_session_runtime_ownership(session_id, None)?; let normalized_model_id = normalize_model_selection(model_id).await?; self.session_manager @@ -1818,6 +1950,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet // Persist the workspace binding inside the session config so execution can // consistently restore the correct workspace regardless of the entry point. config.workspace_path = Some(workspace_path.clone()); + self.ensure_runtime_ownership( + Path::new(&workspace_path), + config.remote_connection_id.as_deref(), + config.remote_ssh_host.as_deref(), + )?; config.workspace_id = Self::resolve_workspace_id_for_config(&config).await; let defaults = Self::agent_model_defaults().await; snapshot_normal_session_model(&mut config, &defaults); @@ -1928,6 +2065,16 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet created_by: Option, ) -> BitFunResult { config.workspace_path = Some(workspace_path); + self.ensure_runtime_ownership( + Path::new( + config + .workspace_path + .as_deref() + .expect("workspace path was assigned above"), + ), + config.remote_connection_id.as_deref(), + config.remote_ssh_host.as_deref(), + )?; config.workspace_id = Self::resolve_workspace_id_for_config(&config).await; let agent_type = Self::normalize_agent_type(&agent_type); self.create_hidden_subagent_session( @@ -2592,6 +2739,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace_path: String, ) -> BitFunResult { let workspace_root = PathBuf::from(&workspace_path); + // Assistant workspaces are local-only. Ownership must be established + // before persona files are created or a persisted Session is attached. + self.ensure_runtime_ownership(&workspace_root, None, None)?; // Empty or partial assistant dirs may never have run create_assistant_workspace; fill only // missing persona stubs (never overwrite), while preserving completed bootstrap state. ensure_workspace_persona_files_for_prompt(&workspace_root).await?; @@ -2841,11 +2991,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ThreadGoalStore::new(self.session_manager.as_ref()) } - async fn resolve_session_restore_path( + async fn resolve_session_restore_scope( workspace_path: &str, remote_connection_id: Option<&str>, remote_ssh_host: Option<&str>, - ) -> BitFunResult { + ) -> BitFunResult { let request = SessionStoragePathRequest { workspace_path: PathBuf::from(workspace_path), remote_connection_id: remote_connection_id.map(ToOwned::to_owned), @@ -2855,10 +3005,19 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet CoreSessionStorePort::default() .resolve_session_storage_path(request) .await - .map(|resolution| resolution.effective_storage_path) .map_err(|error| BitFunError::Session(error.to_string())) } + async fn resolve_session_restore_path( + workspace_path: &str, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + ) -> BitFunResult { + Self::resolve_session_restore_scope(workspace_path, remote_connection_id, remote_ssh_host) + .await + .map(|resolution| resolution.effective_storage_path) + } + fn require_main_session_workspace(&self, session_id: &str) -> BitFunResult { let session = self .session_manager @@ -3619,9 +3778,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .as_ref() .and_then(|session| session.config.project_workspace_path.as_deref()), ); - let requested_restore_path = match storage_workspace_path.as_deref() { + let requested_restore = match storage_workspace_path.as_deref() { Some(workspace_path) => Some( - Self::resolve_session_restore_path( + Self::resolve_session_restore_scope( workspace_path, remote_connection_id.as_deref(), remote_ssh_host.as_deref(), @@ -3636,9 +3795,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet // the same storage identity as this invocation. let session = match loaded_session { Some(session) => { - if let Some(restore_path) = requested_restore_path.as_deref() { - self.session_manager - .ensure_session_storage_path(&session_id, restore_path)?; + if let Some(restore) = requested_restore.as_ref() { + self.session_manager.ensure_session_storage_path( + &session_id, + &restore.effective_storage_path, + )?; } session } @@ -3647,17 +3808,21 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "Session not found in memory, attempting restore before starting dialog: session_id={}", session_id ); - let restore_path = requested_restore_path.ok_or_else(|| { + let restore = requested_restore.ok_or_else(|| { BitFunError::Validation(format!( "workspace_path is required when restoring session: {}", session_id )) })?; + if !restore.is_remote_storage() { + self.ensure_runtime_ownership(&restore.requested_workspace_path, None, None)?; + } self.session_manager - .restore_session_from_storage_path(&restore_path, &session_id) + .restore_session_from_storage_path(&restore.effective_storage_path, &session_id) .await? } }; + self.ensure_session_runtime_ownership(&session_id, None)?; let previous_agent_type = session.last_user_dialog_agent_type.clone(); let requested_agent_type = agent_type.trim().to_string(); @@ -4978,6 +5143,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace_path: &Path, session_id: &str, ) -> BitFunResult { + self.ensure_runtime_ownership(workspace_path, None, None)?; self.session_manager .restore_session(workspace_path, session_id) .await @@ -5008,6 +5174,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet request: SessionStoragePathRequest, session_id: &str, ) -> BitFunResult { + self.ensure_runtime_ownership( + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + )?; self.session_manager .restore_session_for_workspace(request, session_id) .await @@ -5018,6 +5189,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet request: SessionStoragePathRequest, session_id: &str, ) -> BitFunResult { + self.ensure_runtime_ownership( + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + )?; self.session_manager .restore_internal_session_for_workspace(request, session_id) .await @@ -5028,6 +5204,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace_path: &Path, session_id: &str, ) -> BitFunResult { + self.ensure_runtime_ownership(workspace_path, None, None)?; self.session_manager .restore_internal_session(workspace_path, session_id) .await @@ -5039,6 +5216,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace_path: &Path, session_id: &str, ) -> BitFunResult<(Session, Vec)> { + self.ensure_runtime_ownership(workspace_path, None, None)?; self.session_manager .restore_session_with_turns(workspace_path, session_id) .await @@ -5069,6 +5247,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet request: SessionStoragePathRequest, session_id: &str, ) -> BitFunResult<(Session, Vec)> { + self.ensure_runtime_ownership( + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + )?; self.session_manager .restore_session_with_turns_for_workspace(request, session_id) .await @@ -5079,6 +5262,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet request: SessionStoragePathRequest, session_id: &str, ) -> BitFunResult<(Session, Vec)> { + self.ensure_runtime_ownership( + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + )?; self.session_manager .restore_internal_session_with_turns_for_workspace(request, session_id) .await @@ -5089,6 +5277,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace_path: &Path, session_id: &str, ) -> BitFunResult<(Session, Vec)> { + self.ensure_runtime_ownership(workspace_path, None, None)?; self.session_manager .restore_internal_session_with_turns(workspace_path, session_id) .await @@ -8279,6 +8468,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet user_message: &str, max_length: Option, ) -> BitFunResult { + self.ensure_session_runtime_ownership(session_id, None)?; let allow_ai = is_ai_session_title_generation_enabled().await; let resolved = self .session_manager @@ -8309,6 +8499,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: &str, title: &str, ) -> BitFunResult { + self.ensure_session_runtime_ownership(session_id, None)?; let normalized = title.trim().to_string(); if normalized.is_empty() { return Err(BitFunError::validation( @@ -8328,6 +8519,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: &str, agent_type: &str, ) -> BitFunResult<()> { + self.ensure_session_runtime_ownership(session_id, None)?; let normalized = Self::normalize_agent_type(agent_type); self.session_manager .update_session_agent_type(session_id, &normalized) @@ -8335,6 +8527,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } pub async fn update_session_mode(&self, session_id: &str, mode_id: &str) -> BitFunResult<()> { + self.ensure_session_runtime_ownership(session_id, None)?; let mode_id = mode_id.trim(); if mode_id.is_empty() { return Err(BitFunError::Validation( @@ -8365,6 +8558,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: &str, agent_type: &str, ) -> BitFunResult<()> { + self.ensure_session_runtime_ownership(session_id, None)?; let normalized = Self::normalize_agent_type(agent_type); self.session_manager .update_last_submitted_agent_type(session_id, &normalized) @@ -8514,11 +8708,7 @@ async fn create_agent_session_from_runtime_request( .await .map_err(map_core_error)?; - Ok(bitfun_runtime_ports::AgentSessionCreateResult { - session_id: session.session_id, - session_name: session.session_name, - agent_type: session.agent_type, - }) + Ok(session.into()) } #[async_trait::async_trait] @@ -8645,7 +8835,17 @@ impl bitfun_runtime_ports::AgentSubmissionPort for ConversationCoordinator { return Ok(None); }; - self.restore_session_from_storage_path(&binding.session_storage_dir(), session_id) + let restore_request = SessionStoragePathRequest { + workspace_path: PathBuf::from(binding.root_path_string()), + remote_connection_id: binding.connection_id().map(ToOwned::to_owned), + remote_ssh_host: if binding.is_remote() { + Some(binding.session_identity.hostname.clone()) + .filter(|value| !value.trim().is_empty()) + } else { + None + }, + }; + self.restore_session_for_workspace(restore_request, session_id) .await .map(|session| Some(session.agent_type)) .map_err(|error| { @@ -8779,6 +8979,12 @@ impl bitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordinato message, ) })?; + self.ensure_runtime_ownership( + Path::new(&request.workspace_path), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .map_err(runtime_port_error_preserving_message)?; let effective_storage_path = Self::resolve_session_restore_path( &request.workspace_path, request.remote_connection_id.as_deref(), @@ -8812,6 +9018,12 @@ impl bitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordinato message, ) })?; + self.ensure_runtime_ownership( + Path::new(&request.workspace_path), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .map_err(runtime_port_error_preserving_message)?; let effective_storage_path = Self::resolve_session_restore_path( &request.workspace_path, request.remote_connection_id.as_deref(), @@ -8862,6 +9074,12 @@ impl bitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordinato message, ) })?; + self.ensure_runtime_ownership( + Path::new(&request.workspace_path), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .map_err(runtime_port_error_preserving_message)?; let effective_storage_path = Self::resolve_session_restore_path( &request.workspace_path, request.remote_connection_id.as_deref(), @@ -8978,6 +9196,8 @@ impl bitfun_runtime_ports::AgentLocalCommandTurnPort for ConversationCoordinator &self, request: bitfun_runtime_ports::AgentLocalCommandTurnRecordRequest, ) -> bitfun_runtime_ports::PortResult<()> { + self.ensure_session_runtime_ownership(&request.session_id, None) + .map_err(runtime_port_error_preserving_message)?; let metadata = if request.metadata.is_empty() { None } else { @@ -9060,6 +9280,11 @@ impl bitfun_runtime_ports::AgentThreadGoalManagementPort for ConversationCoordin &self, request: bitfun_runtime_ports::AgentThreadGoalCreateRequest, ) -> bitfun_runtime_ports::PortResult { + self.ensure_session_runtime_ownership( + &request.session_id, + Some(Path::new(&request.workspace_path)), + ) + .map_err(runtime_port_error_preserving_message)?; self.create_thread_goal( &request.session_id, std::path::Path::new(&request.workspace_path), @@ -9074,6 +9299,11 @@ impl bitfun_runtime_ports::AgentThreadGoalManagementPort for ConversationCoordin &self, request: bitfun_runtime_ports::AgentThreadGoalUpdateStatusRequest, ) -> bitfun_runtime_ports::PortResult { + self.ensure_session_runtime_ownership( + &request.session_id, + Some(Path::new(&request.workspace_path)), + ) + .map_err(runtime_port_error_preserving_message)?; self.update_thread_goal_status( &request.session_id, std::path::Path::new(&request.workspace_path), @@ -9396,18 +9626,21 @@ mod tests { use crate::agentic::tools::{ToolPipeline, ToolStateManager}; use crate::agentic::TurnSkillAgentSnapshot; use crate::infrastructure::PathManager; + use crate::runtime_ownership::CoreRuntimeOwnership; use crate::service::config::{AgentModelDefaultsConfig, SubagentModelSelection}; use crate::service::remote_ssh::workspace_state::init_remote_workspace_manager; use crate::service::session::{SessionMetadata, SessionStatus}; + use crate::service::workspace::WorkspaceKind; use bitfun_agent_runtime::permission::AUTO_APPROVE_ASK_CONTEXT_KEY; use bitfun_runtime_ports::{ AgentSessionArchiveRequest, AgentSessionCreateRequest, AgentSessionManagementPort, AgentSessionRenameRequest, AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionSource, AgentThreadGoalGetRequest, AgentThreadGoalManagementPort, DelegationPolicy, PermissionEffect, PermissionRule, PermissionRuntimeCeiling, - SubagentContextMode, ThreadGoal, ThreadGoalStatus, + SessionStoragePathRequest, SubagentContextMode, ThreadGoal, ThreadGoalStatus, }; use std::collections::HashMap; + use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use tokio_util::sync::CancellationToken; @@ -9890,9 +10123,10 @@ mod tests { } use tokio::sync::RwLock as TokioRwLock; - fn test_coordinator_with_config( + fn test_coordinator_with_config_and_ownership( max_active_sessions: usize, enable_persistence: bool, + runtime_ownership: Arc, ) -> (ConversationCoordinator, Arc) { let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); let coordination_database_file = std::env::temp_dir() @@ -9935,6 +10169,7 @@ mod tests { event_queue, Arc::new(EventRouter::new()), coordination_database_file, + runtime_ownership, ); coordinator.set_terminal_port( bitfun_runtime_services::test_support::FakeRuntimeServicesProvider::terminal_port(), @@ -9946,6 +10181,25 @@ mod tests { (coordinator, session_manager) } + fn test_coordinator_with_config( + max_active_sessions: usize, + enable_persistence: bool, + ) -> (ConversationCoordinator, Arc) { + let ownership_root = std::env::temp_dir().join(format!( + "bitfun-runtime-ownership-test-{}", + uuid::Uuid::new_v4() + )); + test_coordinator_with_config_and_ownership( + max_active_sessions, + enable_persistence, + Arc::new(CoreRuntimeOwnership::embedded_with_facts( + ownership_root, + "bitfun".to_string(), + "test", + )), + ) + } + fn test_coordinator_with_max_active_sessions( max_active_sessions: usize, ) -> (ConversationCoordinator, Arc) { @@ -9960,6 +10214,279 @@ mod tests { test_coordinator_with_max_active_sessions(100) } + #[tokio::test] + async fn create_session_checks_runtime_ownership_before_persisting() { + let ownership_root = tempfile::tempdir().expect("ownership root"); + let workspace = tempfile::tempdir().expect("workspace"); + 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.path(), + &key, + bitfun_services_core::runtime_ownership::RuntimeDeployment::Shared, + ) + .expect("shared owner"); + let owner = Arc::new(CoreRuntimeOwnership::embedded_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "test", + )); + let (coordinator, session_manager) = + test_coordinator_with_config_and_ownership(100, true, owner); + + let error = coordinator + .create_session_with_id( + Some("ownership-conflict".to_string()), + "blocked".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect_err("Shared owner must block local session creation"); + + assert!(error.to_string().contains("ownership")); + assert!(session_manager.get_session("ownership-conflict").is_none()); + } + + #[tokio::test] + async fn assistant_bootstrap_checks_runtime_ownership_before_files_or_attach() { + let ownership_root = tempfile::tempdir().expect("ownership root"); + let workspace = tempfile::tempdir().expect("workspace"); + 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.path(), + &key, + bitfun_services_core::runtime_ownership::RuntimeDeployment::Shared, + ) + .expect("shared owner"); + let owner = Arc::new(CoreRuntimeOwnership::embedded_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "test", + )); + let (coordinator, session_manager) = + test_coordinator_with_config_and_ownership(100, true, owner); + + let error = coordinator + .ensure_assistant_bootstrap( + "assistant-bootstrap-conflict".to_string(), + workspace.path().to_string_lossy().to_string(), + ) + .await + .expect_err("Shared owner must block assistant bootstrap"); + + assert!(error.to_string().contains("ownership")); + assert!(session_manager + .get_session("assistant-bootstrap-conflict") + .is_none()); + assert_eq!( + std::fs::read_dir(workspace.path()) + .expect("workspace remains readable") + .count(), + 0, + "ownership failure must happen before persona or gitignore writes" + ); + } + + #[test] + fn workspace_open_owner_gates_before_open_and_guards_snapshot_by_kind() { + let source = include_str!("coordinator.rs"); + let helper = source + .split("pub async fn open_workspace_with_runtime_ownership") + .nth(1) + .and_then(|source| { + source + .split("pub fn ensure_session_runtime_ownership") + .next() + }) + .expect("workspace open owner"); + let ownership_gate = helper + .find("ensure_runtime_ownership") + .expect("workspace ownership gate"); + let workspace_open = 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")); + + let bot_router = include_str!("../../service/remote_connect/bot/command_router.rs"); + assert!(bot_router.contains("open_workspace_with_runtime_ownership")); + assert!(!bot_router.contains("initialize_snapshot_manager_for_workspace")); + } + + #[tokio::test] + async fn workspace_open_owner_resolves_known_remote_before_ownership_gate() { + 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) + .await; + let remote_path = PathBuf::from(format!( + "/bitfun-tests/known-remote-{}", + uuid::Uuid::new_v4() + )); + workspace_service + .track_workspace_activity( + remote_path.clone(), + crate::service::workspace::WorkspaceCreateOptions { + workspace_kind: WorkspaceKind::Remote, + remote_connection_id: Some("conn-known-remote".to_string()), + remote_ssh_host: Some("known-host".to_string()), + ..Default::default() + }, + crate::service::workspace::WorkspaceActivityMode::RefreshMetadata, + ) + .await + .expect("remember remote workspace"); + let owner = Arc::new(CoreRuntimeOwnership::embedded_with_facts( + root.path().join("ownership"), + "bitfun".to_string(), + "test", + )); + let (coordinator, _) = test_coordinator_with_config_and_ownership(100, false, owner); + + let opened = coordinator + .open_workspace_with_runtime_ownership( + &workspace_service, + remote_path, + None, + None, + "known remote test", + ) + .await + .expect("path-only known remote must not acquire a local lease"); + + assert_eq!(opened.workspace_kind, WorkspaceKind::Remote); + assert_eq!(opened.remote_ssh_connection_id(), Some("conn-known-remote")); + } + + #[tokio::test] + async fn unverified_remote_hint_cannot_bypass_local_workspace_ownership() { + let ownership_root = tempfile::tempdir().expect("ownership root"); + let workspace = tempfile::tempdir().expect("workspace"); + 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.path(), + &key, + bitfun_services_core::runtime_ownership::RuntimeDeployment::Shared, + ) + .expect("shared owner"); + let owner = Arc::new(CoreRuntimeOwnership::embedded_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "test", + )); + let (coordinator, _) = test_coordinator_with_config_and_ownership(100, false, owner); + let path_manager = Arc::new(PathManager::with_user_root_for_tests( + workspace.path().join("user-root"), + )); + let workspace_service = + crate::service::workspace::WorkspaceService::new_for_test_path_manager(path_manager) + .await; + + let error = coordinator + .open_workspace_with_runtime_ownership( + &workspace_service, + workspace.path().to_path_buf(), + Some("bogus-connection"), + Some("bogus-host"), + "unverified remote hint test", + ) + .await + .expect_err("unverified hints must not bypass local ownership"); + + assert!(error.to_string().contains("ownership")); + } + + #[tokio::test] + async fn attach_and_mutation_paths_check_runtime_ownership_before_side_effects() { + let ownership_root = tempfile::tempdir().expect("ownership root"); + let workspace = tempfile::tempdir().expect("workspace"); + 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.path(), + &key, + bitfun_services_core::runtime_ownership::RuntimeDeployment::Shared, + ) + .expect("shared owner"); + let owner = Arc::new(CoreRuntimeOwnership::embedded_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "test", + )); + let (coordinator, session_manager) = + test_coordinator_with_config_and_ownership(100, true, owner); + let workspace_path = workspace.path().to_string_lossy().to_string(); + + let hidden_error = coordinator + .create_hidden_subagent_session_with_workspace( + Some("hidden-ownership-conflict".to_string()), + "hidden".to_string(), + "agentic".to_string(), + SessionConfig::default(), + workspace_path.clone(), + None, + ) + .await + .expect_err("Hidden session creation must honor runtime ownership"); + assert!(hidden_error.to_string().contains("ownership")); + + let restore_error = coordinator + .restore_session_for_workspace( + SessionStoragePathRequest { + workspace_path: workspace.path().to_path_buf(), + remote_connection_id: None, + remote_ssh_host: None, + }, + "missing-session", + ) + .await + .expect_err("Runtime attach must honor ownership before reading persistence"); + assert!(restore_error.to_string().contains("ownership")); + + let archive_error = bitfun_runtime_ports::AgentSessionManagementPort::set_session_archived( + &coordinator, + bitfun_runtime_ports::AgentSessionArchiveStateRequest { + workspace_path, + session_id: "missing-session".to_string(), + archived: true, + remote_connection_id: None, + remote_ssh_host: None, + }, + ) + .await + .expect_err("Metadata mutation must honor ownership before touching persistence"); + assert!(archive_error.message.contains("ownership")); + assert!(session_manager + .get_session("hidden-ownership-conflict") + .is_none()); + } + async fn register_test_background_task( coordinator: &ConversationCoordinator, parent_session_id: &str, @@ -10921,6 +11448,13 @@ mod tests { } let loaded_session_id = format!("loaded-remote-goal-{fixture_id}"); + coordinator + .ensure_verified_remote_workspace_runtime_ownership( + std::path::Path::new(logical_workspace_path), + &remote_identities[0].0, + Some(&remote_identities[0].1), + ) + .expect("Workspace owner should verify the remote binding before loading a session"); coordinator .create_session_with_id( Some(loaded_session_id.clone()), @@ -11019,6 +11553,70 @@ mod tests { } } + #[tokio::test] + async fn thread_goal_mutations_use_loaded_remote_workspace_facts() { + let (coordinator, session_manager) = test_persistent_coordinator(); + let fixture_id = uuid::Uuid::new_v4(); + let session_id = format!("remote-goal-mutation-{fixture_id}"); + let logical_workspace_path = format!("/workspace/remote-goal-{fixture_id}"); + let remote_connection_id = format!("connection-{fixture_id}"); + let remote_ssh_host = format!("host-{fixture_id}"); + + coordinator + .ensure_verified_remote_workspace_runtime_ownership( + std::path::Path::new(&logical_workspace_path), + &remote_connection_id, + Some(&remote_ssh_host), + ) + .expect("Workspace owner should verify the remote binding before loading a session"); + coordinator + .create_session_with_id( + Some(session_id.clone()), + "Remote goal mutation".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(logical_workspace_path.clone()), + remote_connection_id: Some(remote_connection_id), + remote_ssh_host: Some(remote_ssh_host), + ..Default::default() + }, + ) + .await + .expect("remote session should load without local ownership"); + + let created = AgentThreadGoalManagementPort::create_thread_goal( + &coordinator, + bitfun_runtime_ports::AgentThreadGoalCreateRequest { + session_id: session_id.clone(), + workspace_path: logical_workspace_path.clone(), + objective: "Keep remote ownership structured".to_string(), + token_budget: None, + }, + ) + .await + .expect("remote goal creation must not acquire a local workspace lock"); + let updated = AgentThreadGoalManagementPort::update_thread_goal_status( + &coordinator, + bitfun_runtime_ports::AgentThreadGoalUpdateStatusRequest { + session_id: session_id.clone(), + workspace_path: logical_workspace_path, + status: ThreadGoalStatus::Complete, + turn_id: None, + }, + ) + .await + .expect("remote goal update must not acquire a local workspace lock"); + + assert_eq!(created.session_id, session_id); + assert_eq!(updated.status, ThreadGoalStatus::Complete); + if let Some(binding) = session_manager + .resolve_session_workspace_binding(&session_id) + .await + { + let _ = std::fs::remove_dir_all(binding.session_storage_dir()); + } + } + #[tokio::test] async fn normal_sessions_keep_the_mode_default_snapshotted_at_creation() { let (coordinator, session_manager) = test_coordinator(); diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index f5c4f90632..2ca87e7591 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -2436,6 +2436,16 @@ mod tests { tool_pipeline, event_queue.clone(), Arc::new(EventRouter::new()), + Arc::new( + crate::runtime_ownership::CoreRuntimeOwnership::embedded_with_facts( + std::env::temp_dir().join(format!( + "bitfun-scheduler-ownership-test-{}", + uuid::Uuid::new_v4() + )), + "bitfun".to_string(), + "test", + ), + ), )); ( DialogScheduler::new(coordinator, session_manager.clone()), diff --git a/src/crates/assembly/core/src/agentic/mod.rs b/src/crates/assembly/core/src/agentic/mod.rs index 3a8c8aef21..84edde742e 100644 --- a/src/crates/assembly/core/src/agentic/mod.rs +++ b/src/crates/assembly/core/src/agentic/mod.rs @@ -75,5 +75,8 @@ pub use round_preempt::{ pub use session::*; pub use side_question::*; pub use skill_agent_snapshot::*; -pub use system::{init_agentic_system, init_agentic_system_for_profile, AgenticSystem}; +pub use system::{ + init_agentic_system, init_agentic_system_for_profile, + init_agentic_system_for_profile_with_runtime_ownership, AgenticSystem, +}; pub use workspace::{WorkspaceBackend, WorkspaceBinding}; diff --git a/src/crates/assembly/core/src/agentic/system.rs b/src/crates/assembly/core/src/agentic/system.rs index f4212a16b5..71ae188663 100644 --- a/src/crates/assembly/core/src/agentic/system.rs +++ b/src/crates/assembly/core/src/agentic/system.rs @@ -14,6 +14,7 @@ use crate::agentic::session; use crate::agentic::tools; use crate::infrastructure::ai::AIClientFactory; use crate::infrastructure::try_get_path_manager_arc; +use crate::runtime_ownership::CoreRuntimeOwnership; use crate::service::token_usage::{TokenUsageService, TokenUsageSubscriber}; use bitfun_product_capabilities::DeliveryProfile; @@ -44,6 +45,22 @@ pub fn select_agentic_system_profile(delivery_profile: DeliveryProfile) -> Resul /// Initialize the single process-wide agentic runtime for one product profile. pub async fn init_agentic_system_for_profile( delivery_profile: DeliveryProfile, +) -> Result { + let path_manager = try_get_path_manager_arc()?; + let runtime_ownership = Arc::new(CoreRuntimeOwnership::embedded( + path_manager.as_ref(), + "embedded-host", + )); + init_agentic_system_for_profile_with_runtime_ownership(delivery_profile, runtime_ownership) + .await +} + +/// Initializes one product runtime with an explicitly selected ownership +/// deployment. First-party fixed-workspace hosts use this before protocol/UI +/// readiness; public Agent Runtime contracts remain unchanged. +pub async fn init_agentic_system_for_profile_with_runtime_ownership( + delivery_profile: DeliveryProfile, + runtime_ownership: Arc, ) -> Result { info!("Initializing agentic system for profile {delivery_profile}"); @@ -103,6 +120,7 @@ pub async fn init_agentic_system_for_profile( tool_pipeline, event_queue.clone(), event_router.clone(), + runtime_ownership, )); coordination::ConversationCoordinator::set_global(coordinator.clone()); diff --git a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs index 85fb1ea9ff..9eb613c1e4 100644 --- a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs +++ b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs @@ -321,6 +321,11 @@ impl PathManager { .join("coordination.sqlite") } + /// Process-level ownership locks for local Agent Runtime deployments. + pub fn agent_runtime_ownership_dir(&self) -> PathBuf { + self.user_data_dir().join("agent-runtime").join("ownership") + } + /// Get user memory workspace root directory: ~/.bitfun/memories/ pub fn memories_root_dir(&self) -> PathBuf { self.bitfun_home_dir().join("memories") @@ -730,6 +735,20 @@ mod tests { static ENV_LOCK: Mutex<()> = Mutex::new(()); + #[test] + fn runtime_ownership_lives_under_the_agent_runtime_data_root() { + let user_root = std::env::temp_dir().join("bitfun-runtime-ownership-path-test"); + let path_manager = PathManager::with_user_root_for_tests(user_root); + + assert_eq!( + path_manager.agent_runtime_ownership_dir(), + path_manager + .user_data_dir() + .join("agent-runtime") + .join("ownership") + ); + } + #[test] fn strict_path_access_rejects_a_cached_temporary_fallback() { let state = GlobalPathManagerState::fallback( diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 882ae93df3..99f8e180ff 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -41,6 +41,10 @@ pub mod product_assembly; pub(crate) mod product_domain_runtime; #[cfg(feature = "product-full")] pub mod product_runtime; +#[cfg(feature = "product-full")] +pub mod runtime_ownership; +#[cfg(all(test, feature = "product-full"))] +mod runtime_ownership_tests; pub mod service; // Workspace, Config, FileSystem, Terminal, Git #[cfg(feature = "service-integrations")] pub(crate) mod service_agent_runtime; diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index 83baefca9f..d22586eb14 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -39,8 +39,8 @@ use crate::agentic::session::CoreSessionStorePort; use crate::service::session::{DialogTurnData, SessionMetadata}; use crate::service::session_usage::{generate_session_usage_report, SessionUsageReport}; use crate::service::snapshot::{ - get_snapshot_manager_for_workspace, initialize_snapshot_manager_for_workspace, SnapshotError, - SnapshotManager, + get_snapshot_manager_for_workspace, initialize_snapshot_manager_for_workspace, + open_snapshot_manager_for_view, SnapshotError, SnapshotManager, }; use crate::service::token_usage::TokenUsageService; use crate::service_agent_runtime::CoreServiceAgentRuntime; @@ -279,6 +279,15 @@ async fn ensure_local_snapshot_manager(workspace_path: &Path) -> PortResult PortResult> { + validate_local_snapshot_workspace(workspace_path)?; + open_snapshot_manager_for_view(workspace_path) + .await + .map_err(snapshot_port_error) +} + /// Core-backed access to the existing local workspace snapshot owner. /// /// The returned port is intentionally separate from the Agent Runtime SDK and @@ -303,8 +312,8 @@ impl LocalWorkspaceSnapshotPort for CoreLocalWorkspaceSnapshot { request: LocalWorkspaceSnapshotSessionRequest, ) -> PortResult> { validate_persisted_session_id(&request.session_id).map_err(runtime_port_error)?; - ensure_local_snapshot_manager(&request.workspace_path) - .await? + let manager = local_snapshot_manager_for_view(&request.workspace_path).await?; + manager .get_session_files(&request.session_id) .await .map_err(snapshot_port_error) @@ -315,8 +324,8 @@ impl LocalWorkspaceSnapshotPort for CoreLocalWorkspaceSnapshot { request: LocalWorkspaceSnapshotSessionRequest, ) -> PortResult { validate_persisted_session_id(&request.session_id).map_err(runtime_port_error)?; - let stats = ensure_local_snapshot_manager(&request.workspace_path) - .await? + let manager = local_snapshot_manager_for_view(&request.workspace_path).await?; + let stats = manager .get_session_stats_fact(&request.session_id) .await .map_err(snapshot_port_error)?; @@ -503,6 +512,19 @@ impl CoreAgentRuntimeCompatibility { } } + /// Applies the same Core deployment owner before a product compatibility + /// path attaches to or mutates a structured workspace scope. + pub fn ensure_workspace_runtime_ownership( + &self, + request: &SessionStoragePathRequest, + ) -> BitFunResult<()> { + self.coordinator.ensure_workspace_runtime_ownership( + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + } + pub async fn restore_session_from_storage_path( &self, storage_path: &Path, @@ -1033,6 +1055,13 @@ impl AgentSessionForkPort for CoreSessionOperationsPort { remote_connection_id, remote_ssh_host, } = request; + self.coordinator + .ensure_workspace_runtime_ownership( + Path::new(&workspace_path), + remote_connection_id.as_deref(), + remote_ssh_host.as_deref(), + ) + .map_err(runtime_port_error)?; let storage_path = self .resolve_fork_storage_path(workspace_path, remote_connection_id, remote_ssh_host) .await?; @@ -1050,6 +1079,13 @@ impl AgentSessionForkPort for CoreSessionOperationsPort { &self, request: AgentSessionForkAtTurnRequest, ) -> PortResult { + self.coordinator + .ensure_workspace_runtime_ownership( + Path::new(&request.workspace_path), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .map_err(runtime_port_error)?; let storage_path = self .resolve_fork_storage_path( request.workspace_path, @@ -1122,10 +1158,10 @@ mod tests { #[allow(deprecated)] use super::CoreProductAgentEventSource; use super::{ - generate_core_session_usage_report, latest_persisted_turn_id, runtime_port_error, - validate_latest_turn_fork_scope, validate_persisted_session_id, - CoreAgentRuntimeCompatibility, CoreLocalWorkspaceSnapshot, CoreProductAgentRuntime, - CoreProductEventQueueOwner, CoreSessionOperationsPort, + generate_core_session_usage_report, get_snapshot_manager_for_workspace, + latest_persisted_turn_id, runtime_port_error, validate_latest_turn_fork_scope, + validate_persisted_session_id, CoreAgentRuntimeCompatibility, CoreLocalWorkspaceSnapshot, + CoreProductAgentRuntime, CoreProductEventQueueOwner, CoreSessionOperationsPort, }; use crate::agentic::coordination::{ConversationCoordinator, DialogScheduler}; use crate::agentic::events::{EventQueue, EventQueueConfig, EventRouter}; @@ -1307,6 +1343,26 @@ mod tests { let _ = build; } + #[test] + fn sdk_session_forks_reuse_the_coordinator_runtime_owner() { + let source = include_str!("product_runtime.rs"); + let fork_impl = source + .split("impl AgentSessionForkPort for CoreSessionOperationsPort") + .nth(1) + .and_then(|source| source.split("impl AgentSessionUsagePort").next()) + .expect("session fork implementation"); + + assert_eq!( + fork_impl + .matches("ensure_workspace_runtime_ownership") + .count(), + 2, + "latest-turn and explicit-turn forks must share the Coordinator ownership gate" + ); + assert!(!fork_impl.contains("RuntimeOwnershipKey")); + assert!(!fork_impl.contains("try_acquire")); + } + #[test] fn remaining_compatibility_operations_have_one_core_owned_facade() { fn build( @@ -1373,6 +1429,31 @@ mod tests { .is_empty()); } + #[tokio::test] + async fn local_workspace_snapshot_views_do_not_initialize_a_writer() { + let workspace = TestWorkspace::new(); + let port = CoreLocalWorkspaceSnapshot::build(); + let request = LocalWorkspaceSnapshotSessionRequest { + workspace_path: workspace.path().to_path_buf(), + session_id: "session-view-only".to_string(), + }; + + assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); + assert!(port + .get_session_files(request.clone()) + .await + .expect("view-only files") + .is_empty()); + assert_eq!( + port.get_session_stats(request) + .await + .expect("view-only stats") + .total_changes, + 0 + ); + assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); + } + #[tokio::test] async fn local_workspace_snapshot_port_rejects_non_local_inputs_before_backend_access() { let workspace = TestWorkspace::new(); @@ -1514,6 +1595,16 @@ mod tests { tool_pipeline, event_queue, Arc::new(EventRouter::new()), + Arc::new( + crate::runtime_ownership::CoreRuntimeOwnership::embedded_with_facts( + std::env::temp_dir().join(format!( + "bitfun-product-runtime-ownership-test-{}", + uuid::Uuid::new_v4() + )), + "bitfun".to_string(), + "test", + ), + ), )); let token_usage_service = Arc::new( TokenUsageService::new_in_base_dir(workspace.path().join("tokens")) diff --git a/src/crates/assembly/core/src/runtime_ownership.rs b/src/crates/assembly/core/src/runtime_ownership.rs new file mode 100644 index 0000000000..1b20b43dc8 --- /dev/null +++ b/src/crates/assembly/core/src/runtime_ownership.rs @@ -0,0 +1,386 @@ +//! First-party product assembly for local Agent Runtime ownership. +//! +//! The reusable lock primitive lives in `bitfun-services-core`. This owner +//! selects one deployment for the process, retains acquired workspace leases, +//! and keeps that deployment fact out of Agent Runtime SDK and wire contracts. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +pub use bitfun_services_core::runtime_ownership::RuntimeDeployment; +use bitfun_services_core::runtime_ownership::{ + RuntimeOwnershipError, RuntimeOwnershipKey, WorkspaceRuntimeOwnership, +}; +use log::{info, warn}; + +use crate::infrastructure::PathManager; + +const DEFAULT_PRODUCT_IDENTITY: &str = "bitfun"; + +enum CoreRuntimeOwnershipDeployment { + Embedded { + leases: Mutex>, + }, + Shared { + key: RuntimeOwnershipKey, + _lease: WorkspaceRuntimeOwnership, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct VerifiedRemoteRuntimeScope { + workspace_path: String, + connection_id: String, + ssh_host: Option, +} + +/// Process-lifetime owner for first-party local Agent Runtime workspaces. +pub struct CoreRuntimeOwnership { + ownership_root: PathBuf, + product_identity: String, + entrypoint: &'static str, + deployment: CoreRuntimeOwnershipDeployment, + verified_remote_scopes: Mutex>, +} + +impl CoreRuntimeOwnership { + /// Builds and acquires the process owner for a fixed local workspace. + pub fn fixed_workspace( + path_manager: &PathManager, + entrypoint: &'static str, + workspace: &Path, + deployment: RuntimeDeployment, + ) -> Result { + match deployment { + RuntimeDeployment::Embedded => { + let owner = Self::embedded(path_manager, entrypoint); + owner.ensure_local_workspace(workspace)?; + Ok(owner) + } + RuntimeDeployment::Shared => Self::shared(path_manager, entrypoint, workspace), + } + } + + /// Builds the normal first-party Embedded deployment. + pub fn embedded(path_manager: &PathManager, entrypoint: &'static str) -> Self { + Self::embedded_with_facts( + path_manager.agent_runtime_ownership_dir(), + product_identity().to_string(), + entrypoint, + ) + } + + /// Builds the opt-in single-workspace Shared deployment and acquires its + /// exclusive lease before any Agent Runtime is initialized. + pub fn shared( + path_manager: &PathManager, + entrypoint: &'static str, + workspace: &Path, + ) -> Result { + Self::shared_with_facts( + path_manager.agent_runtime_ownership_dir(), + product_identity().to_string(), + entrypoint, + workspace, + ) + } + + pub(crate) fn embedded_with_facts( + ownership_root: PathBuf, + product_identity: String, + entrypoint: &'static str, + ) -> Self { + Self { + ownership_root, + product_identity, + entrypoint, + deployment: CoreRuntimeOwnershipDeployment::Embedded { + leases: Mutex::new(HashMap::new()), + }, + verified_remote_scopes: Mutex::new(HashSet::new()), + } + } + + pub(crate) fn shared_with_facts( + ownership_root: PathBuf, + product_identity: String, + entrypoint: &'static str, + workspace: &Path, + ) -> Result { + let key = RuntimeOwnershipKey::for_workspace(workspace, &product_identity)?; + let lease = WorkspaceRuntimeOwnership::try_acquire( + &ownership_root, + &key, + RuntimeDeployment::Shared, + ) + .map_err(|error| { + log_acquisition_failure(entrypoint, RuntimeDeployment::Shared, &key, &error); + error + })?; + log_acquired(entrypoint, RuntimeDeployment::Shared, &key); + Ok(Self { + ownership_root, + product_identity, + entrypoint, + deployment: CoreRuntimeOwnershipDeployment::Shared { key, _lease: lease }, + verified_remote_scopes: Mutex::new(HashSet::new()), + }) + } + + /// Records a Remote workspace binding resolved by the Workspace owner. + /// Raw transport strings are never sufficient to bypass local ownership. + pub(crate) fn register_verified_remote_scope( + &self, + workspace: &Path, + connection_id: &str, + ssh_host: Option<&str>, + ) -> Result<(), CoreRuntimeOwnershipError> { + let scope = verified_remote_scope(workspace, connection_id, ssh_host)?; + self.verified_remote_scopes + .lock() + .map_err(|_| CoreRuntimeOwnershipError::OwnershipStateUnavailable)? + .insert(scope); + Ok(()) + } + + /// Acquires the local workspace unless structured remote facts assign + /// execution ownership to another host. + pub fn ensure_workspace_scope( + &self, + workspace: &Path, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + ) -> Result<(), CoreRuntimeOwnershipError> { + if let Some(connection_id) = remote_connection_id + .map(str::trim) + .filter(|connection_id| !connection_id.is_empty()) + { + let requested = verified_remote_scope(workspace, connection_id, remote_ssh_host)?; + let verified = self + .verified_remote_scopes + .lock() + .map_err(|_| CoreRuntimeOwnershipError::OwnershipStateUnavailable)? + .iter() + .any(|known| remote_scope_matches(known, &requested)); + if verified { + return Ok(()); + } + return Err(CoreRuntimeOwnershipError::UnverifiedRemoteWorkspaceScope); + } + self.ensure_local_workspace(workspace) + } + + /// Idempotently retains ownership of one local workspace for this process. + pub fn ensure_local_workspace( + &self, + workspace: &Path, + ) -> Result<(), CoreRuntimeOwnershipError> { + let key = RuntimeOwnershipKey::for_workspace(workspace, &self.product_identity)?; + match &self.deployment { + CoreRuntimeOwnershipDeployment::Embedded { leases } => { + let mut leases = leases + .lock() + .map_err(|_| CoreRuntimeOwnershipError::OwnershipStateUnavailable)?; + if leases.contains_key(&key) { + return Ok(()); + } + let lease = WorkspaceRuntimeOwnership::try_acquire( + &self.ownership_root, + &key, + RuntimeDeployment::Embedded, + ) + .map_err(|error| { + log_acquisition_failure( + self.entrypoint, + RuntimeDeployment::Embedded, + &key, + &error, + ); + error + })?; + log_acquired(self.entrypoint, RuntimeDeployment::Embedded, &key); + leases.insert(key, lease); + Ok(()) + } + CoreRuntimeOwnershipDeployment::Shared { + key: shared_key, .. + } if shared_key == &key => Ok(()), + 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, + workspace: &Path, + ) -> Result { + let key = RuntimeOwnershipKey::for_workspace(workspace, product_identity())?; + match WorkspaceRuntimeOwnership::try_acquire( + &path_manager.agent_runtime_ownership_dir(), + &key, + RuntimeDeployment::Shared, + ) { + Ok(_) => Ok(false), + Err(RuntimeOwnershipError::OwnershipUnavailable { .. }) => Ok(true), + Err(error) => Err(error.into()), + } + } + + /// Distinguishes compatible Embedded shared locks from a Shared Runtime's + /// exclusive lock without publishing another deployment protocol. + pub fn embedded_runtime_owner_present( + path_manager: &PathManager, + workspace: &Path, + ) -> Result { + let key = RuntimeOwnershipKey::for_workspace(workspace, product_identity())?; + let ownership_root = path_manager.agent_runtime_ownership_dir(); + match WorkspaceRuntimeOwnership::try_acquire( + &ownership_root, + &key, + RuntimeDeployment::Shared, + ) { + Ok(_) => Ok(false), + Err(RuntimeOwnershipError::OwnershipUnavailable { .. }) => { + match WorkspaceRuntimeOwnership::try_acquire( + &ownership_root, + &key, + RuntimeDeployment::Embedded, + ) { + Ok(_) => Ok(true), + Err(RuntimeOwnershipError::OwnershipUnavailable { .. }) => Ok(false), + Err(error) => Err(error.into()), + } + } + Err(error) => Err(error.into()), + } + } + + /// Product-wide identity used by ownership and private first-party IPC. + pub fn distribution_identity() -> &'static str { + product_identity() + } + + pub fn error_message(&self, error: &CoreRuntimeOwnershipError) -> String { + let deployment = match &self.deployment { + CoreRuntimeOwnershipDeployment::Embedded { .. } => RuntimeDeployment::Embedded, + CoreRuntimeOwnershipDeployment::Shared { .. } => RuntimeDeployment::Shared, + }; + error.startup_message(deployment, self.entrypoint) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum CoreRuntimeOwnershipError { + #[error(transparent)] + Primitive(#[from] RuntimeOwnershipError), + #[error("runtime ownership state is unavailable")] + OwnershipStateUnavailable, + #[error("Shared Agent Runtime is limited to its startup workspace")] + SharedRuntimeWorkspaceMismatch, + #[error("remote workspace binding was not verified by the Workspace owner")] + UnverifiedRemoteWorkspaceScope, +} + +impl CoreRuntimeOwnershipError { + pub fn code(&self) -> &'static str { + match self { + Self::Primitive(error) => error.code(), + Self::OwnershipStateUnavailable => "ownership_state_unavailable", + Self::SharedRuntimeWorkspaceMismatch => "shared_runtime_workspace_mismatch", + Self::UnverifiedRemoteWorkspaceScope => "unverified_remote_workspace_scope", + } + } + + pub fn startup_message(&self, deployment: RuntimeDeployment, entrypoint: &str) -> String { + let prefix = format!("Agent Runtime ownership failed ({}): {self}", self.code()); + if !matches!( + self, + Self::Primitive(RuntimeOwnershipError::OwnershipUnavailable { .. }) + ) { + return prefix; + } + let guidance = match deployment { + RuntimeDeployment::Embedded if entrypoint == "cli-interactive" => "A Shared TUI Runtime owns this workspace; use `bitfun chat --shared`, or close its clients and wait up to 30 seconds", + RuntimeDeployment::Embedded => "A Shared TUI Runtime owns this workspace; close its clients and wait up to 30 seconds before retrying this application", + RuntimeDeployment::Shared => "An Embedded BitFun process owns this workspace; close it before using `--shared`", + }; + format!("{prefix}. {guidance}") + } +} + +fn verified_remote_scope( + workspace: &Path, + connection_id: &str, + ssh_host: Option<&str>, +) -> Result { + let connection_id = connection_id.trim(); + if connection_id.is_empty() { + return Err(CoreRuntimeOwnershipError::UnverifiedRemoteWorkspaceScope); + } + let mut workspace_path = workspace.to_string_lossy().replace('\\', "/"); + while workspace_path.len() > 1 && workspace_path.ends_with('/') { + workspace_path.pop(); + } + if workspace_path.is_empty() { + return Err(CoreRuntimeOwnershipError::UnverifiedRemoteWorkspaceScope); + } + Ok(VerifiedRemoteRuntimeScope { + workspace_path, + connection_id: connection_id.to_string(), + ssh_host: ssh_host + .map(str::trim) + .filter(|host| !host.is_empty()) + .map(str::to_ascii_lowercase), + }) +} + +fn remote_scope_matches( + known: &VerifiedRemoteRuntimeScope, + requested: &VerifiedRemoteRuntimeScope, +) -> bool { + known.workspace_path == requested.workspace_path + && known.connection_id == requested.connection_id + && requested + .ssh_host + .as_ref() + .map_or(true, |host| known.ssh_host.as_ref() == Some(host)) +} + +fn product_identity() -> &'static str { + option_env!("BITFUN_PRODUCT_BINARY_NAME").unwrap_or(DEFAULT_PRODUCT_IDENTITY) +} + +fn log_acquired(entrypoint: &str, deployment: RuntimeDeployment, key: &RuntimeOwnershipKey) { + info!( + "Agent Runtime ownership acquired: deployment={}, entrypoint={}, ownership_key_prefix={}", + deployment, + entrypoint, + key_prefix(key) + ); +} + +fn log_acquisition_failure( + entrypoint: &str, + deployment: RuntimeDeployment, + key: &RuntimeOwnershipKey, + error: &RuntimeOwnershipError, +) { + warn!( + "Agent Runtime ownership unavailable: deployment={}, entrypoint={}, error_code={}, ownership_key_prefix={}", + deployment, + entrypoint, + error.code(), + key_prefix(key) + ); +} + +fn key_prefix(key: &RuntimeOwnershipKey) -> &str { + key.as_str().get(..12).unwrap_or(key.as_str()) +} diff --git a/src/crates/assembly/core/src/runtime_ownership_tests.rs b/src/crates/assembly/core/src/runtime_ownership_tests.rs new file mode 100644 index 0000000000..eba6d90846 --- /dev/null +++ b/src/crates/assembly/core/src/runtime_ownership_tests.rs @@ -0,0 +1,215 @@ +use std::sync::{Arc, Barrier}; + +use bitfun_services_core::runtime_ownership::{ + RuntimeDeployment, RuntimeOwnershipKey, WorkspaceRuntimeOwnership, +}; +use tempfile::tempdir; + +use crate::runtime_ownership::CoreRuntimeOwnership; + +#[test] +fn embedded_owner_is_idempotent_and_keeps_one_workspace_lease() { + let ownership_root = tempdir().expect("ownership root"); + let workspace = tempdir().expect("workspace"); + let owner = CoreRuntimeOwnership::embedded_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "test", + ); + + owner + .ensure_local_workspace(workspace.path()) + .expect("first acquisition"); + owner + .ensure_local_workspace(&workspace.path().join(".")) + .expect("idempotent acquisition"); + + let key = + RuntimeOwnershipKey::for_workspace(workspace.path(), "bitfun").expect("ownership key"); + assert!(WorkspaceRuntimeOwnership::try_acquire( + ownership_root.path(), + &key, + RuntimeDeployment::Shared, + ) + .is_err()); +} + +#[test] +fn embedded_owner_serializes_concurrent_first_acquisition() { + let ownership_root = tempdir().expect("ownership root"); + let workspace = tempdir().expect("workspace"); + let owner = Arc::new(CoreRuntimeOwnership::embedded_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "test", + )); + let barrier = Arc::new(Barrier::new(5)); + let mut threads = Vec::new(); + for _ in 0..4 { + let owner = Arc::clone(&owner); + let barrier = Arc::clone(&barrier); + let workspace = workspace.path().to_path_buf(); + threads.push(std::thread::spawn(move || { + barrier.wait(); + owner.ensure_local_workspace(&workspace) + })); + } + barrier.wait(); + for thread in threads { + thread + .join() + .expect("acquisition thread") + .expect("concurrent acquisition"); + } + + let key = + RuntimeOwnershipKey::for_workspace(workspace.path(), "bitfun").expect("ownership key"); + assert!(WorkspaceRuntimeOwnership::try_acquire( + ownership_root.path(), + &key, + RuntimeDeployment::Shared, + ) + .is_err()); +} + +#[test] +fn shared_owner_accepts_only_its_startup_workspace() { + let ownership_root = tempdir().expect("ownership root"); + let workspace = tempdir().expect("workspace"); + let other_workspace = tempdir().expect("other workspace"); + let owner = CoreRuntimeOwnership::shared_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "test", + workspace.path(), + ) + .expect("shared owner"); + + owner + .ensure_local_workspace(workspace.path()) + .expect("same workspace"); + let error = owner + .ensure_local_workspace(other_workspace.path()) + .expect_err("second workspace must fail closed"); + + assert_eq!(error.code(), "shared_runtime_workspace_mismatch"); +} + +#[test] +fn unverified_remote_workspace_cannot_bypass_local_ownership() { + let ownership_root = tempdir().expect("ownership root"); + let missing_local_path = ownership_root.path().join("remote-path-is-not-local"); + let owner = CoreRuntimeOwnership::embedded_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "test", + ); + + let error = owner + .ensure_workspace_scope(&missing_local_path, Some("connection"), Some("host")) + .expect_err("raw remote facts are not execution authority"); + assert_eq!(error.code(), "unverified_remote_workspace_scope"); + assert_eq!( + std::fs::read_dir(ownership_root.path()) + .expect("read ownership root") + .count(), + 0 + ); +} + +#[test] +fn verified_remote_workspace_does_not_touch_local_ownership() { + let ownership_root = tempdir().expect("ownership root"); + let missing_local_path = ownership_root.path().join("remote-path-is-not-local"); + let owner = CoreRuntimeOwnership::embedded_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "test", + ); + + owner + .register_verified_remote_scope(&missing_local_path, "connection", Some("host")) + .expect("verified remote scope"); + owner + .ensure_workspace_scope(&missing_local_path, Some("connection"), Some("host")) + .expect("verified remote scope must skip local ownership"); + assert_eq!( + std::fs::read_dir(ownership_root.path()) + .expect("read ownership root") + .count(), + 0 + ); +} + +#[test] +fn ssh_host_without_connection_id_cannot_bypass_local_ownership() { + let ownership_root = tempdir().expect("ownership root"); + let workspace = tempdir().expect("workspace"); + let shared = CoreRuntimeOwnership::shared_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "shared-test", + workspace.path(), + ) + .expect("shared owner"); + let embedded = CoreRuntimeOwnership::embedded_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "embedded-test", + ); + + let error = embedded + .ensure_workspace_scope(workspace.path(), None, Some("host-only")) + .expect_err("host-only facts must still protect local storage"); + + assert_eq!(error.code(), "runtime_ownership_unavailable"); + drop(shared); +} + +#[test] +fn startup_errors_expose_codes_without_mislabeling_path_failures_as_conflicts() { + let ownership_root = tempdir().expect("ownership root"); + let owner = CoreRuntimeOwnership::embedded_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "test", + ); + let missing = ownership_root.path().join("missing-workspace"); + + let error = owner + .ensure_local_workspace(&missing) + .expect_err("missing workspace must fail"); + let message = error.startup_message(RuntimeDeployment::Embedded, "sdk-host"); + + assert!(message.contains("canonicalize_workspace_failed")); + assert!(!message.contains("Shared TUI Runtime owns")); +} + +#[test] +fn ownership_conflict_guidance_matches_the_calling_product_surface() { + let ownership_root = tempdir().expect("ownership root"); + let workspace = tempdir().expect("workspace"); + let shared = CoreRuntimeOwnership::shared_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "shared-tui-runtime", + workspace.path(), + ) + .expect("shared owner"); + let error = CoreRuntimeOwnership::embedded_with_facts( + ownership_root.path().to_path_buf(), + "bitfun".to_string(), + "sdk-host", + ) + .ensure_local_workspace(workspace.path()) + .expect_err("Shared owner must block an Embedded SDK Host"); + + let tui_message = error.startup_message(RuntimeDeployment::Embedded, "cli-interactive"); + assert!(tui_message.contains("bitfun chat --shared")); + for entrypoint in ["cli-headless", "acp", "sdk-host", "desktop"] { + let message = error.startup_message(RuntimeDeployment::Embedded, entrypoint); + assert!(!message.contains("bitfun chat --shared"), "{entrypoint}"); + assert!(message.contains("close its clients"), "{entrypoint}"); + } + drop(shared); +} diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs index 2763aba94c..34fe3db3d3 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs @@ -455,6 +455,27 @@ async fn select_model( // ── Public entry points ──────────────────────────────────────────── +async fn open_bot_workspace( + workspace_service: &crate::service::workspace::WorkspaceService, + path: std::path::PathBuf, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + log_context: &str, +) -> Result { + let coordinator = crate::agentic::coordination::get_global_coordinator() + .ok_or_else(|| "Conversation coordinator not initialized".to_string())?; + coordinator + .open_workspace_with_runtime_ownership( + workspace_service, + path, + remote_connection_id, + remote_ssh_host, + log_context, + ) + .await + .map_err(|error| error.to_string()) +} + /// IM pairing bootstrap: assistant mode + default assistant workspace + new /// Claw session. Mutates `state.display_mode/current_assistant/ /// current_session_id` on success. @@ -488,14 +509,16 @@ pub async fn bootstrap_im_chat_after_pairing(state: &mut BotChatState) -> String return s.bootstrap_workspace_unavailable.to_string(); }; - let path_buf = ws_info.root_path.clone(); - if let Err(e) = ws_service.open_workspace(path_buf.clone()).await { - return format!("{}{e}", s.workspace_open_failed_prefix); - } - if let Err(e) = - crate::service::snapshot::initialize_snapshot_manager_for_workspace(path_buf, None).await + if let Err(e) = open_bot_workspace( + ws_service.as_ref(), + ws_info.root_path.clone(), + None, + None, + "IM bot pairing", + ) + .await { - error!("IM bot bootstrap: snapshot init after pairing: {e}"); + return format!("{}{e}", s.workspace_open_failed_prefix); } state.current_assistant = Some(ws_info.root_path.to_string_lossy().to_string()); @@ -1205,23 +1228,16 @@ async fn select_workspace( } }; let path_buf = std::path::PathBuf::from(&choice.path); - match ws_service - .open_workspace_resolving_known( - path_buf, - choice.remote_connection_id.as_deref(), - choice.remote_ssh_host.as_deref(), - ) - .await + match open_bot_workspace( + ws_service.as_ref(), + path_buf, + choice.remote_connection_id.as_deref(), + choice.remote_ssh_host.as_deref(), + "bot workspace switch", + ) + .await { Ok(info) => { - if let Err(e) = crate::service::snapshot::initialize_snapshot_manager_for_workspace( - info.root_path.clone(), - None, - ) - .await - { - error!("Failed to init snapshot after bot workspace switch: {e}"); - } let workspace_path = info.root_path.to_string_lossy().to_string(); let remote_connection_id = info .remote_ssh_connection_id() @@ -1280,16 +1296,16 @@ async fn select_assistant( } }; let path_buf = std::path::PathBuf::from(path); - match ws_service.open_workspace(path_buf).await { - Ok(info) => { - if let Err(e) = crate::service::snapshot::initialize_snapshot_manager_for_workspace( - info.root_path.clone(), - None, - ) - .await - { - error!("Failed to init snapshot after bot assistant switch: {e}"); - } + match open_bot_workspace( + ws_service.as_ref(), + path_buf, + None, + None, + "bot assistant switch", + ) + .await + { + Ok(_info) => { state.current_assistant = Some(path.to_string()); state.current_assistant_name = Some(name.to_string()); state.current_session_id = None; diff --git a/src/crates/assembly/core/src/service/snapshot/isolation_manager.rs b/src/crates/assembly/core/src/service/snapshot/isolation_manager.rs index cb8c62a07d..47c044b0f7 100644 --- a/src/crates/assembly/core/src/service/snapshot/isolation_manager.rs +++ b/src/crates/assembly/core/src/service/snapshot/isolation_manager.rs @@ -142,20 +142,29 @@ impl IsolationManager { /// Validates that a file path is safe (does not impact Git). pub fn is_path_safe_for_modification(&self, path: &Path) -> bool { - if !path.starts_with(&self.workspace_dir) { - return false; - } - let git_dir = self.workspace_dir.join(".git"); - if path.starts_with(&git_dir) { + if path_starts_with_scope(path, &git_dir) + || path_starts_with_scope(path, &self.runtime_context.runtime_root) + { return false; } - if path.starts_with(&self.runtime_context.runtime_root) { + let Some(path) = canonicalize_for_scope(path) else { return false; - } + }; + let Some(workspace_dir) = canonicalize_for_scope(&self.workspace_dir) else { + return false; + }; + let Some(git_dir) = canonicalize_for_scope(&git_dir) else { + return false; + }; + let Some(runtime_root) = canonicalize_for_scope(&self.runtime_context.runtime_root) else { + return false; + }; - true + path_starts_with_scope(&path, &workspace_dir) + && !path_starts_with_scope(&path, &git_dir) + && !path_starts_with_scope(&path, &runtime_root) } /// Returns a path relative to the workspace directory. @@ -171,3 +180,149 @@ impl IsolationManager { }) } } + +fn canonicalize_for_scope(path: &Path) -> Option { + let mut ancestor = path; + let mut missing_suffix = Vec::new(); + + loop { + if let Ok(mut resolved) = dunce::canonicalize(ancestor) { + for component in missing_suffix.iter().rev() { + resolved.push(component); + } + return Some(resolved); + } + + missing_suffix.push(ancestor.file_name()?.to_os_string()); + ancestor = ancestor.parent()?; + } +} + +#[cfg(not(windows))] +fn path_starts_with_scope(path: &Path, root: &Path) -> bool { + path.starts_with(root) +} + +#[cfg(windows)] +fn path_starts_with_scope(path: &Path, root: &Path) -> bool { + use std::os::windows::ffi::OsStrExt; + + fn lower_ascii(unit: u16) -> u16 { + if (u16::from(b'A')..=u16::from(b'Z')).contains(&unit) { + unit + u16::from(b'a' - b'A') + } else { + unit + } + } + + let mut path_components = path.components(); + root.components().all(|root_component| { + path_components.next().is_some_and(|path_component| { + path_component + .as_os_str() + .encode_wide() + .map(lower_ascii) + .eq(root_component.as_os_str().encode_wide().map(lower_ascii)) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::IsolationManager; + use crate::service::workspace_runtime::{WorkspaceRuntimeContext, WorkspaceRuntimeTarget}; + use std::path::{Path, PathBuf}; + + fn manager(workspace_dir: PathBuf, runtime_root: PathBuf) -> IsolationManager { + let runtime_context = WorkspaceRuntimeContext::new( + WorkspaceRuntimeTarget::LocalWorkspace { + workspace_root: workspace_dir.clone(), + }, + runtime_root, + ); + IsolationManager::new(workspace_dir, runtime_context) + } + + fn aliased_workspace(root: &Path) -> PathBuf { + let anchor = root.join("alias-anchor"); + std::fs::create_dir_all(&anchor).expect("alias anchor"); + anchor.join("..") + } + + #[test] + fn accepts_existing_file_through_workspace_alias() { + let workspace = tempfile::tempdir().expect("workspace"); + let workspace_root = dunce::canonicalize(workspace.path()).expect("canonical workspace"); + let alias = aliased_workspace(workspace.path()); + let file = workspace.path().join("tracked.txt"); + std::fs::write(&file, "tracked").expect("tracked file"); + let manager = manager(workspace_root, workspace.path().join(".bitfun")); + + assert!(manager.is_path_safe_for_modification(&alias.join("tracked.txt"))); + } + + #[test] + fn accepts_nested_new_file_through_workspace_alias() { + let workspace = tempfile::tempdir().expect("workspace"); + let workspace_root = dunce::canonicalize(workspace.path()).expect("canonical workspace"); + let alias = aliased_workspace(workspace.path()); + let manager = manager(workspace_root, workspace.path().join(".bitfun")); + + assert!(manager.is_path_safe_for_modification(&alias.join("new/deep/file.txt"))); + } + + #[test] + fn rejects_runtime_path_before_alias_resolution() { + let workspace = tempfile::tempdir().expect("workspace"); + let workspace_root = dunce::canonicalize(workspace.path()).expect("canonical workspace"); + let alias = aliased_workspace(workspace.path()); + let runtime_root = alias.join(".bitfun"); + std::fs::create_dir_all(&runtime_root).expect("runtime root"); + let manager = manager(workspace_root, runtime_root.clone()); + + assert!(!manager.is_path_safe_for_modification(&runtime_root.join("state.json"))); + } + + #[cfg(windows)] + #[test] + fn rejects_case_variant_missing_git_directory() { + let workspace = tempfile::tempdir().expect("workspace"); + let workspace_root = dunce::canonicalize(workspace.path()).expect("canonical workspace"); + let manager = manager(workspace_root, workspace.path().join(".bitfun")); + + assert!(!manager.is_path_safe_for_modification(&workspace.path().join(".GIT/config"))); + } + + #[cfg(unix)] + #[test] + fn rejects_git_symlink_target_inside_workspace() { + use std::os::unix::fs::symlink; + + let workspace = tempfile::tempdir().expect("workspace"); + let metadata = workspace.path().join("metadata"); + std::fs::create_dir_all(&metadata).expect("metadata target"); + std::fs::write(metadata.join("config"), "config").expect("git config"); + symlink(&metadata, workspace.path().join(".git")).expect("git symlink"); + let workspace_root = dunce::canonicalize(workspace.path()).expect("canonical workspace"); + let manager = manager(workspace_root, workspace.path().join(".bitfun")); + + assert!(!manager.is_path_safe_for_modification(&workspace.path().join(".git/config"))); + } + + #[cfg(unix)] + #[test] + fn rejects_workspace_symlink_that_escapes_scope() { + use std::os::unix::fs::symlink; + + let workspace = tempfile::tempdir().expect("workspace"); + let outside = tempfile::tempdir().expect("outside"); + std::fs::write(outside.path().join("outside.txt"), "outside").expect("outside file"); + symlink(outside.path(), workspace.path().join("escape")).expect("escape symlink"); + let workspace_root = dunce::canonicalize(workspace.path()).expect("canonical workspace"); + let manager = manager(workspace_root, workspace.path().join(".bitfun")); + + assert!( + !manager.is_path_safe_for_modification(&workspace.path().join("escape/outside.txt")) + ); + } +} diff --git a/src/crates/assembly/core/src/service/snapshot/manager.rs b/src/crates/assembly/core/src/service/snapshot/manager.rs index b45af79a6c..8dc144d96e 100644 --- a/src/crates/assembly/core/src/service/snapshot/manager.rs +++ b/src/crates/assembly/core/src/service/snapshot/manager.rs @@ -37,7 +37,7 @@ impl SnapshotManager { config: Option, ) -> SnapshotResult { #[cfg(test)] - record_snapshot_manager_new_for_test().await; + record_snapshot_manager_new_for_test(&workspace_dir).await; info!( "Creating snapshot manager: workspace={}", @@ -329,20 +329,44 @@ fn snapshot_manager_init_locks() -> &'static AsyncMutex Arc> { + let workspace_key = snapshot_workspace_key(workspace_dir); let mut locks = snapshot_manager_init_locks().lock().await; locks - .entry(workspace_dir.to_path_buf()) + .entry(workspace_key) .or_insert_with(|| Arc::new(AsyncMutex::new(()))) .clone() } +fn snapshot_workspace_key(workspace_dir: &Path) -> PathBuf { + dunce::canonicalize(workspace_dir).unwrap_or_else(|_| workspace_dir.to_path_buf()) +} + #[cfg(test)] static SNAPSHOT_MANAGER_NEW_COUNT_FOR_TEST: AtomicUsize = AtomicUsize::new(0); #[cfg(test)] static SNAPSHOT_MANAGER_NEW_DELAY_MS_FOR_TEST: AtomicU64 = AtomicU64::new(0); #[cfg(test)] -async fn record_snapshot_manager_new_for_test() { +fn snapshot_manager_observed_workspace_for_test() -> &'static StdRwLock> { + static WORKSPACE: OnceLock>> = OnceLock::new(); + WORKSPACE.get_or_init(|| StdRwLock::new(None)) +} + +#[cfg(test)] +fn snapshot_manager_test_serial_lock() -> &'static AsyncMutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| AsyncMutex::new(())) +} + +#[cfg(test)] +async fn record_snapshot_manager_new_for_test(workspace_dir: &Path) { + let observed_workspace = snapshot_manager_observed_workspace_for_test() + .read() + .ok() + .and_then(|workspace| workspace.clone()); + if observed_workspace.as_deref() != Some(workspace_dir) { + return; + } SNAPSHOT_MANAGER_NEW_COUNT_FOR_TEST.fetch_add(1, Ordering::SeqCst); let delay_ms = SNAPSHOT_MANAGER_NEW_DELAY_MS_FOR_TEST.load(Ordering::SeqCst); if delay_ms > 0 { @@ -351,7 +375,10 @@ async fn record_snapshot_manager_new_for_test() { } #[cfg(test)] -fn reset_snapshot_manager_new_count_for_test() { +fn observe_snapshot_manager_new_for_test(workspace_dir: &Path) { + if let Ok(mut observed_workspace) = snapshot_manager_observed_workspace_for_test().write() { + *observed_workspace = Some(snapshot_workspace_key(workspace_dir)); + } SNAPSHOT_MANAGER_NEW_COUNT_FOR_TEST.store(0, Ordering::SeqCst); } @@ -368,7 +395,7 @@ fn set_snapshot_manager_new_delay_for_test(delay: Duration) { #[cfg(test)] pub(crate) fn clear_snapshot_manager_for_test(workspace_dir: &Path) { if let Ok(mut managers) = snapshot_managers().write() { - managers.remove(workspace_dir); + managers.remove(&snapshot_workspace_key(workspace_dir)); } } @@ -552,6 +579,8 @@ impl Tool for WrappedTool { self.name() ); + self.ensure_delete_snapshot_target_supported(input, context)?; + match self.handle_file_modification_internal(input, context).await { Ok(results) => { return Ok(results); @@ -571,6 +600,42 @@ impl Tool for WrappedTool { } impl WrappedTool { + /// Snapshot storage currently preserves file bytes, not link objects. A + /// tracked Delete must therefore stop before removing a link instead of + /// falling back to an operation that cannot be rolled back faithfully. + fn ensure_delete_snapshot_target_supported( + &self, + input: &Value, + context: &ToolUseContext, + ) -> crate::util::errors::BitFunResult<()> { + if !matches!(self.name(), "Delete" | "delete_file") { + return Ok(()); + } + + let raw_path = self + .extract_file_path(input, context) + .map_err(|error| crate::util::errors::BitFunError::Tool(error.to_string()))?; + let resolved = context.resolve_tool_path(raw_path.to_string_lossy().as_ref())?; + if resolved.uses_remote_workspace_backend() { + return Ok(()); + } + + match std::fs::symlink_metadata(&resolved.resolved_path) { + Ok(metadata) if is_symlink_or_reparse_point(&metadata) => { + Err(crate::util::errors::BitFunError::Tool(format!( + "Snapshot-tracked Delete cannot remove a symbolic link or reparse point because rollback cannot restore the link object: {}. The delete was not performed", + resolved.logical_path + ))) + } + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(crate::util::errors::BitFunError::Tool(format!( + "Failed to inspect Delete target for Snapshot safety: path={} error={}", + resolved.logical_path, error + ))), + } + } + /// Handles a file-modification tool. async fn handle_file_modification_internal( &self, @@ -583,7 +648,7 @@ impl WrappedTool { ) })?; - let raw_path = match self.extract_file_path_simple(input) { + let raw_path = match self.extract_file_path(input, context) { Ok(path) => path, Err(e) => return Err(crate::util::errors::BitFunError::Tool(e.to_string())), }; @@ -701,8 +766,13 @@ impl WrappedTool { .unwrap_or(0) } - /// Simplified file path extraction. - fn extract_file_path_simple(&self, input: &Value) -> SnapshotResult { + /// Extracts the concrete input object used by legacy file tools, falling + /// back to the owner-resolved permission resource for payload-based tools. + fn extract_file_path( + &self, + input: &Value, + context: &ToolUseContext, + ) -> SnapshotResult { let possible_fields = ["file_path", "path", "target_file", "filename"]; for field in &possible_fields { @@ -713,6 +783,18 @@ impl WrappedTool { } } + let permission_intents = self + .original_tool + .permission_intents(input, context) + .map_err(|error| SnapshotError::ConfigError(error.to_string()))?; + if let Some(resource) = permission_intents + .iter() + .find(|intent| intent.action == "edit") + .and_then(|intent| intent.resources.first()) + { + return Ok(PathBuf::from(resource)); + } + Err(SnapshotError::ConfigError( "Failed to extract file path from tool input".to_string(), )) @@ -736,18 +818,35 @@ impl WrappedTool { } } +fn is_symlink_or_reparse_point(metadata: &std::fs::Metadata) -> bool { + if metadata.file_type().is_symlink() { + return true; + } + + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; + } + + #[cfg(not(windows))] + false +} + pub async fn get_or_create_snapshot_manager( workspace_dir: PathBuf, config: Option, ) -> SnapshotResult> { - if let Some(existing) = get_snapshot_manager_for_workspace(&workspace_dir) { + let workspace_key = snapshot_workspace_key(&workspace_dir); + if let Some(existing) = get_snapshot_manager_for_workspace(&workspace_key) { return Ok(existing); } - let init_lock = snapshot_manager_init_lock(&workspace_dir).await; + let init_lock = snapshot_manager_init_lock(&workspace_key).await; let _init_guard = init_lock.lock().await; - if let Some(existing) = get_snapshot_manager_for_workspace(&workspace_dir) { + if let Some(existing) = get_snapshot_manager_for_workspace(&workspace_key) { debug!( "Snapshot manager initialized by concurrent request: workspace={}", workspace_dir.display() @@ -760,15 +859,15 @@ pub async fn get_or_create_snapshot_manager( "Snapshot manager cold initialization started: workspace={}", workspace_dir.display() ); - let manager = Arc::new(SnapshotManager::new(workspace_dir.clone(), config).await?); + let manager = Arc::new(SnapshotManager::new(workspace_key.clone(), config).await?); { let mut managers = snapshot_managers().write().map_err(|_| { SnapshotError::ConfigError("Snapshot manager store lock poisoned".to_string()) })?; - if let Some(existing) = managers.get(&workspace_dir) { + if let Some(existing) = managers.get(&workspace_key) { return Ok(existing.clone()); } - managers.insert(workspace_dir, manager.clone()); + managers.insert(workspace_key, manager.clone()); } info!( "Snapshot manager cold initialization completed: duration_ms={}", @@ -779,10 +878,36 @@ pub async fn get_or_create_snapshot_manager( } pub fn get_snapshot_manager_for_workspace(workspace_dir: &Path) -> Option> { + let workspace_key = snapshot_workspace_key(workspace_dir); snapshot_managers() .read() .ok() - .and_then(|managers| managers.get(workspace_dir).cloned()) + .and_then(|managers| managers.get(&workspace_key).cloned()) +} + +/// Opens persisted Snapshot facts for queries without registering a writer or +/// creating workspace runtime state. +pub async fn open_snapshot_manager_for_view( + workspace_dir: &Path, +) -> SnapshotResult> { + let workspace_key = snapshot_workspace_key(workspace_dir); + if let Some(manager) = get_snapshot_manager_for_workspace(&workspace_key) { + return Ok(manager); + } + + let init_lock = snapshot_manager_init_lock(&workspace_key).await; + let _init_guard = init_lock.lock().await; + if let Some(manager) = get_snapshot_manager_for_workspace(&workspace_key) { + return Ok(manager); + } + + let runtime_context = + get_workspace_runtime_service_arc().context_for_local_workspace(&workspace_key); + let mut snapshot_service = SnapshotService::new(workspace_key, runtime_context, None); + snapshot_service.initialize_for_view().await?; + Ok(Arc::new(SnapshotManager { + snapshot_service: Arc::new(RwLock::new(snapshot_service)), + })) } pub fn ensure_snapshot_manager_for_workspace( @@ -810,13 +935,22 @@ pub async fn initialize_snapshot_manager_for_workspace( mod tests { use super::{ clear_snapshot_manager_for_test, get_or_create_snapshot_manager, - reset_snapshot_manager_new_count_for_test, set_snapshot_manager_new_delay_for_test, - snapshot_manager_new_count_for_test, + get_snapshot_manager_for_workspace, observe_snapshot_manager_new_for_test, + open_snapshot_manager_for_view, set_snapshot_manager_new_delay_for_test, + snapshot_manager_new_count_for_test, snapshot_manager_test_serial_lock, + wrap_tool_for_snapshot_tracking, }; + use crate::agentic::tools::framework::ToolUseContext; + use crate::agentic::tools::implementations::delete_file_tool::DeleteFileTool; + use crate::agentic::tools::implementations::file_write_tool::FileWriteTool; + use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::WorkspaceBinding; use crate::infrastructure::PathManager; + use crate::service::snapshot::types::OperationType; use crate::service::workspace_runtime::{ set_workspace_runtime_service_for_current_test, WorkspaceRuntimeService, }; + use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -846,8 +980,110 @@ mod tests { } } + fn tool_context(workspace: PathBuf, session_id: &str) -> ToolUseContext { + ToolUseContext { + tool_call_id: Some("snapshot-write-call".to_string()), + agent_type: None, + session_id: Some(session_id.to_string()), + dialog_turn_id: None, + workspace: Some(WorkspaceBinding::new(None, workspace)), + loaded_deferred_tool_specs: Vec::new(), + primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), + } + } + + #[test] + fn delete_keeps_its_input_path_instead_of_canonical_permission_resource() { + let workspace = TestWorkspace::new(); + let context = tool_context(workspace.path().to_path_buf(), "delete-session"); + let tool = super::WrappedTool::new(Arc::new(DeleteFileTool::new())); + + assert_eq!( + tool.extract_file_path(&serde_json::json!({ "path": "link.txt" }), &context) + .expect("Delete path"), + PathBuf::from("link.txt") + ); + } + + #[tokio::test] + async fn wrapped_delete_rejects_symlink_before_mutation() { + let workspace = TestWorkspace::new(); + let _runtime_guard = set_workspace_runtime_service_for_current_test(Arc::new( + WorkspaceRuntimeService::new(Arc::new(PathManager::with_user_root_for_tests( + workspace.path().join("user-root"), + ))), + )); + let target = workspace.path().join("target.txt"); + let link = workspace.path().join("link.txt"); + std::fs::write(&target, "target").expect("target file"); + #[cfg(unix)] + std::os::unix::fs::symlink(&target, &link).expect("file symlink"); + #[cfg(windows)] + if std::os::windows::fs::symlink_file(&target, &link).is_err() { + return; + } + let context = tool_context(workspace.path().to_path_buf(), "delete-link-session"); + let tool = wrap_tool_for_snapshot_tracking(Arc::new(DeleteFileTool::new())); + + let error = tool + .call(&serde_json::json!({ "path": "link.txt" }), &context) + .await + .expect_err("Snapshot-tracked Delete must reject a symlink"); + + assert!(error.to_string().contains("symbolic link")); + assert!(std::fs::symlink_metadata(&link) + .expect("link must remain") + .file_type() + .is_symlink()); + assert_eq!(std::fs::read_to_string(target).unwrap(), "target"); + } + + #[tokio::test] + async fn wrapped_write_payload_records_and_rolls_back_created_file() { + let workspace = TestWorkspace::new(); + let _runtime_guard = set_workspace_runtime_service_for_current_test(Arc::new( + WorkspaceRuntimeService::new(Arc::new(PathManager::with_user_root_for_tests( + workspace.path().join("user-root"), + ))), + )); + let alias_anchor = workspace.path().join("alias-anchor"); + std::fs::create_dir_all(&alias_anchor).expect("alias anchor"); + let workspace_alias = alias_anchor.join(".."); + let context = tool_context(workspace_alias, "write-session"); + let tool = wrap_tool_for_snapshot_tracking(Arc::new(FileWriteTool::new())); + let file = workspace.path().join("new/deep/file.txt"); + + tool.call( + &serde_json::json!({ "payload": "+++ new/deep/file.txt\ncreated" }), + &context, + ) + .await + .expect("wrapped Write should succeed"); + + let manager = get_snapshot_manager_for_workspace(workspace.path()) + .expect("Write should initialize snapshot manager"); + assert_eq!( + manager + .get_session_files("write-session") + .await + .expect("recorded files"), + vec![dunce::canonicalize(&file).expect("canonical written file")] + ); + + manager + .rollback_session("write-session") + .await + .expect("rollback created file"); + assert!(!file.exists()); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrent_get_or_create_initializes_snapshot_manager_once_per_workspace() { + let _test_guard = snapshot_manager_test_serial_lock().lock().await; let workspace = TestWorkspace::new(); let _runtime_guard = set_workspace_runtime_service_for_current_test(Arc::new( WorkspaceRuntimeService::new(Arc::new(PathManager::with_user_root_for_tests( @@ -855,7 +1091,7 @@ mod tests { ))), )); clear_snapshot_manager_for_test(workspace.path()); - reset_snapshot_manager_new_count_for_test(); + observe_snapshot_manager_new_for_test(workspace.path()); set_snapshot_manager_new_delay_for_test(Duration::from_millis(80)); let first = get_or_create_snapshot_manager(workspace.path().to_path_buf(), None); @@ -870,4 +1106,93 @@ mod tests { assert!(Arc::ptr_eq(&first, &second)); assert_eq!(snapshot_manager_new_count_for_test(), 1); } + + #[tokio::test] + async fn read_only_view_reloads_persisted_history_without_becoming_a_writer() { + let workspace = TestWorkspace::new(); + let _runtime_guard = set_workspace_runtime_service_for_current_test(Arc::new( + WorkspaceRuntimeService::new(Arc::new(PathManager::with_user_root_for_tests( + workspace.path().join("user-root"), + ))), + )); + let file = workspace.path().join("tracked.txt"); + tokio::fs::write(&file, "before").await.expect("seed file"); + let writer = get_or_create_snapshot_manager(workspace.path().to_path_buf(), None) + .await + .expect("writer manager"); + let operation_id = writer + .record_file_change( + "session-1", + 1, + file.clone(), + OperationType::Modify, + "test".to_string(), + ) + .await + .expect("start operation"); + tokio::fs::write(&file, "after").await.expect("change file"); + writer + .get_snapshot_service() + .read() + .await + .complete_file_modification("session-1", &operation_id, 1) + .await + .expect("complete operation"); + clear_snapshot_manager_for_test(workspace.path()); + + let view = open_snapshot_manager_for_view(workspace.path()) + .await + .expect("read-only view"); + + assert_eq!( + view.get_session_files("session-1").await.unwrap(), + vec![file] + ); + assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none()); + let error = view + .record_file_change( + "session-2", + 1, + workspace.path().join("blocked.txt"), + OperationType::Create, + "test".to_string(), + ) + .await + .expect_err("read-only view must reject mutations"); + assert!(error.to_string().contains("read-only"), "{error}"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn read_only_view_waits_for_an_in_flight_writer_initialization() { + let _test_guard = snapshot_manager_test_serial_lock().lock().await; + let workspace = TestWorkspace::new(); + let _runtime_guard = set_workspace_runtime_service_for_current_test(Arc::new( + WorkspaceRuntimeService::new(Arc::new(PathManager::with_user_root_for_tests( + workspace.path().join("user-root"), + ))), + )); + clear_snapshot_manager_for_test(workspace.path()); + observe_snapshot_manager_new_for_test(workspace.path()); + set_snapshot_manager_new_delay_for_test(Duration::from_millis(80)); + + let workspace_path = workspace.path().to_path_buf(); + let writer_task = tokio::spawn(async move { + get_or_create_snapshot_manager(workspace_path, None) + .await + .expect("writer manager") + }); + while snapshot_manager_new_count_for_test() == 0 { + tokio::task::yield_now().await; + } + let alias_anchor = workspace.path().join("alias-anchor"); + std::fs::create_dir_all(&alias_anchor).expect("alias anchor"); + let workspace_alias = alias_anchor.join(".."); + let view = open_snapshot_manager_for_view(&workspace_alias) + .await + .expect("aliased view waits for writer"); + let writer = writer_task.await.expect("writer task"); + set_snapshot_manager_new_delay_for_test(Duration::ZERO); + + assert!(Arc::ptr_eq(&view, &writer)); + } } diff --git a/src/crates/assembly/core/src/service/snapshot/mod.rs b/src/crates/assembly/core/src/service/snapshot/mod.rs index 64dd4b38a7..d8c26bd65c 100644 --- a/src/crates/assembly/core/src/service/snapshot/mod.rs +++ b/src/crates/assembly/core/src/service/snapshot/mod.rs @@ -14,7 +14,8 @@ pub use events::{ pub use manager::{ ensure_snapshot_manager_for_workspace, get_or_create_snapshot_manager, get_snapshot_manager_for_workspace, get_snapshot_wrapped_tools, - initialize_snapshot_manager_for_workspace, wrap_tool_for_snapshot_tracking, SnapshotManager, + initialize_snapshot_manager_for_workspace, open_snapshot_manager_for_view, + wrap_tool_for_snapshot_tracking, SnapshotManager, }; pub use service::{SnapshotService, SystemStats}; pub use snapshot_core::{FileChangeEntry, FileChangeQueue, SessionStats, SnapshotCore}; diff --git a/src/crates/assembly/core/src/service/snapshot/service.rs b/src/crates/assembly/core/src/service/snapshot/service.rs index afcf6821da..9fbd6fb5cb 100644 --- a/src/crates/assembly/core/src/service/snapshot/service.rs +++ b/src/crates/assembly/core/src/service/snapshot/service.rs @@ -22,6 +22,7 @@ pub struct SnapshotService { workspace_dir: PathBuf, runtime_context: WorkspaceRuntimeContext, initialized: bool, + read_only: bool, } impl SnapshotService { @@ -50,6 +51,7 @@ impl SnapshotService { workspace_dir, runtime_context, initialized: false, + read_only: false, } } @@ -88,6 +90,7 @@ impl SnapshotService { step_started_at.elapsed().as_millis() ); self.initialized = true; + self.read_only = false; let step_started_at = Instant::now(); let isolation_status = { @@ -108,6 +111,20 @@ impl SnapshotService { Ok(()) } + /// Loads persisted Snapshot facts without creating runtime directories, + /// isolation state, or file-lock ownership. + pub(crate) async fn initialize_for_view(&mut self) -> SnapshotResult<()> { + if self.initialized { + return Ok(()); + } + if self.runtime_context.snapshot_operations_dir.exists() { + self.snapshot_core.write().await.initialize().await?; + } + self.initialized = true; + self.read_only = true; + Ok(()) + } + /// Record a file change (before the actual change). Returns operation_id. pub async fn record_file_change( &self, @@ -117,7 +134,7 @@ impl SnapshotService { operation_type: OperationType, tool_name: String, ) -> SnapshotResult { - self.ensure_initialized().await?; + self.ensure_writable().await?; self.validate_file_path(&file_path).await?; let mut snapshot_core = self.snapshot_core.write().await; @@ -146,7 +163,7 @@ impl SnapshotService { operation_type: OperationType, operation_id_override: Option, ) -> SnapshotResult { - self.ensure_initialized().await?; + self.ensure_writable().await?; self.validate_file_path(file_path).await?; let operation_id = { @@ -220,7 +237,7 @@ impl SnapshotService { operation_id: &str, execution_time_ms: u64, ) -> SnapshotResult<()> { - self.ensure_initialized().await?; + self.ensure_writable().await?; let completed_op = { let mut snapshot_core = self.snapshot_core.write().await; @@ -269,7 +286,7 @@ impl SnapshotService { } pub async fn rollback_session(&self, session_id: &str) -> SnapshotResult> { - self.ensure_initialized().await?; + self.ensure_writable().await?; info!("Rolling back session: session_id={}", session_id); let mut snapshot_core = self.snapshot_core.write().await; @@ -294,7 +311,7 @@ impl SnapshotService { session_id: &str, turn_index: usize, ) -> SnapshotResult> { - self.ensure_initialized().await?; + self.ensure_writable().await?; info!( "Rolling back to turn: session_id={} turn_index={}", session_id, turn_index @@ -305,7 +322,7 @@ impl SnapshotService { } pub async fn accept_session(&self, session_id: &str) -> SnapshotResult<()> { - self.ensure_initialized().await?; + self.ensure_writable().await?; info!("Accepting session changes: session_id={}", session_id); let mut snapshot_core = self.snapshot_core.write().await; @@ -326,7 +343,7 @@ impl SnapshotService { } pub async fn accept_file(&self, session_id: &str, file_path: &Path) -> SnapshotResult<()> { - self.ensure_initialized().await?; + self.ensure_writable().await?; self.validate_file_path(file_path).await?; let mut snapshot_core = self.snapshot_core.write().await; @@ -346,7 +363,7 @@ impl SnapshotService { session_id: &str, file_path: &Path, ) -> SnapshotResult> { - self.ensure_initialized().await?; + self.ensure_writable().await?; self.validate_file_path(file_path).await?; let mut snapshot_core = self.snapshot_core.write().await; @@ -468,7 +485,7 @@ impl SnapshotService { file_path: &Path, tool_name: &str, ) -> SnapshotResult { - self.ensure_initialized().await?; + self.ensure_writable().await?; self.file_lock_manager .try_acquire_lock(&file_path.to_path_buf(), session_id, tool_name) .await @@ -479,7 +496,7 @@ impl SnapshotService { session_id: &str, file_path: &Path, ) -> SnapshotResult<()> { - self.ensure_initialized().await?; + self.ensure_writable().await?; self.file_lock_manager .release_lock(&file_path.to_path_buf(), session_id) .await @@ -536,6 +553,16 @@ impl SnapshotService { Ok(()) } + async fn ensure_writable(&self) -> SnapshotResult<()> { + self.ensure_initialized().await?; + if self.read_only { + return Err(SnapshotError::ConfigError( + "snapshot view is read-only".to_string(), + )); + } + Ok(()) + } + async fn validate_file_path(&self, file_path: &Path) -> SnapshotResult<()> { let isolation_manager = self.isolation_manager.read().await; if !isolation_manager.is_path_safe_for_modification(file_path) { diff --git a/src/crates/assembly/core/src/service/snapshot/snapshot_core.rs b/src/crates/assembly/core/src/service/snapshot/snapshot_core.rs index 12990bc742..bb3e6f6084 100644 --- a/src/crates/assembly/core/src/service/snapshot/snapshot_core.rs +++ b/src/crates/assembly/core/src/service/snapshot/snapshot_core.rs @@ -4,6 +4,7 @@ use crate::service::snapshot::types::{ ToolContext, }; use crate::service::workspace_runtime::WorkspaceRuntimeContext; +use bitfun_services_core::json_store::JsonFileStore; use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -956,10 +957,15 @@ impl SnapshotCore { return Ok(()); }; let path = self.session_file_path(session_id); - let data = serde_json::to_string_pretty(session).map_err(SnapshotError::Serialization)?; - tokio::fs::write(path, data) + JsonFileStore + .write_atomic_strict(&path, session) .await - .map_err(SnapshotError::Io)?; + .map_err(|error| { + SnapshotError::ConfigError(format!( + "Failed to persist snapshot session history {}: {error}", + path.display() + )) + })?; Ok(()) } diff --git a/src/crates/assembly/core/src/service/workspace/service.rs b/src/crates/assembly/core/src/service/workspace/service.rs index 980365bf01..053f3ada54 100644 --- a/src/crates/assembly/core/src/service/workspace/service.rs +++ b/src/crates/assembly/core/src/service/workspace/service.rs @@ -284,6 +284,28 @@ impl WorkspaceService { Ok(service) } + #[cfg(test)] + pub(crate) async fn new_for_test_path_manager(path_manager: Arc) -> Self { + path_manager + .initialize_user_directories() + .await + .expect("test user directories should initialize"); + let config = WorkspaceManagerConfig::default(); + let persistence = Arc::new( + PersistenceService::new_user_level(path_manager.clone()) + .await + .expect("test persistence should initialize"), + ); + let runtime_service = Arc::new(WorkspaceRuntimeService::new(path_manager.clone())); + Self { + manager: Arc::new(RwLock::new(WorkspaceManager::new(config.clone()))), + config, + persistence, + path_manager, + runtime_service, + } + } + /// Returns the path manager. pub fn path_manager(&self) -> &Arc { &self.path_manager @@ -318,17 +340,26 @@ impl WorkspaceService { preferred_ssh_host: Option<&str>, ) -> BitFunResult { let path_str = path.to_string_lossy().to_string(); - if let Some(known) = self + let known = self .find_known_remote_workspace_for_path( &path_str, preferred_connection_id, preferred_ssh_host, ) + .await; + self.open_workspace_after_known_resolution(path, known) .await - { + } + + pub(crate) async fn open_workspace_after_known_resolution( + &self, + path: PathBuf, + known_remote: Option, + ) -> BitFunResult { + let path_str = path.to_string_lossy().to_string(); + if let Some(known) = known_remote { return self.open_known_remote_workspace(&known).await; } - match self.open_workspace(path).await { Ok(info) => Ok(info), Err(error) => { @@ -385,7 +416,7 @@ impl WorkspaceService { result } - async fn find_known_remote_workspace_for_path( + pub(crate) async fn find_known_remote_workspace_for_path( &self, path: &str, preferred_connection_id: Option<&str>, @@ -2323,7 +2354,7 @@ pub fn get_global_workspace_service() -> Option> { mod tests { use super::*; use crate::agentic::persistence::PersistenceManager; - use crate::infrastructure::storage::{PersistenceService, StorageOptions}; + use crate::infrastructure::storage::StorageOptions; use crate::service::session::SessionMetadata; use crate::service::workspace::WorkspaceWorktreeInfo; use std::collections::HashMap; @@ -2361,26 +2392,7 @@ mod tests { } async fn build_test_workspace_service(path_manager: Arc) -> WorkspaceService { - path_manager - .initialize_user_directories() - .await - .expect("user directories should initialize"); - - let config = WorkspaceManagerConfig::default(); - let persistence = Arc::new( - PersistenceService::new_user_level(path_manager.clone()) - .await - .expect("persistence should initialize"), - ); - let runtime_service = Arc::new(WorkspaceRuntimeService::new(path_manager.clone())); - - WorkspaceService { - manager: Arc::new(RwLock::new(WorkspaceManager::new(config.clone()))), - config, - persistence, - path_manager, - runtime_service, - } + WorkspaceService::new_for_test_path_manager(path_manager).await } #[tokio::test] diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index de83d22cd2..0b9312097b 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -40,7 +40,7 @@ use bitfun_services_integrations::remote_connect::{ RemoteWorkspaceKind as RemoteConnectWorkspaceKind, RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, }; -use log::{debug, error, info}; +use log::{debug, info}; use std::sync::Arc; use std::time::Duration; @@ -62,6 +62,18 @@ fn current_workspace_path() -> Option { .and_then(|service| service.try_get_current_workspace_path()) } +fn session_storage_request_from_binding(binding: &WorkspaceBinding) -> SessionStoragePathRequest { + SessionStoragePathRequest { + workspace_path: binding.logical_workspace_path().to_path_buf(), + remote_connection_id: binding.connection_id().map(ToOwned::to_owned), + remote_ssh_host: if binding.is_remote() { + Some(binding.session_identity.hostname.clone()).filter(|value| !value.trim().is_empty()) + } else { + None + }, + } +} + fn remote_workspace_kind( kind: crate::service::workspace::WorkspaceKind, ) -> RemoteConnectWorkspaceKind { @@ -125,21 +137,20 @@ async fn open_workspace_with_snapshot( remote_connection_id: Option<&str>, remote_ssh_host: Option<&str>, ) -> Result { + let coordinator = get_global_coordinator() + .ok_or_else(|| "Conversation coordinator not initialized".to_string())?; let workspace_service = crate::service::workspace::get_global_workspace_service() .ok_or_else(|| "Workspace service not available".to_string())?; - let path_buf = std::path::PathBuf::from(path); - let info = workspace_service - .open_workspace_resolving_known(path_buf, remote_connection_id, remote_ssh_host) + let info = coordinator + .open_workspace_with_runtime_ownership( + workspace_service.as_ref(), + std::path::PathBuf::from(path), + remote_connection_id, + remote_ssh_host, + snapshot_log_context, + ) .await .map_err(|error| error.to_string())?; - 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}"); - } let remote_connection_id = info.remote_ssh_connection_id().map(str::to_string); let remote_ssh_host = info .metadata @@ -326,10 +337,12 @@ async fn resolve_session_model_id(session_id: &str) -> Option { let session_storage_dir = CoreServiceAgentRuntime::resolve_session_storage_dir(session_id).await?; coordinator - .restore_session_from_storage_path(&session_storage_dir, session_id) + .restore_session_view_from_storage_path_timed(&session_storage_dir, session_id) .await .ok() - .and_then(|session| normalize_remote_session_model_id(session.config.model_id.as_deref())) + .and_then(|(session, _, _)| { + normalize_remote_session_model_id(session.config.model_id.as_deref()) + }) } fn core_dialog_submission_policy(policy: RemoteDialogSubmissionPolicy) -> DialogSubmissionPolicy { @@ -855,14 +868,16 @@ impl CoreServiceAgentRuntime { .get_session(session_id) .is_none() { - let Some(session_storage_dir) = Self::resolve_session_storage_dir(session_id).await - else { + let Some(binding) = Self::resolve_session_workspace_binding(session_id).await else { return Err(format!( - "Session storage directory not available for session: {session_id}" + "Session workspace binding not available for session: {session_id}" )); }; coordinator - .restore_session_from_storage_path(&session_storage_dir, session_id) + .restore_session_for_workspace( + session_storage_request_from_binding(&binding), + session_id, + ) .await .map_err(|e| format!("Failed to restore session: {e}"))?; } @@ -1432,26 +1447,18 @@ impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { session_id: &str, workspace: RemoteDialogWorkspaceBinding, ) -> Result<(), String> { - if let Some(session_storage_dir) = - CoreServiceAgentRuntime::resolve_session_storage_dir(session_id).await - { - self.coordinator - .restore_session_from_storage_path(&session_storage_dir, session_id) - .await - } else { - self.coordinator - .restore_session_for_workspace( - SessionStoragePathRequest { - workspace_path: std::path::PathBuf::from(workspace.workspace_path), - remote_connection_id: workspace.remote_connection_id, - remote_ssh_host: workspace.remote_ssh_host, - }, - session_id, - ) - .await - } - .map(|_| ()) - .map_err(|e| e.to_string()) + self.coordinator + .restore_session_for_workspace( + SessionStoragePathRequest { + workspace_path: std::path::PathBuf::from(workspace.workspace_path), + remote_connection_id: workspace.remote_connection_id, + remote_ssh_host: workspace.remote_ssh_host, + }, + session_id, + ) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) } fn prewarm_remote_terminal(&self, request: RemoteTerminalPrewarmRequest) { @@ -1699,16 +1706,19 @@ impl RemoteSessionRuntimeHost for CoreRemoteSessionRuntimeHost { return Ok(()); } - let Some(session_storage_dir) = - CoreServiceAgentRuntime::resolve_session_storage_dir(session_id).await + let Some(binding) = + CoreServiceAgentRuntime::resolve_session_workspace_binding(session_id).await else { return Err(format!( - "Session storage directory not available for session: {}", + "Session workspace binding not available for session: {}", session_id )); }; self.coordinator - .restore_session_from_storage_path(&session_storage_dir, session_id) + .restore_session_for_workspace( + session_storage_request_from_binding(&binding), + session_id, + ) .await .map(|_| ()) .map_err(|error| format!("Failed to restore session: {error}")) @@ -1738,6 +1748,22 @@ impl RemoteSessionRuntimeHost for CoreRemoteSessionRuntimeHost { session_storage_dir: &std::path::Path, session_id: &str, ) -> Result<(), String> { + let binding = CoreServiceAgentRuntime::resolve_session_workspace_binding(session_id) + .await + .ok_or_else(|| { + format!("Session workspace binding not available for session: {session_id}") + })?; + self.coordinator + .ensure_workspace_runtime_ownership( + binding.logical_workspace_path(), + binding.connection_id(), + if binding.is_remote() { + Some(binding.session_identity.hostname.as_str()) + } else { + None + }, + ) + .map_err(|error| error.to_string())?; self.coordinator .delete_session(session_storage_dir, session_id) .await @@ -1818,13 +1844,18 @@ impl RemoteCancelRuntimeHost for CoreRemoteCancelRuntimeHost { async fn restore_remote_session( &self, session_id: &str, - restore_path_hint: &str, + _restore_path_hint: &str, ) -> Result<(), String> { - let restore_path = CoreServiceAgentRuntime::resolve_session_storage_dir(session_id) + let binding = CoreServiceAgentRuntime::resolve_session_workspace_binding(session_id) .await - .unwrap_or_else(|| std::path::PathBuf::from(restore_path_hint)); + .ok_or_else(|| { + format!("Session workspace binding not available for session: {session_id}") + })?; self.coordinator - .restore_session_from_storage_path(&restore_path, session_id) + .restore_session_for_workspace( + session_storage_request_from_binding(&binding), + session_id, + ) .await .map(|_| ()) .map_err(|error| error.to_string()) @@ -1875,6 +1906,69 @@ mod tests { assert_runtime_ports::(); } + #[test] + fn remote_attach_and_mutation_paths_preserve_workspace_ownership_facts() { + let source = include_str!("service_agent_runtime.rs"); + let open_workspace = source + .split("async fn open_workspace_with_snapshot") + .nth(1) + .and_then(|source| { + source + .split("async fn load_remote_session_metadata_for_workspace") + .next() + }) + .expect("remote workspace open helper"); + assert!(open_workspace.contains("open_workspace_with_runtime_ownership")); + assert!(!open_workspace.contains("open_workspace_resolving_known")); + assert!(!open_workspace.contains("initialize_snapshot_manager_for_workspace")); + + for (start, end) in [ + ("pub(crate) async fn update_remote_session_model", "/// Persist the shared selector"), + ("async fn restore_remote_session(\n &self,\n session_id: &str,\n workspace: RemoteDialogWorkspaceBinding", "fn prewarm_remote_terminal"), + ("async fn ensure_session_loaded(&self, session_id: &str)", "async fn update_session_title"), + ("async fn restore_remote_session(\n &self,\n session_id: &str,\n _restore_path_hint: &str", "async fn cancel_remote_turn"), + ] { + let body = source + .split(start) + .nth(1) + .and_then(|source| source.split(end).next()) + .expect("reviewed remote runtime method"); + assert!( + body.contains("restore_session_for_workspace"), + "remote attach or mutation must use structured workspace facts" + ); + assert!( + !body.contains("restore_session_from_storage_path"), + "remote attach or mutation must not bypass the Coordinator ownership gate" + ); + } + + let remote_session_host = source + .split("impl RemoteSessionRuntimeHost for CoreRemoteSessionRuntimeHost") + .nth(1) + .and_then(|source| source.split("impl RemotePollRuntimeHost").next()) + .expect("remote session host implementation"); + let delete = remote_session_host + .split("async fn delete_session") + .nth(1) + .and_then(|source| source.split("fn remove_tracker").next()) + .expect("remote session delete"); + assert!(delete.contains("ensure_workspace_runtime_ownership")); + } + + #[test] + fn remote_model_lookup_keeps_read_only_restore_lock_free() { + let source = include_str!("service_agent_runtime.rs"); + let body = source + .split("async fn resolve_session_model_id") + .nth(1) + .and_then(|source| source.split("fn core_dialog_submission_policy").next()) + .expect("remote model lookup"); + + assert!(body.contains("restore_session_view_from_storage_path_timed")); + assert!(!body.contains("restore_session_from_storage_path")); + } + #[test] fn remote_generated_turn_ids_are_uuid_unique() { let ids = (0..1_024) diff --git a/src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs b/src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs index 982c0e8ce2..b3f3bff1de 100644 --- a/src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs +++ b/src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs @@ -54,11 +54,11 @@ impl bitfun_runtime_ports::AgentSubmissionPort for ProductShapeSubmissionPort { &self, request: bitfun_runtime_ports::AgentSessionCreateRequest, ) -> PortResult { - Ok(bitfun_runtime_ports::AgentSessionCreateResult { - session_id: "product-shape-session".to_string(), - session_name: request.session_name, - agent_type: request.agent_type, - }) + Ok(bitfun_runtime_ports::AgentSessionCreateResult::new( + "product-shape-session", + request.session_name, + request.agent_type, + )) } async fn submit_message( diff --git a/src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs b/src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs index 69434608dd..d03fee46d8 100644 --- a/src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs +++ b/src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs @@ -26,11 +26,11 @@ impl AgentSubmissionPort for ProductSdkAgentProvider { request: AgentSessionCreateRequest, ) -> PortResult { self.created_sessions.lock().unwrap().push(request.clone()); - Ok(AgentSessionCreateResult { - session_id: "product-sdk-session".to_string(), - session_name: request.session_name, - agent_type: request.agent_type, - }) + Ok(AgentSessionCreateResult::new( + "product-sdk-session", + request.session_name, + request.agent_type, + )) } async fn submit_message( diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 3583a362a3..a2148dbdfc 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -13,8 +13,7 @@ use tokio_util::sync::CancellationToken; pub use bitfun_core_types::{ SessionExecutionTarget, SessionExecutionTargetKind, SessionExecutionTargetRequest, - WorktreeError, WorktreeErrorCode, WorktreeLifecycle, WorktreeSettings, - WorktreeSummary, + WorktreeError, WorktreeErrorCode, WorktreeLifecycle, WorktreeSettings, WorktreeSummary, }; mod local_workspace_snapshot; @@ -996,6 +995,32 @@ pub struct AgentSessionCreateResult { #[serde(default)] pub session_name: String, pub agent_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_target: Option, +} + +impl AgentSessionCreateResult { + pub fn new( + session_id: impl Into, + session_name: impl Into, + agent_type: impl Into, + ) -> Self { + Self { + session_id: session_id.into(), + session_name: session_name.into(), + agent_type: agent_type.into(), + workspace_path: None, + workspace_id: None, + project_workspace_path: None, + execution_target: None, + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -2392,6 +2417,63 @@ mod tests { assert!(json.get("modelId").is_none()); } + #[test] + fn agent_session_create_result_keeps_legacy_payload_shape() { + let legacy = serde_json::json!({ + "sessionId": "session_1", + "sessionName": "Main", + "agentType": "agentic" + }); + + let result: AgentSessionCreateResult = + serde_json::from_value(legacy.clone()).expect("deserialize legacy create result"); + + assert_eq!(result.workspace_path, None); + assert_eq!(result.workspace_id, None); + assert_eq!(result.project_workspace_path, None); + assert_eq!(result.execution_target, None); + assert_eq!( + serde_json::to_value(result).expect("serialize legacy create result"), + legacy + ); + } + + #[test] + fn agent_session_create_result_carries_normalized_workspace_facts() { + let result: AgentSessionCreateResult = serde_json::from_value(serde_json::json!({ + "sessionId": "session_1", + "sessionName": "Main", + "agentType": "agentic", + "workspacePath": "/worktrees/session_1", + "workspaceId": "workspace_1", + "projectWorkspacePath": "/workspace/project", + "executionTarget": { + "kind": "managedWorktree", + "worktreeId": "worktree_1", + "rootPath": "/worktrees/session_1", + "baseRef": "main", + "baseCommit": "0123456789abcdef", + "branch": "bitfun/session_1", + "lifecycle": "managed" + } + })) + .expect("deserialize complete create result"); + + assert_eq!( + result.workspace_path.as_deref(), + Some("/worktrees/session_1") + ); + assert_eq!(result.workspace_id.as_deref(), Some("workspace_1")); + assert_eq!( + result.project_workspace_path.as_deref(), + Some("/workspace/project") + ); + let target = result.execution_target.expect("resolved execution target"); + assert_eq!(target.kind, SessionExecutionTargetKind::ManagedWorktree); + assert_eq!(target.worktree_id.as_deref(), Some("worktree_1")); + assert_eq!(target.root_path, "/worktrees/session_1"); + } + #[test] fn port_error_display_keeps_kind_and_message() { let error = PortError::new(PortErrorKind::NotAvailable, "coordinator missing"); diff --git a/src/crates/execution/agent-runtime/examples/sdk_minimal.rs b/src/crates/execution/agent-runtime/examples/sdk_minimal.rs index fe1640c839..7e405cd267 100644 --- a/src/crates/execution/agent-runtime/examples/sdk_minimal.rs +++ b/src/crates/execution/agent-runtime/examples/sdk_minimal.rs @@ -21,11 +21,11 @@ impl AgentSubmissionPort for ExampleAgentProvider { request: AgentSessionCreateRequest, ) -> PortResult { self.created_sessions.lock().unwrap().push(request.clone()); - Ok(AgentSessionCreateResult { - session_id: "example-session".to_string(), - session_name: request.session_name, - agent_type: request.agent_type, - }) + Ok(AgentSessionCreateResult::new( + "example-session", + request.session_name, + request.agent_type, + )) } async fn submit_message( diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index 40b4d218cf..7443e98daf 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -1616,11 +1616,11 @@ mod tests { request: AgentSessionCreateRequest, ) -> PortResult { self.created_sessions.lock().unwrap().push(request.clone()); - Ok(AgentSessionCreateResult { - session_id: "session_1".to_string(), - session_name: request.session_name, - agent_type: request.agent_type, - }) + Ok(AgentSessionCreateResult::new( + "session_1", + request.session_name, + request.agent_type, + )) } async fn create_session_with_id( @@ -1629,16 +1629,15 @@ mod tests { request: AgentSessionCreateRequest, ) -> PortResult { self.created_sessions.lock().unwrap().push(request.clone()); - Ok(AgentSessionCreateResult { - session_id: self - .exact_session_result_id + Ok(AgentSessionCreateResult::new( + self.exact_session_result_id .lock() .unwrap() .clone() .unwrap_or(session_id), - session_name: request.session_name, - agent_type: request.agent_type, - }) + request.session_name, + request.agent_type, + )) } async fn submit_message( diff --git a/src/crates/execution/agent-runtime/src/session.rs b/src/crates/execution/agent-runtime/src/session.rs index dd730aa60d..7520589f19 100644 --- a/src/crates/execution/agent-runtime/src/session.rs +++ b/src/crates/execution/agent-runtime/src/session.rs @@ -135,6 +135,17 @@ impl Session { } } +impl From for bitfun_runtime_ports::AgentSessionCreateResult { + fn from(session: Session) -> Self { + let mut result = Self::new(session.session_id, session.session_name, session.agent_type); + result.workspace_path = session.config.workspace_path; + result.workspace_id = session.config.workspace_id; + result.project_workspace_path = session.config.project_workspace_path; + result.execution_target = session.config.execution_target; + result + } +} + /// Session configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionConfig { @@ -279,6 +290,10 @@ mod tests { SessionConfig, SessionContinuationPolicy, SessionModelBindingPolicy, }; use crate::session_state::{ProcessingPhase, SessionState}; + use bitfun_core_types::{ + SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle, + }; + use bitfun_runtime_ports::AgentSessionCreateResult; use serde_json::json; #[test] @@ -357,6 +372,47 @@ mod tests { assert!(session.snapshot_session_id.is_none()); } + #[test] + fn session_create_result_preserves_normalized_workspace_facts() { + let execution_target = SessionExecutionTarget { + kind: SessionExecutionTargetKind::ManagedWorktree, + worktree_id: Some("worktree_1".to_string()), + root_path: "/worktrees/session_1".to_string(), + base_ref: Some("main".to_string()), + base_commit: Some("0123456789abcdef".to_string()), + branch: Some("bitfun/session_1".to_string()), + lifecycle: Some(WorktreeLifecycle::Managed), + }; + let session = Session::new_with_id( + "session_1".to_string(), + "Main".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some("/worktrees/session_1".to_string()), + workspace_id: Some("workspace_1".to_string()), + project_workspace_path: Some("/workspace/project".to_string()), + execution_target: Some(execution_target.clone()), + ..SessionConfig::default() + }, + ); + + let result = AgentSessionCreateResult::from(session); + + assert_eq!(result.session_id, "session_1"); + assert_eq!(result.session_name, "Main"); + assert_eq!(result.agent_type, "agentic"); + assert_eq!( + result.workspace_path.as_deref(), + Some("/worktrees/session_1") + ); + assert_eq!(result.workspace_id.as_deref(), Some("workspace_1")); + assert_eq!( + result.project_workspace_path.as_deref(), + Some("/workspace/project") + ); + assert_eq!(result.execution_target, Some(execution_target)); + } + #[test] fn persisted_session_state_sanitizes_processing_to_idle() { let sanitized = sanitize_persisted_session_state(&SessionState::Processing { diff --git a/src/crates/execution/agent-runtime/tests/sdk_smoke.rs b/src/crates/execution/agent-runtime/tests/sdk_smoke.rs index 0c635e78f5..2ac325300c 100644 --- a/src/crates/execution/agent-runtime/tests/sdk_smoke.rs +++ b/src/crates/execution/agent-runtime/tests/sdk_smoke.rs @@ -164,11 +164,11 @@ impl AgentSubmissionPort for FakeSdkAgentProvider { request: AgentSessionCreateRequest, ) -> PortResult { self.created_sessions.lock().unwrap().push(request.clone()); - Ok(AgentSessionCreateResult { - session_id: "sdk-session-1".to_string(), - session_name: request.session_name, - agent_type: request.agent_type, - }) + Ok(AgentSessionCreateResult::new( + "sdk-session-1", + request.session_name, + request.agent_type, + )) } async fn submit_message( diff --git a/src/crates/execution/agent-runtime/tests/session_model_sdk.rs b/src/crates/execution/agent-runtime/tests/session_model_sdk.rs index ccba67b853..293aa3e934 100644 --- a/src/crates/execution/agent-runtime/tests/session_model_sdk.rs +++ b/src/crates/execution/agent-runtime/tests/session_model_sdk.rs @@ -17,11 +17,11 @@ impl AgentSubmissionPort for FakeSubmissionPort { &self, request: AgentSessionCreateRequest, ) -> PortResult { - Ok(AgentSessionCreateResult { - session_id: "session-1".to_string(), - session_name: request.session_name, - agent_type: request.agent_type, - }) + Ok(AgentSessionCreateResult::new( + "session-1", + request.session_name, + request.agent_type, + )) } async fn submit_message( diff --git a/src/crates/execution/agent-runtime/tests/session_operation_ports.rs b/src/crates/execution/agent-runtime/tests/session_operation_ports.rs index 7424d83168..268937a38f 100644 --- a/src/crates/execution/agent-runtime/tests/session_operation_ports.rs +++ b/src/crates/execution/agent-runtime/tests/session_operation_ports.rs @@ -18,11 +18,11 @@ impl AgentSubmissionPort for FakeSubmissionPort { &self, request: AgentSessionCreateRequest, ) -> PortResult { - Ok(AgentSessionCreateResult { - session_id: "session-1".to_string(), - session_name: request.session_name, - agent_type: request.agent_type, - }) + Ok(AgentSessionCreateResult::new( + "session-1", + request.session_name, + request.agent_type, + )) } async fn submit_message( diff --git a/src/crates/interfaces/sdk-host/src/host.rs b/src/crates/interfaces/sdk-host/src/host.rs index 828f97c20a..3df96d4154 100644 --- a/src/crates/interfaces/sdk-host/src/host.rs +++ b/src/crates/interfaces/sdk-host/src/host.rs @@ -1531,12 +1531,7 @@ impl SdkHostConnection { let delivered = connection .send_success( request_id, - SessionCreateResult { - session_id: created.session_id, - session_name: created.session_name, - agent: created.agent_type, - lifetime: SessionLifetime::Connection, - }, + SessionCreateResult::from_runtime(created, SessionLifetime::Connection), ) .await; if delivered { diff --git a/src/crates/interfaces/sdk-host/src/protocol.rs b/src/crates/interfaces/sdk-host/src/protocol.rs index cde91ea0b0..5cb055f0ea 100644 --- a/src/crates/interfaces/sdk-host/src/protocol.rs +++ b/src/crates/interfaces/sdk-host/src/protocol.rs @@ -1,5 +1,7 @@ //! Versioned JSON-RPC contracts for the local SDK Host. +use bitfun_core_types::SessionExecutionTarget; +use bitfun_runtime_ports::AgentSessionCreateResult; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -333,6 +335,29 @@ pub struct SessionCreateResult { pub session_name: String, pub agent: String, pub lifetime: SessionLifetime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_target: Option, +} + +impl SessionCreateResult { + pub fn from_runtime(created: AgentSessionCreateResult, lifetime: SessionLifetime) -> Self { + Self { + session_id: created.session_id, + session_name: created.session_name, + agent: created.agent_type, + lifetime, + workspace_path: created.workspace_path, + workspace_id: created.workspace_id, + project_workspace_path: created.project_workspace_path, + execution_target: created.execution_target, + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -467,3 +492,32 @@ impl QueryResultError { fn empty_object() -> serde_json::Value { serde_json::Value::Object(serde_json::Map::new()) } + +#[cfg(test)] +mod tests { + use super::{SessionCreateResult, SessionLifetime}; + use bitfun_core_types::SessionExecutionTarget; + use bitfun_runtime_ports::AgentSessionCreateResult; + + #[test] + fn session_create_result_preserves_runtime_placement_facts() { + let mut created = AgentSessionCreateResult::new("session_1", "Main", "agentic"); + created.workspace_path = Some("/worktrees/session_1".to_string()); + created.workspace_id = Some("workspace_1".to_string()); + created.project_workspace_path = Some("/workspace/project".to_string()); + created.execution_target = Some(SessionExecutionTarget::local("/worktrees/session_1")); + + let result = SessionCreateResult::from_runtime(created, SessionLifetime::Connection); + let json = serde_json::to_value(result).expect("serialize SDK Host create result"); + + assert_eq!(json["sessionId"], "session_1"); + assert_eq!(json["sessionName"], "Main"); + assert_eq!(json["agent"], "agentic"); + assert!(json.get("agentType").is_none()); + assert_eq!(json["workspacePath"], "/worktrees/session_1"); + assert_eq!(json["workspaceId"], "workspace_1"); + assert_eq!(json["projectWorkspacePath"], "/workspace/project"); + assert_eq!(json["executionTarget"]["kind"], "local"); + assert_eq!(json["lifetime"], "connection"); + } +} diff --git a/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs b/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs index bbd28f12a6..482f24c105 100644 --- a/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs +++ b/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs @@ -203,11 +203,11 @@ impl AgentSubmissionPort for FakeOwner { .lock() .unwrap() .push(session_id.clone()); - Ok(AgentSessionCreateResult { + Ok(AgentSessionCreateResult::new( session_id, - session_name: request.session_name, - agent_type: request.agent_type, - }) + request.session_name, + request.agent_type, + )) } async fn create_session_with_id( @@ -226,11 +226,11 @@ impl AgentSubmissionPort for FakeOwner { if self.panic_after_session_create { panic!("fixture panics after creating the transient Session"); } - Ok(AgentSessionCreateResult { + Ok(AgentSessionCreateResult::new( session_id, - session_name: request.session_name, - agent_type: request.agent_type, - }) + request.session_name, + request.agent_type, + )) } async fn create_transient_session_with_id( diff --git a/src/crates/services/services-core/src/json_store.rs b/src/crates/services/services-core/src/json_store.rs index c7666b2b91..013afe58f2 100644 --- a/src/crates/services/services-core/src/json_store.rs +++ b/src/crates/services/services-core/src/json_store.rs @@ -408,22 +408,13 @@ impl JsonFileStore { target_path: &Path, tmp_path: &Path, ) -> std::io::Result<()> { - use std::os::windows::ffi::OsStrExt; use windows::core::PCWSTR; use windows::Win32::Storage::FileSystem::{ MoveFileExW, ReplaceFileW, MOVEFILE_WRITE_THROUGH, REPLACEFILE_WRITE_THROUGH, }; - let temp = tmp_path - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - let target = target_path - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); + let temp = Self::windows_extended_path(tmp_path)?; + let target = Self::windows_extended_path(target_path)?; let result = unsafe { if target_path.exists() { ReplaceFileW( @@ -445,6 +436,34 @@ impl JsonFileStore { result.map_err(|error| std::io::Error::other(error.to_string())) } + #[cfg(windows)] + fn windows_extended_path(path: &Path) -> std::io::Result> { + use std::os::windows::ffi::OsStrExt; + + // `\\?\` disables Win32 normalization. Resolve separators plus dot + // segments before adding the prefix so both new and existing targets + // keep normal Path semantics at extended lengths. + let absolute = std::path::absolute(path)?; + let path = absolute.as_os_str().encode_wide().collect::>(); + let slash = b'\\' as u16; + let mut extended = if path.starts_with(&[slash, slash, b'?' as u16, slash]) + || path.starts_with(&[slash, slash, b'.' as u16, slash]) + { + path + } else if path.starts_with(&[slash, slash]) { + r"\\?\UNC\" + .encode_utf16() + .chain(path.into_iter().skip(2)) + .collect() + } else if path.len() >= 3 && path[1] == b':' as u16 && path[2] == slash { + r"\\?\".encode_utf16().chain(path).collect() + } else { + path + }; + extended.push(0); + Ok(extended) + } + #[cfg(not(windows))] async fn replace_file_from_temp_strict( target_path: &Path, diff --git a/src/crates/services/services-core/src/runtime_ownership.rs b/src/crates/services/services-core/src/runtime_ownership.rs index 597618ce40..25c37b4033 100644 --- a/src/crates/services/services-core/src/runtime_ownership.rs +++ b/src/crates/services/services-core/src/runtime_ownership.rs @@ -152,6 +152,19 @@ pub enum RuntimeOwnershipError { }, } +impl RuntimeOwnershipError { + /// Stable low-cardinality classification for product diagnostics and logs. + pub fn code(&self) -> &'static str { + match self { + Self::InvalidProductIdentity => "invalid_product_identity", + Self::CanonicalizeWorkspace { .. } => "canonicalize_workspace_failed", + Self::CreateOwnershipDirectory { .. } => "ownership_root_create_failed", + Self::OpenLockFile { .. } => "ownership_lock_open_failed", + Self::OwnershipUnavailable { .. } => "runtime_ownership_unavailable", + } + } +} + impl fmt::Display for RuntimeDeployment { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(self.as_str()) diff --git a/src/crates/services/services-core/tests/json_store_contracts.rs b/src/crates/services/services-core/tests/json_store_contracts.rs index 6bf45fdc82..e1b637bd49 100644 --- a/src/crates/services/services-core/tests/json_store_contracts.rs +++ b/src/crates/services/services-core/tests/json_store_contracts.rs @@ -101,6 +101,49 @@ async fn json_store_creates_parent_dirs_and_round_trips_payload() { assert_eq!(loaded, Some(payload)); } +#[cfg(windows)] +#[tokio::test] +async fn strict_atomic_write_supports_extended_length_windows_paths() { + let root = TestTempDir::new("long-path"); + let segment = "snapshot-history-segment".repeat(5); + let path = root + .path() + .join(&segment) + .join(&segment) + .join("session.json"); + assert!(path.to_string_lossy().len() > 260); + let initial = TestPayload { + label: "persisted".to_string(), + count: 1, + }; + let forward_slash_path = PathBuf::from(path.to_string_lossy().replace('\\', "/")); + + JsonFileStore + .write_atomic_strict(&forward_slash_path, &initial) + .await + .expect("strict first write should normalize long forward-slash paths"); + + let alias_anchor = path.parent().unwrap().join("alias-anchor"); + std::fs::create_dir_all(&alias_anchor).expect("create long-path alias anchor"); + let aliased_path = alias_anchor.join("..").join("session.json"); + let replacement = TestPayload { + label: "replaced".to_string(), + count: 2, + }; + JsonFileStore + .write_atomic_strict(&aliased_path, &replacement) + .await + .expect("strict replacement should normalize dot segments"); + + assert_eq!( + JsonFileStore + .read_optional::(&path) + .await + .expect("long-path payload should be readable"), + Some(replacement) + ); +} + #[tokio::test] async fn json_store_reports_no_parent_directory() { let store = JsonFileStore; 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 e3cffd27b4..7e90d34e2f 100644 --- a/src/crates/services/services-core/tests/runtime_ownership_contracts.rs +++ b/src/crates/services/services-core/tests/runtime_ownership_contracts.rs @@ -1,8 +1,52 @@ use bitfun_services_core::runtime_ownership::{ - RuntimeDeployment, RuntimeOwnershipKey, WorkspaceRuntimeOwnership, + RuntimeDeployment, RuntimeOwnershipError, RuntimeOwnershipKey, WorkspaceRuntimeOwnership, }; +use std::path::PathBuf; use tempfile::tempdir; +#[test] +fn ownership_errors_expose_stable_low_cardinality_codes() { + let io_error = || std::io::Error::other("fixture"); + let cases = [ + ( + RuntimeOwnershipError::InvalidProductIdentity, + "invalid_product_identity", + ), + ( + RuntimeOwnershipError::CanonicalizeWorkspace { + path: PathBuf::from("workspace"), + source: io_error(), + }, + "canonicalize_workspace_failed", + ), + ( + RuntimeOwnershipError::CreateOwnershipDirectory { + path: PathBuf::from("ownership"), + source: io_error(), + }, + "ownership_root_create_failed", + ), + ( + RuntimeOwnershipError::OpenLockFile { + path: PathBuf::from("ownership.lock"), + source: io_error(), + }, + "ownership_lock_open_failed", + ), + ( + RuntimeOwnershipError::OwnershipUnavailable { + deployment: RuntimeDeployment::Shared, + source: io_error(), + }, + "runtime_ownership_unavailable", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.code(), expected); + } +} + #[test] fn ownership_key_is_stable_and_scoped_by_workspace_and_product() { let first_workspace = tempdir().expect("first workspace"); diff --git a/src/web-ui/src/app/hooks/useSnapshot.ts b/src/web-ui/src/app/hooks/useSnapshot.ts index 6e4aa209cf..79f807359d 100644 --- a/src/web-ui/src/app/hooks/useSnapshot.ts +++ b/src/web-ui/src/app/hooks/useSnapshot.ts @@ -2,9 +2,10 @@ * Snapshot system data hook. */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { snapshotAPI } from '../../infrastructure/api'; import { createLogger } from '@/shared/utils/logger'; +import { isRemoteWorkspace } from '@/shared/types'; import { useI18n } from '@/infrastructure/i18n'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; @@ -76,7 +77,16 @@ export interface UseSnapshotReturn { export const useSnapshot = (): UseSnapshotReturn => { const { t } = useI18n('errors'); - const { workspacePath } = useCurrentWorkspace(); + const { workspace, workspacePath } = useCurrentWorkspace(); + const workspaceRemoteScope = useMemo(() => { + if (!workspace || !isRemoteWorkspace(workspace)) { + return {}; + } + return { + ...(workspace.connectionId ? { remoteConnectionId: workspace.connectionId } : {}), + ...(workspace.sshHost ? { remoteSshHost: workspace.sshHost } : {}), + }; + }, [workspace]); const [sessions, setSessions] = useState([]); const [operations, setOperations] = useState([]); const [stats, setStats] = useState(null); @@ -87,27 +97,33 @@ export const useSnapshot = (): UseSnapshotReturn => { const loadStats = useCallback(async () => { try { setError(null); - const statsData = await snapshotAPI.getSnapshotStats(workspacePath || undefined); + const statsData = await snapshotAPI.getSnapshotStats( + workspacePath || undefined, + workspaceRemoteScope, + ); setStats(statsData); } catch (err) { log.error('Failed to load snapshot stats', err); setError(t('snapshot.loadStatsFailed')); setStats(null); } - }, [t, workspacePath]); + }, [t, workspacePath, workspaceRemoteScope]); // Load snapshot sessions const loadSessions = useCallback(async () => { try { setError(null); - const sessionsData = await snapshotAPI.getSnapshotSessions(workspacePath || undefined); + const sessionsData = await snapshotAPI.getSnapshotSessions( + workspacePath || undefined, + workspaceRemoteScope, + ); setSessions(sessionsData || []); } catch (err) { log.error('Failed to load snapshot sessions', err); setError(t('snapshot.loadSessionsFailed')); setSessions([]); } - }, [t, workspacePath]); + }, [t, workspacePath, workspaceRemoteScope]); // Load session operations const loadSessionOperations = useCallback(async (sessionId: string) => { diff --git a/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.test.ts index 7f9a0fd6dc..b7a73f59dd 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SnapshotAPI } from './SnapshotAPI'; const invokeMock = vi.hoisted(() => vi.fn()); +const sessionsMock = vi.hoisted(() => new Map()); vi.mock('./ApiClient', () => ({ api: { @@ -9,12 +10,19 @@ vi.mock('./ApiClient', () => ({ }, })); +vi.mock('@/flow_chat/store/FlowChatStore', () => ({ + flowChatStore: { + getState: () => ({ sessions: sessionsMock }), + }, +})); + describe('SnapshotAPI request dedupe', () => { let snapshotAPI: SnapshotAPI; beforeEach(() => { snapshotAPI = new SnapshotAPI(); invokeMock.mockReset(); + sessionsMock.clear(); }); it('deduplicates concurrent session stats requests for the same session and workspace', async () => { @@ -59,4 +67,79 @@ describe('SnapshotAPI request dedupe', () => { expect(invokeMock).toHaveBeenCalledTimes(2); }); + + it('preserves the session remote binding on snapshot mutations', async () => { + sessionsMock.set('remote-session', { + workspacePath: '/srv/project', + remoteConnectionId: 'ssh:user@example.com:22', + remoteSshHost: 'example.com', + config: {}, + }); + invokeMock.mockResolvedValue(undefined); + + await snapshotAPI.rejectFileModifications( + 'remote-session', + 'src/main.rs', + '/srv/project', + ); + + expect(invokeMock).toHaveBeenCalledWith('reject_file', { + request: { + sessionId: 'remote-session', + filePath: 'src/main.rs', + workspacePath: '/srv/project', + remoteConnectionId: 'ssh:user@example.com:22', + remoteSshHost: 'example.com', + }, + }); + }); + + it('preserves the session remote binding on snapshot reads after disconnect', async () => { + sessionsMock.set('remote-session', { + workspacePath: 'D:/workspace/project', + remoteConnectionId: 'ssh:user@example.com:22', + remoteSshHost: 'example.com', + config: {}, + }); + invokeMock.mockResolvedValue({ + session_id: 'remote-session', + total_files: 0, + total_turns: 0, + total_changes: 0, + }); + + await snapshotAPI.getSessionStats('remote-session', 'D:/workspace/project'); + + expect(invokeMock).toHaveBeenCalledWith('get_session_stats', { + request: { + session_id: 'remote-session', + workspacePath: 'D:/workspace/project', + remoteConnectionId: 'ssh:user@example.com:22', + remoteSshHost: 'example.com', + }, + }); + }); + + it('does not treat a persisted localhost hostname as a remote session binding', async () => { + sessionsMock.set('local-history-session', { + workspacePath: 'D:/workspace/project', + remoteSshHost: 'localhost', + config: {}, + }); + invokeMock.mockResolvedValue({ + session_id: 'local-history-session', + total_files: 0, + total_turns: 0, + total_changes: 0, + }); + + await snapshotAPI.getSessionStats('local-history-session', 'D:/workspace/project'); + + expect(invokeMock).toHaveBeenCalledWith('get_session_stats', { + request: { + session_id: 'local-history-session', + workspacePath: 'D:/workspace/project', + }, + }); + }); }); diff --git a/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts index 1c772884d9..c9d024eb26 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts @@ -24,6 +24,33 @@ const requireSessionWorkspacePath = (sessionId: string, workspacePath?: string): return resolved; }; +interface SnapshotSessionScope { + workspacePath: string; + remoteConnectionId?: string; + remoteSshHost?: string; +} + +type SnapshotRemoteScope = Omit; + +const requireSessionSnapshotScope = ( + sessionId: string, + workspacePath?: string, +): SnapshotSessionScope => { + const session = flowChatStore.getState().sessions.get(sessionId); + const remoteConnectionId = session?.remoteConnectionId || session?.config?.remoteConnectionId; + const remoteSshHost = remoteConnectionId + ? session?.remoteSshHost || session?.config?.remoteSshHost + : undefined; + return { + workspacePath: requireSessionWorkspacePath(sessionId, workspacePath), + ...(remoteConnectionId ? { remoteConnectionId } : {}), + ...(remoteSshHost ? { remoteSshHost } : {}), + }; +}; + +const snapshotScopeKey = (scope: SnapshotSessionScope): string => + `${scope.workspacePath}:${scope.remoteConnectionId || ''}:${scope.remoteSshHost || ''}`; + export interface SandboxSessionModifications { hasModifications: boolean; @@ -162,10 +189,10 @@ export class SnapshotAPI { total_changes: number; }> { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); - const key = `get_session_stats:${resolvedWorkspacePath}:${sessionId}`; + const scope = requireSessionSnapshotScope(sessionId, workspacePath); + const key = `get_session_stats:${snapshotScopeKey(scope)}:${sessionId}`; return await this.dedupeInFlight(key, () => api.invoke('get_session_stats', { - request: { session_id: sessionId, workspacePath: resolvedWorkspacePath } + request: { session_id: sessionId, ...scope } })); } catch (error) { throw createTauriCommandError('get_session_stats', error, { sessionId, workspacePath }); @@ -175,10 +202,10 @@ export class SnapshotAPI { async getSessionFiles(sessionId: string, workspacePath?: string): Promise { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); - const key = `get_session_files:${resolvedWorkspacePath}:${sessionId}`; + const scope = requireSessionSnapshotScope(sessionId, workspacePath); + const key = `get_session_files:${snapshotScopeKey(scope)}:${sessionId}`; return await this.dedupeInFlight(key, () => api.invoke('get_session_files', { - request: { session_id: sessionId, workspacePath: resolvedWorkspacePath } + request: { session_id: sessionId, ...scope } })); } catch (error) { throw createTauriCommandError('get_session_files', error, { sessionId, workspacePath }); @@ -193,9 +220,9 @@ export class SnapshotAPI { workspacePath?: string, ): Promise { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); + const scope = requireSessionSnapshotScope(sessionId, workspacePath); return await api.invoke('get_operation_diff', { - request: { sessionId, filePath, operationId, workspacePath: resolvedWorkspacePath } + request: { sessionId, filePath, operationId, ...scope } }); } catch (error) { throw createTauriCommandError('get_operation_diff', error, { @@ -213,10 +240,10 @@ export class SnapshotAPI { workspacePath?: string, ): Promise { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); - const key = `get_session_file_diff_stats:${resolvedWorkspacePath}:${sessionId}:${filePath}`; + const scope = requireSessionSnapshotScope(sessionId, workspacePath); + const key = `get_session_file_diff_stats:${snapshotScopeKey(scope)}:${sessionId}:${filePath}`; return await this.dedupeInFlight(key, () => api.invoke('get_session_file_diff_stats', { - request: { sessionId, filePath, workspacePath: resolvedWorkspacePath }, + request: { sessionId, filePath, ...scope }, })); } catch (error) { throw createTauriCommandError('get_session_file_diff_stats', error, { @@ -233,10 +260,10 @@ export class SnapshotAPI { workspacePath?: string, ): Promise { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); - const key = `get_operation_summary:${resolvedWorkspacePath}:${sessionId}:${operationId}`; + const scope = requireSessionSnapshotScope(sessionId, workspacePath); + const key = `get_operation_summary:${snapshotScopeKey(scope)}:${sessionId}:${operationId}`; return await this.dedupeInFlight(key, () => api.invoke('get_operation_summary', { - request: { sessionId, operationId, workspacePath: resolvedWorkspacePath } + request: { sessionId, operationId, ...scope } })); } catch (error) { throw createTauriCommandError('get_operation_summary', error, { @@ -248,11 +275,15 @@ export class SnapshotAPI { } - async getBaselineSnapshotDiff(filePath: string, workspacePath?: string): Promise { + async getBaselineSnapshotDiff( + filePath: string, + workspacePath?: string, + remoteScope: SnapshotRemoteScope = {}, + ): Promise { try { const resolvedWorkspacePath = requireWorkspacePath(workspacePath); return await api.invoke('get_baseline_snapshot_diff', { - request: { filePath, workspacePath: resolvedWorkspacePath } + request: { filePath, workspacePath: resolvedWorkspacePath, ...remoteScope } }); } catch (error) { throw createTauriCommandError('get_baseline_snapshot_diff', error, { filePath, workspacePath }); @@ -264,9 +295,9 @@ export class SnapshotAPI { async acceptSessionModifications(sessionId: string, workspacePath?: string): Promise { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); + const scope = requireSessionSnapshotScope(sessionId, workspacePath); await api.invoke('accept_session', { - request: { sessionId, workspacePath: resolvedWorkspacePath } + request: { sessionId, ...scope } }); } catch (error) { throw createTauriCommandError('accept_session', error, { sessionId, workspacePath }); @@ -276,9 +307,9 @@ export class SnapshotAPI { async rejectSessionModifications(sessionId: string, workspacePath?: string): Promise { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); + const scope = requireSessionSnapshotScope(sessionId, workspacePath); await api.invoke('rollback_session', { - request: { sessionId, deleteSession: true, workspacePath: resolvedWorkspacePath } + request: { sessionId, deleteSession: true, ...scope } }); } catch (error) { throw createTauriCommandError('rollback_session', error, { sessionId, workspacePath }); @@ -292,9 +323,9 @@ export class SnapshotAPI { workspacePath?: string, ): Promise { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); + const scope = requireSessionSnapshotScope(sessionId, workspacePath); await api.invoke('accept_file', { - request: { sessionId, filePath, workspacePath: resolvedWorkspacePath } + request: { sessionId, filePath, ...scope } }); } catch (error) { throw createTauriCommandError('accept_file', error, { sessionId, filePath, workspacePath }); @@ -308,9 +339,9 @@ export class SnapshotAPI { workspacePath?: string, ): Promise { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); + const scope = requireSessionSnapshotScope(sessionId, workspacePath); await api.invoke('reject_file', { - request: { sessionId, filePath, workspacePath: resolvedWorkspacePath } + request: { sessionId, filePath, ...scope } }); } catch (error) { throw createTauriCommandError('reject_file', error, { sessionId, filePath, workspacePath }); @@ -346,9 +377,9 @@ export class SnapshotAPI { workspacePath?: string, ): Promise { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); + const scope = requireSessionSnapshotScope(sessionId, workspacePath); await api.invoke('accept_operation', { - request: { sessionId, operationId, workspacePath: resolvedWorkspacePath } + request: { sessionId, operationId, ...scope } }); } catch (error) { throw createTauriCommandError('accept_operation', error, { sessionId, operationId, workspacePath }); @@ -362,9 +393,9 @@ export class SnapshotAPI { workspacePath?: string, ): Promise { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); + const scope = requireSessionSnapshotScope(sessionId, workspacePath); await api.invoke('reject_operation', { - request: { sessionId, operationId, workspacePath: resolvedWorkspacePath } + request: { sessionId, operationId, ...scope } }); } catch (error) { throw createTauriCommandError('reject_operation', error, { sessionId, operationId, workspacePath }); @@ -374,9 +405,9 @@ export class SnapshotAPI { async rollbackSession(sessionId: string, workspacePath?: string): Promise { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); + const scope = requireSessionSnapshotScope(sessionId, workspacePath); await api.invoke('rollback_session', { - request: { sessionId, workspacePath: resolvedWorkspacePath } + request: { sessionId, ...scope } }); } catch (error) { throw createTauriCommandError('rollback_session', error, { sessionId, workspacePath }); @@ -394,11 +425,14 @@ export class SnapshotAPI { } - async getSnapshotStats(workspacePath?: string): Promise { + async getSnapshotStats( + workspacePath?: string, + remoteScope: SnapshotRemoteScope = {}, + ): Promise { try { const resolvedWorkspacePath = requireWorkspacePath(workspacePath); return await api.invoke('get_snapshot_system_stats', { - request: { workspacePath: resolvedWorkspacePath } + request: { workspacePath: resolvedWorkspacePath, ...remoteScope } }); } catch (error) { throw createTauriCommandError('get_snapshot_system_stats', error, { workspacePath }); @@ -406,11 +440,14 @@ export class SnapshotAPI { } - async getSnapshotSessions(workspacePath?: string): Promise { + async getSnapshotSessions( + workspacePath?: string, + remoteScope: SnapshotRemoteScope = {}, + ): Promise { try { const resolvedWorkspacePath = requireWorkspacePath(workspacePath); return await api.invoke('get_snapshot_sessions', { - request: { workspacePath: resolvedWorkspacePath } + request: { workspacePath: resolvedWorkspacePath, ...remoteScope } }); } catch (error) { throw createTauriCommandError('get_snapshot_sessions', error, { workspacePath }); @@ -420,9 +457,9 @@ export class SnapshotAPI { async getSessionOperations(sessionId: string, workspacePath?: string): Promise { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); + const scope = requireSessionSnapshotScope(sessionId, workspacePath); return await api.invoke('get_session_operations', { - request: { sessionId, workspacePath: resolvedWorkspacePath } + request: { sessionId, ...scope } }); } catch (error) { throw createTauriCommandError('get_session_operations', error, { sessionId, workspacePath }); @@ -439,12 +476,12 @@ export class SnapshotAPI { workspacePath?: string, ): Promise { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); + const scope = requireSessionSnapshotScope(sessionId, workspacePath); await api.invoke('record_turn_snapshot', { session_id: sessionId, turn_index: turnIndex, modified_files: modifiedFiles, - workspacePath: resolvedWorkspacePath, + ...scope, }); } catch (error) { throw createTauriCommandError('record_turn_snapshot', error, { @@ -464,13 +501,13 @@ export class SnapshotAPI { workspacePath?: string, ): Promise { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); + const scope = requireSessionSnapshotScope(sessionId, workspacePath); return await api.invoke('rollback_to_turn', { request: { session_id: sessionId, turn_index: turnIndex, delete_turns: deleteTurns, - workspacePath: resolvedWorkspacePath, + ...scope, } }); } catch (error) { @@ -490,12 +527,12 @@ export class SnapshotAPI { workspacePath?: string, ): Promise { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); + const scope = requireSessionSnapshotScope(sessionId, workspacePath); return await api.invoke('rollback_session', { request: { session_id: sessionId, delete_session: deleteSession, - workspacePath: resolvedWorkspacePath, + ...scope, } }); } catch (error) { @@ -509,12 +546,12 @@ export class SnapshotAPI { workspacePath?: string, ): Promise { try { - const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); + const scope = requireSessionSnapshotScope(sessionId, workspacePath); const turnIndices: number[] = await api.invoke('get_session_turns', { request: { session_id: sessionId, - workspacePath: resolvedWorkspacePath, + ...scope, } }); @@ -526,7 +563,7 @@ export class SnapshotAPI { request: { session_id: sessionId, turn_index: turnIndex, - workspacePath: resolvedWorkspacePath, + ...scope, } }); @@ -555,11 +592,15 @@ export class SnapshotAPI { } - async getFileChangeHistory(filePath: string, workspacePath?: string): Promise { + async getFileChangeHistory( + filePath: string, + workspacePath?: string, + remoteScope: SnapshotRemoteScope = {}, + ): Promise { try { const resolvedWorkspacePath = requireWorkspacePath(workspacePath); const result = await api.invoke('get_file_change_history', { - request: { file_path: filePath, workspacePath: resolvedWorkspacePath } + request: { file_path: filePath, workspacePath: resolvedWorkspacePath, ...remoteScope } }); return result as FileChangeEntry[]; } catch (error) { @@ -568,11 +609,14 @@ export class SnapshotAPI { } - async getAllModifiedFiles(workspacePath?: string): Promise { + async getAllModifiedFiles( + workspacePath?: string, + remoteScope: SnapshotRemoteScope = {}, + ): Promise { try { const resolvedWorkspacePath = requireWorkspacePath(workspacePath); return await api.invoke('get_all_modified_files', { - request: { workspacePath: resolvedWorkspacePath } + request: { workspacePath: resolvedWorkspacePath, ...remoteScope } }); } catch (error) { throw createTauriCommandError('get_all_modified_files', error, { workspacePath });