diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index f55b70844..bb8c5c521 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -65,14 +65,14 @@ flowchart TB | 范围 | 当前状态 | |---|---| | Embedded Desktop GUI | 继续使用 Desktop 事件投影和 Tauri adapter;按实际打开的本机 workspace 延迟取得并持有 Embedded ownership,不增加后台进程;目标迁入同进程私有 App Server | -| Embedded interactive TUI | 已组装同进程私有 App Server,通过 in-memory transport、`AppServerClient` 和 `AppServerTuiBackend` 完成当前核心聊天与 Session 路径;剩余管理面继续迁移 | +| Embedded interactive TUI | 已组装同进程私有 App Server,通过 in-memory transport、`AppServerClient` 和 `AppServerTuiBackend` 完成当前核心聊天、Session 与 Phase 3/4 管理面路径 | | Embedded Headless CLI/Peer Host | 保留各自独立 Runtime adapter、展示和断流策略;不因交互式 TUI 迁移而强制使用 App Server | | ACP/SDK Host | 使用同一个 Runtime 事件入口的 session-scoped 订阅;各自协议和进程生命周期保持独立 | | Runtime ownership | Desktop、CLI、ACP、SDK Host 和现有 Server agent bootstrap 共用 Core owner;Embedded 取得共享锁,Shared TUI 取得独占锁,同一 workspace 上两种 deployment 互斥 | | Session 写入 | BitFun Runtime 的持久化 Session 由 `SessionManager` 管理;同一存储位置中的同一 Session 同时只允许一个本机进程写入,list/view 等只读操作不受影响 | | 当前 HTTP Server | 已组装 Embedded Runtime 和 `BitfunAppServer`,每个 `/ws` 连接通过 WebSocket transport 运行一条 App Server connection;当前固定 loopback、单用户且缺少连接级身份与作用域绑定,不构成远程或多用户 Server API | | Shared local IPC | 未发布的 v17 本机协议已有 discovery、实例锁、严格握手、Session 控制权、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI compatibility adapter;是否由 Shared App Server 替换仍待评审与等价证据 | -| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,删除未被控制的空闲非当前 Session,通过 `/fork` 从完整历史或选中提示词之前创建分支,重命名当前 Session,读取 transcript,通过 **View subagents** 只读查看当前根 Session 的子会话并定向取消子会话活动 Turn,切换当前 Session 的 Agent mode/model,通过 `/reload [skills|instructions]` 刷新声明式上下文,通过 `/compact` 或 `/summarize` 压缩当前 Session 上下文,在 Turn 空闲时通过 `/diff` 读取 Runtime 绑定工作区的只读差异,提交/取消 Turn,处理 Permission 和 UserInput;Model、Skill、Subagent 和 MCP 管理由 Shared CLI Host 显式装配 App Server 的具体 `AppManagementService` 保留,默认仍是 Embedded | +| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,删除未被控制的空闲非当前 Session,通过 `/fork` 从完整历史或选中提示词之前创建分支,重命名当前 Session,读取 transcript,通过 **View subagents** 只读查看当前根 Session 的子会话并定向取消子会话活动 Turn,切换当前 Session 的 Agent mode/model,通过 `/reload [skills|instructions]` 刷新声明式上下文,通过 `/compact` 或 `/summarize` 压缩当前 Session 上下文,在 Turn 空闲时通过 `/diff` 读取 Runtime 绑定工作区的只读差异,提交/取消 Turn,处理 Permission 和 UserInput;Model、Skill、Subagent、MCP、External Source V1 和 Hook 管理由 Shared CLI Host 显式装配 App Server 的具体 `AppManagementService` 保留;Account/Settings Sync、Worktree 和后续 External Application V2 未由当前 Shared Host 提供,默认仍是 Embedded | | Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;Replay、Observer、通用 Controller transfer 和 Session archive 同样不在当前协议中 | 因此当前交付的是 Embedded TUI App Server 与一条窄的、显式启用的 Shared TUI compatibility deployment,不是通用本机 Server。 @@ -519,7 +519,7 @@ Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理 - 当前入口使用第 1.1 节列出的 adapter;若第 1.2 节目标通过评审并迁移完成,Desktop GUI、Web UI 和交互式 TUI 才统一使用 App Server。 - Client、窗口、Session 或 workspace 数量不会自动等量增加 Runtime 或 Plugin Host 进程。 - 当前 Shared Runtime IPC 是第一方 TUI 的 private compatibility transport,不成为公开 SDK、Remote、Peer、HTTP 或浏览器协议;是否由 App Server Shared transport 替换仍待评审。 -- Shared TUI 的 Model、Skill、Subagent 和 MCP 管理暂由 CLI Host 显式装配的 App Server `AppManagementService` 承接;这不扩展 v17,不改变 Shared Runtime 对 Session/chat 的权威性,也不能用于 Remote workspace 的控制端本机回退。MCP service 的进程状态和 tool registry 只属于当前 CLI 进程,不即时重配已经运行的 Shared Runtime Host;跨进程 MCP 管理需要单独的同步/restart contract。 +- Shared TUI 的 Model、Skill、Subagent、MCP、External Source V1 和 Hook 管理暂由 CLI Host 显式装配的 App Server `AppManagementService` 承接;Account/Settings Sync、Worktree 和后续 External Application V2 未由当前 Shared Host 提供并返回 typed unsupported。这不扩展 v17,不改变 Shared Runtime 对 Session/chat 的权威性,也不能用于 Remote workspace 的控制端本机回退。MCP service 的进程状态和 tool registry 只属于当前 CLI 进程,不即时重配已经运行的 Shared Runtime Host;跨进程 MCP 管理需要单独的同步/restart contract。 - 默认 GUI/TUI/Headless CLI、ACP 与 SDK Host 保持 Embedded;只有交互式 TUI 的显式 `--shared` 选择 Shared。互斥按 `workspace + product` 生效,不再按入口名称缩窄。 - Account/session cloud sync 仍使用既有 Core compatibility 边界,不属于 Shared Runtime 支持。 - Remote workspace 的文件、凭据、进程和 Runtime 位于目标执行域,禁止静默回落本机。 diff --git a/docs/plans/tui-app-server-decoupling-refactor-plan.md b/docs/plans/tui-app-server-decoupling-refactor-plan.md index 8d7f9f088..dbd5dfc96 100644 --- a/docs/plans/tui-app-server-decoupling-refactor-plan.md +++ b/docs/plans/tui-app-server-decoupling-refactor-plan.md @@ -1,8 +1,8 @@ # TUI 与 App Server 解耦重构计划 -> 状态:Phase 0-3 已完成当前定义的边界、协议基础、核心聊天和配置管理迁移;Phase 4 尚未开始,Phase 5 目标待评审。 +> 状态:Phase 0-4 已完成当前定义的边界、协议基础、核心聊天、配置管理和外部集成接口迁移;Phase 5 Shared App Server 目标待评审。 > -> 当前状态基线:2026-08-06。一次性的运行证据保留在对应 PR/Actions 记录中;本文不绑定会因 rebase 失效的提交 SHA。 +> 当前状态基线:2026-08-09。一次性的运行证据保留在对应 PR/Actions 记录中;本文不绑定会因 rebase 失效的提交 SHA。 > > 本文只记录当前差距、阶段和完成证据。稳定架构约束见相邻架构文档;Phase 0 的历史盘点已失效,不再作为当前能力清单。 @@ -58,7 +58,9 @@ Shared TUI (--shared) 两条路径统一的是 TUI 可见的行为端口。Shared compatibility adapter 会把 Runtime IPC 的结果和事件映射为 `TuiBackend` 使用的类型,但它没有运行 `BitfunAppServer`,也不是 Shared App Server transport。 -Phase 3 已将 Mode/Model、Skill、Subagent 和 MCP 管理面迁移到 `TuiBackend` 的 owner-specific typed API。具体 DTO/owner 适配由 App Server 的 `AppManagementService` 持有,并由 Host 显式装配;Embedded TUI 经 App Server 访问既有 owner。Shared 的 Session/chat/mode authority 继续映射 v17,Model、Skill、Subagent 和 MCP 则由 `SharedTuiBackend` 委托同一个具体 management service。该兼容路径保留迁移前的本机同用户产品行为,不扩展 v17 wire,也不适用于 Remote workspace。Phase 4 的 Hook、外部来源、Account、Settings Sync 和 Worktree 管理面仍可能通过 Host 中的 compatibility 路径完成,它们是当前剩余差距,不能据 Phase 3 完成状态宣称整个 TUI 已解耦。 +Phase 3 已将 Mode/Model、Skill、Subagent 和 MCP 管理面迁移到 `TuiBackend` 的 owner-specific typed API。Phase 4 进一步迁移了 External Source、native/external Hook、Account、Settings Sync 和 Worktree 管理面。具体 DTO/owner 适配由 App Server 的 `AppManagementService` 持有,并由 Host 显式装配;Embedded TUI 经 App Server 访问既有 owner,TUI controller 不再直接访问这些 compatibility owner。 + +Shared 的 Session/chat/mode authority 继续映射 v17;Host 实际提供的本机管理 capability 由 `SharedTuiBackend` 委托同一个具体 management service。当前 Shared Host 提供 Phase 4 的 External Source V1 和 Hook 管理,但不注入 Account/Settings Sync 或 Worktree owner;这些能力返回 typed unsupported。Remote workspace 对所有 controller-local management capability fail closed,不回落到控制端本机。Phase 4 完成表示接口边界已迁移,不表示所有 deployment 的 capability 完全相同。Phase 4 之后新增的 External Application V2 控制面目前只在 Embedded App Server 接线,Shared Runtime 明确 unsupported,不重新打开 Phase 4 的旧 owner 直连预算。 ### 2.2 Proposed target @@ -132,10 +134,10 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll | Mode/Model 管理 | Embedded 经 typed mode catalog 和 model list/get/add/update/delete/default API;read DTO 只含 secret configured metadata,mutation 使用 preserve/replace/clear | Phase 3 已完成;Shared mode catalog 来自 Runtime Host,model 管理由 Host 装配的 App Server management service 转发,Session model mutation 仍由 v17 owner 提交 | | Skill/Subagent | TUI 经 typed list/toggle API 消费 visible/manageable read model;App Server management service 委托既有 registry owner | Phase 3 已完成;Embedded 与 Shared 共用具体 service,Shared capability 明确属于本机 CLI compatibility scope | | MCP | 当前 TUI 用例经 typed catalog/status/toggle/add/delete/external decision/conflict API;read projection 与 Debug 输出不暴露凭据 | Phase 3 已完成当前定义;Shared 通过当前 CLI 进程的本地 MCP compatibility service 保留迁移前管理行为。该 service 的 MCP 进程状态和 tool registry 不会即时重配已经运行的 Shared Runtime Host;要取得 Host 侧新状态仍需显式的同步/restart contract,不能把本地 toggle 描述成 v17 远端控制 | -| External Source/Tool/Command/Agent | 当前 App Server production fallback 明确不支持旧 external route | owner snapshot、mutation、review、conflict、generation 和 typed events | -| Hooks | 仍使用既有 native/external hook 管理路径 | native overview 与 external import lifecycle;保持两类 Hook 分离 | -| Account/Settings Sync | 尚无 TUI App Server 闭环 | secret-safe auth flow、sync operation identity、冲突、取消和 snapshot recovery | -| Worktree | Session workspace binding 已进入 sync;bind/release/status 管理未迁移 | owner-scoped worktree lifecycle 和 remote unsupported | +| External Source/Tool/Command/Agent | TUI 经 typed snapshot/control/review、conflict choice、command expansion 和事件接口消费既有 owner;后续 External Application V2 snapshot/review/action 已在 Embedded 接线 | Phase 4 当前定义已完成;Shared 保留 V1 本机 compatibility,V2 明确 unsupported,Remote 不回落本机 | +| Hooks | TUI 经 typed native overview 与 external snapshot/plan/apply/mutate API 消费既有 owner | Phase 4 已完成;native user hooks、compiled-in `post_call_hooks` 和 external hook catalog 继续分离,Remote 明确 unsupported | +| Account/Settings Sync | typed snapshot/login/finalize/logout 与 sync start/snapshot/cancel/local-changed 已接线;凭据不进入 read model 或 Debug 输出 | Phase 4 接口迁移已完成;Embedded Host 注入共享 `AccountRuntime`,App Server 直接做 domain-to-wire 适配;当前 Shared Host 未注入并返回 typed unsupported | +| Worktree | typed repository status、bind/release 和 operation identity 已接线 | Phase 4 接口迁移已完成;Embedded Host 注入 Worktree owner,当前 Shared Host 与 Remote workspace 明确 unsupported | | Desktop/Web Host 安全 | WebSocket Host 仅为 loopback 单用户;Desktop 尚未迁移为 App Server Host | Host allowlist、身份/作用域、真实 limits 与平台 capability provider | ### 3.4 本地保留 @@ -183,7 +185,7 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll | Phase 1:协议基础 | initialize/health、typed events、connection-local cursor、resync、稳定错误和 Embedded connection 已接线 | App Server protocol/client/server focused tests | 已完成 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | | Phase 2:核心聊天 | Embedded 核心用例经 App Server;Shared 经同一 `TuiBackend` 映射 v17;TUI 核心不引用 Runtime SDK/IPC operation | CLI、App Server、Runtime IPC 和 boundary focused tests | 已完成当前定义 | [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) | | Phase 3:配置管理 | TUI controller 不再访问 config/registry/MCP compatibility owner;secret-safe typed APIs 完成,CLI Host adapter 可保留显式 compatibility forwarding | owner tests、App Server contract tests、CLI behavior tests | 已完成当前定义 | 本变更的 protocol/client/server/CLI focused tests 与 Core boundary checks | -| Phase 4:外部集成 | External Source、Hook、Account、Worktree 管理面经 typed backend;remote 不回落本机 | owner/remote/security contract tests | 未开始 | - | +| Phase 4:外部集成 | External Source、Hook、Account、Settings Sync、Worktree 管理面经 typed backend;remote 不回落本机 | owner/remote/security contract tests | 已完成当前定义 | [PR #2146 checks](https://github.com/GCWing/BitFun/pull/2146/checks)、zero-budget contract 与 Core boundary checks | | Phase 5:Shared App Server | Shared Host 达到 v17 治理等价,opt-in 双栈验证完成,并有回滚与删除证据 | 跨 transport parity、故障、性能和安全测试 | 未开始,目标待评审 | - | ### 5.1 Phase 0-2 已交付摘要 @@ -221,6 +223,8 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll 目标:迁移外部来源、Hook、Account、Settings Sync 和 Worktree 管理面。 +状态:已完成当前定义。 + 完成条件: - mutation 有 identity/revision、stale、取消和 audit 语义。 @@ -228,6 +232,14 @@ Shared Runtime IPC v17 在 Shared App Server 的鉴权、实例身份、controll - native user hooks、compiled-in `post_call_hooks` 和 external hook catalog 保持分离。 - remote workspace 不支持的能力返回 typed unsupported,不在 controller 本机执行。 +交付摘要: + +- `app-server-protocol`、client 和 production handlers 已提供 External Source、native/external Hook、Account、Settings Sync 与 Worktree 的 owner-specific typed API;side-effecting 请求使用 operation identity,External Source 与 Hook mutation 保留 owner revision/stale 合同,Settings Sync 提供显式取消与 snapshot。 +- `TuiAgentClient`、Startup 和 Chat controller 只经 `TuiBackend` 调用这些用例。Phase 4 涉及的 `bitfun_core`、account/account-sync compatibility marker 已从 controller 文件移除,对应 Core boundary budget 固定为零。 +- Embedded Host 显式注入共享 `AccountRuntime` 并启用 App Server 内建的本机 Worktree 映射;App Server management service 直接适配 owner,不定义 `AccountManagementHost` 或持有第二份账户、同步、外部来源、Hook、Worktree 权威状态。CLI 的窄 `AccountRuntimeHost` 只实现 daemon、Relay/Peer 路由宿主效果,Session 备份通过独立端口读取 Agent Runtime compatibility owner。 +- Shared adapter 只发布 Host 实际可用的 capability。External Source V1 与 Hook 管理可使用当前本机 compatibility service;Account/Settings Sync、Worktree、Remote workspace 和后续未接线的 External Application V2 返回 typed unsupported,不静默回落本机。 +- Phase 4 未扩展 private Runtime IPC v17,也未改变 Phase 5 的评审门槛。 + ### 5.4 Phase 5 Phase 5 不以“删除 v17”为起点。建议顺序: @@ -255,7 +267,7 @@ cargo test -p bitfun-cli --bin bitfun --offline pnpm run check:core-boundaries ``` -Phase 0-2 的具体命令结果和 CI 状态保留在 [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) 中。Phase 3 已运行上列 protocol、client、server、Runtime IPC、CLI binary 和 Core boundary focused checks;命令均通过。本文只保留可重复执行的验证命令和阶段状态,后续阶段必须在各自变更中重新记录验证结果,不能沿用一次性提交 SHA 作为证据。 +Phase 0-2 的具体命令结果和 CI 状态保留在 [PR #2034 checks](https://github.com/GCWing/BitFun/pull/2034/checks) 中。Phase 3 和 Phase 4 分别运行了对应的 protocol、client、server、CLI binary、owner contract 与 Core boundary focused checks;Phase 4 另有 zero-budget contract 防止 TUI controller 恢复旧 owner 直连。一次性结果保留在对应 PR/Actions 记录中,本文只保留可重复执行的验证命令和阶段状态,后续阶段必须重新记录自己的验证结果。 ### 6.2 行为等价场景 @@ -274,7 +286,7 @@ Shared App Server 实现后,同一 fixture 必须增加 Embedded App Server、 只有同时满足以下条件,才能宣布 TUI/App Server 解耦完成: -1. Phase 3/4 管理面已迁移,或从产品范围明确移除。 +1. Phase 3/4 当前定义的管理面已迁移;后续新增 capability 也不得绕过 `TuiBackend` 或恢复旧 owner 直连。 2. TUI 产品请求和订阅只经过 `TuiBackend`,TUI view/reducer 不执行 backend I/O。 3. protocol/client 和 TUI-facing 依赖闭包不包含 Core、Runtime/Service 实现、`product-full` 或 private IPC operation。 4. capability、limits、身份和作用域来自真实 Host/transport,而不是通用 protocol 默认值。 diff --git a/src/apps/cli/src/account.rs b/src/apps/cli/src/account.rs index cfb5e7926..f0b3c5b22 100644 --- a/src/apps/cli/src/account.rs +++ b/src/apps/cli/src/account.rs @@ -1,1111 +1,563 @@ -//! CLI account login and device-routing (RPC control) support. +//! CLI adapter for account-backed device routing. //! -//! This module lets the CLI log in to a BitFun relay account and then become -//! RPC-controllable by other devices on the same account. -//! -//! Incoming `HostInvoke` / `DeviceEvent` messages are handled by -//! `crate::peer_host` (Peer Device Mode host). Other remote-connect commands -//! still go through `RemoteServer`. -//! -//! The master key lives in memory only and is lost when the CLI exits. +//! Shared account identity, persistence, synchronization, and transitions are +//! owned by [`AccountRuntime`]. This module contains only CLI Host effects: +//! daemon retirement, Relay routing, and Peer Device Mode fan-out fencing. -use std::future::Future; -use std::sync::{ - atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, - Arc, OnceLock, -}; +use std::sync::{Arc, OnceLock, Weak}; use std::time::Duration; use anyhow::{anyhow, Result}; -use tokio::sync::{Notify, RwLock}; +use async_trait::async_trait; +use tokio::sync::RwLock; +use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; +use bitfun_core::service::remote_connect::account::{ + ensure_relay_session_history_exportable, AccountSession, +}; +use bitfun_core::service::remote_connect::account_runtime::{ + build_session_backup, AccountRoutingStartRequest, AccountRuntime, AccountRuntimeHost, + AccountSessionBackup, AccountSessionBackupPort, BackgroundRoutingOwnerRetirementError, +}; use bitfun_core::service::remote_connect::{ self, encryption, relay_client::RelayClient, relay_client::RelayEvent, session_store, - validate_relay_base_url, AccountClient, AccountSession, DeviceIdentity, RemoteServer, + DeviceIdentity, RemoteServer, }; -#[derive(Clone)] -struct AccountContextState { - session: AccountSession, - relay_url: String, -} - -/// Session and relay URL are one atomic account context so concurrent login, -/// logout, routing and sync cannot observe a torn pair. -static ACCOUNT_CONTEXT: OnceLock>>> = OnceLock::new(); -static ACCOUNT_CONTEXT_GENERATION: AtomicU64 = AtomicU64::new(1); -static ACCOUNT_CONTEXT_TRANSITIONS: AtomicUsize = AtomicUsize::new(0); -static ACCOUNT_SYNC_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -/// Serializes candidate credential verification without hiding or stopping the -/// currently active account. Only a fully authenticated candidate may enter -/// the account transition that replaces it. -static ACCOUNT_LOGIN_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -static ACCOUNT_CONTEXT_TRANSITION_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -static ACCOUNT_SYNC_CANCEL: OnceLock = OnceLock::new(); -/// At most one delayed daemon-exit recovery poller may own a generation. -/// A newer generation supersedes an older poller without accumulating tasks. -static ROUTING_RECOVERY_GENERATION: AtomicU64 = AtomicU64::new(0); -/// Read leases cover one routing event through its side effects and response. -/// Account transitions and routing-client ownership changes take the write -/// lease, so a new owner cannot be published while an old handler is active. -static DEVICE_ROUTING_LIFECYCLE: RwLock<()> = RwLock::const_new(()); - -pub(crate) fn account_context_generation() -> u64 { - ACCOUNT_CONTEXT_GENERATION.load(Ordering::Acquire) -} - -pub(crate) fn account_context_is_current(generation: u64) -> bool { - ACCOUNT_CONTEXT_TRANSITIONS.load(Ordering::Acquire) == 0 - && account_context_generation() == generation -} - -struct AccountContextTransitionPermit; - -impl AccountContextTransitionPermit { - fn begin() -> Self { - ACCOUNT_CONTEXT_TRANSITIONS.fetch_add(1, Ordering::AcqRel); - ACCOUNT_CONTEXT_GENERATION.fetch_add(1, Ordering::AcqRel); - account_sync_cancel().notify_waiters(); - Self - } +pub(crate) struct CliAccountRuntimeParts { + pub(crate) runtime: Arc, + pub(crate) routing: Arc, } -impl Drop for AccountContextTransitionPermit { - fn drop(&mut self) { - // Reject work queued during the transition before exposing the newly - // installed (or cleared) account context. - ACCOUNT_CONTEXT_GENERATION.fetch_add(1, Ordering::AcqRel); - ACCOUNT_CONTEXT_TRANSITIONS.fetch_sub(1, Ordering::AcqRel); - } +pub(crate) fn build_account_runtime( + compatibility: CoreAgentRuntimeCompatibility, +) -> CliAccountRuntimeParts { + build_account_runtime_with_backup(Arc::new(CliAccountSessionBackupPort { compatibility })) } -struct AccountContextTransitionGuard { - sync_guard: Option>, - transition: Option, - routing_guard: Option>, - transition_guard: Option>, +pub(crate) fn build_management_account_runtime() -> Arc { + build_account_runtime_with_backup(Arc::new(UnavailableSessionBackup)).runtime } -impl AccountContextTransitionGuard { - fn finish(mut self) -> u64 { - drop(self.sync_guard.take()); - drop(self.transition.take()); - let generation = account_context_generation(); - drop(self.routing_guard.take()); - drop(self.transition_guard.take()); - generation - } +fn build_account_runtime_with_backup( + backup: Arc, +) -> CliAccountRuntimeParts { + let routing = CliAccountRoutingHost::new(); + let runtime = AccountRuntime::new(routing.clone(), backup); + routing.bind_runtime(Arc::downgrade(&runtime)); + CliAccountRuntimeParts { runtime, routing } } -impl Drop for AccountContextTransitionGuard { - fn drop(&mut self) { - drop(self.sync_guard.take()); - drop(self.transition.take()); - drop(self.routing_guard.take()); - drop(self.transition_guard.take()); - } -} +struct UnavailableSessionBackup; -pub(crate) async fn lock_account_sync( - generation: u64, -) -> Result> { - let guard = ACCOUNT_SYNC_LOCK.lock().await; - if !account_context_is_current(generation) { - return Err(anyhow!("account sync cancelled")); +#[async_trait] +impl AccountSessionBackupPort for UnavailableSessionBackup { + async fn list_session_backups( + &self, + _workspace_path: &std::path::Path, + ) -> Result> { + Err(anyhow!( + "Session backup is unavailable in a short-lived management command" + )) } - Ok(guard) } -fn account_sync_cancel() -> &'static Notify { - ACCOUNT_SYNC_CANCEL.get_or_init(Notify::new) +struct CliAccountSessionBackupPort { + compatibility: CoreAgentRuntimeCompatibility, } -pub(crate) async fn await_account_sync_current(generation: u64, future: F) -> Result -where - F: Future, -{ - let mut cancelled = Box::pin(account_sync_cancel().notified()); - cancelled.as_mut().enable(); - if !account_context_is_current(generation) { - return Err(anyhow!("account sync cancelled")); - } - tokio::select! { - _ = &mut cancelled => Err(anyhow!("account sync cancelled")), - result = future => { - if !account_context_is_current(generation) { - Err(anyhow!("account sync cancelled")) - } else { - Ok(result) +#[async_trait] +impl AccountSessionBackupPort for CliAccountSessionBackupPort { + async fn list_session_backups( + &self, + workspace_path: &std::path::Path, + ) -> Result> { + let metadata = self + .compatibility + .list_persisted_sessions(workspace_path) + .await + .map_err(|error| anyhow!("list sessions: {error}"))?; + let mut backups = Vec::new(); + for item in &metadata { + if let Err(error) = ensure_relay_session_history_exportable(item) { + tracing::debug!("Skipping CLI account session export: {error}"); + continue; } + let turns = self + .compatibility + .load_persisted_session_turns(workspace_path, &item.session_id, None) + .await + .map_err(|error| anyhow!("load turns: {error}"))?; + backups.push(build_session_backup(item, &turns)?); } + Ok(backups) } } -async fn invalidate_and_wait_for_account_sync() -> AccountContextTransitionGuard { - let transition_guard = ACCOUNT_CONTEXT_TRANSITION_LOCK.lock().await; - let transition = AccountContextTransitionPermit::begin(); - let sync_guard = ACCOUNT_SYNC_LOCK.lock().await; - bitfun_core::service::remote_connect::settings_sync::wait_for_sync_operations_idle().await; - let routing_guard = DEVICE_ROUTING_LIFECYCLE.write().await; - AccountContextTransitionGuard { - sync_guard: Some(sync_guard), - transition: Some(transition), - routing_guard: Some(routing_guard), - transition_guard: Some(transition_guard), - } +/// CLI-owned routing effects injected into the shared Account Runtime. +pub(crate) struct CliAccountRoutingHost { + self_ref: Weak, + runtime: OnceLock>, + relay_client: RwLock>>, + /// Read leases cover one routing event through its response. Routing owner + /// changes take the write lease, so old events cannot escape through a new + /// account's Relay client. + lifecycle: Arc>, } -async fn invalidate_and_wait_if_account_current( - expected_generation: u64, -) -> Option { - let transition_guard = ACCOUNT_CONTEXT_TRANSITION_LOCK.lock().await; - if !account_context_is_current(expected_generation) { - return None; +impl CliAccountRoutingHost { + fn new() -> Arc { + Arc::new_cyclic(|self_ref| Self { + self_ref: self_ref.clone(), + runtime: OnceLock::new(), + relay_client: RwLock::new(None), + lifecycle: Arc::new(RwLock::new(())), + }) } - let transition = AccountContextTransitionPermit::begin(); - let sync_guard = ACCOUNT_SYNC_LOCK.lock().await; - bitfun_core::service::remote_connect::settings_sync::wait_for_sync_operations_idle().await; - let routing_guard = DEVICE_ROUTING_LIFECYCLE.write().await; - Some(AccountContextTransitionGuard { - sync_guard: Some(sync_guard), - transition: Some(transition), - routing_guard: Some(routing_guard), - transition_guard: Some(transition_guard), - }) -} - -/// The background device-routing relay client. Holding this keeps the WS -/// connection alive (the internal read/write tasks own the socket). Dropping it -/// tears the connection down. -static DEVICE_RELAY_CLIENT: OnceLock>>> = OnceLock::new(); - -/// Set when the relay returns an auth error (token expired or invalid). -/// The chat loop checks this via `is_token_expired()` and prompts the user. -static TOKEN_EXPIRED: AtomicBool = AtomicBool::new(false); - -/// True while credentials succeeded but the user has not yet chosen -/// cloud-vs-local settings. Session is held in memory only; a process kill -/// must not restore a logged-in state (same contract as desktop -/// `account_login` / `account_finalize_login`). -static PENDING_SYNC_CHOICE: AtomicBool = AtomicBool::new(false); - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct AutomaticAccountSyncPolicy { - pub(crate) background_engine: bool, - pub(crate) management_push: bool, -} -fn automatic_account_sync_policy_for_pending( - pending_sync_choice: bool, -) -> AutomaticAccountSyncPolicy { - let allowed = !pending_sync_choice; - AutomaticAccountSyncPolicy { - background_engine: allowed, - management_push: allowed, + fn bind_runtime(&self, runtime: Weak) { + self.runtime + .set(runtime) + .unwrap_or_else(|_| panic!("CLI account routing runtime was bound twice")); } -} - -/// Automatic sync must remain idle while an authenticated account is waiting -/// for the user to choose whether cloud or local settings should win. Explicit -/// first-login sync is intentionally not governed by this policy. -pub(crate) fn automatic_account_sync_policy() -> AutomaticAccountSyncPolicy { - automatic_account_sync_policy_for_pending(PENDING_SYNC_CHOICE.load(Ordering::Acquire)) -} - -fn account_context() -> &'static Arc>> { - ACCOUNT_CONTEXT.get_or_init(|| Arc::new(RwLock::new(None))) -} - -fn device_relay_client() -> &'static RwLock>> { - DEVICE_RELAY_CLIENT.get_or_init(|| RwLock::new(None)) -} - -/// Read both the session and relay URL, returning owned clones to avoid holding -/// locks across awaits. -pub(crate) async fn read_account_context() -> Result<(AccountSession, String)> { - let generation = account_context_generation(); - read_account_context_for_generation(generation).await -} - -async fn read_account_context_raw() -> Result<(AccountSession, String)> { - account_context() - .read() - .await - .clone() - .map(|context| (context.session, context.relay_url)) - .ok_or_else(|| anyhow!("not logged in")) -} -pub(crate) async fn read_account_context_for_generation( - generation: u64, -) -> Result<(AccountSession, String)> { - if !account_context_is_current(generation) { - return Err(anyhow!("account context changed")); - } - let context = read_account_context_raw().await?; - if !account_context_is_current(generation) { - return Err(anyhow!("account context changed")); + fn runtime(&self) -> Result> { + self.runtime + .get() + .and_then(Weak::upgrade) + .ok_or_else(|| anyhow!("account runtime is unavailable")) } - Ok(context) -} -/// Whether an account session is currently held and login is finalized. -/// Matches desktop `account_status`: pending cloud/local sync choice is not -/// treated as logged in. -pub(crate) async fn is_logged_in() -> bool { - if PENDING_SYNC_CHOICE.load(Ordering::Acquire) { - return false; - } - read_account_context().await.is_ok() -} - -pub(crate) fn pending_sync_choice() -> bool { - PENDING_SYNC_CHOICE.load(Ordering::Acquire) -} - -fn normalize_relay_url(relay_url: &str) -> Result { - let parsed = validate_relay_base_url(relay_url.trim())?; - Ok(parsed.as_str().trim_end_matches('/').to_string()) -} - -/// Attempt to restore a persisted session from disk. Called at startup. -/// Returns `Some(user_id)` if a session was restored. -pub(crate) async fn try_restore_session() -> Option { - let _sync_guard = invalidate_and_wait_for_account_sync().await; - match session_store::load_session_detailed() { - Ok(Some(loaded)) => { - let relay_url = match normalize_relay_url(&loaded.relay_url) { - Ok(url) => url, - Err(error) => { - tracing::warn!("Ignoring invalid persisted relay URL: {error}"); - session_store::clear_session(); - return None; - } - }; - let user_id = loaded.user_id.clone(); - if let Some(device_id) = loaded.device_id.as_deref() { - if let Err(e) = DeviceIdentity::adopt_account_device_id(device_id) { - tracing::warn!("Failed to adopt restored session device_id: {e}"); - } - } - let session = AccountSession { - token: loaded.token, - user_id: user_id.clone(), - master_key: loaded.master_key, - }; - *account_context().write().await = Some(AccountContextState { session, relay_url }); - tracing::info!("Restored account session for user {user_id}"); - Some(user_id) - } - Ok(None) => None, - Err(e) => { - tracing::warn!("Failed to load persisted session: {e}"); - None + async fn start_routing(&self, request: AccountRoutingStartRequest) -> Result<()> { + let runtime = self.runtime()?; + if !runtime.account_context_is_current(request.account_generation) { + return Err(anyhow!("account context changed")); } - } -} - -/// Whether the relay has reported the account token as expired/invalid. -/// The TUI prompts re-login via this; the daemon exits on it. -pub(crate) fn is_token_expired() -> bool { - TOKEN_EXPIRED.load(Ordering::Relaxed) -} - -/// Mark the account token as rejected by the relay (expired / revoked). -/// Called by the settings sync engine when a sync request gets a 401. -pub(crate) fn mark_token_expired() { - TOKEN_EXPIRED.store(true, Ordering::Relaxed); -} - -/// Resolve the current device identity (machine-based). -fn current_device_identity() -> Result { - DeviceIdentity::from_current_machine().map_err(|e| anyhow!("detect device: {e}")) -} - -/// Structured result of a successful credential login. -#[derive(Debug, Clone)] -pub(crate) struct LoginResult { - pub user_id: String, - pub relay_url: String, - /// True when the relay already has a settings blob (Desktop overwrite prompt). - pub has_cloud_settings: bool, - pub status_message: String, -} - -/// Log in with credentials collected by the Login TUI form. -/// -/// Same fields as Desktop Account Login: Auth Server (relay URL), Username, -/// Password. Persists encrypted session + non-secret hint, then starts device -/// routing so this CLI becomes a Peer Device Mode host. -pub(crate) async fn login_with_credentials( - relay_url: &str, - username: &str, - password: &str, -) -> Result { - let _login_guard = ACCOUNT_LOGIN_LOCK.lock().await; - let relay_url_input = relay_url.trim(); - let username = username.trim(); - if relay_url_input.is_empty() { - return Err(anyhow!("Auth Server is required")); - } - if username.is_empty() { - return Err(anyhow!("Username is required")); - } - if password.is_empty() { - return Err(anyhow!("Password is required")); - } - let relay_url = normalize_relay_url(relay_url_input)?; - let expected_generation = account_context_generation(); - if !account_context_is_current(expected_generation) { - return Err(anyhow!("account context changed")); - } - - let device = current_device_identity()?; - let client = AccountClient::new(); - let session = client - .login(&relay_url, username, password, &device) - .await - .map_err(|e| anyhow!("login failed: {e}"))?; - - let has_cloud_settings = - match resolve_cloud_settings_probe(client.fetch_settings(&relay_url, &session).await) { - Ok(has_cloud_settings) => has_cloud_settings, - Err(error) => { - revoke_rejected_login_candidate(&client, &relay_url, &session).await; - return Err(error); + self.stop_routing().await; + + let ws_url = format!( + "{}/ws", + request + .relay_url + .replace("https://", "wss://") + .replace("http://", "ws://") + ); + let (client, mut event_rx) = RelayClient::new(); + client.connect(&ws_url).await?; + client + .connect_authenticated(&request.session.token, &request.device_name) + .await?; + let client = Arc::new(client); + { + let _routing_guard = self.lifecycle.write().await; + if !runtime.account_context_is_current(request.account_generation) { + client.disconnect().await; + return Err(anyhow!("account context changed")); } - }; - - // A daemon is a separate process with its own in-memory session and WebSocket. - // Retire it after candidate authentication but before beginning the local - // generation transition. If retirement fails, the old local owner keeps - // its original generation and remains usable. A clean daemon exit is not - // auto-restarted by the generated launchd/systemd service definitions. - // Snapshot the old owner before the guarded replacement. A generation race - // rejects the transition below, in which case this snapshot is never used. - let previous_account_context = account_context().read().await.clone(); - let (retired_daemon, transition_guard) = match begin_candidate_account_transition( - expected_generation, - retire_running_daemon_for_account_switch().await, - ) - .await - { - Ok(transition) => transition, - Err(CandidateAccountTransitionError::DaemonRetirement(failure)) => { - let recovery_message = if failure.daemon_may_exit { - schedule_routing_recovery_after_daemon_exit( - expected_generation, - device.device_name.clone(), - ); - "; this CLI will restore local routing if the daemon exits" - } else { - "" - }; - revoke_rejected_login_candidate(&client, &relay_url, &session).await; - return Err(anyhow!( - "{}; the old account context and generation were preserved{}", - failure.error, - recovery_message - )); + *self.relay_client.write().await = Some(client.clone()); } - Err(CandidateAccountTransitionError::AccountContextChanged) => { - revoke_rejected_login_candidate(&client, &relay_url, &session).await; + if !runtime.account_context_is_current(request.account_generation) { + self.retire_routing_client_if_same(&client).await; + client.disconnect().await; return Err(anyhow!("account context changed")); } - }; - - // The transition owns the routing lifecycle write lease. Retire any - // in-process owner before making the candidate context observable. - clear_replaced_persisted_session(); - stop_device_routing_locked().await; - let user_id = session.user_id.clone(); - let device_name = device.device_name.clone(); - let token = session.token.clone(); - let master_key = session.master_key; - *account_context().write().await = Some(AccountContextState { - session, - relay_url: relay_url.clone(), - }); - session_store::save_credential_hint(username, &relay_url); - TOKEN_EXPIRED.store(false, Ordering::Relaxed); - - if has_cloud_settings { - // Defer disk persist until the sync choice is accepted. Killing the - // process during the choice panel must not restore a logged-in session. - PENDING_SYNC_CHOICE.store(true, Ordering::Release); - transition_guard.finish(); - revoke_replaced_account_context(&client, previous_account_context, &relay_url, &token) - .await; - return Ok(LoginResult { - user_id: user_id.clone(), - relay_url: relay_url.clone(), - has_cloud_settings, - status_message: format!( - "Authenticated as user {} on {}. Choose cloud or local settings to finish login.{}", - user_id, - relay_url, - if retired_daemon { - " The previous CLI daemon was stopped; routing will resume after the sync choice." - } else { - "" + let routing = self + .self_ref + .upgrade() + .ok_or_else(|| anyhow!("account routing is unavailable"))?; + let expected_token = request.session.token; + let generation = request.account_generation; + tokio::spawn(async move { + loop { + if !routing.routing_loop_is_current(generation, &client).await { + tracing::debug!("Stopping stale device routing event loop"); + break; } - ), + let Some(event) = event_rx.recv().await else { + break; + }; + if !routing.routing_loop_is_current(generation, &client).await { + tracing::debug!("Stopping stale device routing event loop"); + break; + } + routing + .handle_relay_event(event, &client, generation, &expected_token) + .await; + } + routing.retire_routing_client_if_same(&client).await; + tracing::info!("Device routing event loop exited"); }); + Ok(()) } - PENDING_SYNC_CHOICE.store(false, Ordering::Release); - if let Err(e) = session_store::save_session_with_device( - &token, - &user_id, - &master_key, - &relay_url, - Some(device.device_id.as_str()), - ) { - tracing::warn!("Failed to persist session: {e}"); + async fn stop_routing(&self) { + let _routing_guard = self.lifecycle.write().await; + self.stop_routing_locked().await; } - let generation = transition_guard.finish(); - let routing_msg = match spawn_device_routing(&relay_url, &device_name, generation).await { - Ok(()) if retired_daemon => " The previous CLI daemon was stopped and routing is connected in this CLI process. Restart `bitfun daemon run` to restore always-on routing.".to_string(), - Ok(()) => " Device routing connected (Peer Host ready). Tip: `bitfun daemon install` keeps this device reachable after exit or reboot.".to_string(), - Err(e) if retired_daemon => format!(" (Warning: the previous CLI daemon was stopped, but replacement routing failed: {e})"), - Err(e) => format!(" (Warning: device routing failed: {e})"), - }; - revoke_replaced_account_context(&client, previous_account_context, &relay_url, &token).await; - - Ok(LoginResult { - user_id: user_id.clone(), - relay_url: relay_url.clone(), - has_cloud_settings, - status_message: format!( - "Logged in as user {} on {}.{}", - user_id, relay_url, routing_msg - ), - }) -} - -async fn revoke_rejected_login_candidate( - client: &AccountClient, - relay_url: &str, - session: &AccountSession, -) { - if let Err(error) = client.revoke_token(relay_url, session).await { - tracing::warn!("Failed to revoke rejected login candidate token: {error}"); + pub(crate) async fn stop_device_routing(&self) { + self.stop_routing().await; } -} - -fn clear_replaced_persisted_session() { - // Once this candidate has won the transition, the old account must never - // be restored after a crash. A finalized replacement is persisted below; - // a pending cloud-sync choice intentionally leaves no restorable session. - session_store::clear_session(); -} - -fn replaced_account_revocation_target( - previous: Option, - replacement_relay_url: &str, - replacement_token: &str, -) -> Option { - previous.filter(|context| { - context.relay_url != replacement_relay_url || context.session.token != replacement_token - }) -} -async fn revoke_replaced_account_context( - client: &AccountClient, - previous: Option, - replacement_relay_url: &str, - replacement_token: &str, -) { - let Some(previous) = - replaced_account_revocation_target(previous, replacement_relay_url, replacement_token) - else { - return; - }; - if let Err(error) = client - .revoke_token(&previous.relay_url, &previous.session) - .await - { - // B is already the committed in-memory owner. Relay cleanup of A is - // best-effort and must never roll the replacement back. - tracing::warn!("Failed to revoke replaced account token: {error}"); + async fn stop_routing_locked(&self) { + if let Some(client) = self.relay_client.write().await.take() { + client.disconnect().await; + } + crate::peer_host::update_controller_presence(Vec::new()).await; } -} - -fn resolve_cloud_settings_probe(result: Result>) -> Result { - result.map(|settings| settings.is_some()).map_err(|error| { - anyhow!("could not check cloud settings: {error}; the current account remains active") - }) -} -struct DaemonRetirementFailure { - error: anyhow::Error, - daemon_may_exit: bool, -} - -enum CandidateAccountTransitionError { - DaemonRetirement(DaemonRetirementFailure), - AccountContextChanged, -} - -async fn begin_candidate_account_transition( - expected_generation: u64, - daemon_retirement: std::result::Result, -) -> std::result::Result<(bool, AccountContextTransitionGuard), CandidateAccountTransitionError> { - let retired_daemon = - daemon_retirement.map_err(CandidateAccountTransitionError::DaemonRetirement)?; - let transition_guard = invalidate_and_wait_if_account_current(expected_generation) - .await - .ok_or(CandidateAccountTransitionError::AccountContextChanged)?; - Ok((retired_daemon, transition_guard)) -} - -async fn retire_running_daemon_for_account_switch( -) -> std::result::Result { - if !crate::daemon::is_daemon_running() { - return Ok(false); - } - if !crate::daemon::request_daemon_shutdown() { - return Err(DaemonRetirementFailure { - error: anyhow!("could not stop the CLI daemon; the current account remains active"), - daemon_may_exit: false, - }); + async fn is_current_routing_client(&self, client: &Arc) -> bool { + same_routing_client(self.relay_client.read().await.as_ref(), client) } - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); - while crate::daemon::is_daemon_running() { - if tokio::time::Instant::now() >= deadline { - return Err(DaemonRetirementFailure { - error: anyhow!( - "CLI daemon did not stop in time; the current account remains active" - ), - daemon_may_exit: true, - }); + async fn routing_loop_is_current( + &self, + account_generation: u64, + client: &Arc, + ) -> bool { + let Ok(runtime) = self.runtime() else { + return false; + }; + if !runtime.account_context_is_current(account_generation) { + return false; } - tokio::time::sleep(Duration::from_millis(50)).await; + let matches = self.is_current_routing_client(client).await; + matches && runtime.account_context_is_current(account_generation) } - Ok(true) -} -fn schedule_routing_recovery_after_daemon_exit(expected_generation: u64, device_name: String) { - if !account_context_is_current(expected_generation) - || ROUTING_RECOVERY_GENERATION.swap(expected_generation, Ordering::AcqRel) - == expected_generation - { - return; - } - tokio::spawn(async move { - while ROUTING_RECOVERY_GENERATION.load(Ordering::Acquire) == expected_generation - && account_context_is_current(expected_generation) - && crate::daemon::is_daemon_running() - { - tokio::time::sleep(Duration::from_millis(100)).await; - } - if ROUTING_RECOVERY_GENERATION.load(Ordering::Acquire) == expected_generation - && account_context_is_current(expected_generation) - && !crate::daemon::is_daemon_running() - { - if let Err(error) = restore_device_routing(&device_name).await { - tracing::warn!( - "Failed to restore old account routing after delayed daemon exit: {error}" - ); - } + async fn retire_routing_client_if_same(&self, client: &Arc) -> bool { + let _routing_guard = self.lifecycle.write().await; + let mut current = self.relay_client.write().await; + if !take_routing_client_if_same(&mut current, client) { + return false; } - let _ = ROUTING_RECOVERY_GENERATION.compare_exchange( - expected_generation, - 0, - Ordering::AcqRel, - Ordering::Acquire, - ); - }); -} - -/// Persist the in-memory session after the user accepts the sync choice, then -/// start device routing (same as a first login with no cloud settings). -pub(crate) async fn finalize_login_after_sync_choice() -> Result<()> { - let generation = account_context_generation(); - let sync_guard = lock_account_sync(generation).await?; - let device = current_device_identity()?; - let (session, relay_url) = read_account_context().await?; - let retired_daemon = retire_running_daemon_for_account_switch() - .await - .map_err(|failure| failure.error)?; - session_store::save_session_with_device( - &session.token, - &session.user_id, - &session.master_key, - &relay_url, - Some(device.device_id.as_str()), - ) - .map_err(|e| anyhow!("persist session: {e}"))?; - PENDING_SYNC_CHOICE.store(false, Ordering::Release); - - if retired_daemon { - tracing::info!( - "Stopped the previous CLI daemon before finalizing replacement account routing" - ); + drop(current); + crate::peer_host::update_controller_presence(Vec::new()).await; + true } - drop(sync_guard); - spawn_device_routing(&relay_url, &device.device_name, generation) - .await - .map_err(|e| anyhow!("device routing failed: {e}")) -} - -/// Snapshot of the logged-in account for the Account status page. -#[derive(Debug, Clone)] -pub(crate) struct AccountInfo { - pub user_id: String, - pub relay_url: String, - pub device_id: String, - pub device_name: String, -} - -pub(crate) async fn account_info() -> Result { - let (session, relay_url) = read_account_context().await?; - let device = current_device_identity()?; - Ok(AccountInfo { - user_id: session.user_id, - relay_url, - device_id: device.device_id, - device_name: device.device_name, - }) -} - -/// Public wrapper for restoring device routing after session restore at startup. -pub(crate) async fn restore_device_routing(device_name: &str) -> Result<()> { - let generation = account_context_generation(); - let (_, relay_url) = read_account_context().await?; - spawn_device_routing(&relay_url, device_name, generation).await -} - -/// Connect to the account relay for device-to-device routing and spawn the -/// background task that handles incoming RPC commands. -async fn spawn_device_routing( - relay_url: &str, - device_name: &str, - account_generation: u64, -) -> Result<()> { - let _sync_guard = lock_account_sync(account_generation).await?; - let relay_url = normalize_relay_url(relay_url)?; - // Tear down any previous connection first. - stop_device_routing().await; - let (session, current_relay_url) = read_account_context().await?; - if current_relay_url != relay_url { - return Err(anyhow!("account context changed")); - } - - let ws_url = format!( - "{}/ws", - relay_url - .replace("https://", "wss://") - .replace("http://", "ws://") - ); - - let (client, mut event_rx) = RelayClient::new(); - client.connect(&ws_url).await?; - client - .connect_authenticated(&session.token, device_name) - .await?; - let client_arc = Arc::new(client); - { - let _routing_guard = DEVICE_ROUTING_LIFECYCLE.write().await; - let mut current_client = device_relay_client().write().await; - if !account_context_is_current(account_generation) { - drop(current_client); - client_arc.disconnect().await; - return Err(anyhow!("account context changed")); + async fn handle_relay_event( + self: &Arc, + event: RelayEvent, + relay_client: &Arc, + account_generation: u64, + expected_token: &str, + ) { + if let RelayEvent::AuthError { message } = event { + self.handle_relay_auth_error(message, relay_client, account_generation, expected_token) + .await; + return; } - *current_client = Some(client_arc.clone()); - } - let account_context = account_context().clone(); - let relay_client_arc = client_arc.clone(); - tokio::spawn(async move { - loop { - if !routing_loop_is_current(account_generation, &relay_client_arc).await { - tracing::debug!("Stopping stale device routing event loop"); - break; - } - let Some(event) = event_rx.recv().await else { - break; - }; - if !routing_loop_is_current(account_generation, &relay_client_arc).await { - tracing::debug!("Stopping stale device routing event loop"); - break; - } - handle_relay_event( - event, - &account_context, - &relay_client_arc, - account_generation, - &session.token, - ) - .await; + let _routing_lease = self.lifecycle.read().await; + if !self + .routing_loop_is_current(account_generation, relay_client) + .await + { + tracing::debug!("Ignoring event from a stale device routing client"); + return; } - retire_routing_client_if_same(&relay_client_arc).await; - tracing::info!("Device routing event loop exited"); - }); - - Ok(()) -} - -/// Disconnect the device-routing connection (if any). -pub(crate) async fn stop_device_routing() { - let _routing_guard = DEVICE_ROUTING_LIFECYCLE.write().await; - stop_device_routing_locked().await; -} - -/// Stop routing while the caller holds the lifecycle write lease. -async fn stop_device_routing_locked() { - let client = { device_relay_client().write().await.take() }; - if let Some(client) = client { - client.disconnect().await; - } - crate::peer_host::update_controller_presence(Vec::new()).await; -} - -async fn is_current_routing_client(client: &Arc) -> bool { - same_routing_client(device_relay_client().read().await.as_ref(), client) -} - -fn same_routing_client(current: Option<&Arc>, expected: &Arc) -> bool { - current.is_some_and(|client| Arc::ptr_eq(client, expected)) -} - -fn take_routing_client_if_same(current: &mut Option>, expected: &Arc) -> bool { - if !same_routing_client(current.as_ref(), expected) { - return false; - } - current.take(); - true -} - -/// Validate both halves of a routing-loop lease. The generation is checked -/// again after awaiting the client slot so a concurrent account transition -/// cannot make the pre-lock snapshot look current. -async fn routing_loop_is_current(account_generation: u64, relay_client: &Arc) -> bool { - if !account_context_is_current(account_generation) { - return false; - } - let matches = is_current_routing_client(relay_client).await; - matches && account_context_is_current(account_generation) -} - -/// Retire only the client owned by this loop. Keep the lifecycle write lease -/// while clearing controller presence so a replacement cannot publish its -/// presence and then have it erased by the old loop's cleanup. -async fn retire_routing_client_if_same(relay_client: &Arc) -> bool { - let _routing_guard = DEVICE_ROUTING_LIFECYCLE.write().await; - let mut current = device_relay_client().write().await; - if !take_routing_client_if_same(&mut current, relay_client) { - return false; - } - drop(current); - crate::peer_host::update_controller_presence(Vec::new()).await; - true -} - -/// Log out: tear down routing, revoke the token (best-effort), clear state. -pub(crate) async fn logout() -> Result<()> { - let _sync_guard = invalidate_and_wait_for_account_sync().await; - stop_device_routing_locked().await; - // Take the always-on daemon down with the account: the token is revoked - // below, so leaving the daemon connected would keep this device online - // with a doomed token until its next reconnect fails. - if crate::daemon::request_daemon_shutdown() { - tracing::info!("Signalled the CLI daemon to shut down after logout"); - } - let result = read_account_context_raw().await; - if let Ok((session, relay_url)) = result { - let _ = AccountClient::new() - .revoke_token(&relay_url, &session) + let fanout_owner = PeerFanoutOwner { + account_generation, + account_token: expected_token.to_string(), + relay_client: Arc::clone(relay_client), + runtime: Arc::downgrade(&self.runtime().expect("bound account runtime")), + routing: Arc::downgrade(self), + }; + ACTIVE_PEER_FANOUT_OWNER + .scope(fanout_owner, async { + self.handle_current_relay_event( + event, + relay_client, + account_generation, + expected_token, + ) + .await; + }) .await; } - *account_context().write().await = None; - PENDING_SYNC_CHOICE.store(false, Ordering::Release); - session_store::clear_session(); - session_store::clear_credential_hint(); - TOKEN_EXPIRED.store(false, Ordering::Relaxed); - Ok(()) -} - -/// Handle a single relay event for the device-routing loop. -async fn handle_relay_event( - event: RelayEvent, - account_context: &Arc>>, - relay_client: &Arc, - account_generation: u64, - expected_token: &str, -) { - let event = match event { - RelayEvent::AuthError { message } => { - handle_relay_auth_error( - message, - account_context, - relay_client, - account_generation, - expected_token, - ) - .await; - return; - } - event => event, - }; - let _routing_lease = DEVICE_ROUTING_LIFECYCLE.read().await; - if !routing_loop_is_current(account_generation, relay_client).await { - tracing::debug!("Ignoring event from a stale device routing client"); - return; - } - let fanout_owner = PeerFanoutOwner { - account_generation, - account_token: expected_token.to_string(), - relay_client: Arc::clone(relay_client), - }; - ACTIVE_PEER_FANOUT_OWNER - .scope(fanout_owner, async { - match event { - RelayEvent::AuthOk { user_id, device_id } => { - tracing::info!("Device routing auth ok: user={user_id} device={device_id}"); - if let Err(e) = DeviceIdentity::adopt_account_device_id(&device_id) { - tracing::warn!("Failed to adopt AuthOk device_id: {e}"); - } else if let Some(context) = account_context.read().await.clone() { - if routing_loop_is_current(account_generation, relay_client).await - && context.session.token == expected_token - { - if let Err(e) = session_store::save_session_with_device( - &context.session.token, - &context.session.user_id, - &context.session.master_key, - &context.relay_url, - Some(device_id.as_str()), - ) { - tracing::warn!( - "Failed to persist AuthOk device_id into session: {e}" - ); - } + async fn handle_current_relay_event( + &self, + event: RelayEvent, + relay_client: &Arc, + account_generation: u64, + expected_token: &str, + ) { + let runtime = match self.runtime() { + Ok(runtime) => runtime, + Err(_) => return, + }; + match event { + RelayEvent::AuthOk { user_id, device_id } => { + tracing::info!("Device routing auth ok: user={user_id} device={device_id}"); + if let Err(error) = DeviceIdentity::adopt_account_device_id(&device_id) { + tracing::warn!("Failed to adopt AuthOk device_id: {error}"); + return; + } + if let Ok((session, relay_url)) = runtime + .read_account_context_for_generation(account_generation) + .await + { + if session.token == expected_token + && self + .routing_loop_is_current(account_generation, relay_client) + .await + { + if let Err(error) = session_store::save_session_with_device( + &session.token, + &session.user_id, + &session.master_key, + &relay_url, + Some(device_id.as_str()), + ) { + tracing::warn!("Failed to persist AuthOk device_id: {error}"); } } } - RelayEvent::AuthError { .. } => { - unreachable!("AuthError handled before routing read lease") + } + RelayEvent::DevicePresence { devices } => { + tracing::info!("Device presence updated: {} online", devices.len()); + if !self + .routing_loop_is_current(account_generation, relay_client) + .await + { + return; } - RelayEvent::DevicePresence { devices } => { - tracing::info!("Device presence updated: {} online", devices.len()); - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - crate::peer_host::update_controller_presence( - devices.into_iter().map(|device| device.device_id).collect(), - ) - .await; - if !routing_loop_is_current(account_generation, relay_client).await { - tracing::debug!("Account changed while applying device presence"); - } + crate::peer_host::update_controller_presence( + devices.into_iter().map(|device| device.device_id).collect(), + ) + .await; + } + RelayEvent::DeviceMessageReceived { + source_device_id, + correlation_id, + encrypted_data, + nonce, + } => { + let Ok((session, _)) = runtime + .read_account_context_for_generation(account_generation) + .await + else { + return; + }; + if session.token != expected_token + || !self + .routing_loop_is_current(account_generation, relay_client) + .await + { + return; } - RelayEvent::DeviceMessageReceived { - source_device_id, - correlation_id, - encrypted_data, - nonce, - } => { - let context = account_context.read().await.clone(); - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - let Some(context) = context else { - return; - }; - if context.session.token != expected_token { + let plaintext = match encryption::decrypt_from_base64( + &session.master_key, + &encrypted_data, + &nonce, + ) { + Ok(plaintext) => plaintext, + Err(error) => { + tracing::warn!("Failed to decrypt device message: {error}"); return; } - let plaintext = match encryption::decrypt_from_base64( - &context.session.master_key, - &encrypted_data, - &nonce, - ) { - Ok(p) => p, - Err(e) => { - tracing::warn!("Failed to decrypt device message: {e}"); - return; - } - }; - use remote_connect::remote_server::{RemoteCommand, RemoteResponse}; - let cmd: RemoteCommand = match serde_json::from_str(&plaintext) { - Ok(c) => c, - Err(e) => { - tracing::warn!("Could not parse device command: {e}"); - return; - } - }; - tracing::info!( - "Device command from {source_device_id}: {cmd:?} corr={correlation_id}" - ); - - if !routing_loop_is_current(account_generation, relay_client).await { + }; + use remote_connect::remote_server::{RemoteCommand, RemoteResponse}; + let command: RemoteCommand = match serde_json::from_str(&plaintext) { + Ok(command) => command, + Err(error) => { + tracing::warn!("Could not parse device command: {error}"); return; } - let response = match &cmd { - RemoteCommand::HostInvoke { command, args } => { - let response = - crate::peer_host::handle_host_invoke(command, args.clone()).await; - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - response - } - RemoteCommand::DeviceEvent { .. } => { - crate::peer_host::handle_device_event_command() - } - other => { - let server = RemoteServer::new(context.session.master_key); - let response = server.dispatch(other).await; - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - response - } - }; - - let resp_json = match serde_json::to_string(&response) { - Ok(s) => s, - Err(e) => { - tracing::warn!("Failed to serialize RPC response: {e}"); - serde_json::to_string(&RemoteResponse::Error { - message: format!("failed to serialize RPC response: {e}"), - }) - .unwrap_or_else(|_| { - r#"{"resp":"error","message":"serialize failed"}"#.to_string() - }) - } - }; - - match encryption::encrypt_to_base64(&context.session.master_key, &resp_json) { - Ok((enc_resp, resp_nonce)) => { - // HTTP RPC bridge expects replies targeted at "rpc". - let reply_target = if source_device_id == "rpc" { - "rpc" - } else { - source_device_id.as_str() - }; - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - let send_result = relay_client - .send_device_message( - reply_target, - &correlation_id, - &enc_resp, - &resp_nonce, - ) - .await; - if !routing_loop_is_current(account_generation, relay_client).await { - return; - } - if let Err(e) = send_result { - tracing::warn!("Failed to send RPC response: {e}"); - } - } - Err(e) => { - tracing::warn!("Failed to encrypt RPC response: {e}"); - } - } - } - RelayEvent::Disconnected => { - tracing::info!("Device routing disconnected"); - if !routing_loop_is_current(account_generation, relay_client).await { - return; + }; + tracing::info!( + "Device command from {source_device_id}: {command:?} corr={correlation_id}" + ); + let response = match &command { + RemoteCommand::HostInvoke { command, args } => { + crate::peer_host::handle_host_invoke(command, args.clone()).await } - crate::peer_host::update_controller_presence(Vec::new()).await; - if !routing_loop_is_current(account_generation, relay_client).await { - tracing::debug!("Account changed while clearing device presence"); + RemoteCommand::DeviceEvent { .. } => { + crate::peer_host::handle_device_event_command() } + other => RemoteServer::new(session.master_key).dispatch(other).await, + }; + if !self + .routing_loop_is_current(account_generation, relay_client) + .await + { + return; } - RelayEvent::Reconnected => { - tracing::info!("Device routing reconnected"); - } - RelayEvent::Error { message } => { - tracing::warn!("Device routing error: {message}"); + let response_json = serde_json::to_string(&response).unwrap_or_else(|error| { + serde_json::to_string(&RemoteResponse::Error { + message: format!("failed to serialize RPC response: {error}"), + }) + .unwrap_or_else(|_| { + r#"{"resp":"error","message":"serialize failed"}"#.to_string() + }) + }); + let Ok((encrypted_response, response_nonce)) = + encryption::encrypt_to_base64(&session.master_key, &response_json) + else { + tracing::warn!("Failed to encrypt RPC response"); + return; + }; + let reply_target = if source_device_id == "rpc" { + "rpc" + } else { + source_device_id.as_str() + }; + if let Err(error) = relay_client + .send_device_message( + reply_target, + &correlation_id, + &encrypted_response, + &response_nonce, + ) + .await + { + tracing::warn!("Failed to send RPC response: {error}"); } - _ => {} } + RelayEvent::Disconnected => { + tracing::info!("Device routing disconnected"); + crate::peer_host::update_controller_presence(Vec::new()).await; + } + RelayEvent::Reconnected => tracing::info!("Device routing reconnected"), + RelayEvent::Error { message } => { + tracing::warn!("Device routing error: {message}") + } + RelayEvent::AuthError { .. } => unreachable!("AuthError handled before routing lease"), + _ => {} + } + } + + async fn handle_relay_auth_error( + &self, + message: String, + relay_client: &Arc, + account_generation: u64, + expected_token: &str, + ) { + tracing::warn!("Device routing auth error: {message}"); + { + let _routing_guard = self.lifecycle.write().await; + let mut current = self.relay_client.write().await; + if !take_routing_client_if_same(&mut current, relay_client) { + tracing::debug!("Ignoring auth error from a replaced routing client"); + return; + } + drop(current); + relay_client.disconnect().await; + } + let Ok(runtime) = self.runtime() else { + return; + }; + if runtime + .expire_rejected_context(account_generation, expected_token) + .await + { + crate::peer_host::update_controller_presence(Vec::new()).await; + } + } + + pub(crate) async fn capture_peer_fanout_owner(&self) -> Result { + let runtime = self.runtime()?; + let generation = runtime.account_context_generation(); + let _routing_lease = self.lifecycle.read().await; + let (session, _) = runtime + .read_account_context_for_generation(generation) + .await?; + let relay_client = self + .relay_client + .read() + .await + .clone() + .ok_or_else(|| anyhow!("device routing not connected"))?; + if !self + .routing_loop_is_current(generation, &relay_client) + .await + { + return Err(anyhow!("account context changed")); + } + Ok(PeerFanoutOwner { + account_generation: generation, + account_token: session.token, + relay_client, + runtime: Arc::downgrade(&runtime), + routing: self.self_ref.clone(), }) - .await; + } } -/// Auth failure starts an account transition, which owns the lifecycle write -/// lease. It cannot be handled under the ordinary event read lease because -/// upgrading a Tokio `RwLock` would deadlock. -async fn handle_relay_auth_error( - message: String, - account_context: &Arc>>, - relay_client: &Arc, - account_generation: u64, - expected_token: &str, -) { - tracing::warn!("Device routing auth error: {message}"); - let Some(_transition_guard) = invalidate_and_wait_if_account_current(account_generation).await - else { - tracing::debug!("Ignoring auth error from a stale account generation"); - return; - }; - if !is_current_routing_client(relay_client).await { - tracing::debug!("Ignoring auth error from a replaced routing client"); - return; +#[async_trait] +impl AccountRuntimeHost for CliAccountRoutingHost { + async fn retire_background_routing_owner( + &self, + ) -> std::result::Result { + if !crate::daemon::is_daemon_running() { + return Ok(false); + } + if !crate::daemon::request_daemon_shutdown() { + return Err(BackgroundRoutingOwnerRetirementError { + error: anyhow!("could not stop the CLI daemon; the current account remains active"), + owner_may_exit: false, + }); + } + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while crate::daemon::is_daemon_running() { + if tokio::time::Instant::now() >= deadline { + return Err(BackgroundRoutingOwnerRetirementError { + error: anyhow!( + "CLI daemon did not stop in time; the current account remains active" + ), + owner_may_exit: true, + }); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + Ok(true) } - let token_matches = account_context - .read() - .await - .as_ref() - .is_some_and(|context| context.session.token == expected_token); - if !token_matches || !is_current_routing_client(relay_client).await { - tracing::debug!("Ignoring auth error from a replaced routing client"); - return; + + fn background_routing_owner_is_running(&self) -> bool { + crate::daemon::is_daemon_running() } - // Keep CLI/daemon semantics aligned with Desktop: a relay-rejected token - // is no longer a usable local login and must not be restored again on the - // next process start. Preserve the non-secret hint for the re-login form. - relay_client.disconnect().await; - if !is_current_routing_client(relay_client).await { - tracing::debug!("Ignoring auth error cleanup for a replaced routing client"); - return; + fn request_background_routing_owner_shutdown(&self) -> bool { + crate::daemon::request_daemon_shutdown() } - let mut current_client = device_relay_client().write().await; - if !same_routing_client(current_client.as_ref(), relay_client) { - tracing::debug!("Ignoring auth error cleanup for a replaced routing client"); - return; + async fn start_device_routing(&self, request: AccountRoutingStartRequest) -> Result<()> { + self.start_routing(request).await } - let mut current_context = account_context.write().await; - if current_context - .as_ref() - .is_none_or(|context| context.session.token != expected_token) - { - tracing::debug!("Ignoring auth error cleanup for a replaced account"); - return; + + async fn stop_device_routing(&self) { + self.stop_routing().await; } - take_routing_client_if_same(&mut current_client, relay_client); - *current_context = None; - drop(current_context); - drop(current_client); - TOKEN_EXPIRED.store(true, Ordering::Relaxed); - PENDING_SYNC_CHOICE.store(false, Ordering::Release); - session_store::clear_session(); - crate::peer_host::update_controller_presence(Vec::new()).await; + fn notify_controllers_settings_changed(&self) { + crate::peer_host::notify_controllers_settings_changed(); + } +} + +fn same_routing_client(current: Option<&Arc>, expected: &Arc) -> bool { + current.is_some_and(|client| Arc::ptr_eq(client, expected)) } -/// Immutable routing owner captured when a Peer DeviceEvent enters the bounded -/// delivery queue. It prevents an event from account A being encrypted or sent -/// through account B after waiting behind older events. +fn take_routing_client_if_same(current: &mut Option>, expected: &Arc) -> bool { + if !same_routing_client(current.as_ref(), expected) { + return false; + } + current.take(); + true +} + +/// Immutable routing owner captured when a Peer DeviceEvent enters the queue. #[derive(Clone)] pub(crate) struct PeerFanoutOwner { account_generation: u64, account_token: String, relay_client: Arc, + runtime: Weak, + routing: Weak, } tokio::task_local! { @@ -1132,6 +584,8 @@ impl PeerFanoutOwner { account_generation, account_token: account_token.to_string(), relay_client: Arc::new(relay_client), + runtime: Weak::new(), + routing: Weak::new(), } } @@ -1141,107 +595,58 @@ impl PeerFanoutOwner { } } -/// Stable fan-out context. The read lease is intentionally retained through -/// encryption and all target sends; account replacement takes the write lease. pub(crate) struct PeerFanoutLease { pub(crate) session: AccountSession, pub(crate) relay_client: Arc, - _routing_lease: tokio::sync::RwLockReadGuard<'static, ()>, -} - -pub(crate) async fn capture_peer_fanout_owner() -> Result { - let generation = account_context_generation(); - let _routing_lease = DEVICE_ROUTING_LIFECYCLE.read().await; - let (session, _) = read_account_context_for_generation(generation).await?; - let client = device_relay_client() - .read() - .await - .clone() - .ok_or_else(|| anyhow!("device routing not connected"))?; - if !account_context_is_current(generation) || !is_current_routing_client(&client).await { - return Err(anyhow!("account context changed")); - } - Ok(PeerFanoutOwner { - account_generation: generation, - account_token: session.token, - relay_client: client, - }) + _routing_lease: tokio::sync::OwnedRwLockReadGuard<()>, } pub(crate) async fn acquire_peer_fanout_lease(owner: &PeerFanoutOwner) -> Result { - let routing_lease = DEVICE_ROUTING_LIFECYCLE.read().await; - if !account_context_is_current(owner.account_generation) { + let runtime = owner + .runtime + .upgrade() + .ok_or_else(|| anyhow!("account runtime stopped"))?; + let routing = owner + .routing + .upgrade() + .ok_or_else(|| anyhow!("account routing stopped"))?; + let routing_lease = routing.lifecycle.clone().read_owned().await; + if !runtime.account_context_is_current(owner.account_generation) { return Err(anyhow!("queued Peer event account changed")); } - let context = account_context() - .read() - .await - .clone() - .ok_or_else(|| anyhow!("not logged in"))?; - let client = device_relay_client() + let (session, _) = runtime + .read_account_context_for_generation(owner.account_generation) + .await?; + let client = routing + .relay_client .read() .await .clone() .ok_or_else(|| anyhow!("device routing not connected"))?; - if !account_context_is_current(owner.account_generation) - || !owner.matches( - account_context_generation(), - &context.session.token, - &client, - ) - { + if !owner.matches( + runtime.account_context_generation(), + &session.token, + &client, + ) { return Err(anyhow!("queued Peer event routing owner changed")); } Ok(PeerFanoutLease { - session: context.session, + session, relay_client: client, _routing_lease: routing_lease, }) } -/// A textual device listing entry for display. -pub(crate) struct AccountDevice { - pub(crate) device_id: String, - pub(crate) device_name: String, - pub(crate) online: bool, -} - -/// List all devices in the account. -pub(crate) async fn list_devices() -> Result> { - let (session, relay_url) = read_account_context().await?; - let devices = AccountClient::new() - .list_devices(&relay_url, &session) - .await?; - Ok(devices - .into_iter() - .map(|d| AccountDevice { - device_id: d.device_id, - device_name: d.device_name, - online: d.online, - }) - .collect()) -} - #[cfg(test)] mod tests { - use std::sync::Arc; + use super::*; use std::time::Duration; - use super::{ - account_context_generation, automatic_account_sync_policy_for_pending, - begin_candidate_account_transition, clear_replaced_persisted_session, - inherited_peer_fanout_owner, login_with_credentials, replaced_account_revocation_target, - resolve_cloud_settings_probe, take_routing_client_if_same, AccountContextState, - CandidateAccountTransitionError, DaemonRetirementFailure, PeerFanoutOwner, - ACCOUNT_LOGIN_LOCK, ACTIVE_PEER_FANOUT_OWNER, DEVICE_ROUTING_LIFECYCLE, - }; - #[test] fn stale_routing_loop_cannot_clear_replacement_client() { let stale = Arc::new("stale"); let replacement = Arc::new("replacement"); let mut current = Some(Arc::clone(&replacement)); - assert!(!take_routing_client_if_same(&mut current, &stale)); assert!(current .as_ref() @@ -1249,226 +654,32 @@ mod tests { } #[test] - fn routing_loop_can_clear_only_its_own_client() { - let owned = Arc::new("owned"); - let mut current = Some(Arc::clone(&owned)); - - assert!(take_routing_client_if_same(&mut current, &owned)); - assert!(current.is_none()); + fn queued_fanout_owner_requires_generation_token_and_client_identity() { + let owner = PeerFanoutOwner::for_test(11, "token-a"); + let owned_client = Arc::clone(&owner.relay_client); + let replacement = PeerFanoutOwner::for_test(12, "token-b"); + assert!(owner.matches(11, "token-a", &owned_client)); + assert!(!owner.matches(12, "token-a", &owned_client)); + assert!(!owner.matches(11, "token-b", &owned_client)); + assert!(!owner.matches(11, "token-a", &replacement.relay_client)); } #[tokio::test] - async fn routing_replacement_waits_for_in_flight_event_lease() { - let event_lease = DEVICE_ROUTING_LIFECYCLE.read().await; - let (attempting_tx, attempting_rx) = tokio::sync::oneshot::channel(); + async fn routing_replacement_waits_for_an_in_flight_event_lease() { + let routing = CliAccountRoutingHost::new(); + let event_lease = routing.lifecycle.read().await; + let lifecycle = routing.lifecycle.clone(); let replacement = tokio::spawn(async move { - let _ = attempting_tx.send(()); - let _replacement_lease = DEVICE_ROUTING_LIFECYCLE.write().await; + let _replacement_lease = lifecycle.write().await; }); - attempting_rx.await.expect("replacement task started"); tokio::task::yield_now().await; assert!(!replacement.is_finished()); - drop(event_lease); - tokio::time::timeout(Duration::from_secs(1), replacement) - .await - .expect("replacement should acquire the lifecycle after event completion") - .expect("replacement task should finish"); - } - - #[tokio::test] - async fn inherited_fanout_owner_does_not_reacquire_routing_read_lease() { - let event_lease = DEVICE_ROUTING_LIFECYCLE.read().await; - let (attempting_tx, attempting_rx) = tokio::sync::oneshot::channel(); - let replacement = tokio::spawn(async move { - let _ = attempting_tx.send(()); - let _replacement_lease = DEVICE_ROUTING_LIFECYCLE.write().await; - }); - - attempting_rx.await.expect("replacement task started"); - tokio::task::yield_now().await; - assert!(!replacement.is_finished()); - let owner = PeerFanoutOwner::for_test(21, "token-a"); - let inherited = tokio::time::timeout( - Duration::from_millis(100), - ACTIVE_PEER_FANOUT_OWNER.scope(owner, async { inherited_peer_fanout_owner() }), - ) - .await - .expect("inherited owner lookup must not wait behind the queued writer") - .expect("task-local owner should be visible"); - assert_eq!(inherited.generation_for_test(), 21); - assert!(!replacement.is_finished()); - - drop(event_lease); tokio::time::timeout(Duration::from_secs(1), replacement) .await - .expect("replacement should proceed after the outer event lease is released") + .expect("replacement should acquire the lifecycle after event completion") .expect("replacement task should finish"); } - - #[tokio::test] - async fn invalid_login_does_not_invalidate_the_current_account() { - let generation = account_context_generation(); - - let error = login_with_credentials("", "user", "password") - .await - .expect_err("empty relay URL must be rejected"); - - assert!(error.to_string().contains("Auth Server is required")); - assert_eq!(account_context_generation(), generation); - } - - #[test] - fn cloud_settings_probe_errors_are_not_treated_as_missing_settings() { - assert!(!resolve_cloud_settings_probe(Ok(None)).expect("missing settings is valid")); - assert!( - resolve_cloud_settings_probe(Ok(Some("encrypted settings".to_string()))) - .expect("existing settings is valid") - ); - - let error = resolve_cloud_settings_probe(Err(anyhow::anyhow!("relay unavailable"))) - .expect_err("probe failure must reject the candidate login"); - assert!(error.to_string().contains("could not check cloud settings")); - assert!(error.to_string().contains("relay unavailable")); - } - - #[test] - fn pending_sync_choice_blocks_automatic_pull_and_push_until_finalized() { - let pending = automatic_account_sync_policy_for_pending(true); - assert!(!pending.background_engine); - assert!(!pending.management_push); - - let finalized = automatic_account_sync_policy_for_pending(false); - assert!(finalized.background_engine); - assert!(finalized.management_push); - } - - #[tokio::test] - async fn daemon_retirement_failure_does_not_begin_account_transition() { - let generation = account_context_generation(); - let result = begin_candidate_account_transition( - generation, - Err(DaemonRetirementFailure { - error: anyhow::anyhow!("daemon stayed alive"), - daemon_may_exit: true, - }), - ) - .await; - - assert!(matches!( - result, - Err(CandidateAccountTransitionError::DaemonRetirement(_)) - )); - assert_eq!(account_context_generation(), generation); - } - - #[test] - fn pending_replacement_cannot_restore_the_previous_persisted_account() { - let directory = std::env::temp_dir().join(format!( - "bitfun-cli-account-session-{}", - uuid::Uuid::new_v4() - )); - bitfun_core::service::remote_connect::session_store::set_session_store_directory_for_test( - directory, - ); - let old_master_key = [7_u8; 32]; - bitfun_core::service::remote_connect::session_store::save_session_with_device( - "account-a-token", - "account-a", - &old_master_key, - "https://relay-a.example", - Some("device-a"), - ) - .expect("persist account A"); - assert_eq!( - bitfun_core::service::remote_connect::session_store::load_session_detailed() - .expect("load account A") - .expect("account A should be persisted") - .token, - "account-a-token" - ); - - // This is the disk step used after candidate B wins the transition and - // before B is exposed as awaiting its cloud/local sync choice. - clear_replaced_persisted_session(); - - assert!( - bitfun_core::service::remote_connect::session_store::load_session_detailed() - .expect("load after candidate B becomes pending") - .is_none() - ); - } - - #[test] - fn replacement_revokes_only_the_previous_distinct_token() { - let previous = AccountContextState { - session: bitfun_core::service::remote_connect::AccountSession { - token: "old-token".to_string(), - user_id: "same-account".to_string(), - master_key: [3_u8; 32], - }, - relay_url: "https://relay.example".to_string(), - }; - - let target = replaced_account_revocation_target( - Some(previous.clone()), - "https://relay.example", - "new-token", - ) - .expect("a new token for the same account must retire the old bearer"); - assert_eq!(target.session.token, "old-token"); - assert_eq!(target.session.user_id, "same-account"); - assert_eq!(target.relay_url, "https://relay.example"); - - assert!(replaced_account_revocation_target( - Some(previous.clone()), - "https://relay.example", - "old-token" - ) - .is_none()); - assert!(replaced_account_revocation_target( - Some(previous), - "https://other-relay.example", - "old-token" - ) - .is_some()); - assert!( - replaced_account_revocation_target(None, "https://relay.example", "new-token") - .is_none() - ); - } - - #[tokio::test] - async fn candidate_login_attempts_are_serialized() { - let first_candidate = ACCOUNT_LOGIN_LOCK.lock().await; - let (attempting_tx, attempting_rx) = tokio::sync::oneshot::channel(); - let second_candidate = tokio::spawn(async move { - let _ = attempting_tx.send(()); - let _guard = ACCOUNT_LOGIN_LOCK.lock().await; - }); - - attempting_rx.await.expect("second candidate started"); - tokio::task::yield_now().await; - assert!(!second_candidate.is_finished()); - - drop(first_candidate); - tokio::time::timeout(Duration::from_secs(1), second_candidate) - .await - .expect("second candidate should proceed after the first") - .expect("second candidate task should finish"); - } - - #[test] - fn queued_fanout_owner_requires_generation_token_and_client_identity() { - let owner = PeerFanoutOwner::for_test(11, "token-a"); - let owned_client = Arc::clone(&owner.relay_client); - let replacement = PeerFanoutOwner::for_test(12, "token-b"); - - assert!(owner.matches(11, "token-a", &owned_client)); - assert!(!owner.matches(12, "token-a", &owned_client)); - assert!(!owner.matches(11, "token-b", &owned_client)); - assert!(!owner.matches(11, "token-a", &replacement.relay_client)); - } } diff --git a/src/apps/cli/src/account_sync.rs b/src/apps/cli/src/account_sync.rs deleted file mode 100644 index 576c12921..000000000 --- a/src/apps/cli/src/account_sync.rs +++ /dev/null @@ -1,508 +0,0 @@ -//! CLI account auto-sync (settings + session upload), matching Desktop semantics. - -use std::path::{Path, PathBuf}; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, OnceLock, -}; - -use anyhow::{anyhow, Result}; -use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; - -use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; -use bitfun_core::service::config::get_global_config_service; -use bitfun_core::service::remote_connect::account::{ - ensure_relay_session_history_exportable, relay_session_export_metadata, -}; -use bitfun_core::service::remote_connect::settings_sync; -use bitfun_core::service::remote_connect::{sync_state, AccountClient}; - -use crate::account::{ - account_context_generation, account_context_is_current, automatic_account_sync_policy, - await_account_sync_current, lock_account_sync, read_account_context, -}; - -const UPLOAD_CONCURRENCY_CHUNK: usize = 5; - -/// Start the continuous settings sync loop (debounced push + 30s pull). -/// Started once per process (interactive TUI and daemon); every cycle -/// silently skips while logged out and converges as soon as an account -/// session exists. Peer Mode controllers are notified via DeviceEvent when -/// this host's effective settings change. -pub(crate) fn start_settings_sync_loop() { - let hooks = settings_sync::SettingsSyncHooks { - account_context: Some(Arc::new(|| { - Box::pin(async { - if !automatic_account_sync_policy().background_engine { - return Err(anyhow!("account login is awaiting a sync choice")); - } - let generation = account_context_generation(); - if !account_context_is_current(generation) { - return Err(anyhow!("account context is transitioning")); - } - let (account, relay_url) = read_account_context().await?; - if !automatic_account_sync_policy().background_engine - || !account_context_is_current(generation) - { - return Err(anyhow!("account context changed while reading")); - } - Ok((account, relay_url, generation)) - }) - })), - is_account_context_current: Some(Arc::new(account_context_is_current)), - on_settings_applied: Some(Arc::new(|| { - crate::peer_host::notify_controllers_settings_changed(); - })), - on_settings_pushed: Some(Arc::new(|| { - crate::peer_host::notify_controllers_settings_changed(); - })), - on_token_expired: Some(Arc::new(crate::account::mark_token_expired)), - ..Default::default() - }; - settings_sync::start_settings_sync_engine(hooks); -} - -/// Notify the sync loop that local settings changed (TUI edits, peer -/// `set_config`). Upload is debounced and content-hash deduped. -pub(crate) fn notify_local_settings_changed() { - settings_sync::notify_settings_changed(); -} - -/// Best-effort one-shot settings push for short-lived management commands -/// (e.g. `bitfun models set-default`) where the sync loop never starts. -/// Silently no-ops when logged out; failures are logged, not fatal. -pub(crate) async fn push_settings_after_local_change() { - if !automatic_account_sync_policy().management_push { - return; - } - // Management commands never restore the persisted account session into - // memory — do it on demand so the push can authenticate. - if read_account_context().await.is_err() { - crate::account::try_restore_session().await; - } - let generation = account_context_generation(); - let Ok(_sync_guard) = lock_account_sync(generation).await else { - return; - }; - if !automatic_account_sync_policy().management_push { - return; - } - let Ok((account, relay_url)) = read_account_context().await else { - return; - }; - match settings_sync::push_settings_now(&account, &relay_url).await { - Ok(true) => tracing::info!("Settings pushed to account cloud"), - Ok(false) => {} - Err(e) => tracing::warn!("Settings push failed: {e}"), - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(crate) enum SyncStatus { - #[default] - Idle, - Syncing, - Done, - Failed, - Cancelled, -} - -#[derive(Debug, Clone)] -pub(crate) struct SyncProgress { - pub operation_id: Option, - pub status: SyncStatus, - pub phase: String, - pub percent: u8, - pub current: Option, - pub total: Option, - pub detail: Option, - pub error: Option, - pub settings_synced: bool, - pub sessions_exported: usize, -} - -impl Default for SyncProgress { - fn default() -> Self { - Self { - operation_id: None, - status: SyncStatus::Idle, - phase: String::new(), - percent: 0, - current: None, - total: None, - detail: None, - error: None, - settings_synced: false, - sessions_exported: 0, - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct AutoSyncResult { - pub settings_synced: bool, - pub sessions_exported: usize, - #[allow(dead_code)] - pub sessions_imported: usize, -} - -#[derive(Serialize, Deserialize)] -struct SessionBundle { - session_id: String, - metadata: serde_json::Value, - turns: Vec, - source_device_id: Option, - source_device_name: Option, -} - -static SYNC_PROGRESS: OnceLock>> = OnceLock::new(); -static AUTO_SYNC_IN_FLIGHT: AtomicBool = AtomicBool::new(false); - -fn sync_progress_store() -> &'static Arc> { - SYNC_PROGRESS.get_or_init(|| Arc::new(RwLock::new(SyncProgress::default()))) -} - -pub(crate) async fn current_sync_progress() -> SyncProgress { - sync_progress_store().read().await.clone() -} - -async fn set_progress(mut update: impl FnMut(&mut SyncProgress)) { - let mut guard = sync_progress_store().write().await; - update(&mut guard); -} - -async fn emit_progress( - phase: &str, - percent: u8, - current: Option, - total: Option, - detail: Option<&str>, -) { - set_progress(|p| { - p.status = SyncStatus::Syncing; - p.phase = phase.to_string(); - p.percent = percent; - p.current = current; - p.total = total; - p.detail = detail.map(|s| s.to_string()); - p.error = None; - }) - .await; -} - -/// Start auto-sync in the background. Returns immediately; progress is in -/// [`current_sync_progress`]. -pub(crate) async fn start_auto_sync_background( - compatibility: CoreAgentRuntimeCompatibility, - operation_id: String, - is_first_login: bool, - workspace_path: PathBuf, -) -> bool { - if AUTO_SYNC_IN_FLIGHT.swap(true, Ordering::SeqCst) { - tracing::warn!("Account auto-sync already in flight; skipping duplicate start"); - return false; - } - set_progress(|progress| { - progress.operation_id = Some(operation_id.clone()); - }) - .await; - tokio::spawn(async move { - let result = run_auto_sync(&compatibility, is_first_login, &workspace_path).await; - AUTO_SYNC_IN_FLIGHT.store(false, Ordering::SeqCst); - match result { - Ok(r) => { - set_progress(|p| { - if p.operation_id.as_deref() != Some(operation_id.as_str()) { - return; - } - p.status = SyncStatus::Done; - p.phase = "done".into(); - p.percent = 100; - p.settings_synced = r.settings_synced; - p.sessions_exported = r.sessions_exported; - p.error = None; - }) - .await; - } - Err(e) => { - set_progress(|p| { - if p.operation_id.as_deref() != Some(operation_id.as_str()) { - return; - } - p.status = SyncStatus::Failed; - p.error = Some(e.to_string()); - }) - .await; - tracing::warn!("Account auto-sync failed: {e}"); - } - } - }); - true -} - -pub(crate) async fn mark_sync_cancelled(operation_id: String) { - set_progress(|progress| { - progress.operation_id = Some(operation_id.clone()); - progress.status = SyncStatus::Cancelled; - progress.phase = "cancelled".to_string(); - progress.error = None; - }) - .await; -} - -pub(crate) async fn run_auto_sync( - compatibility: &CoreAgentRuntimeCompatibility, - is_first_login: bool, - workspace_path: &Path, -) -> Result { - let generation = account_context_generation(); - let _sync_guard = lock_account_sync(generation).await?; - set_progress(|p| { - *p = SyncProgress { - operation_id: p.operation_id.clone(), - status: SyncStatus::Syncing, - phase: "starting".into(), - percent: 1, - ..SyncProgress::default() - }; - }) - .await; - - let (acct_session, relay_url) = read_account_context().await?; - let client = AccountClient::new(); - - let settings_synced = if is_first_login { - emit_progress("uploading_settings", 5, None, None, None).await; - let config_service = get_global_config_service() - .await - .map_err(|e| anyhow!("config service: {e}"))?; - let exported = config_service - .export_config() - .await - .map_err(|e| anyhow!("export config: {e}"))?; - let config_json = - serde_json::to_string(&exported).map_err(|e| anyhow!("serialize config: {e}"))?; - await_account_sync_current( - generation, - settings_sync::upload_settings_payload(&acct_session, &relay_url, &config_json), - ) - .await? - .map_err(|e| anyhow!("upload settings: {e}"))?; - emit_progress("settings_done", 15, None, None, None).await; - true - } else { - emit_progress("downloading_settings", 5, None, None, None).await; - let cloud = await_account_sync_current( - generation, - client.fetch_settings_with_version(&relay_url, &acct_session), - ) - .await? - .map_err(|e| anyhow!("fetch settings: {e}"))?; - if let Some(blob) = cloud { - emit_progress("applying_settings", 10, None, None, None).await; - // Explicit user choice ("use cloud") — always apply, even when the - // cursor says this device already has this version. - await_account_sync_current( - generation, - settings_sync::apply_settings_blob(&acct_session, &blob, true), - ) - .await? - .map_err(|e| anyhow!("apply cloud config: {e}"))?; - emit_progress("settings_done", 15, None, None, None).await; - true - } else { - emit_progress("settings_done", 15, None, None, None).await; - false - } - }; - - emit_progress("listing_sessions", 18, None, None, None).await; - let storage_path = workspace_path.to_path_buf(); - - let local_sessions = compatibility - .list_persisted_sessions(&storage_path) - .await - .map_err(|e| anyhow!("list sessions: {e}"))?; - - emit_progress( - "exporting_sessions", - 20, - Some(0), - Some(local_sessions.len()), - None, - ) - .await; - - let mut sync_state_local = sync_state::load(&acct_session.user_id); - let mut pending_uploads: Vec<(String, String, String)> = Vec::new(); - for meta in local_sessions.iter() { - if !account_context_is_current(generation) { - return Err(anyhow!("account sync cancelled")); - } - if let Err(error) = ensure_relay_session_history_exportable(meta) { - tracing::debug!("Skipping CLI account session export: {error}"); - continue; - } - let turns = compatibility - .load_persisted_session_turns(&storage_path, &meta.session_id, None) - .await - .map_err(|e| anyhow!("load turns: {e}"))?; - let metadata = relay_session_export_metadata(meta, turns.len()); - let metadata_json = - serde_json::to_value(metadata).map_err(|e| anyhow!("serialize metadata: {e}"))?; - let turns_json: Vec = turns - .iter() - .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null)) - .collect(); - let bundle = SessionBundle { - session_id: meta.session_id.clone(), - metadata: metadata_json, - turns: turns_json, - source_device_id: None, - source_device_name: None, - }; - let bundle_json = - serde_json::to_string(&bundle).map_err(|e| anyhow!("serialize bundle: {e}"))?; - let hash = sync_state::content_hash(&bundle_json); - if sync_state_local.uploaded_hash(&meta.session_id) == Some(hash.as_str()) { - continue; - } - pending_uploads.push((meta.session_id.clone(), bundle_json, hash)); - } - - let upload_total = pending_uploads.len(); - emit_progress("exporting_sessions", 20, Some(0), Some(upload_total), None).await; - - let mut uploaded: Vec<(String, String, i64)> = Vec::new(); - let mut upload_errors: Vec = Vec::new(); - for (chunk_idx, chunk) in pending_uploads.chunks(UPLOAD_CONCURRENCY_CHUNK).enumerate() { - let mut handles = Vec::new(); - for (session_id, bundle_json, hash) in chunk { - let client = AccountClient::new(); - let relay_url = relay_url.clone(); - let acct_session = acct_session.clone(); - let session_id = session_id.clone(); - let bundle_json = bundle_json.clone(); - let hash = hash.clone(); - handles.push(tokio::spawn(async move { - let result = await_account_sync_current( - generation, - client.upload_session(&relay_url, &acct_session, &session_id, &bundle_json), - ) - .await; - (session_id, hash, result) - })); - } - for handle in handles { - let done_base = chunk_idx * UPLOAD_CONCURRENCY_CHUNK; - match handle.await { - Ok((session_id, hash, Ok(Ok(version)))) => { - uploaded.push((session_id.clone(), hash, version)); - let done = uploaded.len(); - let percent = if upload_total == 0 { - 95u8 - } else { - 20 + ((75 * done) / upload_total) as u8 - }; - emit_progress( - "exporting_sessions", - percent.min(95), - Some(done), - Some(upload_total), - Some(&session_id), - ) - .await; - } - Ok((session_id, _, Ok(Err(e)))) => { - tracing::warn!("Auto-sync upload {session_id} failed: {e}"); - upload_errors.push(format!("{session_id}: {e}")); - let _ = done_base; - } - Ok((_, _, Err(e))) => return Err(e), - Err(e) => { - tracing::warn!("Auto-sync upload task join failed: {e}"); - upload_errors.push(format!("upload task join failed: {e}")); - } - } - } - if !account_context_is_current(generation) { - return Err(anyhow!("account sync cancelled")); - } - } - - let exported = uploaded.len(); - let mut max_uploaded_version = sync_state_local.last_session_since; - for (session_id, hash, version) in uploaded { - sync_state_local.set_uploaded_hash(&session_id, hash); - if version > max_uploaded_version { - max_uploaded_version = version; - } - } - if max_uploaded_version > sync_state_local.last_session_since { - sync_state_local.last_session_since = max_uploaded_version; - } - let _ = sync_state::save(&acct_session.user_id, &sync_state_local); - - ensure_session_backup_complete(upload_total, exported, &upload_errors)?; - - tracing::info!("Auto-sync: settings={settings_synced} exported={exported} imported=0"); - emit_progress("done", 100, Some(exported), Some(0), None).await; - - Ok(AutoSyncResult { - settings_synced, - sessions_exported: exported, - sessions_imported: 0, - }) -} - -fn ensure_session_backup_complete( - total: usize, - uploaded: usize, - upload_errors: &[String], -) -> Result<()> { - if uploaded == total { - return Ok(()); - } - let detail = upload_errors - .first() - .map(|err| err.as_str()) - .unwrap_or("retry will resume remaining sessions"); - Err(anyhow!( - "session backup incomplete: uploaded {uploaded} of {total}; {detail}" - )) -} - -#[cfg(test)] -mod tests { - use super::ensure_session_backup_complete; - - #[test] - fn partial_session_backup_is_not_reported_as_success() { - assert!(ensure_session_backup_complete(4, 4, &[]).is_ok()); - assert!(ensure_session_backup_complete( - 4, - 1, - &["s1: relay returned HTTP 507 Insufficient Storage".into()] - ) - .unwrap_err() - .to_string() - .contains("HTTP 507")); - } - - #[test] - fn cli_session_backup_uses_the_shared_import_guard_and_visible_count() { - let source = include_str!("account_sync.rs").replace("\r\n", "\n"); - let export_loop = source - .split_once("for meta in local_sessions.iter()") - .expect("CLI account Session export loop") - .1 - .split_once("let upload_total = pending_uploads.len()") - .expect("CLI account Session export loop boundary") - .0; - - assert!(export_loop.contains("ensure_relay_session_history_exportable(meta)")); - assert!(export_loop.contains("relay_session_export_metadata(meta, turns.len())")); - assert!(export_loop.contains("pending_uploads.push")); - } -} diff --git a/src/apps/cli/src/daemon/runner.rs b/src/apps/cli/src/daemon/runner.rs index 7f41286a9..7f406dfe9 100644 --- a/src/apps/cli/src/daemon/runner.rs +++ b/src/apps/cli/src/daemon/runner.rs @@ -11,7 +11,7 @@ use anyhow::{anyhow, Result}; use bitfun_core::service::remote_connect::DeviceIdentity; -use crate::{account, runtime, BootstrapProfile}; +use crate::{runtime, BootstrapProfile}; use super::pid; @@ -27,14 +27,15 @@ pub(crate) async fn run_daemon() -> Result<()> { // The daemon is not bound to the caller's cwd; peer commands carry their // own workspace paths. Home is a stable root for the runtime context. let workspace_root = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - let _runtime = crate::initialize_core_services( + let runtime = crate::initialize_core_services( &workspace_root, runtime::approval::CliApprovalPolicy::Ask, BootstrapProfile::Interactive, ) .await?; - let Some(user_id) = account::try_restore_session().await else { + let account = runtime.account_runtime(); + let Some(user_id) = account.try_restore_session().await else { return Err(anyhow!( "not logged in; run `bitfun`, log in with `/login`, then start the daemon again" )); @@ -43,12 +44,12 @@ pub(crate) async fn run_daemon() -> Result<()> { let device = DeviceIdentity::from_current_machine().map_err(|e| anyhow!("detect device: {e}"))?; - account::restore_device_routing(&device.device_name).await?; + account.restore_device_routing(&device.device_name).await?; // Continuous account settings sync (30s pull + debounced push) so this // always-on host converges with cloud changes made on other devices and // attached controllers see fresh config without reconnecting. - crate::account_sync::start_settings_sync_loop(); + account.start_settings_sync_loop(); pid::write_pid_file()?; tracing::info!("bitfun daemon running (pid {})", std::process::id()); @@ -62,7 +63,7 @@ pub(crate) async fn run_daemon() -> Result<()> { break; } _ = expired_check.tick() => { - if account::is_token_expired() { + if account.is_token_expired() { // Exit 0 on purpose: re-authentication needs a human, so // Restart=on-failure must not loop the daemon. tracing::warn!("Account token rejected by the relay; daemon exiting"); @@ -72,7 +73,7 @@ pub(crate) async fn run_daemon() -> Result<()> { } } - account::stop_device_routing().await; + runtime.account_routing().stop_device_routing().await; pid::remove_pid_file(); crate::shutdown_mcp_servers().await; tracing::info!("bitfun daemon stopped"); diff --git a/src/apps/cli/src/embedded_app_server.rs b/src/apps/cli/src/embedded_app_server.rs index ca39c1a3f..062d463f8 100644 --- a/src/apps/cli/src/embedded_app_server.rs +++ b/src/apps/cli/src/embedded_app_server.rs @@ -24,14 +24,9 @@ impl EmbeddedAppServerHost { runtime.agent_event_source(), ) .with_context_reload(Arc::new(runtime.compatibility().clone())); - let account_host = Arc::new( - crate::tui_account_management::CliAccountManagementHost::new( - runtime.compatibility().clone(), - ), - ); - let worktree_host = Arc::new(crate::tui_worktree_management::CliWorktreeManagementHost); let management = Arc::new( - AppManagementService::load_with_hosts(Some(account_host), Some(worktree_host)).await?, + AppManagementService::load_for_local_host(Some(runtime.account_runtime().clone())) + .await?, ); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let server_thread = std::thread::Builder::new() diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index c1cf58859..2cfc3d4f3 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -9,7 +9,6 @@ /// - Single command execution /// - Batch task processing mod account; -mod account_sync; mod acp_cli; mod actions; mod agent; @@ -37,9 +36,7 @@ mod self_update; mod shared_runtime; mod shared_tui_backend; mod terminal_attention; -mod tui_account_management; mod tui_backend; -mod tui_worktree_management; mod ui; use anyhow::{anyhow, Result}; @@ -987,7 +984,10 @@ async fn run_interactive( }; // 3.5 Restore persisted account session (if any) if !shared { - if let Some(user_id) = account::try_restore_session().await { + let runtime = runtime + .as_ref() + .expect("Embedded account startup requires the CLI Runtime"); + if let Some(user_id) = runtime.account_runtime().try_restore_session().await { tracing::info!("Restored account session for user {user_id}"); if daemon::is_daemon_running() { tracing::info!( @@ -996,7 +996,11 @@ async fn run_interactive( } else { let device = DeviceIdentity::from_current_machine() .map_err(|e| anyhow!("detect device: {e}"))?; - if let Err(e) = account::restore_device_routing(&device.device_name).await { + if let Err(e) = runtime + .account_runtime() + .restore_device_routing(&device.device_name) + .await + { tracing::warn!("Failed to restore device routing: {e}"); } } @@ -1006,7 +1010,11 @@ async fn run_interactive( // 3.6 Continuous account settings sync (30s pull + debounced push). // Safe to start before login: cycles skip while logged out. if !shared { - account_sync::start_settings_sync_loop(); + runtime + .as_ref() + .expect("Embedded settings sync requires the CLI Runtime") + .account_runtime() + .start_settings_sync_loop(); } // Resolve agent override: validate against the agent registry AFTER core services init diff --git a/src/apps/cli/src/management.rs b/src/apps/cli/src/management.rs index 26ea57db5..dba216345 100644 --- a/src/apps/cli/src/management.rs +++ b/src/apps/cli/src/management.rs @@ -189,7 +189,9 @@ pub(crate) async fn set_default_model(model_id: &str) -> Result<()> { // Short-lived management process: the sync loop never runs here, so push // the change directly (no-op when logged out). - crate::account_sync::push_settings_after_local_change().await; + crate::account::build_management_account_runtime() + .push_settings_after_local_change() + .await; Ok(()) } diff --git a/src/apps/cli/src/peer_host/bootstrap.rs b/src/apps/cli/src/peer_host/bootstrap.rs index 81856d809..d073e0149 100644 --- a/src/apps/cli/src/peer_host/bootstrap.rs +++ b/src/apps/cli/src/peer_host/bootstrap.rs @@ -40,6 +40,8 @@ pub(crate) async fn ensure_peer_host_ready(runtime: &CliRuntimeContext) -> Resul agent_runtime: runtime.agent_runtime().clone(), local_workspace_snapshot: runtime.local_workspace_snapshot().clone(), compatibility: runtime.compatibility().clone(), + account_runtime: runtime.account_runtime().clone(), + account_routing: runtime.account_routing().clone(), turns: PeerTurnTracker::new(), workspace_service, filesystem_service, diff --git a/src/apps/cli/src/peer_host/commands/config.rs b/src/apps/cli/src/peer_host/commands/config.rs index a5b82f2a2..f771ccbd4 100644 --- a/src/apps/cli/src/peer_host/commands/config.rs +++ b/src/apps/cli/src/peer_host/commands/config.rs @@ -8,6 +8,7 @@ use bitfun_core::service::config::get_global_config_service; use bitfun_core::util::errors::BitFunError; use crate::peer_host::args::{optional_bool, request_value}; +use crate::peer_host::state::PeerHostState; fn is_expected_config_path_not_found(error: &BitFunError, path: Option<&str>) -> bool { match (error, path) { @@ -86,7 +87,7 @@ pub(crate) async fn get_configs(args: &Value) -> Result { Ok(json!(configs)) } -pub(crate) async fn set_config(args: &Value) -> Result { +pub(crate) async fn set_config(state: &PeerHostState, args: &Value) -> Result { let request = request_value(args); let path = request .get("path") @@ -110,7 +111,7 @@ pub(crate) async fn set_config(args: &Value) -> Result { // Config changed on this host via a peer controller — schedule the cloud // push so other same-account devices converge. - crate::account_sync::notify_local_settings_changed(); + state.account_runtime.notify_local_settings_changed(); Ok(json!("Configuration set successfully")) } diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index 4921847a4..e4cbddf0b 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -36,7 +36,7 @@ pub(crate) async fn dispatch( "reload_config" => workspace::reload_config().await, "get_config" => config::get_config(args).await, "get_configs" => config::get_configs(args).await, - "set_config" => config::set_config(args).await, + "set_config" => config::set_config(state, args).await, "get_agent_profile_config" => config::get_agent_profile_config(args).await, "get_agent_profile_configs" => config::get_agent_profile_configs().await, command if external_sources::supports(command) => { diff --git a/src/apps/cli/src/peer_host/fanout.rs b/src/apps/cli/src/peer_host/fanout.rs index 3fa826358..d1a15b76e 100644 --- a/src/apps/cli/src/peer_host/fanout.rs +++ b/src/apps/cli/src/peer_host/fanout.rs @@ -408,7 +408,9 @@ async fn handle_agentic_event(state: &PeerHostState, event: AgenticEvent) -> Res return Err("no attached Peer controller can receive Agent events".to_string()); } let generation = state.turns.current_event_stream_generation()?; - let owner = crate::account::capture_peer_fanout_owner() + let owner = state + .account_routing + .capture_peer_fanout_owner() .await .map_err(|error| format!("Peer event routing owner unavailable: {error}"))?; enqueue_peer_device_event( @@ -546,8 +548,14 @@ pub(crate) async fn fanout_peer_device_event(event: String, payload: serde_json: let inherits_routing_lease = inherited_owner.is_some(); let owner = match inherited_owner { Some(owner) => owner, - None => match crate::account::capture_peer_fanout_owner().await { - Ok(owner) => owner, + None => match super::state::peer_host_state().map(|state| state.account_routing.clone()) { + Ok(routing) => match routing.capture_peer_fanout_owner().await { + Ok(owner) => owner, + Err(error) => { + tracing::debug!("Peer event fanout skipped before enqueue: {error}"); + return; + } + }, Err(error) => { tracing::debug!("Peer event fanout skipped before enqueue: {error}"); return; diff --git a/src/apps/cli/src/peer_host/state.rs b/src/apps/cli/src/peer_host/state.rs index 25209d6b2..8e0cad1f1 100644 --- a/src/apps/cli/src/peer_host/state.rs +++ b/src/apps/cli/src/peer_host/state.rs @@ -901,6 +901,9 @@ pub(crate) struct PeerHostState { pub(crate) agent_runtime: AgentRuntime, pub(crate) local_workspace_snapshot: Arc, pub(crate) compatibility: CoreAgentRuntimeCompatibility, + pub(crate) account_runtime: + Arc, + pub(crate) account_routing: Arc, pub(crate) turns: PeerTurnTracker, pub(crate) workspace_service: Arc, pub(crate) filesystem_service: Arc, diff --git a/src/apps/cli/src/runtime/mod.rs b/src/apps/cli/src/runtime/mod.rs index 05bc062cc..e33ce328c 100644 --- a/src/apps/cli/src/runtime/mod.rs +++ b/src/apps/cli/src/runtime/mod.rs @@ -10,9 +10,11 @@ use bitfun_core::product_runtime::{ CoreLocalWorkspaceSnapshot, CoreProductAgentRuntime, CoreProductEventQueueOwner, }; use bitfun_core::runtime_ports::PluginRuntimeAvailability; +use bitfun_core::service::remote_connect::account_runtime::AccountRuntime; use bitfun_runtime_ports::LocalWorkspaceSnapshotPort; use bitfun_runtime_services::RuntimeServices; +use crate::account::{build_account_runtime, CliAccountRoutingHost}; use crate::product_assembly::{assemble_acp_runtime_parts, assemble_cli_runtime_parts}; pub(crate) mod approval; @@ -53,6 +55,8 @@ pub(crate) struct CliRuntimeContext { agent_runtime: AgentRuntime, local_workspace_snapshot: Arc, compatibility: CoreAgentRuntimeCompatibility, + account_runtime: Arc, + account_routing: Arc, _agent_event_queue_owner: CoreProductEventQueueOwner, services: RuntimeServices, product: CliProductRuntimeState, @@ -97,6 +101,7 @@ impl CliRuntimeContext { .context("Failed to build CLI Agent Runtime SDK")?; let compatibility = CoreAgentRuntimeCompatibility::build(agentic_system.coordinator.clone(), scheduler); + let account = build_account_runtime(compatibility.clone()); let local_workspace_snapshot = CoreLocalWorkspaceSnapshot::build(); debug_assert_eq!( @@ -114,6 +119,8 @@ impl CliRuntimeContext { agent_runtime, local_workspace_snapshot, compatibility, + account_runtime: account.runtime, + account_routing: account.routing, services, product, approval_policy, @@ -136,6 +143,14 @@ impl CliRuntimeContext { &self.compatibility } + pub(crate) fn account_runtime(&self) -> &Arc { + &self.account_runtime + } + + pub(crate) fn account_routing(&self) -> &Arc { + &self.account_routing + } + pub(crate) fn local_workspace_snapshot(&self) -> &Arc { &self.local_workspace_snapshot } diff --git a/src/apps/cli/src/tui_account_management.rs b/src/apps/cli/src/tui_account_management.rs deleted file mode 100644 index af7ad1bcd..000000000 --- a/src/apps/cli/src/tui_account_management.rs +++ /dev/null @@ -1,248 +0,0 @@ -use std::path::PathBuf; - -use async_trait::async_trait; -use bitfun_app_server::management::{ - AccountManagementHost, AppManagementError, AppManagementResult, -}; -use bitfun_app_server_protocol::account::*; -use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; - -#[derive(Clone)] -pub(crate) struct CliAccountManagementHost { - compatibility: CoreAgentRuntimeCompatibility, -} - -impl CliAccountManagementHost { - pub(crate) fn new(compatibility: CoreAgentRuntimeCompatibility) -> Self { - Self { compatibility } - } - - async fn snapshot(&self, workspace_path: String) -> AccountSnapshotResponse { - let logged_in = crate::account::is_logged_in().await; - let info = if logged_in { - crate::account::account_info() - .await - .ok() - .map(project_account_info) - } else { - None - }; - let devices = if logged_in { - crate::account::list_devices() - .await - .unwrap_or_default() - .into_iter() - .map(project_account_device) - .collect() - } else { - Vec::new() - }; - let _ = workspace_path; - AccountSnapshotResponse { - logged_in, - pending_sync_choice: crate::account::pending_sync_choice(), - info, - devices, - sync: project_sync_progress(crate::account_sync::current_sync_progress().await), - } - } -} - -#[async_trait] -impl AccountManagementHost for CliAccountManagementHost { - async fn account_snapshot( - &self, - request: AccountSnapshotRequest, - ) -> AppManagementResult { - Ok(self.snapshot(request.workspace_path).await) - } - - async fn account_login( - &self, - request: AccountLoginRequest, - ) -> AppManagementResult { - validate_operation_id(&request.operation_id)?; - let result = crate::account::login_with_credentials( - &request.relay_url, - &request.username, - &request.password, - ) - .await - .map_err(|error| account_error(error, &request))?; - Ok(AccountLoginResponse { - user_id: result.user_id, - relay_url: result.relay_url, - has_cloud_settings: result.has_cloud_settings, - status_message: result.status_message, - }) - } - - async fn account_finalize_login( - &self, - request: AccountFinalizeLoginRequest, - ) -> AppManagementResult { - validate_operation_id(&request.operation_id)?; - crate::account::finalize_login_after_sync_choice() - .await - .map_err(internal_account_error)?; - if !crate::account_sync::start_auto_sync_background( - self.compatibility.clone(), - request.operation_id.clone(), - request.choice == AccountSyncChoice::Local, - PathBuf::from(&request.workspace_path), - ) - .await - { - return Err(AppManagementError::invalid_request( - "Account settings sync is already in progress", - )); - } - Ok(self.snapshot(request.workspace_path).await) - } - - async fn account_logout( - &self, - request: AccountLogoutRequest, - ) -> AppManagementResult { - validate_operation_id(&request.operation_id)?; - crate::account::logout() - .await - .map_err(internal_account_error)?; - crate::account_sync::mark_sync_cancelled(request.operation_id).await; - Ok(self.snapshot(request.workspace_path).await) - } - - async fn settings_sync_start( - &self, - request: SettingsSyncStartRequest, - ) -> AppManagementResult { - validate_operation_id(&request.operation_id)?; - if !crate::account::is_logged_in().await { - return Err(AppManagementError::invalid_request( - "Account login must be finalized before settings sync starts", - )); - } - if !crate::account_sync::start_auto_sync_background( - self.compatibility.clone(), - request.operation_id, - request.is_first_login, - PathBuf::from(request.workspace_path), - ) - .await - { - return Err(AppManagementError::invalid_request( - "Account settings sync is already in progress", - )); - } - Ok(current_sync_response().await) - } - - async fn settings_sync_snapshot( - &self, - _request: SettingsSyncSnapshotRequest, - ) -> AppManagementResult { - Ok(current_sync_response().await) - } - - async fn settings_sync_cancel( - &self, - request: SettingsSyncCancelRequest, - ) -> AppManagementResult { - validate_operation_id(&request.operation_id)?; - crate::account::logout() - .await - .map_err(internal_account_error)?; - crate::account_sync::mark_sync_cancelled(request.operation_id).await; - Ok(current_sync_response().await) - } - - async fn settings_sync_local_changed( - &self, - request: SettingsSyncLocalChangedRequest, - ) -> AppManagementResult { - validate_operation_id(&request.operation_id)?; - crate::account_sync::notify_local_settings_changed(); - Ok(current_sync_response().await) - } -} - -async fn current_sync_response() -> SettingsSyncResponse { - SettingsSyncResponse { - progress: project_sync_progress(crate::account_sync::current_sync_progress().await), - } -} - -fn project_account_info(info: crate::account::AccountInfo) -> AccountInfo { - AccountInfo { - user_id: info.user_id, - relay_url: info.relay_url, - device_id: info.device_id, - device_name: info.device_name, - } -} - -fn project_account_device(device: crate::account::AccountDevice) -> AccountDevice { - AccountDevice { - device_id: device.device_id, - device_name: device.device_name, - online: device.online, - } -} - -fn project_sync_progress(progress: crate::account_sync::SyncProgress) -> SettingsSyncProgress { - SettingsSyncProgress { - operation_id: progress.operation_id, - status: match progress.status { - crate::account_sync::SyncStatus::Idle => SettingsSyncStatus::Idle, - crate::account_sync::SyncStatus::Syncing => SettingsSyncStatus::Syncing, - crate::account_sync::SyncStatus::Done => SettingsSyncStatus::Done, - crate::account_sync::SyncStatus::Failed => SettingsSyncStatus::Failed, - crate::account_sync::SyncStatus::Cancelled => SettingsSyncStatus::Cancelled, - }, - phase: progress.phase, - percent: progress.percent, - current: progress.current, - total: progress.total, - detail: progress.detail, - error: progress.error, - settings_synced: progress.settings_synced, - sessions_exported: progress.sessions_exported, - } -} - -fn validate_operation_id(operation_id: &str) -> AppManagementResult<()> { - let valid = !operation_id.trim().is_empty() - && operation_id.len() <= 128 - && operation_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')); - if valid { - Ok(()) - } else { - Err(AppManagementError::invalid_request( - "Account operation ID is invalid", - )) - } -} - -fn account_error(error: anyhow::Error, request: &AccountLoginRequest) -> AppManagementError { - let mut message = error.to_string(); - for secret in [&request.relay_url, &request.username, &request.password] { - if !secret.is_empty() { - message = message.replace(secret, ""); - } - } - AppManagementError::internal(bounded_error(message)) -} - -fn internal_account_error(error: anyhow::Error) -> AppManagementError { - AppManagementError::internal(bounded_error(error.to_string())) -} - -fn bounded_error(message: String) -> String { - message - .chars() - .filter(|character| !character.is_control()) - .take(500) - .collect() -} diff --git a/src/apps/cli/src/tui_worktree_management.rs b/src/apps/cli/src/tui_worktree_management.rs deleted file mode 100644 index 961943bcc..000000000 --- a/src/apps/cli/src/tui_worktree_management.rs +++ /dev/null @@ -1,143 +0,0 @@ -use async_trait::async_trait; -use bitfun_app_server::management::{ - AppManagementError, AppManagementResult, WorktreeManagementHost, -}; -use bitfun_app_server_protocol::worktree::*; -use bitfun_core::service::git::GitService; -use bitfun_core::service::worktree::{WorktreeService, WorktreeSessionBindingRequest}; -use bitfun_core_types::{WorktreeError, WorktreeErrorCode}; -use bitfun_runtime_ports::AgentSessionWorkspaceBinding; - -#[derive(Clone, Default)] -pub(crate) struct CliWorktreeManagementHost; - -#[async_trait] -impl WorktreeManagementHost for CliWorktreeManagementHost { - async fn repository_status( - &self, - request: WorktreeRepositoryStatusRequest, - ) -> AppManagementResult { - if request.is_remote() { - return Err(worktree_error(WorktreeOperationError { - code: WorktreeErrorCode::RemoteUnsupported, - message: "Repository status is not supported for remote workspaces".to_string(), - recovery_path: None, - operation_id: None, - })); - } - - let repository = - match GitService::resolve_worktree_repository(&request.workspace_path).await { - Ok(repository) => GitService::get_repository_basic(repository.query_path).await, - Err(error) => Err(error), - }; - match repository { - Ok(repository) => Ok(WorktreeRepositoryStatusResponse { - is_repository: true, - current_branch: Some(repository.current_branch), - }), - Err(_) => Ok(WorktreeRepositoryStatusResponse { - is_repository: false, - current_branch: None, - }), - } - } - - async fn bind_session( - &self, - request: WorktreeBindSessionRequest, - ) -> AppManagementResult { - self.transition( - request.is_remote(), - request.operation_id, - request.session_id, - request.project_workspace_path, - true, - ) - .await - } - - async fn release_session( - &self, - request: WorktreeReleaseSessionRequest, - ) -> AppManagementResult { - self.transition( - request.is_remote(), - request.operation_id, - request.session_id, - request.project_workspace_path, - false, - ) - .await - } -} - -impl CliWorktreeManagementHost { - async fn transition( - &self, - remote: bool, - operation_id: String, - session_id: String, - project_workspace_path: Option, - enabled: bool, - ) -> AppManagementResult { - validate_operation_id(&operation_id)?; - if remote { - return Err(worktree_error(WorktreeOperationError { - code: WorktreeErrorCode::RemoteUnsupported, - message: "Managed worktrees are not supported for remote workspaces".to_string(), - recovery_path: None, - operation_id: Some(operation_id), - })); - } - - let result = WorktreeService::bind_session(WorktreeSessionBindingRequest { - request_id: operation_id.clone(), - session_id, - project_workspace_path, - enabled, - }) - .await - .map_err(|error| worktree_error(project_error(error, Some(operation_id.clone()))))?; - let execution_target = result.execution_target.clone(); - Ok(WorktreeBindingResponse { - workspace_binding: AgentSessionWorkspaceBinding { - workspace_id: result.workspace_id, - workspace_path: result.workspace_path, - project_workspace_path: Some(result.project_workspace_path), - execution_target: Some(execution_target), - remote_connection_id: None, - remote_ssh_host: None, - }, - retained_worktree_path: result.retained_worktree_path, - }) - } -} - -fn validate_operation_id(operation_id: &str) -> AppManagementResult<()> { - if !operation_id.trim().is_empty() - && operation_id.len() <= 160 - && operation_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) - { - Ok(()) - } else { - Err(AppManagementError::invalid_request( - "Worktree operation ID is invalid", - )) - } -} - -fn project_error(error: WorktreeError, operation_id: Option) -> WorktreeOperationError { - WorktreeOperationError { - code: error.code, - message: error.message, - recovery_path: error.recovery_path, - operation_id, - } -} - -fn worktree_error(error: WorktreeOperationError) -> AppManagementError { - AppManagementError::internal(error.encode()) -} diff --git a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs index b74d4d218..2f453a9c9 100644 --- a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs +++ b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs @@ -141,7 +141,10 @@ fn doctor_rejects_incomplete_e2e_storage_roots() { #[test] fn remaining_cli_local_persistence_stays_behind_explicit_owner_boundaries() { - const ACCOUNT_SYNC: &str = include_str!("../../src/account_sync.rs"); + const ACCOUNT_ADAPTER: &str = include_str!("../../src/account.rs"); + const ACCOUNT_RUNTIME: &str = include_str!( + "../../../../crates/assembly/core/src/service/remote_connect/account_runtime.rs" + ); const STARTUP_PAGE: &str = include_str!("../../src/ui/startup.rs"); const PEER_BOOTSTRAP: &str = include_str!("../../src/peer_host/bootstrap.rs"); const PEER_STATE: &str = include_str!("../../src/peer_host/state.rs"); @@ -151,7 +154,7 @@ fn remaining_cli_local_persistence_stays_behind_explicit_owner_boundaries() { include_str!("../../../../crates/assembly/core/src/product_runtime/runtime_services.rs"); for (path, source) in [ - ("account_sync.rs", ACCOUNT_SYNC), + ("account.rs", ACCOUNT_ADAPTER), ("ui/startup.rs", STARTUP_PAGE), ("peer_host/bootstrap.rs", PEER_BOOTSTRAP), ("peer_host/state.rs", PEER_STATE), @@ -165,8 +168,10 @@ fn remaining_cli_local_persistence_stays_behind_explicit_owner_boundaries() { } assert!( - ACCOUNT_SYNC.contains("CoreAgentRuntimeCompatibility"), - "account sync must receive the narrow Core compatibility facade" + ACCOUNT_RUNTIME.contains("pub struct AccountRuntime") + && ACCOUNT_ADAPTER.contains("impl AccountRuntimeHost for CliAccountRoutingHost") + && ACCOUNT_ADAPTER.contains("impl AccountSessionBackupPort"), + "account state must live in the shared owner while CLI keeps narrow Host adapters" ); assert!( STARTUP_PAGE.contains("self.agent.account_snapshot()") @@ -205,6 +210,26 @@ fn remaining_cli_local_persistence_stays_behind_explicit_owner_boundaries() { ); } +#[test] +fn embedded_account_management_adapts_the_shared_runtime_directly() { + const EMBEDDED_APP_SERVER: &str = include_str!("../../src/embedded_app_server.rs"); + const CLI_MAIN: &str = include_str!("../../src/main.rs"); + const MANAGEMENT: &str = + include_str!("../../../../crates/interfaces/app-server/src/management.rs"); + const MANAGEMENT_SERVICE: &str = + include_str!("../../../../crates/interfaces/app-server/src/management/service.rs"); + + assert!( + EMBEDDED_APP_SERVER.contains("runtime.account_runtime().clone()") + && MANAGEMENT_SERVICE.contains("Option>") + && MANAGEMENT_SERVICE.contains("login_with_credentials") + && !MANAGEMENT.contains("AccountManagementHost") + && !CLI_MAIN.contains("mod tui_account_management") + && !CLI_MAIN.contains("mod account_sync"), + "Embedded account management must adapt AccountRuntime without a management Host trait" + ); +} + #[test] fn peer_session_control_and_usage_persistence_use_runtime_sdk() { const PEER_SESSION_COMMANDS: &str = include_str!("../../src/peer_host/commands/session.rs"); @@ -475,8 +500,10 @@ fn interactive_tui_worktrees_stay_behind_the_typed_backend() { const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); const TUI_BACKEND: &str = include_str!("../../src/tui_backend.rs"); const SHARED_BACKEND: &str = include_str!("../../src/shared_tui_backend.rs"); - const WORKTREE_HOST: &str = include_str!("../../src/tui_worktree_management.rs"); + const WORKTREE_MANAGEMENT: &str = + include_str!("../../../../crates/interfaces/app-server/src/management/worktree.rs"); const EMBEDDED_APP_SERVER: &str = include_str!("../../src/embedded_app_server.rs"); + const CLI_MAIN: &str = include_str!("../../src/main.rs"); for direct_owner in [ "GitService", @@ -504,13 +531,16 @@ fn interactive_tui_worktrees_stay_behind_the_typed_backend() { ); } assert!( - WORKTREE_HOST.contains("WorktreeService::bind_session") - && EMBEDDED_APP_SERVER.contains("CliWorktreeManagementHost"), - "the Embedded Host must inject the CLI Worktree owner" + WORKTREE_MANAGEMENT.contains("WorktreeService::bind_session") + && EMBEDDED_APP_SERVER.contains("load_for_local_host") + && !EMBEDDED_APP_SERVER.contains("LocalWorktreeManagement") + && !EMBEDDED_APP_SERVER.contains("tui_worktree_management"), + "the Embedded Host must enable the App Server's built-in local Worktree management" ); assert!( SHARED_BACKEND.contains("WORKTREES_CAPABILITY") - && SHARED_BACKEND.contains("does not fall back"), + && SHARED_BACKEND.contains("does not fall back") + && CLI_MAIN.contains("AppManagementService::load().await?"), "Shared Worktree management must fail closed" ); } diff --git a/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs b/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs new file mode 100644 index 000000000..4c582263c --- /dev/null +++ b/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs @@ -0,0 +1,1278 @@ +//! Shared account runtime owner for product Hosts. +//! +//! The runtime owns account identity transitions, persisted credentials, +//! settings synchronization, and account-backed Session backup. Product Hosts +//! inject device-routing and background-owner lifecycle effects without +//! exposing App Server wire DTOs to this owner. + +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tokio::sync::{Mutex, MutexGuard, Notify, RwLock}; + +use bitfun_services_integrations::remote_connect::account::{ + ensure_relay_session_history_exportable, relay_session_export_metadata, AccountClient, + AccountSession, +}; +use bitfun_services_integrations::remote_connect::{session_store, sync_state, DeviceIdentity}; + +use super::{settings_sync, validate_relay_base_url}; + +const UPLOAD_CONCURRENCY_CHUNK: usize = 5; + +#[derive(Debug, Clone)] +struct AccountContextState { + session: AccountSession, + relay_url: String, +} + +#[derive(Debug, Clone)] +pub struct AccountRoutingStartRequest { + pub session: AccountSession, + pub relay_url: String, + pub device_name: String, + pub account_generation: u64, +} + +#[derive(Debug)] +pub struct BackgroundRoutingOwnerRetirementError { + pub error: anyhow::Error, + pub owner_may_exit: bool, +} + +#[async_trait] +pub trait AccountRuntimeHost: Send + Sync { + async fn retire_background_routing_owner( + &self, + ) -> std::result::Result; + + fn background_routing_owner_is_running(&self) -> bool; + + fn request_background_routing_owner_shutdown(&self) -> bool; + + async fn start_device_routing(&self, request: AccountRoutingStartRequest) -> Result<()>; + + async fn stop_device_routing(&self); + + fn notify_controllers_settings_changed(&self); +} + +#[derive(Debug, Clone)] +pub struct AccountSessionBackup { + pub session_id: String, + pub metadata: serde_json::Value, + pub turns: Vec, +} + +#[async_trait] +pub trait AccountSessionBackupPort: Send + Sync { + async fn list_session_backups( + &self, + workspace_path: &Path, + ) -> Result>; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AutomaticAccountSyncPolicy { + pub background_engine: bool, + pub management_push: bool, +} + +fn automatic_account_sync_policy_for_pending( + pending_sync_choice: bool, +) -> AutomaticAccountSyncPolicy { + let allowed = !pending_sync_choice; + AutomaticAccountSyncPolicy { + background_engine: allowed, + management_push: allowed, + } +} + +#[derive(Debug, Clone)] +pub struct AccountLoginResult { + pub user_id: String, + pub relay_url: String, + pub has_cloud_settings: bool, + pub routing_owner_replaced: bool, + pub routing_connected: bool, + pub routing_error: Option, +} + +#[derive(Debug, Clone)] +pub struct AccountInfo { + pub user_id: String, + pub relay_url: String, + pub device_id: String, + pub device_name: String, +} + +#[derive(Debug, Clone)] +pub struct AccountDevice { + pub device_id: String, + pub device_name: String, + pub online: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AccountSyncStatus { + #[default] + Idle, + Syncing, + Done, + Failed, + Cancelled, +} + +#[derive(Debug, Clone)] +pub struct AccountSyncProgress { + pub operation_id: Option, + pub status: AccountSyncStatus, + pub phase: String, + pub percent: u8, + pub current: Option, + pub total: Option, + pub detail: Option, + pub error: Option, + pub settings_synced: bool, + pub sessions_exported: usize, +} + +impl Default for AccountSyncProgress { + fn default() -> Self { + Self { + operation_id: None, + status: AccountSyncStatus::Idle, + phase: String::new(), + percent: 0, + current: None, + total: None, + detail: None, + error: None, + settings_synced: false, + sessions_exported: 0, + } + } +} + +#[derive(Debug, Clone)] +pub struct AccountSnapshot { + pub logged_in: bool, + pub pending_sync_choice: bool, + pub info: Option, + pub devices: Vec, + pub sync: AccountSyncProgress, +} + +#[derive(Debug, Clone)] +struct AutoSyncResult { + settings_synced: bool, + sessions_exported: usize, +} + +#[derive(Serialize, Deserialize)] +struct SessionBundle { + session_id: String, + metadata: serde_json::Value, + turns: Vec, + source_device_id: Option, + source_device_name: Option, +} + +pub struct AccountRuntime { + host: Arc, + session_backup: Arc, + account_context: RwLock>, + account_context_generation: AtomicU64, + account_context_transitions: AtomicUsize, + account_sync_lock: Mutex<()>, + account_login_lock: Mutex<()>, + account_context_transition_lock: Mutex<()>, + account_sync_cancel: Notify, + routing_recovery_generation: AtomicU64, + token_expired: AtomicBool, + pending_sync_choice: AtomicBool, + sync_progress: RwLock, + auto_sync_in_flight: AtomicBool, +} + +impl AccountRuntime { + pub fn new( + host: Arc, + session_backup: Arc, + ) -> Arc { + Arc::new(Self { + host, + session_backup, + account_context: RwLock::new(None), + account_context_generation: AtomicU64::new(1), + account_context_transitions: AtomicUsize::new(0), + account_sync_lock: Mutex::new(()), + account_login_lock: Mutex::new(()), + account_context_transition_lock: Mutex::new(()), + account_sync_cancel: Notify::new(), + routing_recovery_generation: AtomicU64::new(0), + token_expired: AtomicBool::new(false), + pending_sync_choice: AtomicBool::new(false), + sync_progress: RwLock::new(AccountSyncProgress::default()), + auto_sync_in_flight: AtomicBool::new(false), + }) + } + + pub fn account_context_generation(&self) -> u64 { + self.account_context_generation.load(Ordering::Acquire) + } + + pub fn account_context_is_current(&self, generation: u64) -> bool { + self.account_context_transitions.load(Ordering::Acquire) == 0 + && self.account_context_generation() == generation + } + + pub fn automatic_account_sync_policy(&self) -> AutomaticAccountSyncPolicy { + automatic_account_sync_policy_for_pending(self.pending_sync_choice.load(Ordering::Acquire)) + } + + pub fn pending_sync_choice(&self) -> bool { + self.pending_sync_choice.load(Ordering::Acquire) + } + + pub fn is_token_expired(&self) -> bool { + self.token_expired.load(Ordering::Relaxed) + } + + pub fn mark_token_expired(&self) { + self.token_expired.store(true, Ordering::Relaxed); + } + + async fn lock_account_sync(&self, generation: u64) -> Result> { + let guard = self.account_sync_lock.lock().await; + if !self.account_context_is_current(generation) { + return Err(anyhow!("account sync cancelled")); + } + Ok(guard) + } + + async fn await_account_sync_current(&self, generation: u64, future: F) -> Result + where + F: Future, + { + let mut cancelled = Box::pin(self.account_sync_cancel.notified()); + cancelled.as_mut().enable(); + if !self.account_context_is_current(generation) { + return Err(anyhow!("account sync cancelled")); + } + tokio::select! { + _ = &mut cancelled => Err(anyhow!("account sync cancelled")), + result = future => { + if !self.account_context_is_current(generation) { + Err(anyhow!("account sync cancelled")) + } else { + Ok(result) + } + } + } + } + + async fn begin_account_transition(&self) -> AccountContextTransitionGuard<'_> { + let transition_guard = self.account_context_transition_lock.lock().await; + self.account_context_transitions + .fetch_add(1, Ordering::AcqRel); + self.account_context_generation + .fetch_add(1, Ordering::AcqRel); + self.account_sync_cancel.notify_waiters(); + let sync_guard = self.account_sync_lock.lock().await; + settings_sync::wait_for_sync_operations_idle().await; + AccountContextTransitionGuard { + runtime: self, + sync_guard: Some(sync_guard), + transition_guard: Some(transition_guard), + active: true, + } + } + + async fn begin_account_transition_if_current( + &self, + expected_generation: u64, + ) -> Option> { + let transition_guard = self.account_context_transition_lock.lock().await; + if !self.account_context_is_current(expected_generation) { + return None; + } + self.account_context_transitions + .fetch_add(1, Ordering::AcqRel); + self.account_context_generation + .fetch_add(1, Ordering::AcqRel); + self.account_sync_cancel.notify_waiters(); + let sync_guard = self.account_sync_lock.lock().await; + settings_sync::wait_for_sync_operations_idle().await; + Some(AccountContextTransitionGuard { + runtime: self, + sync_guard: Some(sync_guard), + transition_guard: Some(transition_guard), + active: true, + }) + } + + async fn read_account_context_raw(&self) -> Result<(AccountSession, String)> { + self.account_context + .read() + .await + .clone() + .map(|context| (context.session, context.relay_url)) + .ok_or_else(|| anyhow!("not logged in")) + } + + pub async fn read_account_context(&self) -> Result<(AccountSession, String)> { + let generation = self.account_context_generation(); + self.read_account_context_for_generation(generation).await + } + + pub async fn read_account_context_for_generation( + &self, + generation: u64, + ) -> Result<(AccountSession, String)> { + if !self.account_context_is_current(generation) { + return Err(anyhow!("account context changed")); + } + let context = self.read_account_context_raw().await?; + if !self.account_context_is_current(generation) { + return Err(anyhow!("account context changed")); + } + Ok(context) + } + + pub async fn is_logged_in(&self) -> bool { + if self.pending_sync_choice.load(Ordering::Acquire) { + return false; + } + self.read_account_context().await.is_ok() + } + + pub async fn try_restore_session(&self) -> Option { + let transition = self.begin_account_transition().await; + self.host.stop_device_routing().await; + let restored = match session_store::load_session_detailed() { + Ok(Some(loaded)) => { + let relay_url = match normalize_relay_url(&loaded.relay_url) { + Ok(url) => url, + Err(error) => { + log::warn!("Ignoring invalid persisted relay URL: {error}"); + session_store::clear_session(); + transition.finish(); + return None; + } + }; + let user_id = loaded.user_id.clone(); + if let Some(device_id) = loaded.device_id.as_deref() { + if let Err(error) = DeviceIdentity::adopt_account_device_id(device_id) { + log::warn!("Failed to adopt restored session device_id: {error}"); + } + } + let session = AccountSession { + token: loaded.token, + user_id: user_id.clone(), + master_key: loaded.master_key, + }; + *self.account_context.write().await = + Some(AccountContextState { session, relay_url }); + log::info!("Restored account session for user {user_id}"); + Some(user_id) + } + Ok(None) => None, + Err(error) => { + log::warn!("Failed to load persisted session: {error}"); + None + } + }; + transition.finish(); + restored + } + + pub async fn login_with_credentials( + self: &Arc, + relay_url: &str, + username: &str, + password: &str, + ) -> Result { + let _login_guard = self.account_login_lock.lock().await; + let relay_url_input = relay_url.trim(); + let username = username.trim(); + if relay_url_input.is_empty() { + return Err(anyhow!("Auth Server is required")); + } + if username.is_empty() { + return Err(anyhow!("Username is required")); + } + if password.is_empty() { + return Err(anyhow!("Password is required")); + } + let relay_url = normalize_relay_url(relay_url_input)?; + let expected_generation = self.account_context_generation(); + if !self.account_context_is_current(expected_generation) { + return Err(anyhow!("account context changed")); + } + + let device = current_device_identity()?; + let client = AccountClient::new(); + let session = client + .login(&relay_url, username, password, &device) + .await + .map_err(|error| anyhow!("login failed: {error}"))?; + let has_cloud_settings = + match resolve_cloud_settings_probe(client.fetch_settings(&relay_url, &session).await) { + Ok(value) => value, + Err(error) => { + revoke_rejected_login_candidate(&client, &relay_url, &session).await; + return Err(error); + } + }; + + let previous_account_context = self.account_context.read().await.clone(); + let retired_background_owner = match self.host.retire_background_routing_owner().await { + Ok(retired) => retired, + Err(failure) => { + if failure.owner_may_exit { + self.schedule_routing_recovery_after_background_owner_exit( + expected_generation, + device.device_name.clone(), + ); + } + revoke_rejected_login_candidate(&client, &relay_url, &session).await; + return Err(failure.error); + } + }; + let Some(transition) = self + .begin_account_transition_if_current(expected_generation) + .await + else { + revoke_rejected_login_candidate(&client, &relay_url, &session).await; + return Err(anyhow!("account context changed")); + }; + self.host.stop_device_routing().await; + session_store::clear_session(); + + let user_id = session.user_id.clone(); + let token = session.token.clone(); + let master_key = session.master_key; + *self.account_context.write().await = Some(AccountContextState { + session: session.clone(), + relay_url: relay_url.clone(), + }); + session_store::save_credential_hint(username, &relay_url); + self.token_expired.store(false, Ordering::Relaxed); + + if has_cloud_settings { + self.pending_sync_choice.store(true, Ordering::Release); + transition.finish(); + revoke_replaced_account_context(&client, previous_account_context, &relay_url, &token) + .await; + return Ok(AccountLoginResult { + user_id, + relay_url, + has_cloud_settings, + routing_owner_replaced: retired_background_owner, + routing_connected: false, + routing_error: None, + }); + } + + self.pending_sync_choice.store(false, Ordering::Release); + if let Err(error) = session_store::save_session_with_device( + &token, + &user_id, + &master_key, + &relay_url, + Some(device.device_id.as_str()), + ) { + log::warn!("Failed to persist session: {error}"); + } + let generation = transition.finish(); + let routing = self + .host + .start_device_routing(AccountRoutingStartRequest { + session, + relay_url: relay_url.clone(), + device_name: device.device_name, + account_generation: generation, + }) + .await; + revoke_replaced_account_context(&client, previous_account_context, &relay_url, &token) + .await; + + Ok(AccountLoginResult { + user_id, + relay_url, + has_cloud_settings, + routing_owner_replaced: retired_background_owner, + routing_connected: routing.is_ok(), + routing_error: routing.err().map(|error| error.to_string()), + }) + } + + pub async fn finalize_login_after_sync_choice(self: &Arc) -> Result<()> { + let generation = self.account_context_generation(); + let sync_guard = self.lock_account_sync(generation).await?; + let device = current_device_identity()?; + let (session, relay_url) = self.read_account_context().await?; + let retired_background_owner = self + .host + .retire_background_routing_owner() + .await + .map_err(|failure| failure.error)?; + session_store::save_session_with_device( + &session.token, + &session.user_id, + &session.master_key, + &relay_url, + Some(device.device_id.as_str()), + ) + .map_err(|error| anyhow!("persist session: {error}"))?; + self.pending_sync_choice.store(false, Ordering::Release); + if retired_background_owner { + log::info!("Stopped the previous background account routing owner"); + } + drop(sync_guard); + self.host + .start_device_routing(AccountRoutingStartRequest { + session, + relay_url, + device_name: device.device_name, + account_generation: generation, + }) + .await + .map_err(|error| anyhow!("device routing failed: {error}")) + } + + pub async fn restore_device_routing(self: &Arc, device_name: &str) -> Result<()> { + let generation = self.account_context_generation(); + let (session, relay_url) = self.read_account_context_for_generation(generation).await?; + self.host + .start_device_routing(AccountRoutingStartRequest { + session, + relay_url, + device_name: device_name.to_string(), + account_generation: generation, + }) + .await + } + + pub async fn logout(&self) -> Result<()> { + let transition = self.begin_account_transition().await; + self.host.stop_device_routing().await; + if self.host.request_background_routing_owner_shutdown() { + log::info!("Signalled the background account routing owner to shut down"); + } + if let Ok((session, relay_url)) = self.read_account_context_raw().await { + let _ = AccountClient::new() + .revoke_token(&relay_url, &session) + .await; + } + *self.account_context.write().await = None; + self.pending_sync_choice.store(false, Ordering::Release); + session_store::clear_session(); + session_store::clear_credential_hint(); + self.token_expired.store(false, Ordering::Relaxed); + transition.finish(); + Ok(()) + } + + pub async fn expire_rejected_context( + &self, + account_generation: u64, + expected_token: &str, + ) -> bool { + let Some(transition) = self + .begin_account_transition_if_current(account_generation) + .await + else { + return false; + }; + self.host.stop_device_routing().await; + let mut context = self.account_context.write().await; + if context + .as_ref() + .is_none_or(|context| context.session.token != expected_token) + { + transition.finish(); + return false; + } + *context = None; + drop(context); + self.token_expired.store(true, Ordering::Relaxed); + self.pending_sync_choice.store(false, Ordering::Release); + session_store::clear_session(); + transition.finish(); + true + } + + pub async fn account_info(&self) -> Result { + let (session, relay_url) = self.read_account_context().await?; + let device = current_device_identity()?; + Ok(AccountInfo { + user_id: session.user_id, + relay_url, + device_id: device.device_id, + device_name: device.device_name, + }) + } + + pub async fn list_devices(&self) -> Result> { + let (session, relay_url) = self.read_account_context().await?; + let devices = AccountClient::new() + .list_devices(&relay_url, &session) + .await?; + Ok(devices + .into_iter() + .map(|device| AccountDevice { + device_id: device.device_id, + device_name: device.device_name, + online: device.online, + }) + .collect()) + } + + pub async fn snapshot(&self) -> AccountSnapshot { + let logged_in = self.is_logged_in().await; + let info = if logged_in { + self.account_info().await.ok() + } else { + None + }; + let devices = if logged_in { + self.list_devices().await.unwrap_or_default() + } else { + Vec::new() + }; + AccountSnapshot { + logged_in, + pending_sync_choice: self.pending_sync_choice(), + info, + devices, + sync: self.current_sync_progress().await, + } + } + + pub fn start_settings_sync_loop(self: &Arc) { + let weak_runtime = Arc::downgrade(self); + let context_runtime = weak_runtime.clone(); + let current_runtime = weak_runtime.clone(); + let settings_runtime = weak_runtime.clone(); + let pushed_runtime = weak_runtime.clone(); + let expired_runtime = weak_runtime; + settings_sync::start_settings_sync_engine(settings_sync::SettingsSyncHooks { + account_context: Some(Arc::new(move || { + let runtime = context_runtime.clone(); + Box::pin(async move { + let runtime = runtime + .upgrade() + .ok_or_else(|| anyhow!("account runtime stopped"))?; + if !runtime.automatic_account_sync_policy().background_engine { + return Err(anyhow!("account login is awaiting a sync choice")); + } + let generation = runtime.account_context_generation(); + let (account, relay_url) = runtime + .read_account_context_for_generation(generation) + .await?; + if !runtime.automatic_account_sync_policy().background_engine { + return Err(anyhow!("account login is awaiting a sync choice")); + } + Ok((account, relay_url, generation)) + }) + })), + is_account_context_current: Some(Arc::new(move |generation| { + current_runtime + .upgrade() + .is_some_and(|runtime| runtime.account_context_is_current(generation)) + })), + on_settings_applied: Some(Arc::new(move || { + if let Some(runtime) = settings_runtime.upgrade() { + runtime.host.notify_controllers_settings_changed(); + } + })), + on_settings_pushed: Some(Arc::new(move || { + if let Some(runtime) = pushed_runtime.upgrade() { + runtime.host.notify_controllers_settings_changed(); + } + })), + on_token_expired: Some(Arc::new(move || { + if let Some(runtime) = expired_runtime.upgrade() { + runtime.mark_token_expired(); + } + })), + ..Default::default() + }); + } + + pub fn notify_local_settings_changed(&self) { + settings_sync::notify_settings_changed(); + } + + pub async fn push_settings_after_local_change(&self) { + if !self.automatic_account_sync_policy().management_push { + return; + } + if self.read_account_context().await.is_err() { + self.try_restore_session().await; + } + let generation = self.account_context_generation(); + let Ok(_sync_guard) = self.lock_account_sync(generation).await else { + return; + }; + if !self.automatic_account_sync_policy().management_push { + return; + } + let Ok((account, relay_url)) = self.read_account_context().await else { + return; + }; + match settings_sync::push_settings_now(&account, &relay_url).await { + Ok(true) => log::info!("Settings pushed to account cloud"), + Ok(false) => {} + Err(error) => log::warn!("Settings push failed: {error}"), + } + } + + pub async fn current_sync_progress(&self) -> AccountSyncProgress { + self.sync_progress.read().await.clone() + } + + async fn set_progress(&self, mut update: impl FnMut(&mut AccountSyncProgress)) { + let mut progress = self.sync_progress.write().await; + update(&mut progress); + } + + async fn emit_progress( + &self, + phase: &str, + percent: u8, + current: Option, + total: Option, + detail: Option<&str>, + ) { + self.set_progress(|progress| { + progress.status = AccountSyncStatus::Syncing; + progress.phase = phase.to_string(); + progress.percent = percent; + progress.current = current; + progress.total = total; + progress.detail = detail.map(str::to_string); + progress.error = None; + }) + .await; + } + + pub async fn start_auto_sync_background( + self: &Arc, + operation_id: String, + is_first_login: bool, + workspace_path: PathBuf, + ) -> bool { + if self.auto_sync_in_flight.swap(true, Ordering::SeqCst) { + log::warn!("Account auto-sync already in flight; skipping duplicate start"); + return false; + } + self.set_progress(|progress| { + progress.operation_id = Some(operation_id.clone()); + }) + .await; + let runtime = Arc::clone(self); + tokio::spawn(async move { + let result = runtime.run_auto_sync(is_first_login, &workspace_path).await; + runtime.auto_sync_in_flight.store(false, Ordering::SeqCst); + match result { + Ok(result) => { + runtime + .set_progress(|progress| { + if progress.operation_id.as_deref() != Some(operation_id.as_str()) { + return; + } + if progress.status == AccountSyncStatus::Cancelled { + return; + } + progress.status = AccountSyncStatus::Done; + progress.phase = "done".to_string(); + progress.percent = 100; + progress.settings_synced = result.settings_synced; + progress.sessions_exported = result.sessions_exported; + progress.error = None; + }) + .await; + } + Err(error) => { + runtime + .set_progress(|progress| { + if progress.operation_id.as_deref() != Some(operation_id.as_str()) { + return; + } + if progress.status == AccountSyncStatus::Cancelled { + return; + } + progress.status = AccountSyncStatus::Failed; + progress.error = Some(error.to_string()); + }) + .await; + log::warn!("Account auto-sync failed: {error}"); + } + } + }); + true + } + + pub async fn mark_sync_cancelled(&self, operation_id: String) { + self.set_progress(|progress| { + progress.operation_id = Some(operation_id.clone()); + progress.status = AccountSyncStatus::Cancelled; + progress.phase = "cancelled".to_string(); + progress.error = None; + }) + .await; + } + + pub async fn cancel_sync(&self, operation_id: String) -> Result { + self.logout().await?; + self.mark_sync_cancelled(operation_id).await; + Ok(self.current_sync_progress().await) + } + + async fn run_auto_sync( + self: &Arc, + is_first_login: bool, + workspace_path: &Path, + ) -> Result { + let generation = self.account_context_generation(); + let _sync_guard = self.lock_account_sync(generation).await?; + self.set_progress(|progress| { + *progress = AccountSyncProgress { + operation_id: progress.operation_id.clone(), + status: AccountSyncStatus::Syncing, + phase: "starting".to_string(), + percent: 1, + ..AccountSyncProgress::default() + }; + }) + .await; + + let (account, relay_url) = self.read_account_context().await?; + let client = AccountClient::new(); + let settings_synced = if is_first_login { + self.emit_progress("uploading_settings", 5, None, None, None) + .await; + let config_service = crate::service::config::get_global_config_service() + .await + .map_err(|error| anyhow!("config service: {error}"))?; + let exported = config_service + .export_config() + .await + .map_err(|error| anyhow!("export config: {error}"))?; + let config_json = serde_json::to_string(&exported) + .map_err(|error| anyhow!("serialize config: {error}"))?; + self.await_account_sync_current( + generation, + settings_sync::upload_settings_payload(&account, &relay_url, &config_json), + ) + .await??; + self.emit_progress("settings_done", 15, None, None, None) + .await; + true + } else { + self.emit_progress("downloading_settings", 5, None, None, None) + .await; + let cloud = self + .await_account_sync_current( + generation, + client.fetch_settings_with_version(&relay_url, &account), + ) + .await??; + if let Some(blob) = cloud { + self.emit_progress("applying_settings", 10, None, None, None) + .await; + self.await_account_sync_current( + generation, + settings_sync::apply_settings_blob(&account, &blob, true), + ) + .await??; + self.emit_progress("settings_done", 15, None, None, None) + .await; + true + } else { + self.emit_progress("settings_done", 15, None, None, None) + .await; + false + } + }; + + self.emit_progress("listing_sessions", 18, None, None, None) + .await; + let local_sessions = self + .await_account_sync_current( + generation, + self.session_backup.list_session_backups(workspace_path), + ) + .await??; + self.emit_progress( + "exporting_sessions", + 20, + Some(0), + Some(local_sessions.len()), + None, + ) + .await; + + let mut local_sync_state = sync_state::load(&account.user_id); + let mut pending_uploads = Vec::new(); + for backup in local_sessions { + if !self.account_context_is_current(generation) { + return Err(anyhow!("account sync cancelled")); + } + let bundle = SessionBundle { + session_id: backup.session_id.clone(), + metadata: backup.metadata, + turns: backup.turns, + source_device_id: None, + source_device_name: None, + }; + let bundle_json = serde_json::to_string(&bundle) + .map_err(|error| anyhow!("serialize bundle: {error}"))?; + let hash = sync_state::content_hash(&bundle_json); + if local_sync_state.uploaded_hash(&backup.session_id) == Some(hash.as_str()) { + continue; + } + pending_uploads.push((backup.session_id, bundle_json, hash)); + } + + let upload_total = pending_uploads.len(); + self.emit_progress("exporting_sessions", 20, Some(0), Some(upload_total), None) + .await; + let mut uploaded = Vec::new(); + let mut upload_errors = Vec::new(); + for chunk in pending_uploads.chunks(UPLOAD_CONCURRENCY_CHUNK) { + let mut handles = Vec::new(); + for (session_id, bundle_json, hash) in chunk { + let runtime = Arc::clone(self); + let client = AccountClient::new(); + let relay_url = relay_url.clone(); + let account = account.clone(); + let session_id = session_id.clone(); + let bundle_json = bundle_json.clone(); + let hash = hash.clone(); + handles.push(tokio::spawn(async move { + let result = runtime + .await_account_sync_current( + generation, + client.upload_session(&relay_url, &account, &session_id, &bundle_json), + ) + .await; + (session_id, hash, result) + })); + } + for handle in handles { + match handle.await { + Ok((session_id, hash, Ok(Ok(version)))) => { + uploaded.push((session_id.clone(), hash, version)); + let done = uploaded.len(); + let percent = if upload_total == 0 { + 95 + } else { + 20 + ((75 * done) / upload_total) as u8 + }; + self.emit_progress( + "exporting_sessions", + percent.min(95), + Some(done), + Some(upload_total), + Some(&session_id), + ) + .await; + } + Ok((session_id, _, Ok(Err(error)))) => { + log::warn!("Auto-sync upload {session_id} failed: {error}"); + upload_errors.push(format!("{session_id}: {error}")); + } + Ok((_, _, Err(error))) => return Err(error), + Err(error) => { + log::warn!("Auto-sync upload task join failed: {error}"); + upload_errors.push(format!("upload task join failed: {error}")); + } + } + } + if !self.account_context_is_current(generation) { + return Err(anyhow!("account sync cancelled")); + } + } + + let exported = uploaded.len(); + let mut max_uploaded_version = local_sync_state.last_session_since; + for (session_id, hash, version) in uploaded { + local_sync_state.set_uploaded_hash(&session_id, hash); + max_uploaded_version = max_uploaded_version.max(version); + } + if max_uploaded_version > local_sync_state.last_session_since { + local_sync_state.last_session_since = max_uploaded_version; + } + let _ = sync_state::save(&account.user_id, &local_sync_state); + ensure_session_backup_complete(upload_total, exported, &upload_errors)?; + log::info!("Auto-sync: settings={settings_synced} exported={exported} imported=0"); + self.emit_progress("done", 100, Some(exported), Some(0), None) + .await; + Ok(AutoSyncResult { + settings_synced, + sessions_exported: exported, + }) + } + + fn schedule_routing_recovery_after_background_owner_exit( + self: &Arc, + expected_generation: u64, + device_name: String, + ) { + if !self.account_context_is_current(expected_generation) + || self + .routing_recovery_generation + .swap(expected_generation, Ordering::AcqRel) + == expected_generation + { + return; + } + let runtime = Arc::clone(self); + tokio::spawn(async move { + while runtime.routing_recovery_generation.load(Ordering::Acquire) == expected_generation + && runtime.account_context_is_current(expected_generation) + && runtime.host.background_routing_owner_is_running() + { + tokio::time::sleep(Duration::from_millis(100)).await; + } + if runtime.routing_recovery_generation.load(Ordering::Acquire) == expected_generation + && runtime.account_context_is_current(expected_generation) + && !runtime.host.background_routing_owner_is_running() + { + if let Err(error) = runtime.restore_device_routing(&device_name).await { + log::warn!( + "Failed to restore account routing after background owner exit: {error}" + ); + } + } + let _ = runtime.routing_recovery_generation.compare_exchange( + expected_generation, + 0, + Ordering::AcqRel, + Ordering::Acquire, + ); + }); + } +} + +struct AccountContextTransitionGuard<'a> { + runtime: &'a AccountRuntime, + sync_guard: Option>, + transition_guard: Option>, + active: bool, +} + +impl AccountContextTransitionGuard<'_> { + fn finish(mut self) -> u64 { + self.release() + } + + fn release(&mut self) -> u64 { + drop(self.sync_guard.take()); + if self.active { + self.runtime + .account_context_generation + .fetch_add(1, Ordering::AcqRel); + self.runtime + .account_context_transitions + .fetch_sub(1, Ordering::AcqRel); + self.active = false; + } + let generation = self.runtime.account_context_generation(); + drop(self.transition_guard.take()); + generation + } +} + +impl Drop for AccountContextTransitionGuard<'_> { + fn drop(&mut self) { + self.release(); + } +} + +fn normalize_relay_url(relay_url: &str) -> Result { + let parsed = validate_relay_base_url(relay_url.trim())?; + Ok(parsed.as_str().trim_end_matches('/').to_string()) +} + +fn current_device_identity() -> Result { + DeviceIdentity::from_current_machine().map_err(|error| anyhow!("detect device: {error}")) +} + +async fn revoke_rejected_login_candidate( + client: &AccountClient, + relay_url: &str, + session: &AccountSession, +) { + if let Err(error) = client.revoke_token(relay_url, session).await { + log::warn!("Failed to revoke rejected login candidate token: {error}"); + } +} + +fn replaced_account_revocation_target( + previous: Option, + replacement_relay_url: &str, + replacement_token: &str, +) -> Option { + previous.filter(|context| { + context.relay_url != replacement_relay_url || context.session.token != replacement_token + }) +} + +async fn revoke_replaced_account_context( + client: &AccountClient, + previous: Option, + replacement_relay_url: &str, + replacement_token: &str, +) { + let Some(previous) = + replaced_account_revocation_target(previous, replacement_relay_url, replacement_token) + else { + return; + }; + if let Err(error) = client + .revoke_token(&previous.relay_url, &previous.session) + .await + { + log::warn!("Failed to revoke replaced account token: {error}"); + } +} + +fn resolve_cloud_settings_probe(result: Result>) -> Result { + result.map(|settings| settings.is_some()).map_err(|error| { + anyhow!("could not check cloud settings: {error}; the current account remains active") + }) +} + +fn ensure_session_backup_complete( + total: usize, + uploaded: usize, + upload_errors: &[String], +) -> Result<()> { + if uploaded == total { + return Ok(()); + } + let detail = upload_errors + .first() + .map(String::as_str) + .unwrap_or("retry will resume remaining sessions"); + Err(anyhow!( + "session backup incomplete: uploaded {uploaded} of {total}; {detail}" + )) +} + +pub fn build_session_backup( + metadata: &bitfun_services_core::session::SessionMetadata, + turns: &[bitfun_services_core::session::DialogTurnData], +) -> Result { + ensure_relay_session_history_exportable(metadata).map_err(anyhow::Error::msg)?; + let metadata = relay_session_export_metadata(metadata, turns.len()); + Ok(AccountSessionBackup { + session_id: metadata.session_id.clone(), + metadata: serde_json::to_value(metadata) + .map_err(|error| anyhow!("serialize metadata: {error}"))?, + turns: turns + .iter() + .map(|turn| serde_json::to_value(turn).unwrap_or(serde_json::Value::Null)) + .collect(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestAccountRuntimeHost; + + #[async_trait] + impl AccountRuntimeHost for TestAccountRuntimeHost { + async fn retire_background_routing_owner( + &self, + ) -> std::result::Result { + Ok(false) + } + + fn background_routing_owner_is_running(&self) -> bool { + false + } + + fn request_background_routing_owner_shutdown(&self) -> bool { + false + } + + async fn start_device_routing(&self, _request: AccountRoutingStartRequest) -> Result<()> { + Ok(()) + } + + async fn stop_device_routing(&self) {} + + fn notify_controllers_settings_changed(&self) {} + } + + struct EmptySessionBackup; + + #[async_trait] + impl AccountSessionBackupPort for EmptySessionBackup { + async fn list_session_backups( + &self, + _workspace_path: &Path, + ) -> Result> { + Ok(Vec::new()) + } + } + + fn test_runtime() -> Arc { + AccountRuntime::new( + Arc::new(TestAccountRuntimeHost), + Arc::new(EmptySessionBackup), + ) + } + + #[test] + fn pending_sync_choice_blocks_automatic_sync() { + let pending = automatic_account_sync_policy_for_pending(true); + assert!(!pending.background_engine); + assert!(!pending.management_push); + + let finalized = automatic_account_sync_policy_for_pending(false); + assert!(finalized.background_engine); + assert!(finalized.management_push); + } + + #[test] + fn cloud_settings_probe_errors_are_not_treated_as_missing_settings() { + assert!(!resolve_cloud_settings_probe(Ok(None)).expect("missing settings")); + assert!(resolve_cloud_settings_probe(Ok(Some("settings".to_string()))).unwrap()); + assert!(resolve_cloud_settings_probe(Err(anyhow!("relay unavailable"))).is_err()); + } + + #[test] + fn partial_session_backup_is_not_reported_as_success() { + assert!(ensure_session_backup_complete(4, 4, &[]).is_ok()); + assert!(ensure_session_backup_complete(4, 1, &["quota full".to_string()]).is_err()); + } + + #[tokio::test] + async fn invalid_login_does_not_advance_the_account_generation() { + let runtime = test_runtime(); + let generation = runtime.account_context_generation(); + + let error = runtime + .login_with_credentials("", "user", "password") + .await + .expect_err("empty relay URL must be rejected"); + + assert!(error.to_string().contains("Auth Server is required")); + assert_eq!(runtime.account_context_generation(), generation); + } +} diff --git a/src/crates/assembly/core/src/service/remote_connect/mod.rs b/src/crates/assembly/core/src/service/remote_connect/mod.rs index 034bee9c1..f96c0f9f8 100644 --- a/src/crates/assembly/core/src/service/remote_connect/mod.rs +++ b/src/crates/assembly/core/src/service/remote_connect/mod.rs @@ -8,6 +8,7 @@ //! tears down the relay side; bots keep running. Use `stop_bot()` or //! `stop_all()` to shut everything down. +pub mod account_runtime; pub mod bot; pub mod embedded_relay_host; pub mod lan; diff --git a/src/crates/interfaces/app-server/Cargo.toml b/src/crates/interfaces/app-server/Cargo.toml index 8e361c6a0..ace5b67b6 100644 --- a/src/crates/interfaces/app-server/Cargo.toml +++ b/src/crates/interfaces/app-server/Cargo.toml @@ -25,7 +25,6 @@ futures = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } -async-trait = { workspace = true } log = { workspace = true } tracing = { workspace = true } url = { workspace = true } diff --git a/src/crates/interfaces/app-server/src/management.rs b/src/crates/interfaces/app-server/src/management.rs index 2c6e2d04a..518f5cb0e 100644 --- a/src/crates/interfaces/app-server/src/management.rs +++ b/src/crates/interfaces/app-server/src/management.rs @@ -1,11 +1,9 @@ //! Host-injected management service and capability boundary. -use async_trait::async_trait; -use bitfun_app_server_protocol::account::*; use bitfun_app_server_protocol::app::{CapabilityAvailability, CapabilityDescriptor}; -use bitfun_app_server_protocol::worktree::*; mod service; +mod worktree; pub use service::AppManagementService; @@ -21,58 +19,6 @@ pub const ACCOUNT_CAPABILITY: &str = "tui.account"; pub const SETTINGS_SYNC_CAPABILITY: &str = "tui.settingsSync"; pub const WORKTREES_CAPABILITY: &str = "tui.worktrees"; -#[async_trait] -pub trait WorktreeManagementHost: Send + Sync { - async fn repository_status( - &self, - request: WorktreeRepositoryStatusRequest, - ) -> AppManagementResult; - async fn bind_session( - &self, - request: WorktreeBindSessionRequest, - ) -> AppManagementResult; - async fn release_session( - &self, - request: WorktreeReleaseSessionRequest, - ) -> AppManagementResult; -} - -#[async_trait] -pub trait AccountManagementHost: Send + Sync { - async fn account_snapshot( - &self, - request: AccountSnapshotRequest, - ) -> AppManagementResult; - async fn account_login( - &self, - request: AccountLoginRequest, - ) -> AppManagementResult; - async fn account_finalize_login( - &self, - request: AccountFinalizeLoginRequest, - ) -> AppManagementResult; - async fn account_logout( - &self, - request: AccountLogoutRequest, - ) -> AppManagementResult; - async fn settings_sync_start( - &self, - request: SettingsSyncStartRequest, - ) -> AppManagementResult; - async fn settings_sync_snapshot( - &self, - request: SettingsSyncSnapshotRequest, - ) -> AppManagementResult; - async fn settings_sync_cancel( - &self, - request: SettingsSyncCancelRequest, - ) -> AppManagementResult; - async fn settings_sync_local_changed( - &self, - request: SettingsSyncLocalChangedRequest, - ) -> AppManagementResult; -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct AppManagementCapabilities { pub modes: CapabilityAvailability, diff --git a/src/crates/interfaces/app-server/src/management/service.rs b/src/crates/interfaces/app-server/src/management/service.rs index 12452dc49..39a9e42c8 100644 --- a/src/crates/interfaces/app-server/src/management/service.rs +++ b/src/crates/interfaces/app-server/src/management/service.rs @@ -16,17 +16,20 @@ use bitfun_app_server_protocol::model::*; use bitfun_app_server_protocol::skill::*; use bitfun_app_server_protocol::subagent::*; use bitfun_app_server_protocol::worktree::*; +use bitfun_core::service::remote_connect::account_runtime::{ + AccountRuntime, AccountSyncProgress, AccountSyncStatus, +}; use super::{ - AccountManagementHost, AppManagementCapabilities, AppManagementError, AppManagementResult, - WorktreeManagementHost, ACCOUNT_CAPABILITY, SETTINGS_SYNC_CAPABILITY, WORKTREES_CAPABILITY, + AppManagementCapabilities, AppManagementError, AppManagementResult, ACCOUNT_CAPABILITY, + SETTINGS_SYNC_CAPABILITY, WORKTREES_CAPABILITY, }; /// App Server adapter shared by Embedded and local Shared compatibility Hosts. /// /// The service delegates to the existing config, registry, MCP, and external -/// source owners. Hosts must inject it explicitly; constructing an App Server -/// does not make local management capabilities available by default. +/// source owners. Local-only capabilities must be enabled through the local +/// Host constructor; constructing an App Server does not enable them by default. pub struct AppManagementService { config: Arc, mcp: Option>, @@ -35,24 +38,22 @@ pub struct AppManagementService { bitfun_product_domains::external_sources::ExternalSourcePublicSnapshot, )>, external_source_subscriptions: Arc>>, - account: Option>, - worktree: Option>, + account: Option>, + local_worktrees_enabled: bool, } impl AppManagementService { pub async fn load() -> Result { - Self::load_with_hosts(None, None).await + Self::load_inner(None, false).await } - pub async fn load_with_account_host( - account: Option>, - ) -> Result { - Self::load_with_hosts(account, None).await + pub async fn load_for_local_host(account: Option>) -> Result { + Self::load_inner(account, true).await } - pub async fn load_with_hosts( - account: Option>, - worktree: Option>, + async fn load_inner( + account: Option>, + local_worktrees_enabled: bool, ) -> Result { let config = bitfun_core::service::config::get_global_config_service() .await @@ -64,20 +65,24 @@ impl AppManagementService { external_source_updates, external_source_subscriptions: Arc::new(Mutex::new(HashSet::new())), account, - worktree, + local_worktrees_enabled, }) } - fn account_host(&self, capability: &str) -> AppManagementResult<&dyn AccountManagementHost> { + fn account_runtime(&self, capability: &str) -> AppManagementResult<&Arc> { self.account - .as_deref() + .as_ref() .ok_or_else(|| AppManagementError::unsupported(format!("{capability} is unavailable"))) } - fn worktree_host(&self) -> AppManagementResult<&dyn WorktreeManagementHost> { - self.worktree.as_deref().ok_or_else(|| { - AppManagementError::unsupported(format!("{WORKTREES_CAPABILITY} is unavailable")) - }) + fn require_local_worktrees(&self) -> AppManagementResult<()> { + if self.local_worktrees_enabled { + Ok(()) + } else { + Err(AppManagementError::unsupported(format!( + "{WORKTREES_CAPABILITY} is unavailable" + ))) + } } async fn model_config( @@ -192,7 +197,8 @@ fn external_source_string_error_with_id(error: String, operation_id: &str) -> Ap } fn validate_external_operation(operation_id: &str) -> AppManagementResult<()> { - validate_operation_id(operation_id).map_err(AppManagementError::invalid_request) + bitfun_app_server_protocol::external_source::validate_operation_id(operation_id) + .map_err(AppManagementError::invalid_request) } const MAX_NATIVE_HOOK_COMMAND_CHARS: usize = 200; @@ -849,10 +855,10 @@ impl AppManagementService { capabilities.settings_sync = bitfun_app_server_protocol::app::CapabilityAvailability::Unavailable { reason }; } - if self.worktree.is_none() { + if !self.local_worktrees_enabled { capabilities.worktrees = bitfun_app_server_protocol::app::CapabilityAvailability::Unavailable { - reason: "The Host did not provide a Worktree owner".to_string(), + reason: "The Host did not enable local Worktree management".to_string(), }; } capabilities @@ -862,93 +868,158 @@ impl AppManagementService { &self, request: WorktreeRepositoryStatusRequest, ) -> AppManagementResult { - self.worktree_host()?.repository_status(request).await + self.require_local_worktrees()?; + super::worktree::repository_status(request).await } pub async fn worktree_bind_session( &self, request: WorktreeBindSessionRequest, ) -> AppManagementResult { - self.worktree_host()?.bind_session(request).await + self.require_local_worktrees()?; + super::worktree::bind_session(request).await } pub async fn worktree_release_session( &self, request: WorktreeReleaseSessionRequest, ) -> AppManagementResult { - self.worktree_host()?.release_session(request).await + self.require_local_worktrees()?; + super::worktree::release_session(request).await } pub async fn account_snapshot( &self, request: AccountSnapshotRequest, ) -> AppManagementResult { - self.account_host(ACCOUNT_CAPABILITY)? - .account_snapshot(request) - .await + let _ = request.workspace_path; + Ok(project_account_snapshot( + self.account_runtime(ACCOUNT_CAPABILITY)?.snapshot().await, + )) } pub async fn account_login( &self, request: AccountLoginRequest, ) -> AppManagementResult { - self.account_host(ACCOUNT_CAPABILITY)? - .account_login(request) + validate_account_operation_id(&request.operation_id)?; + let result = self + .account_runtime(ACCOUNT_CAPABILITY)? + .login_with_credentials(&request.relay_url, &request.username, &request.password) .await + .map_err(|error| account_error(error, &request))?; + let status_message = account_login_status_message(&result); + Ok(AccountLoginResponse { + user_id: result.user_id, + relay_url: result.relay_url, + has_cloud_settings: result.has_cloud_settings, + status_message, + }) } pub async fn account_finalize_login( &self, request: AccountFinalizeLoginRequest, ) -> AppManagementResult { - self.account_host(ACCOUNT_CAPABILITY)? - .account_finalize_login(request) + validate_account_operation_id(&request.operation_id)?; + let account = self.account_runtime(ACCOUNT_CAPABILITY)?; + account + .finalize_login_after_sync_choice() + .await + .map_err(internal_account_error)?; + if !account + .start_auto_sync_background( + request.operation_id, + request.choice == AccountSyncChoice::Local, + PathBuf::from(request.workspace_path), + ) .await + { + return Err(AppManagementError::invalid_request( + "Account settings sync is already in progress", + )); + } + Ok(project_account_snapshot(account.snapshot().await)) } pub async fn account_logout( &self, request: AccountLogoutRequest, ) -> AppManagementResult { - self.account_host(ACCOUNT_CAPABILITY)? - .account_logout(request) - .await + validate_account_operation_id(&request.operation_id)?; + let account = self.account_runtime(ACCOUNT_CAPABILITY)?; + account.logout().await.map_err(internal_account_error)?; + account.mark_sync_cancelled(request.operation_id).await; + Ok(project_account_snapshot(account.snapshot().await)) } pub async fn settings_sync_start( &self, request: SettingsSyncStartRequest, ) -> AppManagementResult { - self.account_host(SETTINGS_SYNC_CAPABILITY)? - .settings_sync_start(request) + validate_account_operation_id(&request.operation_id)?; + let account = self.account_runtime(SETTINGS_SYNC_CAPABILITY)?; + if !account.is_logged_in().await { + return Err(AppManagementError::invalid_request( + "Account login must be finalized before settings sync starts", + )); + } + if !account + .start_auto_sync_background( + request.operation_id, + request.is_first_login, + PathBuf::from(request.workspace_path), + ) .await + { + return Err(AppManagementError::invalid_request( + "Account settings sync is already in progress", + )); + } + Ok(SettingsSyncResponse { + progress: project_sync_progress(account.current_sync_progress().await), + }) } pub async fn settings_sync_snapshot( &self, request: SettingsSyncSnapshotRequest, ) -> AppManagementResult { - self.account_host(SETTINGS_SYNC_CAPABILITY)? - .settings_sync_snapshot(request) - .await + let _ = request; + let progress = self + .account_runtime(SETTINGS_SYNC_CAPABILITY)? + .current_sync_progress() + .await; + Ok(SettingsSyncResponse { + progress: project_sync_progress(progress), + }) } pub async fn settings_sync_cancel( &self, request: SettingsSyncCancelRequest, ) -> AppManagementResult { - self.account_host(SETTINGS_SYNC_CAPABILITY)? - .settings_sync_cancel(request) + validate_account_operation_id(&request.operation_id)?; + let progress = self + .account_runtime(SETTINGS_SYNC_CAPABILITY)? + .cancel_sync(request.operation_id) .await + .map_err(internal_account_error)?; + Ok(SettingsSyncResponse { + progress: project_sync_progress(progress), + }) } pub async fn settings_sync_local_changed( &self, request: SettingsSyncLocalChangedRequest, ) -> AppManagementResult { - self.account_host(SETTINGS_SYNC_CAPABILITY)? - .settings_sync_local_changed(request) - .await + validate_account_operation_id(&request.operation_id)?; + let account = self.account_runtime(SETTINGS_SYNC_CAPABILITY)?; + account.notify_local_settings_changed(); + Ok(SettingsSyncResponse { + progress: project_sync_progress(account.current_sync_progress().await), + }) } pub async fn native_hook_overview( @@ -1741,6 +1812,114 @@ impl AppManagementService { } } +fn project_account_snapshot( + snapshot: bitfun_core::service::remote_connect::account_runtime::AccountSnapshot, +) -> AccountSnapshotResponse { + AccountSnapshotResponse { + logged_in: snapshot.logged_in, + pending_sync_choice: snapshot.pending_sync_choice, + info: snapshot.info.map(|info| AccountInfo { + user_id: info.user_id, + relay_url: info.relay_url, + device_id: info.device_id, + device_name: info.device_name, + }), + devices: snapshot + .devices + .into_iter() + .map(|device| AccountDevice { + device_id: device.device_id, + device_name: device.device_name, + online: device.online, + }) + .collect(), + sync: project_sync_progress(snapshot.sync), + } +} + +fn project_sync_progress(progress: AccountSyncProgress) -> SettingsSyncProgress { + SettingsSyncProgress { + operation_id: progress.operation_id, + status: match progress.status { + AccountSyncStatus::Idle => SettingsSyncStatus::Idle, + AccountSyncStatus::Syncing => SettingsSyncStatus::Syncing, + AccountSyncStatus::Done => SettingsSyncStatus::Done, + AccountSyncStatus::Failed => SettingsSyncStatus::Failed, + AccountSyncStatus::Cancelled => SettingsSyncStatus::Cancelled, + }, + phase: progress.phase, + percent: progress.percent, + current: progress.current, + total: progress.total, + detail: progress.detail, + error: progress.error, + settings_synced: progress.settings_synced, + sessions_exported: progress.sessions_exported, + } +} + +fn account_login_status_message( + result: &bitfun_core::service::remote_connect::account_runtime::AccountLoginResult, +) -> String { + if result.has_cloud_settings { + return format!( + "Authenticated as user {} on {}. Choose cloud or local settings to finish login.", + result.user_id, result.relay_url + ); + } + if result.routing_connected { + format!( + "Logged in as user {} on {}. Device routing connected.", + result.user_id, result.relay_url + ) + } else if let Some(error) = &result.routing_error { + format!( + "Logged in as user {} on {}. Device routing failed: {}", + result.user_id, + result.relay_url, + bounded_error(error.clone()) + ) + } else { + format!( + "Logged in as user {} on {}.", + result.user_id, result.relay_url + ) + } +} + +fn validate_account_operation_id(operation_id: &str) -> AppManagementResult<()> { + let valid = !operation_id.trim().is_empty() + && operation_id.len() <= 128 + && operation_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')); + valid + .then_some(()) + .ok_or_else(|| AppManagementError::invalid_request("Account operation ID is invalid")) +} + +fn account_error(error: anyhow::Error, request: &AccountLoginRequest) -> AppManagementError { + let mut message = error.to_string(); + for secret in [&request.relay_url, &request.username, &request.password] { + if !secret.is_empty() { + message = message.replace(secret, ""); + } + } + AppManagementError::internal(bounded_error(message)) +} + +fn internal_account_error(error: anyhow::Error) -> AppManagementError { + AppManagementError::internal(bounded_error(error.to_string())) +} + +fn bounded_error(message: String) -> String { + message + .chars() + .filter(|character| !character.is_control()) + .take(500) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/crates/interfaces/app-server/src/management/worktree.rs b/src/crates/interfaces/app-server/src/management/worktree.rs new file mode 100644 index 000000000..3c86d77af --- /dev/null +++ b/src/crates/interfaces/app-server/src/management/worktree.rs @@ -0,0 +1,127 @@ +use bitfun_app_server_protocol::worktree::*; +use bitfun_core::service::git::GitService; +use bitfun_core::service::worktree::{WorktreeService, WorktreeSessionBindingRequest}; +use bitfun_runtime_ports::{AgentSessionWorkspaceBinding, WorktreeError, WorktreeErrorCode}; + +use super::{AppManagementError, AppManagementResult}; + +pub(crate) async fn repository_status( + request: WorktreeRepositoryStatusRequest, +) -> AppManagementResult { + if request.is_remote() { + return Err(worktree_error(WorktreeOperationError { + code: WorktreeErrorCode::RemoteUnsupported, + message: "Repository status is not supported for remote workspaces".to_string(), + recovery_path: None, + operation_id: None, + })); + } + + let repository = match GitService::resolve_worktree_repository(&request.workspace_path).await { + Ok(repository) => GitService::get_repository_basic(repository.query_path).await, + Err(error) => Err(error), + }; + match repository { + Ok(repository) => Ok(WorktreeRepositoryStatusResponse { + is_repository: true, + current_branch: Some(repository.current_branch), + }), + Err(_) => Ok(WorktreeRepositoryStatusResponse { + is_repository: false, + current_branch: None, + }), + } +} + +pub(crate) async fn bind_session( + request: WorktreeBindSessionRequest, +) -> AppManagementResult { + transition( + request.is_remote(), + request.operation_id, + request.session_id, + request.project_workspace_path, + true, + ) + .await +} + +pub(crate) async fn release_session( + request: WorktreeReleaseSessionRequest, +) -> AppManagementResult { + transition( + request.is_remote(), + request.operation_id, + request.session_id, + request.project_workspace_path, + false, + ) + .await +} + +async fn transition( + remote: bool, + operation_id: String, + session_id: String, + project_workspace_path: Option, + enabled: bool, +) -> AppManagementResult { + validate_operation_id(&operation_id)?; + if remote { + return Err(worktree_error(WorktreeOperationError { + code: WorktreeErrorCode::RemoteUnsupported, + message: "Managed worktrees are not supported for remote workspaces".to_string(), + recovery_path: None, + operation_id: Some(operation_id), + })); + } + + let result = WorktreeService::bind_session(WorktreeSessionBindingRequest { + request_id: operation_id.clone(), + session_id, + project_workspace_path, + enabled, + }) + .await + .map_err(|error| worktree_error(project_error(error, Some(operation_id.clone()))))?; + let execution_target = result.execution_target.clone(); + Ok(WorktreeBindingResponse { + workspace_binding: AgentSessionWorkspaceBinding { + workspace_id: result.workspace_id, + workspace_path: result.workspace_path, + project_workspace_path: Some(result.project_workspace_path), + execution_target: Some(execution_target), + remote_connection_id: None, + remote_ssh_host: None, + }, + retained_worktree_path: result.retained_worktree_path, + }) +} + +fn validate_operation_id(operation_id: &str) -> AppManagementResult<()> { + if !operation_id.trim().is_empty() + && operation_id.len() <= 160 + && operation_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + Ok(()) + } else { + Err(AppManagementError::invalid_request( + "Worktree operation ID is invalid", + )) + } +} + +fn project_error(error: WorktreeError, operation_id: Option) -> WorktreeOperationError { + WorktreeOperationError { + code: error.code, + message: error.message, + recovery_path: error.recovery_path, + operation_id, + } +} + +fn worktree_error(error: WorktreeOperationError) -> AppManagementError { + AppManagementError::internal(error.encode()) +}