d58e38d58f
PR #14 从旧分支复制文件导致回退了 PR #10 的 lint 修复。 修复内容: - autoflake 移除未使用导入/变量 - autopep8 修复缩进/空格 - 手动修复 F821(pathlib→Path), F541(f-string), F841(未使用变量) - 所有修复均通过 flake8 --max-line-length=120 --extend-ignore=E501 检查 (0 errors)
361 lines
12 KiB
Python
361 lines
12 KiB
Python
"""多项目管理 — 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)"""
|