From 7b85c26e00dd5d742e51fab60d8c0195fc2c11b9 Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 6 Jul 2026 13:00:13 +0800 Subject: [PATCH] refactor(core): migrate concrete service owners --- .../opencode-plugin-surface-audit.md | 10 +- docs/plans/core-decomposition-completed.md | 4 +- docs/plans/core-decomposition-plan.md | 45 +- .../core/src/agentic/memories/workspace.rs | 412 ++------------ .../implementations/computer_use_actions.rs | 34 +- .../assembly/core/src/service/mcp/auth.rs | 21 +- .../src/session/memory_workspace.rs | 506 ++++++++++++++++++ .../services/services-core/src/session/mod.rs | 6 + .../services-core/src/system/local_actions.rs | 38 +- 9 files changed, 639 insertions(+), 437 deletions(-) create mode 100644 src/crates/services/services-core/src/session/memory_workspace.rs diff --git a/docs/architecture/opencode-plugin-surface-audit.md b/docs/architecture/opencode-plugin-surface-audit.md index d26c23abd9..57123e39e9 100644 --- a/docs/architecture/opencode-plugin-surface-audit.md +++ b/docs/architecture/opencode-plugin-surface-audit.md @@ -1,7 +1,7 @@ # OpenCode 插件兼容暴露面审计 本文独立审视 BitFun 当前核心迁移、公共 API 暴露面和未来受控接入 OpenCode -插件生态的风险。本文不替代 `core-decomposition.md` 和 +插件生态的风险。本文不替代 `product-architecture.md` 和 `agent-runtime-services-design.md`,也不记录单次 PR 进度或维护独立执行路线图。 ## 1. 复核方式 @@ -210,10 +210,10 @@ BitFun 产品策略仍有混合。 |---|---|---| | 旧 public surface 过宽 | Stage A:Public API Closure | stable external、workspace-internal、compatibility API 明确分层,并阻断旧 core 路径回流 | | Tool ABI / runtime context 混合 | Stage B:Tool ABI、Event Manifest 与 Security Control Plane | materialized snapshot、provider identity、permission/effect filter、stale call guard、public event manifest、version、aggregate identity、replay/retention 具备测试 | -| Plugin Runtime Host 生命周期和安全桥接 | Stage C:Plugin Runtime Host Foundation | contribution 以 descriptor 暴露,Product Assembly 内部 materialize provider;注册可撤销,候选效果不能写权威状态 | -| UI 扩展合同缺口 | Stage D:UI Extension Contract 与产品形态矩阵 | descriptor-only、只读 state view、入口 fallback 和 unsupported/unavailable 行为具备 round-trip 测试 | -| OpenCode 分级适配 | Stage E:OpenCode Compatibility Adapter | support matrix、typed unsupported、permission/effect、event manifest、UI contribution 和远程/workspace 映射全部可验证 | -| 剩余 concrete owner | Stage F:剩余 Concrete Owner 与 SDK Readiness | Product Assembly 选择 concrete provider;普通层级只依赖 port、descriptor 或 stable contract | +| 剩余 concrete owner | Stage D:剩余 Concrete Owner 与 SDK Readiness | Product Assembly 选择 concrete provider;普通层级只依赖 port、descriptor 或 stable contract | +| UI 扩展合同缺口 | Stage E:UI Extension Contract 与产品形态矩阵 | descriptor-only、只读 state view、入口 fallback 和 unsupported/unavailable 行为具备 round-trip 测试 | +| Plugin Runtime Host 生命周期和安全桥接 | Stage F:Plugin Runtime Host 执行边界 | contribution 以 descriptor 暴露,Product Assembly 内部 materialize provider;注册可撤销,候选效果不能写权威状态 | +| OpenCode 分级适配 | Stage G:OpenCode Compatibility Adapter | support matrix、typed unsupported、permission/effect、event manifest、UI contribution 和远程/workspace 映射全部可验证 | ## 7. 执行准则 diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index 6484d6109d..d4aad1d148 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -17,9 +17,11 @@ - `services-core` 已承接 session layout、metadata store CRUD / index rebuild、metadata pagination、metadata construction / mutation、lineage / branch shaping、JSON file store、filesystem primitives、managed runtime command resolution / PATH merge、LSP plugin registry / extension matching / command-target mapping、diagnostic redaction、session usage/token usage 基础服务。 - `services-core` 已承接 workspace-runtime legacy session-store merge、metadata 冲突选择、index rebuild 和 legacy path copy/move fallback;core workspace-runtime 只保留路径计算、runtime layout ensure 和错误兼容映射。 +- `services-core` 已承接 LocalSystemAction 的稳定错误码投射;core Computer Use 系统动作路径只把这些 stable code 适配到既有 ControlHub 工具 envelope。 +- `services-core` 已承接 memory workspace Git baseline、diff collection 和 diff file rendering;core memory workspace 只保留业务文件生成、Phase2 diff 清理、兼容 API 路径和错误类别映射。 - `runtime-services` 已承接 typed runtime service assembly、capability availability、provider registry、capability validation、无副作用 capability marker ports 和 backend event delivery;core backend event system 只保留兼容 re-export。 - `bitfun-events` 已承接 backend event DTO、agentic event DTO、framework-neutral Agentic frontend event projection、AgenticEvent projection manifest、event version / aggregate / replay / retention facts、legacy WebSocket event allowlist 和 platform-neutral `EventEmitter` trait;Tauri/WebSocket transport 只负责 delivery。 -- `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、LAN IP/URL 探测、ngrok 进程/tunnel lifecycle、mobile-web relay upload manifest / incremental upload / fallback upload、IM bot provider-neutral config / persistence / file auto-push / locale / menu / state / command parsing、Weixin provider client、workspace search concrete owner、remote SSH/SFTP/PTY owner、Remote SSH disabled runtime surface、Remote SSH workspace/session identity helper、remote workspace-search disabled surface、DeepResearch report IO / display-map sidecar、MiniApp host dispatch / storage / worker / import IO、announcement remote fetch/cache、browser CDP endpoint HTTP probing / page creation、WebFetch / WebSearch concrete HTTP provider、debug-log HTTP ingest posting、review-platform HTTP transport,以及 MCP server registry、connection pool、catalog cache、reconnect retry state、runtime-only config overlay、local command resolution helper 和 lifecycle status policy。 +- `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、LAN IP/URL 探测、ngrok 进程/tunnel lifecycle、mobile-web relay upload manifest / incremental upload / fallback upload、IM bot provider-neutral config / persistence / file auto-push / locale / menu / state / command parsing、Weixin provider client、workspace search concrete owner、remote SSH/SFTP/PTY owner、Remote SSH disabled runtime surface、Remote SSH workspace/session identity helper、remote workspace-search disabled surface、DeepResearch report IO / display-map sidecar、MiniApp host dispatch / storage / worker / import IO、announcement remote fetch/cache、browser CDP endpoint HTTP probing / page creation、WebFetch / WebSearch concrete HTTP provider、debug-log HTTP ingest posting、review-platform HTTP transport,以及 MCP server registry、connection pool、catalog cache、reconnect retry state、runtime-only config overlay、local command resolution helper、lifecycle status policy 和 MCP OAuth credential vault / store / authorization bootstrap;core MCP auth 只保留产品 data-dir 注入、授权入口、错误映射和 deprecated 兼容 wrapper。 - `tool-contracts` 已承接 provider-neutral tool DTO、manifest/catalog/admission/result presentation、Computer Use DTO/input parser/screenshot payload、confirmation facts、truncation recovery presentation、runtime restriction policy、provider-entry materialization、materialized tool snapshot、provider identity、permission/effect filter、cancellation contract 和 stale-call guard;core 只保留 Computer Use 旧 public path re-export / compatibility shim、产品 Tool trait 适配与产品执行入口。 - `tool-execution` 已承接 local / remote IO helper、Bash shell helper、batching plan、retry policy、state counting、tool state event payload shaping / result redaction、cancellation-state/token-store policy、background exec output capture、ExecCommand provider-neutral 呈现 / 输入默认值 / 结果 shape / shell metadata / shell argv / remote shell probe / remote env snapshot 解析、cache 与 capture policy / lifecycle facts / control facts / completion shape、prompt-safe tool context facts / custom-data materialization、Computer Use loop detection / screenshot hash / verification / retry policy,以及 File tool 的 provider-neutral 结果展示、写入 mode/status/line-count 规则、Edit guardrail 分类和 Delete success 文本;core 只保留 ToolResult 包装、权限、checkpoint、runtime handles、host adapter 调用、read-state adapter、remote FS 调用和旧工具入口。 - `runtime-ports` / `terminal-core` / `services-integrations` 已承接 ExecCommand 会话执行端口和 concrete provider:`TerminalPort` 暴露本地命令执行、stdin 写入、会话控制和生命周期事件边界,`RemoteExecPort` 暴露远端 SSH 命令执行、bounded one-shot command、stdin、会话控制和生命周期事件边界;`TerminalRuntimePort` 复用原本地 `ExecProcessManager` 行为,`RemoteExecRuntimePort` 复用原 remote exec manager 与旧 SSH one-shot 行为,当前 desktop / CLI 产品入口和保留 server bootstrap 初始化路径通过 `CoreRuntimeServicesProvider` 构造 provider 并显式注入 `ConversationCoordinator` / 执行上下文 / `ToolRuntimeHandles`;core `ExecCommand` / `WriteStdin` / `ExecControl` 只消费端口,不再直接调用全局本地或远端进程 manager。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 45ed3fe3ce..a835b3c48e 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -26,6 +26,7 @@ - workspace 已按六层物理目录展开:`interfaces -> assembly -> adapters -> services -> execution -> contracts`。 - Runtime Services、Agent Runtime、Tool Contracts、Tool Execution、Harness、Product Domains、Services Core、Services Integrations 等 owner crate 已建立。 - `bitfun-core --no-default-features` 已裁掉多批 concrete provider 和 direct provider 依赖;Desktop、CLI、ACP 仍通过 `bitfun-core/product-full` 获取完整产品能力。 +- Computer Use 系统动作错误码、memory workspace Git baseline / diff / render、MCP OAuth credential store 已继续收口到 services owner;core 只保留既有工具 envelope、产品路径注入、授权入口和 deprecated 兼容 wrapper。 - Agentic frontend event projection 和 AgenticEvent projection manifest 已进入 `bitfun-events`;Tauri/WebSocket transport 不再内联事件字段映射或 legacy event allowlist。 - Tool ABI 基础合同已进入 `tool-contracts`:materialized snapshot、provider identity、default permission/effect filter、cancellation contract 和 stale-call guard 由 owner crate 提供,core 只投射现有产品 Tool 元数据。 - Terminal / ExecCommand、remote SSH concrete execution、workspace search、debug ingest、AI provider adapter runtime、browser CDP、WebFetch/WebSearch、review platform transport 等多批 owner 已迁出或收口到 port/provider。 @@ -35,15 +36,30 @@ | 差距 | 影响 | 收敛要求 | |---|---|---| -| Plugin Runtime Host 仍缺少真实执行 Host 和生态 adapter | 插件能力只能表达 disabled / projection-only,不能加载或执行外部插件 | 在 UI Extension Contract 后落地受控 Host facade、effect / trust / diagnostics / deadline / epoch;生态 adapter 在 Host 边界稳定后接入 | +| 部分 concrete owner 仍在 core 或产品命令路径 | 层级依赖和平台差异仍可能回流 | 继续迁移剩余 process/session host adapter、SDK-facing concrete provider 选择和其他仍由 core 持有的 I/O owner | +| SDK readiness 仍未闭环 | 独立 Agent Runtime SDK 可能牵引 product-full 或 concrete provider | fake-provider smoke、minimal feature、cargo tree/metadata 对比和 API version 保护 | | UI Extension Contract 与产品形态矩阵仍需实现 | Desktop/Web/CLI/SDK/ACP 的插件 UI 行为可能不一致 | 建立 descriptor round-trip、fallback、unsupported/unavailable 和只读 state view | +| Plugin Runtime Host 仍缺少真实执行 Host 和生态 adapter | 插件能力只能表达 disabled / projection-only,不能加载或执行外部插件 | 在 UI Extension Contract 后落地受控 Host facade、effect / trust / diagnostics / deadline / epoch;生态 adapter 在 Host 边界稳定后接入 | | OpenCode compatibility adapter 仍缺少真实消费路径 | OpenCode 插件能力无法受控进入 BitFun | 插件 Host 边界稳定后再接入;具体生态 adapter、JS/TS runtime 和可写插件能力后置 | -| 部分 concrete owner 仍在 core 或产品命令路径 | 层级依赖和平台差异仍可能回流 | 继续迁移 Computer Use OS action、Git/process/session host adapter、MCP auth URL helper 等 | -| SDK readiness 仍未闭环 | 独立 Agent Runtime SDK 可能牵引 product-full 或 concrete provider | fake-provider smoke、minimal feature、cargo tree/metadata 对比和 API version 保护 | ## 4. 后续大型阶段 -### Stage D:UI Extension Contract 与产品形态矩阵 +### Stage D:剩余 Concrete Owner 与 SDK Readiness + +目标:继续把 concrete owner 从 `bitfun-core` / 产品命令路径收口到对应 owner crate,并验证独立 Agent Runtime SDK 边界不会牵引完整产品实现。 + +范围: + +- 继续迁移剩余 process/session host adapter、SDK-facing concrete provider 选择和其他仍由 core 持有的 I/O owner。 +- Product Assembly 负责选择 provider;Kernel、Execution、Extension、Product Feature 不直接依赖 platform concrete。 +- 建立 SDK minimal fake-provider smoke,确认 minimal feature 不牵引 Desktop、Tauri、Git provider、MCP client、AI HTTP client、remote SSH 或产品 UI。 + +准出: + +- 至少完成 2-3 个 concrete owner 的实际迁移,并同步删除或简化 core 旧主体路径。 +- `cargo check --workspace`、`cargo check -p bitfun-core --no-default-features`、SDK minimal smoke、cargo metadata/tree 对比和必要 product checks 通过。 + +### Stage E:UI Extension Contract 与产品形态矩阵 目标:为插件 UI contribution 提供声明式 descriptor,并明确不同交付形态的支持、禁用和降级行为。 @@ -58,7 +74,7 @@ - Product Assembly 维护 UI contribution registry、capability matrix 和 unsupported/unavailable fallback。 - 建立 Desktop、Web、CLI、Server、Remote、ACP、SDK、Mobile Web 的插件能力矩阵。 -Stage D 目标 UI Extension 形态矩阵: +Stage E 目标 UI Extension 形态矩阵: | 形态 | UI Extension 状态 | 降级要求 | |---|---|---| @@ -70,7 +86,7 @@ Stage D 目标 UI Extension 形态矩阵: - UI descriptor round-trip、host fallback、unsupported/unavailable 和 product-shape focused tests 通过。 - Web、Desktop、CLI 不因 UI Extension Contract 引入互相依赖。 -### Stage E:Plugin Runtime Host 执行边界 +### Stage F:Plugin Runtime Host 执行边界 目标:在 disabled/projection-only 边界和 UI Extension Contract 之后,建立真实插件 Host 的受控执行边界,但仍不直接绑定 OpenCode、Claude Code 或 Codex 等具体生态实现。 @@ -87,7 +103,7 @@ Stage D 目标 UI Extension 形态矩阵: - disabled、projection-only、unavailable、host failure、dispose 和 permission/effect focused tests 通过。 - 默认不开放可写 transform 或外部 JS/TS plugin runtime;这些能力需要单独安全评审。 -### Stage F:OpenCode Compatibility Adapter +### Stage G:OpenCode Compatibility Adapter 目标:在 Plugin Runtime Host、Tool ABI、Event Manifest 和 UI Extension Contract 可用后,实现受控 OpenCode 兼容适配。 @@ -103,21 +119,6 @@ Stage D 目标 UI Extension 形态矩阵: - OpenCode adapter 不依赖 `bitfun-core/product-full`、full `RuntimeServices` bundle、UI implementation 或 concrete provider handle。 - adapter、permission/effect、event manifest、UI contribution 和 Desktop/CLI/Server/Remote/ACP/Web/Mobile Web/SDK product shape checks 通过。 -### Stage G:剩余 Concrete Owner 与 SDK Readiness - -目标:完成剩余 concrete owner 收口,并验证独立 Agent Runtime SDK 边界。 - -范围: - -- 继续迁移 Computer Use OS action、部分 Git/process/session host adapter、MCP auth URL helper 等剩余 concrete owner。 -- Product Assembly 负责选择 provider;Kernel、Execution、Extension、Product Feature 不直接依赖 platform concrete。 -- 建立 SDK minimal fake-provider smoke,确认 minimal feature 不牵引 Desktop、Tauri、Git provider、MCP client、AI HTTP client、remote SSH 或产品 UI。 - -准出: - -- 至少完成 2-3 个 concrete owner 的实际迁移,并同步删除或简化 core 旧主体路径。 -- `cargo check --workspace`、`cargo check -p bitfun-core --no-default-features`、SDK minimal smoke、cargo metadata/tree 对比和必要 product checks 通过。 - ## 5. 固定执行流程 1. 同步最新 `main`,检查主干新增的 CLI、tool、terminal、session、scheduler、remote、MiniApp、ACP、OpenCode、plugin、UI 或 product interface 变更。 diff --git a/src/crates/assembly/core/src/agentic/memories/workspace.rs b/src/crates/assembly/core/src/agentic/memories/workspace.rs index ede27add8d..01d3aab055 100644 --- a/src/crates/assembly/core/src/agentic/memories/workspace.rs +++ b/src/crates/assembly/core/src/agentic/memories/workspace.rs @@ -1,11 +1,14 @@ use crate::agentic::memories::db::MemoryRow; use crate::infrastructure::get_path_manager_arc; use crate::util::errors::{BitFunError, BitFunResult}; +use bitfun_services_core::session::MemoryWorkspaceGitError; +pub use bitfun_services_core::session::{ + MemoryWorkspaceChange, MemoryWorkspaceChangeStatus, MemoryWorkspaceDiff, +}; use chrono::{DateTime, Utc}; use std::collections::HashSet; use std::fmt::Write as _; use std::path::{Path, PathBuf}; -use std::process::Command; use tokio::io::AsyncWriteExt; use uuid::Uuid; @@ -19,8 +22,6 @@ pub const MEMORY_EXTENSIONS_DIR_NAME: &str = "extensions"; pub const AD_HOC_EXTENSION_NAME: &str = "ad_hoc"; pub const AD_HOC_NOTES_DIR_NAME: &str = "notes"; pub const AD_HOC_INSTRUCTIONS_FILE_NAME: &str = "instructions.md"; -const PHASE2_WORKSPACE_DIFF_MAX_BYTES: usize = 200_000; -const MEMORY_BASELINE_COMMIT_MESSAGE: &str = "Memory workspace baseline"; const AD_HOC_INSTRUCTIONS: &str = r#"# Ad-hoc notes ## Instructions @@ -224,46 +225,13 @@ pub async fn sync_phase2_workspace_inputs(root: &Path, rows: &[MemoryRow]) -> Bi Ok(()) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MemoryWorkspaceChangeStatus { - Added, - Modified, - Deleted, -} - -impl MemoryWorkspaceChangeStatus { - pub fn label(self) -> &'static str { - match self { - Self::Added => "A", - Self::Modified => "M", - Self::Deleted => "D", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MemoryWorkspaceChange { - pub status: MemoryWorkspaceChangeStatus, - pub path: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MemoryWorkspaceDiff { - pub changes: Vec, - pub unified_diff: String, -} - -impl MemoryWorkspaceDiff { - pub fn has_changes(&self) -> bool { - !self.changes.is_empty() - } -} - pub async fn prepare_memory_workspace(root: &Path) -> BitFunResult<()> { ensure_memory_workspace(root).await?; seed_ad_hoc_memory_extension(root).await?; remove_phase2_workspace_diff(root).await?; - ensure_memory_workspace_git_baseline(root).await + bitfun_services_core::session::ensure_memory_workspace_git_baseline(root) + .await + .map_err(map_memory_workspace_baseline_error) } pub async fn seed_ad_hoc_memory_extension(root: &Path) -> BitFunResult<()> { @@ -317,23 +285,26 @@ pub async fn seed_ad_hoc_memory_extension(root: &Path) -> BitFunResult<()> { pub async fn memory_workspace_diff(root: &Path) -> BitFunResult { remove_phase2_workspace_diff(root).await?; - let root = root.to_path_buf(); - tokio::task::spawn_blocking(move || memory_workspace_diff_sync(&root)) + bitfun_services_core::session::memory_workspace_diff(root) .await - .map_err(|error| { - BitFunError::service(format!("Memory workspace diff task failed: {}", error)) - })? + .map_err(map_memory_workspace_diff_error) } pub async fn write_workspace_diff(root: &Path, diff: &MemoryWorkspaceDiff) -> BitFunResult<()> { ensure_memory_workspace(root).await?; let path = phase2_workspace_diff_file(root); - write_text_file_if_changed(&path, &render_workspace_diff_file(diff)).await + write_text_file_if_changed( + &path, + &bitfun_services_core::session::render_memory_workspace_diff_file(diff), + ) + .await } pub async fn reset_memory_workspace_baseline(root: &Path) -> BitFunResult<()> { remove_phase2_workspace_diff(root).await?; - reset_memory_workspace_git_baseline(root).await + bitfun_services_core::session::reset_memory_workspace_git_baseline(root) + .await + .map_err(map_memory_workspace_baseline_error) } pub async fn clear_phase2_workspace_diff(root: &Path) -> BitFunResult<()> { @@ -353,347 +324,26 @@ pub async fn remove_phase2_workspace_diff(root: &Path) -> BitFunResult<()> { } } -async fn ensure_memory_workspace_git_baseline(root: &Path) -> BitFunResult<()> { - let root = root.to_path_buf(); - tokio::task::spawn_blocking(move || { - std::fs::create_dir_all(&root).map_err(|error| { - BitFunError::io(format!( - "Failed to create memory workspace {}: {}", - root.display(), - error - )) - })?; - - if root.join(".git").is_dir() && run_git(&root, &["rev-parse", "--verify", "HEAD"]).is_ok() - { - return Ok(()); - } - - reset_memory_workspace_git_baseline_sync(&root) - }) - .await - .map_err(|error| { - BitFunError::service(format!("Memory workspace baseline task failed: {}", error)) - })? -} - -async fn reset_memory_workspace_git_baseline(root: &Path) -> BitFunResult<()> { - let root = root.to_path_buf(); - tokio::task::spawn_blocking(move || reset_memory_workspace_git_baseline_sync(&root)) - .await - .map_err(|error| { - BitFunError::service(format!("Memory workspace baseline task failed: {}", error)) - })? -} - -fn reset_memory_workspace_git_baseline_sync(root: &Path) -> BitFunResult<()> { - std::fs::create_dir_all(root).map_err(|error| { - BitFunError::io(format!( - "Failed to create memory workspace {}: {}", - root.display(), - error - )) - })?; - remove_git_metadata(root)?; - run_git_raw(root, &["init"])?; - run_git(root, &["add", "-A"])?; - run_git( - root, - &[ - "commit", - "--allow-empty", - "-m", - MEMORY_BASELINE_COMMIT_MESSAGE, - ], - )?; - Ok(()) -} - -fn remove_git_metadata(root: &Path) -> BitFunResult<()> { - let path = root.join(".git"); - let metadata = match std::fs::symlink_metadata(&path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => { - return Err(BitFunError::io(format!( - "Failed to inspect memory workspace git metadata {}: {}", - path.display(), - error - ))) - } - }; - - if metadata.is_dir() && !metadata.file_type().is_symlink() { - std::fs::remove_dir_all(&path) - } else { - std::fs::remove_file(&path) - } - .map_err(|error| { - BitFunError::io(format!( - "Failed to remove memory workspace git metadata {}: {}", - path.display(), - error - )) - }) -} - -fn memory_workspace_diff_sync(root: &Path) -> BitFunResult { - run_git(root, &["rev-parse", "--verify", "HEAD"])?; - - let tracked_status = git_stdout( - root, - &["diff", "--name-status", "--no-renames", "HEAD", "--"], - )?; - let mut changes = parse_git_name_status(&tracked_status)?; - let head_paths = git_z_stdout(root, &["ls-tree", "-r", "--name-only", "-z", "HEAD"])?; - let head_paths = parse_nul_paths(&head_paths); - let current_paths = collect_current_memory_paths(root)?; - - for path in current_paths.difference(&head_paths) { - changes.push(MemoryWorkspaceChange { - status: MemoryWorkspaceChangeStatus::Added, - path: path.clone(), - }); - } - - changes.sort_by(|left, right| left.path.cmp(&right.path)); - changes.dedup_by(|left, right| left.path == right.path); - - let mut unified_diff = git_stdout( - root, - &["diff", "--no-ext-diff", "--no-renames", "HEAD", "--"], - )?; - for change in changes - .iter() - .filter(|change| change.status == MemoryWorkspaceChangeStatus::Added) - { - unified_diff.push_str(&render_added_file_diff(root, &change.path)?); - } - - Ok(MemoryWorkspaceDiff { - changes, - unified_diff, - }) -} - -fn collect_current_memory_paths(root: &Path) -> BitFunResult> { - let mut paths = std::collections::BTreeSet::new(); - collect_current_memory_paths_inner(root, root, &mut paths)?; - Ok(paths) -} - -fn collect_current_memory_paths_inner( - root: &Path, - dir: &Path, - paths: &mut std::collections::BTreeSet, -) -> BitFunResult<()> { - for entry in std::fs::read_dir(dir).map_err(|error| { - BitFunError::io(format!( - "Failed to read memory workspace directory {}: {}", - dir.display(), - error - )) - })? { - let entry = entry.map_err(|error| { - BitFunError::io(format!( - "Failed to read memory workspace directory entry {}: {}", - dir.display(), - error - )) - })?; - let path = entry.path(); - if path.file_name().and_then(|name| name.to_str()) == Some(".git") { - continue; - } - let metadata = entry.metadata().map_err(|error| { - BitFunError::io(format!( - "Failed to inspect memory workspace entry {}: {}", - path.display(), - error - )) - })?; - if metadata.is_dir() { - collect_current_memory_paths_inner(root, &path, paths)?; - } else if metadata.is_file() { - paths.insert(relative_memory_path(root, &path)?); - } - } - Ok(()) +fn map_memory_workspace_baseline_error(error: MemoryWorkspaceGitError) -> BitFunError { + map_memory_workspace_git_error("Memory workspace baseline task failed", error) } -fn relative_memory_path(root: &Path, path: &Path) -> BitFunResult { - let relative = path.strip_prefix(root).map_err(|error| { - BitFunError::io(format!( - "Failed to normalize memory workspace path {} under {}: {}", - path.display(), - root.display(), - error - )) - })?; - Ok(relative.to_string_lossy().replace('\\', "/")) +fn map_memory_workspace_diff_error(error: MemoryWorkspaceGitError) -> BitFunError { + map_memory_workspace_git_error("Memory workspace diff task failed", error) } -fn parse_git_name_status(output: &str) -> BitFunResult> { - let mut changes = Vec::new(); - for line in output.lines().filter(|line| !line.trim().is_empty()) { - let mut parts = line.splitn(2, char::is_whitespace); - let status = parts.next().unwrap_or_default(); - let path = parts.next().unwrap_or_default().trim(); - let status = match status.chars().next() { - Some('A') => MemoryWorkspaceChangeStatus::Added, - Some('M') => MemoryWorkspaceChangeStatus::Modified, - Some('D') => MemoryWorkspaceChangeStatus::Deleted, - Some(other) => { - return Err(BitFunError::service(format!( - "Unsupported memory workspace git status '{}': {}", - other, line - ))) - } - None => continue, - }; - if !path.is_empty() { - changes.push(MemoryWorkspaceChange { - status, - path: path.replace('\\', "/"), - }); +fn map_memory_workspace_git_error( + join_context: &'static str, + error: MemoryWorkspaceGitError, +) -> BitFunError { + match error { + MemoryWorkspaceGitError::Join { source } => { + BitFunError::service(format!("{join_context}: {source}")) } - } - Ok(changes) -} - -fn parse_nul_paths(output: &[u8]) -> std::collections::BTreeSet { - output - .split(|byte| *byte == 0) - .filter(|part| !part.is_empty()) - .map(|part| String::from_utf8_lossy(part).replace('\\', "/")) - .collect() -} - -fn render_added_file_diff(root: &Path, path: &str) -> BitFunResult { - let body = std::fs::read_to_string(root.join(path)).map_err(|error| { - BitFunError::io(format!( - "Failed to read added memory workspace file {}: {}", - root.join(path).display(), - error - )) - })?; - let diff = similar::TextDiff::from_lines("", &body); - let mut rendered = format!( - "diff --git a/{path} b/{path}\nnew file mode 100644\n--- /dev/null\n+++ b/{path}\n" - ); - for change in diff.iter_all_changes() { - let sign = match change.tag() { - similar::ChangeTag::Delete => "-", - similar::ChangeTag::Insert => "+", - similar::ChangeTag::Equal => " ", - }; - rendered.push_str(sign); - rendered.push_str(change.value()); - } - if !rendered.ends_with('\n') { - rendered.push('\n'); - } - Ok(rendered) -} - -fn render_workspace_diff_file(diff: &MemoryWorkspaceDiff) -> String { - let mut rendered = String::from( - "# Memory Workspace Diff\n\n\ - Generated by BitFun before Phase 2 memory consolidation. Read this file first and do not edit it.\n\n\ - ## Status\n", - ); - - if !diff.has_changes() { - rendered.push_str("- none\n"); - return rendered; - } - - for change in &diff.changes { - rendered.push_str(&format!("- {} {}\n", change.status.label(), change.path)); - } - rendered.push_str("\n## Diff\n\n```diff\n"); - append_bounded_diff(&mut rendered, &diff.unified_diff); - rendered.push_str("```\n"); - rendered -} - -fn append_bounded_diff(rendered: &mut String, diff: &str) { - if diff.len() <= PHASE2_WORKSPACE_DIFF_MAX_BYTES { - rendered.push_str(diff); - if !diff.ends_with('\n') { - rendered.push('\n'); + error @ MemoryWorkspaceGitError::UnsupportedGitStatus { .. } => { + BitFunError::service(error.to_string()) } - return; - } - - let boundary = previous_char_boundary(diff, PHASE2_WORKSPACE_DIFF_MAX_BYTES); - rendered.push_str(&diff[..boundary]); - if !rendered.ends_with('\n') { - rendered.push('\n'); - } - rendered.push_str(&format!( - "\n[workspace diff truncated at {} bytes]\n", - PHASE2_WORKSPACE_DIFF_MAX_BYTES - )); -} - -fn previous_char_boundary(value: &str, max_bytes: usize) -> usize { - if max_bytes >= value.len() { - return value.len(); - } - let mut index = max_bytes; - while !value.is_char_boundary(index) { - index -= 1; - } - index -} - -fn git_stdout(root: &Path, args: &[&str]) -> BitFunResult { - let output = run_git(root, args)?; - String::from_utf8(output.stdout).map_err(|error| { - BitFunError::io(format!( - "Memory workspace git output was not UTF-8: {}", - error - )) - }) -} - -fn git_z_stdout(root: &Path, args: &[&str]) -> BitFunResult> { - run_git(root, args).map(|output| output.stdout) -} - -fn run_git(root: &Path, args: &[&str]) -> BitFunResult { - let mut full_args = vec![ - "-c", - "user.name=BitFun", - "-c", - "user.email=bitfun@localhost", - ]; - full_args.extend_from_slice(args); - run_git_raw(root, &full_args) -} - -fn run_git_raw(root: &Path, args: &[&str]) -> BitFunResult { - let output = Command::new("git") - .arg("-C") - .arg(root) - .args(args) - .output() - .map_err(|error| { - BitFunError::io(format!( - "Failed to run memory workspace git command in {}: {}", - root.display(), - error - )) - })?; - if output.status.success() { - Ok(output) - } else { - Err(BitFunError::io(format!( - "Memory workspace git command failed in {}: {}", - root.display(), - String::from_utf8_lossy(&output.stderr) - ))) + error => BitFunError::io(error.to_string()), } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs index c8f9d5e51e..37bae081e5 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs @@ -12,8 +12,7 @@ use crate::agentic::tools::computer_use_host::{ use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_services_core::system::{ - truncate_with_marker, LocalSystemActionError, LocalSystemActionErrorKind, LocalSystemProvider, - RunScriptRequest, + truncate_with_marker, LocalSystemActionError, LocalSystemProvider, RunScriptRequest, }; use serde_json::{json, Value}; @@ -1562,7 +1561,7 @@ fn local_system_error_response( error: LocalSystemActionError, ) -> Vec { let mut control_error = - ControlHubError::new(error_code_from_local(error.kind()), error.message()); + ControlHubError::new(error_code_from_local(error.stable_code()), error.message()); if !error.hints().is_empty() { control_error = control_error.with_hints(error.hints().to_vec()); } @@ -1570,30 +1569,21 @@ fn local_system_error_response( } fn map_run_script_error(error: LocalSystemActionError) -> BitFunResult> { - match error.kind() { - LocalSystemActionErrorKind::NotAvailable | LocalSystemActionErrorKind::Timeout => { + match error.stable_code() { + "NOT_AVAILABLE" | "TIMEOUT" => { Ok(local_system_error_response("system", "run_script", error)) } - LocalSystemActionErrorKind::UnknownScriptType => { - Err(BitFunError::tool(error.message().to_string())) - } - LocalSystemActionErrorKind::InvalidParams - | LocalSystemActionErrorKind::NotFound - | LocalSystemActionErrorKind::Internal => { - Err(BitFunError::tool(error.message().to_string())) - } + _ => Err(BitFunError::tool(error.message().to_string())), } } -fn error_code_from_local(kind: LocalSystemActionErrorKind) -> ErrorCode { - match kind { - LocalSystemActionErrorKind::InvalidParams => ErrorCode::InvalidParams, - LocalSystemActionErrorKind::NotAvailable => ErrorCode::NotAvailable, - LocalSystemActionErrorKind::NotFound => ErrorCode::NotFound, - LocalSystemActionErrorKind::Timeout => ErrorCode::Timeout, - LocalSystemActionErrorKind::Internal | LocalSystemActionErrorKind::UnknownScriptType => { - ErrorCode::Internal - } +fn error_code_from_local(code: &str) -> ErrorCode { + match code { + "INVALID_PARAMS" => ErrorCode::InvalidParams, + "NOT_AVAILABLE" => ErrorCode::NotAvailable, + "NOT_FOUND" => ErrorCode::NotFound, + "TIMEOUT" => ErrorCode::Timeout, + _ => ErrorCode::Internal, } } diff --git a/src/crates/assembly/core/src/service/mcp/auth.rs b/src/crates/assembly/core/src/service/mcp/auth.rs index b966d3be0e..8c4093fdb0 100644 --- a/src/crates/assembly/core/src/service/mcp/auth.rs +++ b/src/crates/assembly/core/src/service/mcp/auth.rs @@ -1,7 +1,7 @@ //! OAuth support for remote MCP servers. //! //! The owner implementation lives in `bitfun-services-integrations`. This -//! module keeps the legacy core path and injects the product data directory. +//! module keeps the legacy core entrypoints and injects the product data directory. use async_trait::async_trait; use rmcp::transport::auth::{AuthorizationManager, CredentialStore, StoredCredentials}; @@ -19,10 +19,19 @@ fn oauth_data_dir() -> BitFunResult { Ok(try_get_path_manager_arc()?.user_data_dir()) } +pub fn map_auth_error(error: impl ToString) -> BitFunError { + BitFunError::MCPError(format!("OAuth error: {}", error.to_string())) +} + +#[deprecated( + since = "0.2.12", + note = "use bitfun_services_integrations::mcp::auth::MCPRemoteOAuthCredentialVault with an injected data directory" +)] pub struct MCPRemoteOAuthCredentialVault { inner: bitfun_services_integrations::mcp::auth::MCPRemoteOAuthCredentialVault, } +#[allow(deprecated)] impl MCPRemoteOAuthCredentialVault { pub fn new() -> BitFunResult { Ok(Self { @@ -50,10 +59,15 @@ impl MCPRemoteOAuthCredentialVault { } #[derive(Clone)] +#[deprecated( + since = "0.2.12", + note = "use bitfun_services_integrations::mcp::auth::MCPRemoteOAuthCredentialStore with an injected data directory" +)] pub struct MCPRemoteOAuthCredentialStore { server_id: String, } +#[allow(deprecated)] impl MCPRemoteOAuthCredentialStore { pub fn new(server_id: impl Into) -> Self { Self { @@ -62,6 +76,7 @@ impl MCPRemoteOAuthCredentialStore { } } +#[allow(deprecated)] #[async_trait] impl CredentialStore for MCPRemoteOAuthCredentialStore { async fn load(&self) -> Result, rmcp::transport::auth::AuthError> { @@ -92,10 +107,6 @@ impl CredentialStore for MCPRemoteOAuthCredentialStore { } } -pub fn map_auth_error(error: impl ToString) -> BitFunError { - BitFunError::MCPError(format!("OAuth error: {}", error.to_string())) -} - pub async fn has_stored_oauth_credentials(server_id: &str) -> BitFunResult { bitfun_services_integrations::mcp::auth::has_stored_oauth_credentials( oauth_data_dir()?, diff --git a/src/crates/services/services-core/src/session/memory_workspace.rs b/src/crates/services/services-core/src/session/memory_workspace.rs new file mode 100644 index 0000000000..7864ab65fe --- /dev/null +++ b/src/crates/services/services-core/src/session/memory_workspace.rs @@ -0,0 +1,506 @@ +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::Command; +use thiserror::Error; + +const MEMORY_BASELINE_COMMIT_MESSAGE: &str = "Memory workspace baseline"; +const MEMORY_WORKSPACE_DIFF_MAX_BYTES: usize = 200_000; + +#[derive(Debug, Error)] +pub enum MemoryWorkspaceGitError { + #[error("Failed to create memory workspace {root}: {source}")] + CreateRoot { + root: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("Failed to inspect memory workspace git metadata {path}: {source}")] + InspectGitMetadata { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("Failed to remove memory workspace git metadata {path}: {source}")] + RemoveGitMetadata { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("Failed to read memory workspace directory {path}: {source}")] + ReadDirectory { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("Failed to read memory workspace directory entry {path}: {source}")] + ReadDirectoryEntry { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("Failed to inspect memory workspace entry {path}: {source}")] + InspectEntry { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("Failed to normalize memory workspace path {path} under {root}: {source}")] + NormalizePath { + root: PathBuf, + path: PathBuf, + #[source] + source: std::path::StripPrefixError, + }, + #[error("Failed to read added memory workspace file {path}: {source}")] + ReadAddedFile { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("Failed to run memory workspace git command in {root}: {source}")] + RunGit { + root: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("Memory workspace git command failed in {root}: {stderr}")] + GitFailed { root: PathBuf, stderr: String }, + #[error("Memory workspace git output was not UTF-8: {source}")] + GitOutputUtf8 { + #[source] + source: std::string::FromUtf8Error, + }, + #[error("Unsupported memory workspace git status '{status}': {line}")] + UnsupportedGitStatus { status: char, line: String }, + #[error("Memory workspace git task failed: {source}")] + Join { + #[source] + source: tokio::task::JoinError, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MemoryWorkspaceChangeStatus { + Added, + Modified, + Deleted, +} + +impl MemoryWorkspaceChangeStatus { + pub fn label(self) -> &'static str { + match self { + Self::Added => "A", + Self::Modified => "M", + Self::Deleted => "D", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MemoryWorkspaceChange { + pub status: MemoryWorkspaceChangeStatus, + pub path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MemoryWorkspaceDiff { + pub changes: Vec, + pub unified_diff: String, +} + +impl MemoryWorkspaceDiff { + pub fn has_changes(&self) -> bool { + !self.changes.is_empty() + } +} + +pub async fn ensure_memory_workspace_git_baseline( + root: &Path, +) -> Result<(), MemoryWorkspaceGitError> { + let root = root.to_path_buf(); + tokio::task::spawn_blocking(move || { + create_root(&root)?; + + if root.join(".git").is_dir() && run_git(&root, &["rev-parse", "--verify", "HEAD"]).is_ok() + { + return Ok(()); + } + + reset_memory_workspace_git_baseline_sync(&root) + }) + .await + .map_err(|source| MemoryWorkspaceGitError::Join { source })? +} + +pub async fn reset_memory_workspace_git_baseline( + root: &Path, +) -> Result<(), MemoryWorkspaceGitError> { + let root = root.to_path_buf(); + tokio::task::spawn_blocking(move || reset_memory_workspace_git_baseline_sync(&root)) + .await + .map_err(|source| MemoryWorkspaceGitError::Join { source })? +} + +pub async fn memory_workspace_diff( + root: &Path, +) -> Result { + let root = root.to_path_buf(); + tokio::task::spawn_blocking(move || memory_workspace_diff_sync(&root)) + .await + .map_err(|source| MemoryWorkspaceGitError::Join { source })? +} + +pub fn render_memory_workspace_diff_file(diff: &MemoryWorkspaceDiff) -> String { + let mut rendered = String::from( + "# Memory Workspace Diff\n\n\ + Generated by BitFun before Phase 2 memory consolidation. Read this file first and do not edit it.\n\n\ + ## Status\n", + ); + + if !diff.has_changes() { + rendered.push_str("- none\n"); + return rendered; + } + + for change in &diff.changes { + rendered.push_str(&format!("- {} {}\n", change.status.label(), change.path)); + } + rendered.push_str("\n## Diff\n\n```diff\n"); + append_bounded_diff(&mut rendered, &diff.unified_diff); + rendered.push_str("```\n"); + rendered +} + +fn create_root(root: &Path) -> Result<(), MemoryWorkspaceGitError> { + std::fs::create_dir_all(root).map_err(|source| MemoryWorkspaceGitError::CreateRoot { + root: root.to_path_buf(), + source, + }) +} + +fn reset_memory_workspace_git_baseline_sync(root: &Path) -> Result<(), MemoryWorkspaceGitError> { + create_root(root)?; + remove_git_metadata(root)?; + run_git_raw(root, &["init"])?; + run_git(root, &["add", "-A"])?; + run_git( + root, + &[ + "commit", + "--allow-empty", + "-m", + MEMORY_BASELINE_COMMIT_MESSAGE, + ], + )?; + Ok(()) +} + +fn remove_git_metadata(root: &Path) -> Result<(), MemoryWorkspaceGitError> { + let path = root.join(".git"); + let metadata = match std::fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(source) => { + return Err(MemoryWorkspaceGitError::InspectGitMetadata { path, source }); + } + }; + + let result = if metadata.is_dir() && !metadata.file_type().is_symlink() { + std::fs::remove_dir_all(&path) + } else { + std::fs::remove_file(&path) + }; + + result.map_err(|source| MemoryWorkspaceGitError::RemoveGitMetadata { path, source }) +} + +fn memory_workspace_diff_sync(root: &Path) -> Result { + run_git(root, &["rev-parse", "--verify", "HEAD"])?; + + let tracked_status = git_stdout( + root, + &["diff", "--name-status", "--no-renames", "HEAD", "--"], + )?; + let mut changes = parse_git_name_status(&tracked_status)?; + let head_paths = git_z_stdout(root, &["ls-tree", "-r", "--name-only", "-z", "HEAD"])?; + let head_paths = parse_nul_paths(&head_paths); + let current_paths = collect_current_memory_paths(root)?; + + for path in current_paths.difference(&head_paths) { + changes.push(MemoryWorkspaceChange { + status: MemoryWorkspaceChangeStatus::Added, + path: path.clone(), + }); + } + + changes.sort_by(|left, right| left.path.cmp(&right.path)); + changes.dedup_by(|left, right| left.path == right.path); + + let mut unified_diff = git_stdout( + root, + &["diff", "--no-ext-diff", "--no-renames", "HEAD", "--"], + )?; + for change in changes + .iter() + .filter(|change| change.status == MemoryWorkspaceChangeStatus::Added) + { + unified_diff.push_str(&render_added_file_diff(root, &change.path)?); + } + + Ok(MemoryWorkspaceDiff { + changes, + unified_diff, + }) +} + +fn collect_current_memory_paths(root: &Path) -> Result, MemoryWorkspaceGitError> { + let mut paths = BTreeSet::new(); + collect_current_memory_paths_inner(root, root, &mut paths)?; + Ok(paths) +} + +fn collect_current_memory_paths_inner( + root: &Path, + dir: &Path, + paths: &mut BTreeSet, +) -> Result<(), MemoryWorkspaceGitError> { + for entry in + std::fs::read_dir(dir).map_err(|source| MemoryWorkspaceGitError::ReadDirectory { + path: dir.to_path_buf(), + source, + })? + { + let entry = entry.map_err(|source| MemoryWorkspaceGitError::ReadDirectoryEntry { + path: dir.to_path_buf(), + source, + })?; + let path = entry.path(); + if path.file_name().and_then(|name| name.to_str()) == Some(".git") { + continue; + } + let metadata = + entry + .metadata() + .map_err(|source| MemoryWorkspaceGitError::InspectEntry { + path: path.clone(), + source, + })?; + if metadata.is_dir() { + collect_current_memory_paths_inner(root, &path, paths)?; + } else if metadata.is_file() { + paths.insert(relative_memory_path(root, &path)?); + } + } + Ok(()) +} + +fn relative_memory_path(root: &Path, path: &Path) -> Result { + let relative = + path.strip_prefix(root) + .map_err(|source| MemoryWorkspaceGitError::NormalizePath { + root: root.to_path_buf(), + path: path.to_path_buf(), + source, + })?; + Ok(relative.to_string_lossy().replace('\\', "/")) +} + +fn parse_git_name_status( + output: &str, +) -> Result, MemoryWorkspaceGitError> { + let mut changes = Vec::new(); + for line in output.lines().filter(|line| !line.trim().is_empty()) { + let mut parts = line.splitn(2, char::is_whitespace); + let status = parts.next().unwrap_or_default(); + let path = parts.next().unwrap_or_default().trim(); + let status = match status.chars().next() { + Some('A') => MemoryWorkspaceChangeStatus::Added, + Some('M') => MemoryWorkspaceChangeStatus::Modified, + Some('D') => MemoryWorkspaceChangeStatus::Deleted, + Some(status) => { + return Err(MemoryWorkspaceGitError::UnsupportedGitStatus { + status, + line: line.to_string(), + }); + } + None => continue, + }; + if !path.is_empty() { + changes.push(MemoryWorkspaceChange { + status, + path: path.replace('\\', "/"), + }); + } + } + Ok(changes) +} + +fn parse_nul_paths(output: &[u8]) -> BTreeSet { + output + .split(|byte| *byte == 0) + .filter(|part| !part.is_empty()) + .map(|part| String::from_utf8_lossy(part).replace('\\', "/")) + .collect() +} + +fn render_added_file_diff(root: &Path, path: &str) -> Result { + let file_path = root.join(path); + let body = std::fs::read_to_string(&file_path).map_err(|source| { + MemoryWorkspaceGitError::ReadAddedFile { + path: file_path, + source, + } + })?; + let diff = similar::TextDiff::from_lines("", &body); + let mut rendered = format!( + "diff --git a/{path} b/{path}\nnew file mode 100644\n--- /dev/null\n+++ b/{path}\n" + ); + for change in diff.iter_all_changes() { + let sign = match change.tag() { + similar::ChangeTag::Delete => "-", + similar::ChangeTag::Insert => "+", + similar::ChangeTag::Equal => " ", + }; + rendered.push_str(sign); + rendered.push_str(change.value()); + } + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + Ok(rendered) +} + +fn append_bounded_diff(rendered: &mut String, diff: &str) { + if diff.len() <= MEMORY_WORKSPACE_DIFF_MAX_BYTES { + rendered.push_str(diff); + if !diff.ends_with('\n') { + rendered.push('\n'); + } + return; + } + + let boundary = previous_char_boundary(diff, MEMORY_WORKSPACE_DIFF_MAX_BYTES); + rendered.push_str(&diff[..boundary]); + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + rendered.push_str(&format!( + "\n[workspace diff truncated at {} bytes]\n", + MEMORY_WORKSPACE_DIFF_MAX_BYTES + )); +} + +fn previous_char_boundary(value: &str, max_bytes: usize) -> usize { + if max_bytes >= value.len() { + return value.len(); + } + let mut index = max_bytes; + while !value.is_char_boundary(index) { + index -= 1; + } + index +} + +fn git_stdout(root: &Path, args: &[&str]) -> Result { + let output = run_git(root, args)?; + String::from_utf8(output.stdout) + .map_err(|source| MemoryWorkspaceGitError::GitOutputUtf8 { source }) +} + +fn git_z_stdout(root: &Path, args: &[&str]) -> Result, MemoryWorkspaceGitError> { + run_git(root, args).map(|output| output.stdout) +} + +fn run_git(root: &Path, args: &[&str]) -> Result { + let mut full_args = vec![ + "-c", + "user.name=BitFun", + "-c", + "user.email=bitfun@localhost", + ]; + full_args.extend_from_slice(args); + run_git_raw(root, &full_args) +} + +fn run_git_raw( + root: &Path, + args: &[&str], +) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .output() + .map_err(|source| MemoryWorkspaceGitError::RunGit { + root: root.to_path_buf(), + source, + })?; + if output.status.success() { + Ok(output) + } else { + Err(MemoryWorkspaceGitError::GitFailed { + root: root.to_path_buf(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::{ + memory_workspace_diff, render_memory_workspace_diff_file, + reset_memory_workspace_git_baseline, MemoryWorkspaceChange, MemoryWorkspaceChangeStatus, + }; + + #[tokio::test] + async fn memory_workspace_git_diff_detects_added_modified_and_deleted_paths() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + std::fs::write(root.join("MEMORY.md"), "old index\n").unwrap(); + std::fs::write(root.join("memory_summary.md"), "old summary\n").unwrap(); + + reset_memory_workspace_git_baseline(root).await.unwrap(); + + std::fs::write(root.join("MEMORY.md"), "new index\n").unwrap(); + std::fs::remove_file(root.join("memory_summary.md")).unwrap(); + std::fs::create_dir_all(root.join("rollout_summaries")).unwrap(); + std::fs::write( + root.join("rollout_summaries").join("new.md"), + "new summary\n", + ) + .unwrap(); + + let diff = memory_workspace_diff(root).await.unwrap(); + + assert!(diff.has_changes()); + assert!(diff.changes.contains(&MemoryWorkspaceChange { + status: MemoryWorkspaceChangeStatus::Modified, + path: "MEMORY.md".to_string(), + })); + assert!(diff.changes.contains(&MemoryWorkspaceChange { + status: MemoryWorkspaceChangeStatus::Deleted, + path: "memory_summary.md".to_string(), + })); + assert!(diff.changes.contains(&MemoryWorkspaceChange { + status: MemoryWorkspaceChangeStatus::Added, + path: "rollout_summaries/new.md".to_string(), + })); + assert!(diff.unified_diff.contains("new index")); + assert!(diff.unified_diff.contains("new summary")); + } + + #[test] + fn memory_workspace_diff_renderer_preserves_legacy_status_header() { + let rendered = render_memory_workspace_diff_file(&super::MemoryWorkspaceDiff { + changes: vec![MemoryWorkspaceChange { + status: MemoryWorkspaceChangeStatus::Added, + path: "extensions/ad_hoc/notes/example.md".to_string(), + }], + unified_diff: "+note\n".to_string(), + }); + + assert!(rendered.contains("# Memory Workspace Diff")); + assert!(rendered.contains("- A extensions/ad_hoc/notes/example.md")); + assert!(rendered.contains("```diff\n+note\n```")); + } +} diff --git a/src/crates/services/services-core/src/session/mod.rs b/src/crates/services/services-core/src/session/mod.rs index 2becac1715..a6035ea80f 100644 --- a/src/crates/services/services-core/src/session/mod.rs +++ b/src/crates/services/services-core/src/session/mod.rs @@ -1,5 +1,6 @@ pub mod layout; mod lineage; +mod memory_workspace; mod metadata; mod metadata_store; mod migration; @@ -12,6 +13,11 @@ pub use lineage::{ apply_session_lineage, build_branched_session_metadata, collect_hidden_subagent_cascade, BranchSessionMetadataFacts, SessionBranchRequest, SessionBranchResult, }; +pub use memory_workspace::{ + ensure_memory_workspace_git_baseline, memory_workspace_diff, render_memory_workspace_diff_file, + reset_memory_workspace_git_baseline, MemoryWorkspaceChange, MemoryWorkspaceChangeStatus, + MemoryWorkspaceDiff, MemoryWorkspaceGitError, +}; pub use metadata::{ build_session_index_snapshot, build_session_metadata, merge_session_custom_metadata, refresh_session_metadata_from_turns, remove_session_index_entry, set_deep_review_cache, diff --git a/src/crates/services/services-core/src/system/local_actions.rs b/src/crates/services/services-core/src/system/local_actions.rs index 69faa3b002..8ec6ff8c3c 100644 --- a/src/crates/services/services-core/src/system/local_actions.rs +++ b/src/crates/services/services-core/src/system/local_actions.rs @@ -17,6 +17,18 @@ pub enum LocalSystemActionErrorKind { UnknownScriptType, } +impl LocalSystemActionErrorKind { + pub const fn stable_code(self) -> &'static str { + match self { + Self::InvalidParams => "INVALID_PARAMS", + Self::NotAvailable => "NOT_AVAILABLE", + Self::NotFound => "NOT_FOUND", + Self::Timeout => "TIMEOUT", + Self::Internal | Self::UnknownScriptType => "INTERNAL", + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct LocalSystemActionError { kind: LocalSystemActionErrorKind, @@ -33,6 +45,10 @@ impl LocalSystemActionError { &self.message } + pub const fn stable_code(&self) -> &'static str { + self.kind.stable_code() + } + pub fn hints(&self) -> &[String] { &self.hints } @@ -875,7 +891,7 @@ async fn clipboard_write(text: &str) -> Result<(), String> { mod tests { use super::{ clipboard_install_hints, path_command_exists, script_invocation, truncate_with_marker, - LocalSystemActionErrorKind, LocalSystemProvider, RunScriptRequest, + LocalSystemActionError, LocalSystemActionErrorKind, LocalSystemProvider, RunScriptRequest, }; #[test] @@ -909,6 +925,26 @@ mod tests { assert!(!clipboard_install_hints().is_empty()); } + #[test] + fn local_system_error_kind_exposes_stable_tool_codes() { + let cases = [ + (LocalSystemActionErrorKind::InvalidParams, "INVALID_PARAMS"), + (LocalSystemActionErrorKind::NotAvailable, "NOT_AVAILABLE"), + (LocalSystemActionErrorKind::NotFound, "NOT_FOUND"), + (LocalSystemActionErrorKind::Timeout, "TIMEOUT"), + (LocalSystemActionErrorKind::Internal, "INTERNAL"), + (LocalSystemActionErrorKind::UnknownScriptType, "INTERNAL"), + ]; + + for (kind, expected_code) in cases { + assert_eq!(kind.stable_code(), expected_code); + assert_eq!( + LocalSystemActionError::new(kind, "test").stable_code(), + expected_code + ); + } + } + #[test] fn unsupported_url_scheme_is_rejected_before_spawn() { let err = LocalSystemProvider::new()