feat(host): host 模式同时加载 host/ 和 web/ 用户数据
- paths.py: 新增 IS_HOST_MODE / WEB_DATA_DIR / WEB_USER_SPACE_DIR - user_manager.py: host 模式合并两处用户列表与工作区 - 已有 web 工作区直接复用,新数据写入 host/ - 更新 AGENTS.md / CLAUDE.md 文档
This commit is contained in:
parent
ad1e9e1475
commit
f31f8d6fd7
22
AGENTS.md
22
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/<mode>/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`**:固定指向 `<data_root>/web/data`(不受当前模式影响)
|
||||
- **`WEB_USER_SPACE_DIR`**:固定指向 `<data_root>/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` 确认目标。
|
||||
|
||||
@ -169,6 +169,12 @@ tail -f ~/.agents/web/logs/container_stats.log
|
||||
2. 模式根变量 `AGENTS_HOST_HOME` / `AGENTS_WEB_HOME`(整体搬迁该模式数据)
|
||||
3. 兜底 `~/.agents/<mode>`
|
||||
|
||||
**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/<mode>/config/`(`DEPLOY_CONFIG_DIR`),读取走 `config.resolve_deploy_config(name)`,回退链:部署目录 → 源码树 `.json` → 源码树 `.json.example`。含密钥/机器特定的 5 个不进 git,仓库仅留 `.example`。
|
||||
|
||||
**日志策略**:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user