pipeline-service/pipeline_service/project_capability.py

711 lines
34 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" # 已归档
# 「进行中」状态集合跨产线状态兼容2026-09-01
# SDLC 产线用 active投标/商机等产线用 in_progress。
# pause/resume 必须对两者都生效,否则非 SDLC 产线项目无法暂停。
S_ACTIVE_LIKE = {S_ACTIVE, "in_progress"}
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="general", description="",
org_id="0", created_by="", who=None, agent_id=None,
pipeline_id="", default_model=""):
"""创建项目新建记录status=draft。返回 (ok, project_id_or_message)。
pipeline_id 必须与建目录用的 space 一致(工作空间解析 space=pipeline_id
不一致会导致「上传落盘目录 ≠ agent 工作目录」错位)。缺省 'general' 表示
通用会话项目,不挂任何产线。
"""
if not name or not name.strip():
return False, "缺少项目名称"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
pid = getID()
space = pipeline_id or 'general'
# 创建时定死项目目录并写库(统一机制:解析只读库不推导,
# 保证上传落盘=agent工作目录=工作控件显示目录)
from .workspace import alloc_project_dir
ws_path, dname = await alloc_project_dir(
sor, org_id or '0', space, name.strip(), pid)
await sor.C(TABLE, {
'id': pid,
'name': name.strip(),
'description': description or '',
'project_type': project_type or 'general',
'status': S_DRAFT,
'org_id': org_id or '0',
'created_by': created_by or '',
'workspace_dir': ws_path,
'directory_name': dname,
'pipeline_id': space,
'default_model': default_model 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 check_project_owner(project_id, user_id, sor=None):
"""校验 user_id 是否为项目 ownersd_projects.created_by
独立开 context供 pipeline-task 等 dspy 直接调用);传入 sor 时复用它(供
pipeline-sdlc dspy 在已有 get_sor_context 内调用,避免嵌套 context 的 MDL 锁)。
返回 (True, '') 或 (False, 错误信息)。
"""
if not project_id:
return False, "缺少 project_id"
if not user_id:
return False, "未登录"
async def _check(_sor):
recs = await _sor.sqlExe(
"SELECT created_by, name, org_id FROM sd_projects WHERE id=${pid}$",
{"pid": project_id})
await _sor.sqlExe("COMMIT", {})
if not recs:
return False, "项目不存在"
owner = getattr(recs[0], 'created_by', '') or ''
if owner == user_id:
return True, ''
# owner 降级2026-09-01 修复):项目由 agent 创建created_by='agent.*')时
# 没有任何人类是字面 ownerowner 门禁会永久拒绝,导致用户无法管理自己产线里
# agent 建的项目。此时降级为「同机构人类」:项目 org_id == 请求用户的 orgid 即放行。
if owner.startswith("agent."):
proj_org = str(getattr(recs[0], 'org_id', '') or '')
u = await _sor.sqlExe(
"SELECT orgid FROM users WHERE id=${uid}$", {"uid": user_id})
await _sor.sqlExe("COMMIT", {})
if u:
user_org = str(getattr(u[0], 'orgid', '') or '')
if user_org and user_org == proj_org:
return True, ''
return False, "项目由 agent 创建,仅同机构成员可操作"
return False, "仅项目 owner 可执行此操作"
if sor is not None:
return await _check(sor)
db, dbname = _get_db()
async with db.sqlorContext(dbname) as _s:
return await _check(_s)
async def check_task_owner(task_id, user_id):
"""校验 user_id 是否为 task 所属 SDLC 项目的 owner。
SDLC 任务 tenant_id=project_id须校验 owner通用引擎任务 tenant_id=org_id
项目 id放行返回 (True, '')。独立开 context供 pipeline-task dspy 调用)。
"""
if not task_id:
return False, "缺少 task_id"
if not user_id:
return False, "未登录"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT tenant_id FROM pipeline_tasks WHERE id=${tid}$", {"tid": task_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "任务不存在"
tenant = getattr(recs[0], 'tenant_id', '') or ''
p = await sor.sqlExe(
"SELECT id FROM sd_projects WHERE id=${pid}$", {"pid": tenant})
await sor.sqlExe("COMMIT", {})
if not p:
# 通用引擎任务tenant_id 非 SDLC 项目),无 owner 概念,放行
return True, ''
return await check_project_owner(tenant, user_id, sor)
async def check_tenant_owner(tenant_id, user_id):
"""校验 user_id 是否为 tenant_id 所属 SDLC 项目的 owner。非项目 tenant 放行。
独立开 context供 pipeline-task 的 task_submit 等 dspy 调用)。
"""
if not user_id:
return False, "未登录"
if not tenant_id:
return True, ''
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
p = await sor.sqlExe(
"SELECT id FROM sd_projects WHERE id=${pid}$", {"pid": tenant_id})
await sor.sqlExe("COMMIT", {})
if not p:
return True, ''
return await check_project_owner(tenant_id, user_id, sor)
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 _clear_project_todos(sor, project_id):
"""删除项目全部待办:人类任务 + 冒泡问题(待办角标/列表的两个来源)。
关联链project_id 直挂 / iteration_id 经迭代 / task_id 经任务 / tenant_idquestions
调用方保证在 sd_iterations / pipeline_tasks 删除之前执行(子查询依赖)。
"""
if not project_id:
return
for sql in [
"DELETE FROM pipeline_human_tasks WHERE project_id=${pid}$",
"DELETE FROM pipeline_human_tasks WHERE iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${pid}$)",
"DELETE FROM pipeline_human_tasks WHERE task_id IN (SELECT id FROM pipeline_tasks WHERE tenant_id=${pid}$)",
"DELETE FROM pipeline_agent_questions WHERE tenant_id=${pid}$",
"DELETE FROM pipeline_agent_questions WHERE task_id IN (SELECT id FROM pipeline_tasks WHERE tenant_id=${pid}$)",
]:
try:
await sor.sqlExe(sql, {"pid": project_id})
except Exception as e:
logger.warning("clear_project_todos failed: %s err=%s", sql, e)
await sor.sqlExe("COMMIT", {})
logger.info("clear_project_todos: project=%s done", project_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 强制归档)。
归档成功后清空项目全部待办2026-09-01"""
ok, msg = await _transition(project_id, S_COMPLETED, S_ARCHIVED, 'archive',
who=who, agent_id=agent_id)
if not ok:
ok, msg = await _transition(project_id, S_ACTIVE, S_ARCHIVED, 'archive',
who=who, agent_id=agent_id)
if ok:
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
await _clear_project_todos(sor, project_id)
return ok, msg
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):
"""暂停推进:进行中 → paused。暂停后 PM 不再推进项目(不认领审核任务、不创建后续任务),
直到 resume_project 恢复。仅用户明确指令「暂停推进」时才调用——默认必须推进,无需指令。
跨产线状态兼容2026-09-01 修复):进行中状态不硬编码 active——
SDLC 产线是 active投标/商机等产线是 in_progress两者都可暂停。
暂停前的原状态记入审计 detailprev_state=xxx恢复时据此还原。
"""
if not project_id:
return False, "缺少 project_id"
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, "项目不存在"
cur = getattr(recs[0], 'status', '') or ''
if cur not in S_ACTIVE_LIKE:
await sor.sqlExe("COMMIT", {})
return False, f"无法暂停:项目状态 {cur}(仅 {sorted(S_ACTIVE_LIKE)} 可暂停)"
await sor.sqlExe(
f"UPDATE {TABLE} SET status=${{to}}$, updated_at=NOW() "
"WHERE id=${pid}$ AND status=${from}$",
{"to": S_PAUSED, "pid": project_id, "from": cur})
recs2 = await sor.R(TABLE, {'id': project_id})
await sor.sqlExe("COMMIT", {})
if getattr(recs2[0], 'status', '') != S_PAUSED:
return False, f"状态迁移失败(CAS): 期望 to={S_PAUSED} 实际 status={getattr(recs2[0], 'status', '')}"
# 原状态记入审计(恢复时还原的依据)
await record_audit(project_id, TABLE, project_id, 'pause',
from_state=cur, to_state=S_PAUSED,
who=_normalize_role(who), agent_id=agent_id,
detail=f"prev_state={cur}", sor=sor)
# 2026-09-15 修复:暂停不再删除待办。暂停语义是「可逆停摆」,删 pending 人类
# 任务/冒泡问题是破坏性动作——实测首医项目pause 删掉待办后 resume
# human_task_qc 的检查对象消失 → qc_reject 计轮死循环 → 项目再次 failed 暂停。
# 待办清理只保留给 archive/delete项目终态遗留待办确属无意义
return True, S_PAUSED
async def resume_project(project_id, who=None, agent_id=None):
"""恢复推进paused → 原状态(优先还原暂停前的状态,跨产线兼容)。
SDLC 项目还原为 active投标/商机项目若暂停前是 in_progress 则还原为
in_progress2026-09-01原实现硬编码还原 active会把投标项目
改成产线状态机里不存在的状态)。查不到暂停审计时兜底还原 active。
"""
if not project_id:
return False, "缺少 project_id"
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, "项目不存在"
cur = getattr(recs[0], 'status', '') or ''
if cur != S_PAUSED:
await sor.sqlExe("COMMIT", {})
return False, f"无法恢复:项目状态 {cur}(仅 {S_PAUSED} 可恢复)"
# 查最近一次暂停审计,还原暂停前状态
restore_to = S_ACTIVE
audits = await sor.sqlExe(
"SELECT detail FROM audit_log WHERE tenant_id=${pid}$ AND entity=${e}$ "
"AND action='pause' ORDER BY created_at DESC LIMIT 1",
{"pid": project_id, "e": TABLE})
await sor.sqlExe("COMMIT", {})
if audits:
d = getattr(audits[0], 'detail', '') or ''
if d.startswith("prev_state="):
prev = d[len("prev_state="):].strip()
if prev in S_ACTIVE_LIKE:
restore_to = prev
await sor.sqlExe(
f"UPDATE {TABLE} SET status=${{to}}$, updated_at=NOW() "
"WHERE id=${pid}$ AND status=${from}$",
{"to": restore_to, "pid": project_id, "from": S_PAUSED})
recs2 = await sor.R(TABLE, {'id': project_id})
await sor.sqlExe("COMMIT", {})
if getattr(recs2[0], 'status', '') != restore_to:
return False, f"状态迁移失败(CAS): 期望 to={restore_to} 实际 status={getattr(recs2[0], 'status', '')}"
await record_audit(project_id, TABLE, project_id, 'resume',
from_state=S_PAUSED, to_state=restore_to,
who=_normalize_role(who), agent_id=agent_id, sor=sor)
return True, restore_to
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, pipeline_id=None, limit=50) -> list:
"""列出项目(可按组织/状态/产线过滤org_id 空则列全部)。
产线隔离:传 pipeline_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
if pipeline_id:
conditions.append("pipeline_id=${pl}$")
params["pl"] = pipeline_id
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}$))",
'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 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})
# 2a. 清空项目全部待办2026-09-01project_id 直挂 + iteration/task 关联。
# 必须在 sd_iterations / pipeline_tasks 删除之前(子查询依赖)。
await _clear_project_todos(sor, project_id)
# 2b. 任务关联表:先取本项目的任务 id再删 task_id 指向它们的行
# pipeline_deliverables 等同时有 project_id 和 task_id 两列,仅按 project_id
# 删会漏掉「项目删除时 task 已先被删、project_id 已失效」的残留)
tid_rows = await sor.sqlExe(
"SELECT id FROM pipeline_tasks WHERE tenant_id=${pid}$", {"pid": project_id})
await sor.sqlExe("COMMIT", {})
task_ids = [getattr(r, 'id', '') for r in (tid_rows or []) if getattr(r, 'id', '')]
if task_ids:
tid_list = ",".join("'" + t.replace("'", "") + "'" for t in task_ids)
for sql in [
"DELETE FROM pipeline_deliverables WHERE task_id COLLATE utf8mb4_unicode_ci IN (" + tid_list + ")",
"DELETE FROM pipeline_artifacts WHERE task_id IN (" + tid_list + ")",
"DELETE FROM pipeline_task_steps WHERE task_id IN (" + tid_list + ")",
"DELETE FROM pipeline_human_tasks WHERE task_id IN (" + tid_list + ")",
]:
try:
await sor.sqlExe(sql, {})
except Exception as e:
logger.warning("delete_project task-linked cleanup failed: %s err=%s", sql, e)
# 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 sd_project_role_models WHERE project_id=${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}$",
# 会话/全局「当前项目」指针:清空而非删行(行里还有 iteration/llm 等其他设置)。
# 不清理会产生悬空引用:下次打开工作空间/菜单时按会话解析仍命中已删项目,
# 报「请先在会话中切换项目」——这正是本 bug 的源头。
"UPDATE pipeline_session_settings SET current_project_id='' WHERE current_project_id=${pid}$",
"UPDATE pipeline_agent_settings SET current_project_id='' WHERE current_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}"
# ── 孤儿清扫 ────────────────────────────────────────────────────────
#
# 历史缺陷与手工测试会留下「指向已删项目/迭代/任务的孤儿行」。本函数逐表清扫。
#
# 孤儿判据 = 引用列【非空】且指向的主键在父表中已不存在。
# 注意:空串 '' 与 NULL 都表示「未挂载」(合法),不是孤儿——若不加 `<> ''` 守卫,
# `NOT IN` 会把空串当成孤儿误删(实测 sd_features 23 条 iteration_id='' 被误判)。
#
# 列 collation 不一sd_projects.id / sd_iterations.id / pipeline_tasks.id 是 unicode_ci
# sd_project_repos / pipeline_deliverables 的引用列是 general_ci
# 跨表比较处显式 COLLATE utf8mb4_unicode_ci 规避 1267。
# (label, 表, 引用列, 父表, 是否需 COLLATE)
_ORPHAN_CLEANUPS = [
("sd_project_repos(无项目)", "sd_project_repos", "project_id", "sd_projects", True),
("sd_features(无项目)", "sd_features", "project_id", "sd_projects", False),
("sd_project_role_models(无项目)", "sd_project_role_models", "project_id", "sd_projects", False),
("sd_deploy_envs(无项目)", "sd_deploy_envs", "project_id", "sd_projects", False),
("pipeline_deliverables(无项目)", "pipeline_deliverables", "project_id", "sd_projects", True),
("sd_iterations(无项目)", "sd_iterations", "project_id", "sd_projects", False),
("sd_features(无迭代)", "sd_features", "iteration_id", "sd_iterations", False),
("sd_bugs(无迭代)", "sd_bugs", "iteration_id", "sd_iterations", False),
("sd_test_plans(无迭代)", "sd_test_plans", "iteration_id", "sd_iterations", False),
("pipeline_deliverables(无迭代)", "pipeline_deliverables", "iteration_id", "sd_iterations", True),
("sd_test_cases(无计划)", "sd_test_cases", "plan_id", "sd_test_plans", False),
("pipeline_deliverables(无任务)", "pipeline_deliverables", "task_id", "pipeline_tasks", True),
("pipeline_artifacts(无任务)", "pipeline_artifacts", "task_id", "pipeline_tasks", False),
("pipeline_task_steps(无任务)", "pipeline_task_steps", "task_id", "pipeline_tasks", False),
("pipeline_human_tasks(无任务)", "pipeline_human_tasks", "task_id", "pipeline_tasks", False),
("pipeline_agent_questions(无任务)", "pipeline_agent_questions", "task_id", "pipeline_tasks", False),
("sd_features(无任务)", "sd_features", "task_id", "pipeline_tasks", False),
("sd_iterations(无任务)", "sd_iterations", "task_id", "pipeline_tasks", False),
# 「当前项目」指针:指向已删项目的会话/全局设置记录。解析层已能自愈回退,
# 这里清扫是存量脏数据的批量兜底(历史 CRUD 删除不清理指针留下的)。
("pipeline_session_settings(无项目)", "pipeline_session_settings", "current_project_id", "sd_projects", True),
("pipeline_agent_settings(无项目)", "pipeline_agent_settings", "current_project_id", "sd_projects", True),
]
def _orphan_sql(label, table, col, parent, use_collate):
"""生成 (计数SQL, 删除SQL)。守卫:引用列非空(<> '' 排除 NULL 与空串)。"""
lhs = col + " COLLATE utf8mb4_unicode_ci" if use_collate else col
guard = f"{lhs} <> '' AND {lhs} NOT IN (SELECT id FROM {parent})"
cnt = f"SELECT COUNT(*) AS rcnt FROM {table} WHERE {guard}"
dlt = f"DELETE FROM {table} WHERE {guard}"
return cnt, dlt
def _count_rows(recs):
"""从 SELECT COUNT(*) AS rcnt 结果取整数;兼容 DictObject/dict/list。"""
if not recs:
return 0
r = recs[0]
if isinstance(r, dict):
v = r.get('rcnt') or r.get('count') or r.get('COUNT(*)')
return int(v or 0)
if isinstance(r, (list, tuple)):
return int(r[0] if r and r[0] is not None else 0)
v = getattr(r, 'rcnt', None)
if v is None:
v = getattr(r, 'count', None)
return int(v or 0)
async def cleanup_orphans(confirm=False, dry_run=False):
"""清扫指向已删项目/迭代/任务的孤儿行(幂等,可反复执行)。
confirm 必须为 True。dry_run=True 时只统计不删除。
逐表执行,单表失败不中断其余表。
返回 (ok, message)message 含每表行数汇总。
"""
if not confirm:
return False, "孤儿清扫需二次确认confirm=true"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
results = []
total = 0
for label, table, col, parent, use_collate in _ORPHAN_CLEANUPS:
cnt_sql, del_sql = _orphan_sql(label, table, col, parent, use_collate)
try:
recs = await sor.sqlExe(cnt_sql, {})
await sor.sqlExe("COMMIT", {})
n = _count_rows(recs)
except Exception as e:
results.append(f"{label}: 跳过({type(e).__name__})")
continue
if n and not dry_run:
try:
await sor.sqlExe(del_sql, {})
await sor.sqlExe("COMMIT", {})
except Exception as e:
results.append(f"{label}: 删除失败({type(e).__name__})")
continue
total += n
results.append(f"{label}: {n}")
verb = "发现" if dry_run else "清除"
logger.info("cleanup_orphans(%s): %s %d 行孤儿记录。明细: %s",
"dry_run" if dry_run else "执行", verb, total, "; ".join(results))
return True, f"孤儿清扫完成,{verb} {total} 行。明细:{''.join(results)}"