fix: add auto-relocate to workspace health and use repaired flag for telemetry

- workspace_health_service: add ensure_workspace_ready_with_auto_relocate that falls back to
  a managed path (~/.proxycast/projects/{id}) when original root is inaccessible
- WorkspaceReadyResult: include relocated, previousRootPath, warning fields
- workspace_cmd: expose relocated/previousRootPath/warning in WorkspaceEnsureResult response;
  add ensure_workspace_ready_with_auto_relocate to workspace_update path
- useProjects/useWorkspace/AgentChatPage: use repaired (not created) flag to trigger repair
  telemetry, since repaired=true covers both create-new and auto-relocate scenarios

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
coso
2026-03-02 17:53:52 +08:00
co-authored by Claude Sonnet 4.6
parent 61270d6f3a
commit 0f1be80ba7
5 changed files with 170 additions and 33 deletions
+46 -26
View File
@@ -13,7 +13,9 @@
use crate::database::DbConnection;
use crate::models::project_model::ProjectContext;
use crate::services::workspace_health_service::ensure_workspace_root_ready;
use crate::services::workspace_health_service::{
ensure_workspace_ready_with_auto_relocate, ensure_workspace_root_ready,
};
use crate::workspace::{
Workspace, WorkspaceManager, WorkspaceSettings, WorkspaceType, WorkspaceUpdate,
};
@@ -85,6 +87,9 @@ pub struct WorkspaceEnsureResult {
pub existed: bool,
pub created: bool,
pub repaired: bool,
pub relocated: bool,
pub previous_root_path: Option<String>,
pub warning: Option<String>,
}
impl From<Workspace> for WorkspaceListItem {
@@ -200,12 +205,12 @@ pub async fn workspace_update(
let new_root_path = if let Some(ref path_str) = request.root_path {
let path = PathBuf::from(path_str);
// 预校验新路径:路径必须存在且为目录
if !path.exists() {
return Err(format!("指定的路径不存在: {path_str}"));
}
if !path.is_dir() {
return Err(format!("指定的路径不是目录: {path_str}"));
let created = ensure_workspace_root_ready(&path)?;
if created {
tracing::warn!(
"[Workspace] 更新路径时检测到目录缺失,已自动创建: {}",
path.to_string_lossy()
);
}
Some(path)
} else {
@@ -277,23 +282,31 @@ pub async fn workspace_ensure_ready(
let workspace = manager
.get(&id)?
.ok_or_else(|| format!("Workspace 不存在: {id}"))?;
let root_path = workspace.root_path.to_string_lossy().to_string();
let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?;
let root_path = ensured.root_path.to_string_lossy().to_string();
let previous_root_path = ensured
.previous_root_path
.as_ref()
.map(|path| path.to_string_lossy().to_string());
let created = ensure_workspace_root_ready(&workspace.root_path)?;
if created {
if ensured.repaired {
tracing::warn!(
"[Workspace] 检测到目录缺失,已自动创建: id={}, root={}",
"[Workspace] 检测到目录异常并已修复: id={}, root={}, relocated={}",
workspace.id,
root_path
root_path,
ensured.relocated
);
}
Ok(WorkspaceEnsureResult {
workspace_id: workspace.id,
root_path,
existed: !created,
created,
repaired: created,
existed: ensured.existed,
created: ensured.created,
repaired: ensured.repaired,
relocated: ensured.relocated,
previous_root_path,
warning: ensured.warning,
})
}
@@ -306,21 +319,29 @@ pub async fn workspace_ensure_default_ready(
let Some(workspace) = manager.get_default()? else {
return Ok(None);
};
let root_path = workspace.root_path.to_string_lossy().to_string();
let created = ensure_workspace_root_ready(&workspace.root_path)?;
if created {
let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?;
let root_path = ensured.root_path.to_string_lossy().to_string();
let previous_root_path = ensured
.previous_root_path
.as_ref()
.map(|path| path.to_string_lossy().to_string());
if ensured.repaired {
tracing::warn!(
"[Workspace] 启动检查发现默认 workspace 目录缺失,已自动创建: id={}, root={}",
"[Workspace] 启动检查发现默认 workspace 目录异常并已修复: id={}, root={}, relocated={}",
workspace.id,
root_path
root_path,
ensured.relocated
);
}
Ok(Some(WorkspaceEnsureResult {
workspace_id: workspace.id,
root_path,
existed: !created,
created,
repaired: created,
existed: ensured.existed,
created: ensured.created,
repaired: ensured.repaired,
relocated: ensured.relocated,
previous_root_path,
warning: ensured.warning,
}))
}
@@ -375,9 +396,8 @@ pub async fn get_or_create_default_project(
// 不存在则创建默认项目
let default_project_path = get_workspace_projects_root_dir()?.join("default");
std::fs::create_dir_all(&default_project_path).map_err(|e| {
format!("创建默认项目目录失败: {e}")
})?;
std::fs::create_dir_all(&default_project_path)
.map_err(|e| format!("创建默认项目目录失败: {e}"))?;
let workspace = manager.create_with_type(
"默认项目".to_string(),
@@ -1,4 +1,16 @@
use std::path::Path;
use crate::workspace::{Workspace, WorkspaceManager, WorkspaceUpdate};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct WorkspaceReadyResult {
pub root_path: PathBuf,
pub existed: bool,
pub created: bool,
pub repaired: bool,
pub relocated: bool,
pub previous_root_path: Option<PathBuf>,
pub warning: Option<String>,
}
/// 确保 workspace 根目录可用。
///
@@ -41,3 +53,102 @@ pub fn ensure_workspace_root_ready(workspace_root: &Path) -> Result<bool, String
Ok(true)
}
pub fn ensure_workspace_ready_with_auto_relocate(
manager: &WorkspaceManager,
workspace: &Workspace,
) -> Result<WorkspaceReadyResult, String> {
let original_root = workspace.root_path.clone();
match ensure_workspace_root_ready(&original_root) {
Ok(created) => {
return Ok(WorkspaceReadyResult {
root_path: original_root,
existed: !created,
created,
repaired: created,
relocated: false,
previous_root_path: None,
warning: None,
});
}
Err(primary_error) => {
let fallback_root = build_workspace_fallback_root(workspace)?;
if fallback_root == original_root {
return Err(primary_error);
}
let fallback_created =
ensure_workspace_root_ready(&fallback_root).map_err(|fallback_error| {
format!("{primary_error};自动迁移到托管目录失败: {fallback_error}")
})?;
manager
.update(
&workspace.id,
WorkspaceUpdate {
root_path: Some(fallback_root.clone()),
..WorkspaceUpdate::default()
},
)
.map_err(|error| format!("Workspace 自动迁移后更新配置失败: {error}"))?;
let warning = format!(
"Workspace 原路径不可用,已自动迁移到托管目录: {} -> {}",
original_root.display(),
fallback_root.display()
);
tracing::warn!(
"[WorkspaceHealth] {} (workspace_id={})",
warning,
workspace.id
);
Ok(WorkspaceReadyResult {
root_path: fallback_root,
existed: !fallback_created,
created: fallback_created,
repaired: true,
relocated: true,
previous_root_path: Some(original_root),
warning: Some(warning),
})
}
}
}
fn build_workspace_fallback_root(workspace: &Workspace) -> Result<PathBuf, String> {
let home_dir = dirs::home_dir().ok_or_else(|| "无法获取主目录".to_string())?;
let recovered_root = home_dir
.join(".proxycast")
.join("projects")
.join("recovered");
let workspace_name = sanitize_path_segment(&workspace.name);
let short_id: String = workspace.id.chars().take(8).collect();
let dir_name = if short_id.is_empty() {
workspace_name
} else {
format!("{}-{}", workspace_name, short_id)
};
Ok(recovered_root.join(dir_name))
}
fn sanitize_path_segment(raw: &str) -> String {
let sanitized: String = raw
.trim()
.chars()
.map(|ch| match ch {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ if ch.is_control() => '_',
_ => ch,
})
.collect();
let trimmed = sanitized.trim().trim_matches('.').to_string();
if trimmed.is_empty() {
"workspace".to_string()
} else {
trimmed
}
}
+3 -3
View File
@@ -469,11 +469,11 @@ export function AgentChatPage({
const normalizedId = normalizeProjectId(projectId);
if (!normalizedId) return;
invoke<{ created: boolean; rootPath: string }>("workspace_ensure_ready", {
invoke<{ created: boolean; repaired: boolean; rootPath: string }>("workspace_ensure_ready", {
id: normalizedId,
})
.then(({ created, rootPath }) => {
if (created) {
.then(({ repaired, rootPath }) => {
if (repaired) {
recordWorkspaceRepair({
workspaceId: normalizedId,
rootPath,
+4 -1
View File
@@ -21,6 +21,9 @@ interface WorkspaceEnsureResult {
existed: boolean;
created: boolean;
repaired: boolean;
relocated?: boolean;
previousRootPath?: string | null;
warning?: string | null;
}
// WorkspaceType 用于类型定义,暂未使用
// import type { WorkspaceType } from '@/types/workspace';
@@ -79,7 +82,7 @@ export function useProjects(): UseProjectsReturn {
"workspace_ensure_ready",
{ id: defaultProj.id },
);
if (ensureResult.created) {
if (ensureResult.repaired) {
recordWorkspaceRepair({
workspaceId: ensureResult.workspaceId,
rootPath: ensureResult.rootPath,
+5 -2
View File
@@ -71,6 +71,9 @@ interface WorkspaceEnsureResult {
existed: boolean;
created: boolean;
repaired: boolean;
relocated?: boolean;
previousRootPath?: string | null;
warning?: string | null;
}
/**
@@ -103,7 +106,7 @@ export function useWorkspace(): UseWorkspaceReturn {
"workspace_ensure_ready",
{ id: defaultWs.id },
);
if (ensureResult.created) {
if (ensureResult.repaired) {
recordWorkspaceRepair({
workspaceId: ensureResult.workspaceId,
rootPath: ensureResult.rootPath,
@@ -165,7 +168,7 @@ export function useWorkspace(): UseWorkspaceReturn {
"workspace_ensure_ready",
{ id },
);
if (ensureResult.created) {
if (ensureResult.repaired) {
recordWorkspaceRepair({
workspaceId: ensureResult.workspaceId,
rootPath: ensureResult.rootPath,