diff --git a/AGENTS.md b/AGENTS.md index 0c2c258..4f3d60f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,25 @@ > - **程序能力**(`docker_risk_markers.json`、`skill_hints.json`):是程序行为的一部分,随版本演进,仍锚定源码树。`prompts/`、`agentskills/` 同理。 > - **部署级配置**(`custom_models`、`host_workspaces`、`auto_approval`、`goal_review`、`forbidden_commands`、`host_sandbox_policy`):因部署/机器而异或含密钥,外置到 `~/.agents//config/`(即 `DEPLOY_CONFIG_DIR`,可单独用该环境变量覆盖)。读取走 `config.resolve_deploy_config(name)`,回退链:部署目录 → 源码树 `.json` → 源码树 `.json.example`,因此开发环境不必先跑 setup 也能用源码树种子。含密钥/机器特定的 5 个(除 `forbidden_commands`)不纳入 git,仓库仅留 `.example`。 -### 1.5.3 日志策略(2026-06) +### 1.5.3 Host / Web 双路径机制(2026-06 新增) + +`paths.py` 新增三个固定变量,支持 host 模式同时读取 web/ 数据: + +- **`IS_HOST_MODE`**:当前是否为 host 模式(布尔值) +- **`WEB_DATA_DIR`**:固定指向 `/web/data`(不受当前模式影响) +- **`WEB_USER_SPACE_DIR`**:固定指向 `/web/users`(不受当前模式影响) + +**host 模式**(`modules/user_manager.py`): +- 用户列表:同时加载 `host/data/users.json` + `web/data/users.json`(web 用户不覆盖已有) +- 工作区列表:合并 host/ 和 web/ 两处 +- 工作区定位:已有 web 工作区直接复用;新工作区写入 host/ +- 写操作(创建/删除/重命名):始终走 host 路径 + +**web 模式**:只读取 web/ 数据,无变化。 + +> 设计意图:host 模式 = host + web 数据都可用;web 模式 = 仅 web 数据可用。 + +### 1.5.4 日志策略(2026-06) - API 请求体落盘(dump)默认**关闭**,由 `AGENT_API_DUMP_ENABLED` 控制(`1/true/yes/on` 开启)。 - 日志混合轮转(实现见 `utils/log_rotation.py`): @@ -67,7 +85,7 @@ - 按份计量目录(`api_requests/` 一请求一文件)只保留最近 N 个,默认 30。 - 阈值可覆盖:`AGENT_LOG_ROTATE_MAX_BYTES` / `AGENT_LOG_ROTATE_BACKUPS` / `AGENT_DUMP_KEEP`。 -### 1.5.4 数据迁移 +### 1.5.5 数据迁移 - 迁移脚本:`scripts/migrate_runtime_data.py`(源码树 → 运行态根;复制+备份+可回滚+幂等,`logs/` 丢弃不迁)。 - 脚本 `import config` 复用程序同一套路径解析(含 `.env`),**模式由 `.env` 决定**;执行前务必先 `--dry-run` 确认目标。 diff --git a/CLAUDE.md b/CLAUDE.md index 4223ba8..362f226 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,6 +169,12 @@ tail -f ~/.agents/web/logs/container_stats.log 2. 模式根变量 `AGENTS_HOST_HOME` / `AGENTS_WEB_HOME`(整体搬迁该模式数据) 3. 兜底 `~/.agents/` +**Host / Web 双路径机制(2026-06)**: +- `paths.py` 新增 `IS_HOST_MODE`、`WEB_DATA_DIR`、`WEB_USER_SPACE_DIR` 三个固定变量 +- host 模式下 `UserManager` 同时加载 `host/` 和 `web/` 的用户与工作区(web 用户不覆盖已有) +- 已有 web 工作区直接复用(保持旧数据可访问),新工作区写入 host/ +- web 模式:仅读取 web/ 数据,无变化 + > `config/*.json` 分两类:**程序能力**(`docker_risk_markers.json`、`skill_hints.json`)与 `prompts/`、`agentskills/` 一样锚定源码树;**部署级配置**(`custom_models`、`host_workspaces`、`auto_approval`、`goal_review`、`forbidden_commands`、`host_sandbox_policy`)外置到 `~/.agents//config/`(`DEPLOY_CONFIG_DIR`),读取走 `config.resolve_deploy_config(name)`,回退链:部署目录 → 源码树 `.json` → 源码树 `.json.example`。含密钥/机器特定的 5 个不进 git,仓库仅留 `.example`。 **日志策略**: diff --git a/config/paths.py b/config/paths.py index 7d938fe..3999db9 100644 --- a/config/paths.py +++ b/config/paths.py @@ -105,6 +105,14 @@ USER_SPACE_DIR = _runtime_dir("USER_SPACE_DIR", "users") # API 专用用户与工作区(与网页用户隔离) API_USER_SPACE_DIR = _runtime_dir("API_USER_SPACE_DIR", "api/users") +# ── 是否宿主机模式 ── +IS_HOST_MODE = _MODE == "host" + +# ── 固定 web 模式路径(host 模式下作为额外数据源,同时读取)── +_WEB_ROOT = Path(RUNTIME_ROOT) / "web" +WEB_DATA_DIR = str(_WEB_ROOT / "data") +WEB_USER_SPACE_DIR = str(_WEB_ROOT / "users") + # ── 部署级配置目录 —— 放在 data_root 根下,两个模式共享 ── _DEFAULT_DEPLOY_CONFIG = Path(RUNTIME_ROOT) / "config" DEPLOY_CONFIG_DIR = str(_norm(Path(_env("DEPLOY_CONFIG_DIR") or _DEFAULT_DEPLOY_CONFIG))) @@ -174,7 +182,10 @@ __all__ = [ "LOGS_DIR", "AGENT_SKILLS_DIR", "WORKSPACE_SKILLS_DIRNAME", + "IS_HOST_MODE", "USER_SPACE_DIR", + "WEB_DATA_DIR", + "WEB_USER_SPACE_DIR", "USERS_DB_FILE", "INVITE_CODES_FILE", "ADMIN_POLICY_FILE", diff --git a/modules/user_manager.py b/modules/user_manager.py index 120eef6..f6f2001 100644 --- a/modules/user_manager.py +++ b/modules/user_manager.py @@ -19,6 +19,9 @@ from config import ( UPLOAD_QUARANTINE_SUBDIR, MAIN_MEMORY_FILE, TASK_MEMORY_FILE, + IS_HOST_MODE, + WEB_DATA_DIR, + WEB_USER_SPACE_DIR, ) from modules.personalization_manager import ensure_personalization_config from modules.personalization_manager import PERSONALIZATION_FILENAME @@ -63,6 +66,13 @@ class UserManager: self.workspace_root = Path(workspace_root).expanduser().resolve() self.workspace_root.mkdir(parents=True, exist_ok=True) + # host 模式下同时维护 web 路径引用(双数据源) + self._web_workspace_root: Optional[Path] = None + self._web_users_file: Optional[Path] = None + if IS_HOST_MODE: + self._web_workspace_root = Path(WEB_USER_SPACE_DIR).expanduser().resolve() + self._web_users_file = Path(WEB_DATA_DIR) / "users.json" + self._users: Dict[str, UserRecord] = {} self._invites: Dict[str, Dict] = {} self._email_map: Dict[str, str] = {} @@ -141,6 +151,7 @@ class UserManager: return candidate def _project_container_root(self, username: str) -> Path: + """返回 host 模式 projects 容器目录(写操作始终走 host 路径)。""" return (self.workspace_root / username / "projects").resolve() def _personal_root(self, username: str) -> Path: @@ -333,9 +344,20 @@ class UserManager: def ensure_user_workspace(self, username: str, workspace_id: str = "default") -> UserWorkspace: username = self._normalize_username(username) workspace_id = self.normalize_workspace_id(workspace_id) + + # host 模式:检查 web 路径是否已有该工作区(保持旧数据可访问) + if IS_HOST_MODE and self._web_workspace_root: + web_ws_root = (self._web_workspace_root / username / "projects" / workspace_id).resolve() + if web_ws_root.exists(): + return self._build_workspace(username, workspace_id, web_ws_root) + root = (self.workspace_root / username).resolve() self._migrate_legacy_docker_workspace_layout(username) workspace_root = self._project_container_root(username) / workspace_id + return self._build_workspace(username, workspace_id, workspace_root) + + def _build_workspace(self, username: str, workspace_id: str, workspace_root: Path) -> UserWorkspace: + """根据 workspace_root 构建 UserWorkspace 并创建必要目录。""" project_path = workspace_root / "project" data_dir = workspace_root / "data" logs_dir = workspace_root / "logs" @@ -373,19 +395,17 @@ class UserManager: self._migrate_legacy_docker_workspace_layout(username) result: Dict[str, Dict] = {} - projects_root = self._project_container_root(username) - default_project = projects_root / "default" / "project" - default_data = projects_root / "default" / "data" - if default_project.exists() or default_data.exists(): - result["default"] = { - "workspace_id": "default", - "label": "默认项目", - "path": "", - "is_default": True, - "has_conversations": (default_data / "conversations").exists(), - } - else: - # 新用户也始终暴露默认项目,首次访问时再创建目录。 + # 收集主路径的工作区 + self._collect_workspaces(result, self._project_container_root(username)) + + # host 模式:额外收集 web 路径的工作区 + if IS_HOST_MODE and self._web_workspace_root: + web_root = (self._web_workspace_root / username / "projects").resolve() + if web_root.exists(): + self._collect_workspaces(result, web_root) + + # 确保 default 始终存在 + if "default" not in result: result["default"] = { "workspace_id": "default", "label": "默认项目", @@ -393,24 +413,41 @@ class UserManager: "is_default": True, "has_conversations": False, } - - if projects_root.exists(): - for item in sorted(projects_root.iterdir(), key=lambda p: p.name.lower()): - if not item.is_dir(): - continue - ws_id = item.name - if ws_id in self.RESERVED_WORKSPACE_IDS or ws_id.startswith("."): - continue - data_dir = item / "data" - result[ws_id] = { - "workspace_id": ws_id, - "label": self._load_workspace_label(item, ws_id), - "path": "", - "is_default": False, - "has_conversations": (data_dir / "conversations").exists(), - } return result + def _collect_workspaces(self, result: Dict[str, Dict], projects_root: Path) -> None: + """扫描 projects_root 下的工作区,合并到 result(跳过已存在的)。""" + default_project = projects_root / "default" / "project" + default_data = projects_root / "default" / "data" + if "default" not in result: + if default_project.exists() or default_data.exists(): + result["default"] = { + "workspace_id": "default", + "label": "默认项目", + "path": "", + "is_default": True, + "has_conversations": (default_data / "conversations").exists(), + } + + if not projects_root.exists(): + return + for item in sorted(projects_root.iterdir(), key=lambda p: p.name.lower()): + if not item.is_dir(): + continue + ws_id = item.name + if ws_id in self.RESERVED_WORKSPACE_IDS or ws_id.startswith("."): + continue + if ws_id in result: + continue + data_dir = item / "data" + result[ws_id] = { + "workspace_id": ws_id, + "label": self._load_workspace_label(item, ws_id), + "path": "", + "is_default": False, + "has_conversations": (data_dir / "conversations").exists(), + } + def generate_workspace_id(self, username: str, label: str = "") -> str: username = self._normalize_username(username) raw = (label or "project").strip().lower() @@ -579,11 +616,24 @@ class UserManager: def _load_users(self): if not self.users_file.exists(): self._save_users() + # host 模式:即使主文件不存在也尝试加载 web 用户 + self._load_users_from(self.users_file) + if IS_HOST_MODE and self._web_users_file and self._web_users_file.exists(): + self._load_users_from(self._web_users_file, skip_existing=True) return + self._load_users_from(self.users_file) + # host 模式:额外合并 web 用户(不覆盖已有) + if IS_HOST_MODE and self._web_users_file and self._web_users_file.exists(): + self._load_users_from(self._web_users_file, skip_existing=True) + + def _load_users_from(self, file_path: Path, skip_existing: bool = False): + """从指定文件加载用户,skip_existing 为 True 时跳过已有用户。""" + if not file_path.exists(): + return try: migrated = False - with open(self.users_file, "r", encoding="utf-8") as f: + with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): raw_users = data.get("users", {}) @@ -592,6 +642,8 @@ class UserManager: else: raw_users = {} for username, payload in raw_users.items(): + if skip_existing and username in self._users: + continue record = UserRecord( username=username, email=payload.get("email", ""), @@ -608,7 +660,7 @@ class UserManager: if migrated: self._save_users() except json.JSONDecodeError: - raise RuntimeError(f"无法解析用户数据文件: {self.users_file}") + raise RuntimeError(f"无法解析用户数据文件: {file_path}") def _save_users(self): payload = {