agent-Specialization/modules/host_workspace_manager.py
JOJO 08d9812f45 feat(docker): support isolated projects in web mode
Implement Docker Web project management on top of workspace_id while keeping host mode workspace behavior intact.

Backend changes:

- Move Docker user files to users/<user>/projects/<project_id>/{project,data,logs} and shared user state to users/<user>/personal.

- Add automatic legacy migration from old project/data/logs/agentskills layouts and from the temporary project/shared layout.

- Generate project IDs server-side from the project name, reserve internal IDs, and hide project paths from the UI.

- Add project rename/delete APIs; deleting a Docker project removes its project directory and releases terminal/container state.

- Add host workspace rename/delete APIs; deleting a host workspace only removes catalog entries and keeps files on disk.

- Scope Docker containers, terminals, uploads, conversations, storage, and running task metadata by workspace_id.

- Store private skills under users/<user>/personal/agentskills and sync them into each project workspace.

- Inject the current project/workspace name through prompts/workspace_system.txt.

Frontend changes:

- Replace Docker file-tree area with project management UX and reuse host multi-workspace running-task behavior.

- Add a management dialog for creating, renaming, and deleting projects/workspaces.

- Show project labels instead of internal IDs in toasts and project lists.

- Clear stale conversation history during project/workspace switching and show loading until the new list is ready.

- Keep running-task loader/check behavior consistent across host workspaces and Docker projects.

Validation:

- python3 -m py_compile modules/user_manager.py modules/host_workspace_manager.py modules/skills_manager.py core/main_terminal_parts/tools_execution.py server/auth.py server/context.py server/status.py server/conversation.py server/tasks.py utils/context_manager.py

- python3 -m unittest test.test_server_refactor_smoke

- npm run build --silent 2>&1 | tail -n 20
2026-05-29 19:49:48 +08:00

346 lines
11 KiB
Python

"""宿主机模式工作区配置管理。"""
from __future__ import annotations
import json
import os
import re
import tempfile
import threading
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
from config import DEFAULT_PROJECT_PATH, HOST_WORKSPACES_FILE
_WORKSPACE_ID_RE = re.compile(r"^[a-zA-Z0-9._-]{1,64}$")
_REPO_ROOT = Path(__file__).resolve().parents[1]
_HOST_WORKSPACE_LOCK = threading.RLock()
def _resolve_config_path(config_path: Optional[Union[str, Path]] = None) -> Path:
raw = str(config_path or HOST_WORKSPACES_FILE or "").strip()
if not raw:
raw = "./config/host_workspaces.json"
path = Path(raw).expanduser()
if not path.is_absolute():
path = (_REPO_ROOT / path).resolve()
return path
def _default_workspace_path() -> Path:
p = Path(DEFAULT_PROJECT_PATH).expanduser()
if not p.is_absolute():
p = (_REPO_ROOT / p).resolve()
return p
def _default_payload() -> Dict[str, Any]:
return {
"default_workspace_id": "default",
"workspaces": [
{
"workspace_id": "default",
"label": "默认工作区",
"path": str(_default_workspace_path()),
}
],
}
def _atomic_write_json(path: Path, data: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as fp:
json.dump(data, fp, ensure_ascii=False, indent=2)
fp.flush()
os.fsync(fp.fileno())
os.replace(tmp_path, path)
finally:
try:
if os.path.exists(tmp_path):
os.remove(tmp_path)
except Exception:
pass
def _ensure_config_file(path: Path, *, strict: bool = False) -> Dict[str, Any]:
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
try:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
return data
if strict:
raise RuntimeError("host_workspaces 配置格式错误(非 JSON 对象),已停止写入以避免覆盖原文件")
except Exception as exc:
if strict:
raise RuntimeError(f"host_workspaces 配置解析失败,已停止写入以避免覆盖原文件: {exc}") from exc
# 只回退到内存默认值,不覆盖磁盘文件
return _default_payload()
data = _default_payload()
_atomic_write_json(path, data)
return data
def _normalize_workspace_id(raw: Any, index: int) -> str:
value = str(raw or "").strip()
if _WORKSPACE_ID_RE.match(value):
return value
return f"workspace_{index + 1}"
def _slugify_workspace_id(raw: Any) -> str:
value = str(raw or "").strip().lower()
value = re.sub(r"[^a-z0-9._-]+", "-", value)
value = re.sub(r"-{2,}", "-", value).strip("-._")
if not value:
return "workspace"
if _WORKSPACE_ID_RE.match(value):
return value
return "workspace"
def _normalize_workspace_path(raw: Any) -> Path:
value = str(raw or "").strip()
if not value:
return _default_workspace_path()
path = Path(value).expanduser()
if not path.is_absolute():
path = (_REPO_ROOT / path).resolve()
else:
path = path.resolve()
return path
def _normalize_entry(raw: Any, index: int) -> Optional[Dict[str, str]]:
if not isinstance(raw, dict):
return None
workspace_id = _normalize_workspace_id(
raw.get("workspace_id") or raw.get("id") or raw.get("name"), index
)
label = str(raw.get("label") or raw.get("name") or workspace_id).strip() or workspace_id
path = _normalize_workspace_path(raw.get("path"))
path.mkdir(parents=True, exist_ok=True)
return {
"workspace_id": workspace_id,
"label": label,
"path": str(path),
}
def load_host_workspace_catalog(
config_path: Optional[Union[str, Path]] = None,
) -> Dict[str, Any]:
cfg_path = _resolve_config_path(config_path)
with _HOST_WORKSPACE_LOCK:
payload = _ensure_config_file(cfg_path, strict=False)
raw_workspaces = payload.get("workspaces")
if not isinstance(raw_workspaces, list):
raw_workspaces = []
seen_ids = set()
workspaces: List[Dict[str, str]] = []
for idx, item in enumerate(raw_workspaces):
normalized = _normalize_entry(item, idx)
if not normalized:
continue
ws_id = normalized["workspace_id"]
if ws_id in seen_ids:
continue
seen_ids.add(ws_id)
workspaces.append(normalized)
if not workspaces:
fallback = _default_payload()["workspaces"][0]
path = _normalize_workspace_path(fallback.get("path"))
path.mkdir(parents=True, exist_ok=True)
workspaces = [
{
"workspace_id": "default",
"label": "默认工作区",
"path": str(path),
}
]
seen_ids.add("default")
default_workspace_id = str(payload.get("default_workspace_id") or "").strip()
if default_workspace_id not in seen_ids:
default_workspace_id = workspaces[0]["workspace_id"]
return {
"source_path": str(cfg_path),
"default_workspace_id": default_workspace_id,
"workspaces": workspaces,
}
def resolve_host_workspace(
selected_workspace_id: Optional[str] = None,
config_path: Optional[Union[str, Path]] = None,
) -> Tuple[Dict[str, Any], Dict[str, str]]:
catalog = load_host_workspace_catalog(config_path=config_path)
candidates = catalog.get("workspaces") or []
selected_id = str(selected_workspace_id or "").strip()
current = None
if selected_id:
current = next((ws for ws in candidates if ws.get("workspace_id") == selected_id), None)
if not current:
default_id = catalog.get("default_workspace_id")
current = next((ws for ws in candidates if ws.get("workspace_id") == default_id), None)
if not current:
current = candidates[0]
return catalog, current
def create_host_workspace(
path: str,
label: Optional[str] = None,
*,
set_default: bool = False,
config_path: Optional[Union[str, Path]] = None,
) -> Dict[str, Any]:
cfg_path = _resolve_config_path(config_path)
with _HOST_WORKSPACE_LOCK:
payload = _ensure_config_file(cfg_path, strict=True)
raw_workspaces = payload.get("workspaces")
if not isinstance(raw_workspaces, list):
raw_workspaces = []
normalized_path = _normalize_workspace_path(path)
normalized_path.mkdir(parents=True, exist_ok=True)
target_path_str = str(normalized_path)
for idx, item in enumerate(raw_workspaces):
existing = _normalize_entry(item, idx)
if not existing:
continue
if existing.get("path") == target_path_str:
# 已存在相同路径则直接返回
return {
"created": False,
"workspace": existing,
"catalog": load_host_workspace_catalog(config_path=cfg_path),
}
clean_label = str(label or "").strip()
base_id_seed = clean_label or normalized_path.name or "workspace"
base_id = _slugify_workspace_id(base_id_seed)
existing_ids = {
_normalize_workspace_id(item.get("workspace_id") or item.get("id"), i)
for i, item in enumerate(raw_workspaces)
if isinstance(item, dict)
}
workspace_id = base_id
suffix = 2
while workspace_id in existing_ids:
workspace_id = f"{base_id}-{suffix}"
suffix += 1
workspace = {
"workspace_id": workspace_id,
"label": clean_label or normalized_path.name or workspace_id,
"path": target_path_str,
}
raw_workspaces.append(workspace)
payload["workspaces"] = raw_workspaces
default_id = str(payload.get("default_workspace_id") or "").strip()
if set_default or not default_id:
payload["default_workspace_id"] = workspace_id
_atomic_write_json(cfg_path, payload)
return {
"created": True,
"workspace": workspace,
"catalog": load_host_workspace_catalog(config_path=cfg_path),
}
def rename_host_workspace(
workspace_id: str,
label: str,
config_path: Optional[Union[str, Path]] = None,
) -> Dict[str, Any]:
ws_id = str(workspace_id or "").strip()
clean_label = str(label or "").strip()
if not ws_id:
raise ValueError("缺少 workspace_id")
if not clean_label:
raise ValueError("工作区名称不能为空")
cfg_path = _resolve_config_path(config_path)
with _HOST_WORKSPACE_LOCK:
payload = _ensure_config_file(cfg_path, strict=True)
raw_workspaces = payload.get("workspaces")
if not isinstance(raw_workspaces, list):
raw_workspaces = []
updated = None
for idx, item in enumerate(raw_workspaces):
normalized = _normalize_entry(item, idx)
if not normalized or normalized.get("workspace_id") != ws_id:
continue
item["workspace_id"] = ws_id
item["label"] = clean_label
item["path"] = normalized.get("path")
updated = dict(item)
break
if not updated:
raise ValueError("工作区不存在")
payload["workspaces"] = raw_workspaces
_atomic_write_json(cfg_path, payload)
return {
"workspace": updated,
"catalog": load_host_workspace_catalog(config_path=cfg_path),
}
def delete_host_workspace(
workspace_id: str,
config_path: Optional[Union[str, Path]] = None,
) -> Dict[str, Any]:
ws_id = str(workspace_id or "").strip()
if not ws_id:
raise ValueError("缺少 workspace_id")
cfg_path = _resolve_config_path(config_path)
with _HOST_WORKSPACE_LOCK:
payload = _ensure_config_file(cfg_path, strict=True)
raw_workspaces = payload.get("workspaces")
if not isinstance(raw_workspaces, list):
raw_workspaces = []
kept = []
deleted = None
for idx, item in enumerate(raw_workspaces):
normalized = _normalize_entry(item, idx)
if normalized and normalized.get("workspace_id") == ws_id:
deleted = normalized
continue
kept.append(item)
if not deleted:
raise ValueError("工作区不存在")
if not kept:
raise ValueError("至少保留一个工作区")
payload["workspaces"] = kept
if str(payload.get("default_workspace_id") or "").strip() == ws_id:
first = _normalize_entry(kept[0], 0)
payload["default_workspace_id"] = (first or {}).get("workspace_id") or "default"
_atomic_write_json(cfg_path, payload)
return {
"deleted": deleted,
"catalog": load_host_workspace_catalog(config_path=cfg_path),
}
__all__ = [
"load_host_workspace_catalog",
"resolve_host_workspace",
"create_host_workspace",
]