pipeline-service/pipeline_service/project_capability.py

364 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""项目能力 — sd_projects 的状态机语义化迁移(通用、产线无关)。
定位:项目 CRUD 走 xls2ui 生成的端点;本模块只做「生命周期状态流转」——
每个迁移 CAS 原子(防并发/越权)+ 租户隔离 + 审计。
项目的生命周期/流转规则在 project skill 里LLM 读 skill 判断合法性);本模块只固化操作原语。
角色规范role 参数用 agent.{role}(无前缀自动补 agent.);人角色用 {orgtype}.{role}
scope 约定sd_projects 是顶层容器,用 id 唯一标识CAS WHERE 带 id 即可),
org_id 用于 list 过滤租户隔离audit 的 tenant_id 用 project_id项目即最外层容器
"""
import logging
import os
import json
import re
import tarfile
import tempfile
import shutil
from datetime import datetime
from sqlor.dbpools import DBPools
from appPublic.uniqueID import getID
from .audit import record_audit
DBNAME = "pipeline"
logger = logging.getLogger("pipeline.project_capability")
TABLE = "sd_projects"
# 项目状态SDLC 默认,状态机语义见 project skill
S_DRAFT = "draft" # 草稿(已创建,未启动)
S_ACTIVE = "active" # 进行中
S_PAUSED = "paused" # 已暂停暂停推进PM 不再推进项目,除非恢复)
S_COMPLETED = "completed" # 已完成
S_ARCHIVED = "archived" # 已归档
def _get_db():
db = DBPools()
if not db.databases:
from appPublic.jsonConfig import getConfig
config = getConfig()
if config.databases:
db.databases = config.databases
return db, DBNAME
def _normalize_role(role):
"""角色规范agent 角色补 agent. 前缀;人角色 {orgtype}.{role} 保留原样。"""
role = (role or "").strip()
if not role:
return ""
if "." in role:
return role
return f"agent.{role}"
async def _transition(project_id, from_state, to_state, action,
who=None, agent_id=None, detail=None):
"""CAS 状态迁移 + 审计。返回 (ok, message)。"""
if not project_id:
return False, "缺少 project_id"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
await sor.sqlExe(
f"UPDATE {TABLE} SET status=${{to}}$, updated_at=NOW() "
"WHERE id=${pid}$ AND status=${from}$",
{"to": to_state, "pid": project_id, "from": from_state})
recs = await sor.R(TABLE, {'id': project_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "项目不存在"
cur = getattr(recs[0], 'status', '')
if cur != to_state:
return False, f"状态迁移失败(CAS): 期望 from={from_state} 实际 status={cur}"
await record_audit(project_id, TABLE, project_id, action,
from_state=from_state, to_state=to_state,
who=who, agent_id=agent_id, detail=detail, sor=sor)
return True, to_state
async def create_project(name, project_type="web_app", description="",
org_id="0", created_by="", who=None, agent_id=None):
"""创建项目新建记录status=draft。返回 (ok, project_id_or_message)。"""
if not name or not name.strip():
return False, "缺少项目名称"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
pid = getID()
await sor.C(TABLE, {
'id': pid,
'name': name.strip(),
'description': description or '',
'project_type': project_type or 'web_app',
'status': S_DRAFT,
'org_id': org_id or '0',
'created_by': created_by or '',
})
await record_audit(pid, TABLE, pid, 'create',
to_state=S_DRAFT, who=_normalize_role(who),
agent_id=agent_id, sor=sor)
logger.info("create_project: %s name=%s", pid, name.strip())
return True, pid
async def start_project(project_id, who=None, agent_id=None):
"""启动项目draft → active也兼容 archived → active 重新激活)。"""
ok, msg = await _transition(project_id, S_DRAFT, S_ACTIVE, 'start',
who=who, agent_id=agent_id)
if ok:
return ok, msg
return await _transition(project_id, S_ARCHIVED, S_ACTIVE, 'start',
who=who, agent_id=agent_id)
async def complete_project(project_id, who=None, agent_id=None):
"""完成项目active → completed。"""
return await _transition(project_id, S_ACTIVE, S_COMPLETED, 'complete',
who=who, agent_id=agent_id)
async def archive_project(project_id, who=None, agent_id=None):
"""归档项目completed → archived也兼容 active → archived 强制归档)。"""
ok, msg = await _transition(project_id, S_COMPLETED, S_ARCHIVED, 'archive',
who=who, agent_id=agent_id)
if ok:
return ok, msg
return await _transition(project_id, S_ACTIVE, S_ARCHIVED, 'archive',
who=who, agent_id=agent_id)
async def reopen_project(project_id, who=None, agent_id=None):
"""重新打开archived → active。"""
return await _transition(project_id, S_ARCHIVED, S_ACTIVE, 'reopen',
who=who, agent_id=agent_id)
async def pause_project(project_id, who=None, agent_id=None):
"""暂停推进active → paused。暂停后 PM 不再推进项目(不认领审核任务、不创建后续任务),
直到 resume_project 恢复。仅用户明确指令「暂停推进」时才调用——默认必须推进,无需指令。"""
return await _transition(project_id, S_ACTIVE, S_PAUSED, 'pause',
who=who, agent_id=agent_id)
async def resume_project(project_id, who=None, agent_id=None):
"""恢复推进paused → active。"""
return await _transition(project_id, S_PAUSED, S_ACTIVE, 'resume',
who=who, agent_id=agent_id)
async def set_project_state(project_id, from_state, to_state,
who=None, agent_id=None, detail=None):
"""通用 CAS 状态迁移兜底(跨产线自定义状态机用)。"""
return await _transition(project_id, from_state, to_state, 'set_state',
who=who, agent_id=agent_id, detail=detail)
async def list_projects(org_id=None, status=None, limit=50) -> list:
"""列出项目(可按组织/状态过滤org_id 空则列全部)。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
conditions = ["1=1"]
params = {}
if org_id:
conditions.append("org_id=${org}$")
params["org"] = org_id
if status:
conditions.append("status=${status}$")
params["status"] = status
where = " AND ".join(conditions)
try:
limit = int(limit)
except (TypeError, ValueError):
limit = 50
sql = (f"SELECT * FROM {TABLE} WHERE {where} "
f"ORDER BY created_at DESC LIMIT {limit}")
recs = await sor.sqlExe(sql, params)
# 释放 SELECT 元数据锁
await sor.sqlExe("COMMIT", {})
result = []
for rec in (recs or []):
result.append(_rec_to_dict(rec))
return result
def _rec_to_dict(rec):
"""把 sqlor 记录对象转成 dictsqlor 行是 DictObject必须 dict(rec) 取列)。"""
if isinstance(rec, dict):
return dict(rec)
try:
return dict(rec)
except (TypeError, ValueError):
if hasattr(rec, 'to_dict'):
try:
return rec.to_dict()
except Exception:
pass
return {}
async def _dump_project_records(sor, project_id):
"""导出项目及关联表全部记录(归档备份 JSON 用)。"""
dump = {}
# 直接关联project_id / tenant_id
direct = {
'sd_projects': "SELECT * FROM sd_projects WHERE id=${pid}$",
'sd_iterations': "SELECT * FROM sd_iterations WHERE project_id=${pid}$",
'pipeline_tasks': "SELECT * FROM pipeline_tasks WHERE tenant_id=${pid}$",
'pipeline_deliverables': "SELECT * FROM pipeline_deliverables WHERE project_id COLLATE utf8mb4_unicode_ci=${pid}$",
'pipeline_project_agents': "SELECT * FROM pipeline_project_agents WHERE project_id COLLATE utf8mb4_unicode_ci=${pid}$",
'pipeline_agent_questions': "SELECT * FROM pipeline_agent_questions WHERE tenant_id=${pid}$",
'sd_deploy_envs': "SELECT * FROM sd_deploy_envs WHERE project_id=${pid}$",
'sd_features': "SELECT * FROM sd_features WHERE project_id=${pid}$",
'sd_project_repos': "SELECT * FROM sd_project_repos WHERE project_id COLLATE utf8mb4_unicode_ci=${pid}$",
}
for tbl, sql in direct.items():
try:
recs = await sor.sqlExe(sql, {"pid": project_id})
dump[tbl] = [_rec_to_dict(r) for r in (recs or [])]
except Exception as e:
dump[tbl] = {"_error": f"{type(e).__name__}: {str(e)[:200]}"}
# 间接关联iteration_id 通过子查询pipeline_conversations 的 iteration_id 存的是 project_id
indirect = {
'sd_bugs': "SELECT * FROM sd_bugs WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
'sd_test_plans': "SELECT * FROM sd_test_plans WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
'sd_test_cases': "SELECT * FROM sd_test_cases WHERE plan_id IN (SELECT id FROM sd_test_plans WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$))",
'sd_conversations': "SELECT * FROM sd_conversations WHERE iteration_id COLLATE utf8mb4_unicode_ci IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
'pipeline_conversations': "SELECT * FROM pipeline_conversations WHERE iteration_id COLLATE utf8mb4_unicode_ci=${pid}$ OR iteration_id COLLATE utf8mb4_unicode_ci IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
'audit_log': "SELECT * FROM audit_log WHERE tenant_id COLLATE utf8mb4_unicode_ci=${pid}$ ORDER BY created_at ASC",
}
for tbl, sql in indirect.items():
try:
recs = await sor.sqlExe(sql, {"pid": project_id})
dump[tbl] = [_rec_to_dict(r) for r in (recs or [])]
except Exception as e:
dump[tbl] = {"_error": f"{type(e).__name__}: {str(e)[:200]}"}
return dump
async def backup_project(project_id, who=None, agent_id=None):
"""归档备份:打包项目工作目录 + 导出数据库记录 → tgz 到「机构工作目录/_archive/」。
返回 (ok, tgz_path_or_message)。删除项目前必须调用,保证可回退。
"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.R(TABLE, {'id': project_id})
if not recs:
await sor.sqlExe("COMMIT", {})
return False, "项目不存在"
p = recs[0]
pname = getattr(p, 'name', '') or project_id
org_id = getattr(p, 'org_id', '0') or '0'
ws = getattr(p, 'workspace_dir', '') or ''
status = getattr(p, 'status', '')
from .workspace import get_workspace_base
workspace_base = await get_workspace_base(sor)
if not (ws or '').startswith('/'):
ws = os.path.join(workspace_base, str(org_id), pname)
# 机构工作目录下的备份归档目录
archive_dir = os.path.join(workspace_base, str(org_id), '_archive')
os.makedirs(archive_dir, exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
safe_name = re.sub(r'[^\w\u4e00-\u9fff.-]', '_', pname)
tgz_path = os.path.join(archive_dir, f"{safe_name}_{timestamp}.tgz")
# 导出数据库记录
dump = await _dump_project_records(sor, project_id)
try:
with tarfile.open(tgz_path, 'w:gz') as tar:
# 数据库记录 JSON
with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False, encoding='utf-8') as f:
json.dump(dump, f, ensure_ascii=False, default=str)
tmp_json = f.name
tar.add(tmp_json, arcname='project_data.json')
os.unlink(tmp_json)
# 工作目录文件(若存在)
if ws and os.path.isdir(ws):
tar.add(ws, arcname='workspace')
except Exception as e:
logger.error("backup_project tar failed: %s err=%s", project_id, e)
return False, f"打包失败: {type(e).__name__}: {str(e)[:200]}"
await record_audit(project_id, TABLE, project_id, 'backup',
to_state=status, who=_normalize_role(who),
agent_id=agent_id, detail=tgz_path, sor=sor)
logger.info("backup_project: %s -> %s", project_id, tgz_path)
return True, tgz_path
async def delete_project(project_id, who=None, agent_id=None, confirm=False):
"""删除项目:先归档备份,再删除关联表记录 + sd_projects + 工作目录。
confirm 必须为 True删除前二次确认。备份失败则中止删除。
返回 (ok, message)。
"""
if not confirm:
return False, "删除项目需二次确认confirm=true请先确认再执行"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
# 1. 归档备份(失败则中止删除)
ok, backup_msg = await backup_project(project_id, who=who, agent_id=agent_id)
if not ok:
return False, f"归档备份失败,已中止删除:{backup_msg}"
# 读工作目录(删除数据库后清理物理文件)
recs = await sor.R(TABLE, {'id': project_id})
if not recs:
await sor.sqlExe("COMMIT", {})
return False, "项目不存在"
p = recs[0]
pname = getattr(p, 'name', '') or project_id
org_id = getattr(p, 'org_id', '0') or '0'
ws = getattr(p, 'workspace_dir', '') or ''
from .workspace import get_workspace_base
workspace_base = await get_workspace_base(sor)
if not (ws or '').startswith('/'):
ws = os.path.join(workspace_base, str(org_id), pname)
# 2. 删除间接关联表(依赖迭代)
indirect = [
"DELETE FROM sd_test_cases WHERE plan_id IN (SELECT id FROM sd_test_plans WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$))",
"DELETE FROM sd_bugs WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
"DELETE FROM sd_test_plans WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
"DELETE FROM sd_conversations WHERE iteration_id COLLATE utf8mb4_unicode_ci IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
"DELETE FROM pipeline_conversations WHERE iteration_id COLLATE utf8mb4_unicode_ci=${pid}$ OR iteration_id COLLATE utf8mb4_unicode_ci IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
]
for sql in indirect:
await sor.sqlExe(sql, {"pid": project_id})
# 3. 删除直接关联表project_id / tenant_id
direct = [
"DELETE FROM pipeline_deliverables WHERE project_id COLLATE utf8mb4_unicode_ci=${pid}$",
"DELETE FROM pipeline_project_agents WHERE project_id COLLATE utf8mb4_unicode_ci=${pid}$",
"DELETE FROM pipeline_agent_questions WHERE tenant_id=${pid}$",
"DELETE FROM sd_deploy_envs WHERE project_id=${pid}$",
"DELETE FROM sd_features WHERE project_id=${pid}$",
"DELETE FROM sd_project_repos WHERE project_id COLLATE utf8mb4_unicode_ci=${pid}$",
"DELETE FROM pipeline_tasks WHERE tenant_id=${pid}$",
"DELETE FROM audit_log WHERE tenant_id COLLATE utf8mb4_unicode_ci=${pid}$",
"DELETE FROM sd_iterations WHERE project_id=${pid}$",
"DELETE FROM sd_projects WHERE id=${pid}$",
]
for sql in direct:
await sor.sqlExe(sql, {"pid": project_id})
await sor.sqlExe("COMMIT", {})
# 4. 删除工作目录(物理文件,已备份)
if ws and os.path.isdir(ws):
try:
shutil.rmtree(ws, ignore_errors=True)
except Exception as e:
logger.warning("delete_project rmtree failed: %s err=%s", ws, e)
logger.info("delete_project: %s done, backup=%s", project_id, backup_msg)
return True, f"项目已删除,归档备份在 {backup_msg}"