"""多项目管理 — registry.db (SQLite) + 自动发现""" from __future__ import annotations import logging import os import sqlite3 from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional logger = logging.getLogger("moziplus-v2.registry") class ProjectRegistry: """项目注册表(registry.db SQLite)""" def __init__(self, root: Path): self.root = root self.db_path = root / "registry.db" self._init_db() def _init_db(self) -> None: """初始化 registry.db""" self.db_path.parent.mkdir(parents=True, exist_ok=True) conn = self._connect() try: conn.execute("""CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT DEFAULT '', status TEXT DEFAULT 'active', source TEXT DEFAULT 'manual', card_count INTEGER DEFAULT 0, task_count INTEGER DEFAULT 0, config_json TEXT DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT, archived_at TEXT )""") conn.execute( "CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status)") conn.commit() finally: conn.close() def _connect(self) -> sqlite3.Connection: conn = sqlite3.connect(str(self.db_path)) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA busy_timeout=5000") return conn def _now(self) -> str: return datetime.utcnow().isoformat() # =================================================================== # CRUD # =================================================================== def create_project(self, project_id: str, name: str, description: str = "", source: str = "manual", agents: Optional[List[str]] = None) -> Dict[str, Any]: """创建项目""" conn = self._connect() try: conn.execute("BEGIN IMMEDIATE") existing = conn.execute( "SELECT id FROM projects WHERE id=?", (project_id,) ).fetchone() if existing: raise ValueError(f"Project '{project_id}' already exists") import json config = json.dumps({"agents": agents or []}) now = self._now() conn.execute( """INSERT INTO projects (id, name, description, status, source, config_json, created_at) VALUES (?,?,?,?,?,?,?)""", (project_id, name, description, "active", source, config, now), ) # 创建项目目录 project_dir = self.root / project_id project_dir.mkdir(parents=True, exist_ok=True) for subdir in ("artifacts", "experiences", "skills", "config"): (project_dir / subdir).mkdir(exist_ok=True) # 写 per-project config skeleton import yaml project_yaml = project_dir / "config" / "project.yaml" if not project_yaml.exists(): with open(project_yaml, "w") as f: yaml.dump({ "project": { "name": name, "description": description, "agents": agents or [], } }, f, default_flow_style=False, allow_unicode=True) conn.commit() logger.info("Project created: %s (%s)", project_id, name) result = self.get_project(project_id) or {} result["agents"] = agents or [] return result finally: conn.close() def get_project(self, project_id: str) -> Optional[Dict[str, Any]]: """获取单个项目""" conn = self._connect() try: row = conn.execute( "SELECT * FROM projects WHERE id=?", (project_id,) ).fetchone() return dict(row) if row else None finally: conn.close() def list_projects( self, status: Optional[str] = None) -> Dict[str, Dict[str, Any]]: """列出项目""" conn = self._connect() try: if status: rows = conn.execute( "SELECT * FROM projects WHERE status=?", (status,) ).fetchall() else: rows = conn.execute( "SELECT * FROM projects" ).fetchall() return {r["id"]: dict(r) for r in rows} finally: conn.close() def update_project(self, project_id: str, **kwargs) -> bool: """更新项目元数据""" conn = self._connect() try: conn.execute("BEGIN IMMEDIATE") existing = conn.execute( "SELECT id FROM projects WHERE id=?", (project_id,) ).fetchone() if not existing: return False allowed_fields = {"name", "description", "status", "config_json", "card_count", "task_count", "updated_at", "archived_at"} updates = {k: v for k, v in kwargs.items() if k in allowed_fields} if not updates: return True updates["updated_at"] = self._now() set_clause = ", ".join(f"{k}=?" for k in updates) conn.execute( f"UPDATE projects SET {set_clause} WHERE id=?", (*updates.values(), project_id), ) conn.commit() return True finally: conn.close() def archive_project(self, project_id: str) -> bool: """归档项目(只改 status,不移动目录)""" return self.update_project( project_id, status="archived", archived_at=self._now(), ) def delete_project(self, project_id: str) -> bool: """逻辑删除项目(status→deleted,不物理删除)""" return self.update_project( project_id, status="deleted", ) def physical_delete_project( self, project_id: str) -> Optional[Dict[str, Any]]: """物理删除项目(删目录 + 删 registry 条目)""" import shutil info = self.get_project(project_id) if not info: return None # 1. 删除项目目录 project_dir = self.root / project_id dir_existed = project_dir.exists() if dir_existed: shutil.rmtree(project_dir, ignore_errors=True) # 2. 从 registry.db 删除条目 conn = self._connect() try: conn.execute("DELETE FROM projects WHERE id=?", (project_id,)) conn.commit() finally: conn.close() return { "project_id": project_id, "directory": str(project_dir), "directory_existed": dir_existed, } # =================================================================== # 自动发现 # =================================================================== def discover_projects(self, scan_dir: Optional[Path] = None) -> List[str]: """扫描目录,自动注册新项目(只注册含 blackboard.db 的目录)""" scan_dir = scan_dir or self.root discovered = [] if not scan_dir.exists(): return discovered conn = self._connect() try: for child in sorted(scan_dir.iterdir()): if not child.is_dir(): continue # 跳过特殊目录 if child.name.startswith(("_", ".")): continue # 只注册含 blackboard.db 的目录 db_file = child / "blackboard.db" if not db_file.exists(): continue # 检查是否已注册 existing = conn.execute( "SELECT id FROM projects WHERE id=?", (child.name,) ).fetchone() if existing: continue # 自动注册 now = self._now() conn.execute( """INSERT INTO projects (id, name, description, status, source, created_at) VALUES (?,?,?,?,?,?)""", (child.name, child.name, "auto_discovered", "active", "auto_discovered", now), ) discovered.append(child.name) logger.info("Auto-discovered project: %s", child.name) if discovered: conn.commit() finally: conn.close() return discovered # =================================================================== # 迁移(从 _registry.yaml) # =================================================================== def discover_sanguo_projects( self, scan_dir: Optional[Path] = None) -> List[str]: """扫描 sanguo_projects 开发目录,自动注册正式项目""" scan_dir = scan_dir or Path(os.environ.get( "SANGUO_PROJECTS_DIR", str(Path.home() / ".openclaw" / "sanguo_projects") )) discovered = [] if not scan_dir.exists(): return discovered conn = self._connect() try: for child in sorted(scan_dir.iterdir()): if not child.is_dir(): continue if not child.name.startswith("sanguo_"): continue # 跳过已注册的(含 deleted/archived) existing = conn.execute( "SELECT id, status FROM projects WHERE id=?", (child.name,) ).fetchone() if existing: continue # 自动注册,创建 blackboard.db project_dir = self.root / child.name project_dir.mkdir(parents=True, exist_ok=True) for subdir in ("artifacts", "experiences", "skills", "config"): (project_dir / subdir).mkdir(exist_ok=True) now = self._now() conn.execute( """INSERT INTO projects (id, name, description, status, source, created_at) VALUES (?,?,?,?,?,?)""", (child.name, child.name, "auto_discovered", "active", "sanguo_projects_scan", now), ) discovered.append(child.name) logger.info("Auto-discovered sanguo project: %s", child.name) if discovered: conn.commit() finally: conn.close() return discovered def migrate_from_yaml(self, yaml_path: Optional[Path] = None) -> int: """从旧 _registry.yaml 迁移项目数据""" import yaml yaml_path = yaml_path or self.root / "_registry.yaml" if not yaml_path.exists(): return 0 with open(yaml_path) as f: data = yaml.safe_load(f) or {} projects = data.get("projects", {}) migrated = 0 conn = self._connect() try: for pid, info in projects.items(): existing = conn.execute( "SELECT id FROM projects WHERE id=?", (pid,) ).fetchone() if existing: continue import json now = self._now() conn.execute( """INSERT INTO projects (id, name, description, status, source, config_json, created_at, archived_at) VALUES (?,?,?,?,?,?,?,?)""", (pid, info.get("name", pid), info.get("description", ""), info.get("status", "active"), "yaml_migration", json.dumps({"agents": info.get("agents", [])}), info.get("created_at", now), info.get("archived_at")), ) migrated += 1 if migrated: conn.commit() logger.info("Migrated %d projects from YAML", migrated) finally: conn.close() return migrated def reload(self) -> None: """兼容旧接口(SQLite 不需要 reload cache)"""