pipeline-service/pipeline_service/human_task_capability.py

498 lines
21 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.

"""SDLC 项目级人类任务清单 capability。
人类待办三类来源(统一视图,见 list_my_human_todos / count_my_human_tasks
- general 显式人类任务 → 落 pipeline_human_tasksowner/PM 派发)
- bug_acceptance bug 验收任务 → 落 pipeline_human_tasksbug_flow 在 verified 时创建)
- question 冒泡问题 → 不落此表union 查 pipeline_agent_questions
处理权限处理者须同机构org_id 匹配)+ 匹配 assignee_role 或 assignee_id。
状态status(pending/done/rejected) + qc_status(pending/passed/rejected仅 general 走 QC)。
"""
import json
import logging
from sqlor.dbpools import DBPools
from appPublic.uniqueID import getID
DBNAME = "pipeline"
logger = logging.getLogger("pipeline.human_task_capability")
S_PENDING = "pending"
S_DONE = "done"
S_REJECTED = "rejected"
QC_PENDING = "pending"
QC_PASSED = "passed"
QC_REJECTED = "rejected"
T_GENERAL = "general"
T_BUG_ACCEPT = "bug_acceptance"
T_REQ_CONFIRM = "requirement_confirmation"
T_DESIGN_CONFIRM = "design_confirmation"
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 _rec_to_dict(rec):
if rec is None:
return {}
if isinstance(rec, dict):
return dict(rec)
try:
return dict(rec)
except (TypeError, ValueError):
return {}
async def _get_user_org(sor, user_id):
"""查用户 org_id。"""
if not user_id:
return ""
recs = await sor.sqlExe(
"SELECT orgid FROM users WHERE id=${u}$ LIMIT 1", {"u": user_id})
await sor.sqlExe("COMMIT", {})
return getattr(recs[0], 'orgid', '') if recs else ''
async def _get_user_roles(sor, user_id):
"""查用户 RBAC 角色名列表({orgtypeid}.{name} 格式)。"""
roles = []
if not user_id:
return roles
recs = await sor.sqlExe(
"SELECT r.orgtypeid, r.name FROM userrole ur JOIN role r ON ur.roleid=r.id "
"WHERE ur.userid=${u}$", {"u": user_id})
await sor.sqlExe("COMMIT", {})
for r in (recs or []):
o = getattr(r, 'orgtypeid', '') or ''
n = getattr(r, 'name', '') or ''
if o and n:
roles.append(f"{o}.{n}")
return roles
async def create_human_task(project_id, title, description="", task_type=T_GENERAL,
assignee_role=None, assignee_id=None,
iteration_id=None, bug_id=None, created_by=None, task_id="",
form_schema=None):
"""派发项目级人类任务。assignee_role 或 assignee_id 至少指定一个。
task_id确认类任务绑定原引擎任务 id用于确认后继续派发/退回重做)。
form_schema动态表单声明可选与问题通道同格式——待办详情按它渲染
上传/填写界面,人类在待办里直接完成任务(文件落项目工作空间,路径进
result_data 供 QC 与任务链消费)。
返回 (True, human_task_id) 或 (False, 错误信息)
"""
if not project_id:
return False, "缺少 project_id"
if not title or not title.strip():
return False, "缺少标题"
if not assignee_role and not assignee_id:
return False, "须指定 assignee_role 或 assignee_id"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
# 项目存在性 + 迭代(未显式传时取当前迭代)
proj = await sor.sqlExe(
"SELECT id, org_id FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
await sor.sqlExe("COMMIT", {})
if not proj:
return False, "项目不存在"
if not iteration_id:
it = await sor.sqlExe(
"SELECT id FROM sd_iterations WHERE project_id=${pid}$ "
"AND status='in_progress' ORDER BY seq_no DESC LIMIT 1",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
iteration_id = getattr(it[0], 'id', '') if it else ''
hid = getID()
_fs_json = json.dumps(form_schema, ensure_ascii=False) if form_schema else None
await sor.C('pipeline_human_tasks', {
'id': hid,
'task_id': task_id or '', # 确认类任务绑定原引擎任务 id
'step_name': '', # 同上
'version': 1,
'task_type': task_type or T_GENERAL,
'assignee_role': assignee_role or '',
'assignee_id': assignee_id or '',
'form_schema': _fs_json,
'result_data': None,
'status': S_PENDING,
'submitted_by': created_by or '',
'project_id': project_id,
'iteration_id': iteration_id or '',
'bug_id': bug_id or '',
'qc_status': QC_PENDING,
'title': title.strip(),
'description': description or '',
})
await sor.sqlExe("COMMIT", {})
logger.info("create_human_task: %s project=%s type=%s title=%s",
hid, project_id, task_type, title.strip())
return True, hid
async def confirm_stage_gate(human_task_id, confirmed, comment, operator_id):
"""需求/设计人工确认节点owner 确认门禁)。
confirmed=True → 确认通过:任务 done + 创建下一角色任务(继续任务链)。
confirmed=False → 不确定:任务 rejected + 退回原角色重做(带修改意见)。
确认任务由 pm_review_run 在 requirement/design 审核通过后创建,
task_id 字段存原引擎任务 id用于此处继续/退回)。仅 owner 可确认。
"""
if not human_task_id:
return False, "缺少 human_task_id"
if not operator_id:
return False, "未登录"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT * FROM pipeline_human_tasks WHERE id=${hid}$", {"hid": human_task_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "确认任务不存在"
ht = _rec_to_dict(recs[0])
task_type = ht.get('task_type', '') or ''
if task_type not in (T_REQ_CONFIRM, T_DESIGN_CONFIRM):
return False, "该任务不是需求/设计确认任务"
if ht.get('status') != S_PENDING:
return False, f"任务不在待确认状态 (当前: {ht.get('status')})"
# owner 校验:确认任务 assignee_id=owner须等于本人
assignee_id = ht.get('assignee_id', '') or ''
if assignee_id and operator_id != assignee_id:
return False, "仅项目 owner 可确认"
pid = ht.get('project_id', '')
original_task_id = ht.get('task_id', '') or ''
result_json = json.dumps({"confirmed": bool(confirmed), "comment": comment or ''},
ensure_ascii=False)
if confirmed:
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status='done', result_data=${rd}$, "
"submitted_by=${oid}$, submitted_at=NOW() WHERE id=${hid}$",
{"rd": result_json, "oid": operator_id, "hid": human_task_id})
await sor.sqlExe("COMMIT", {})
if original_task_id:
from .agent_loop import _create_next_task, _get_next_role
trecs = await sor.R('pipeline_tasks', {'id': original_task_id})
if trecs:
t = trecs[0]
nrole = await _get_next_role(getattr(t, 'role', ''), pid)
if nrole:
next_tid, next_title = await _create_next_task(
sor, pid, t, nrole, comment or "owner 确认通过")
await sor.sqlExe("COMMIT", {})
return True, f"已确认,继续派发 {nrole}"
return True, "已确认"
else:
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status='rejected', result_data=${rd}$, "
"submitted_by=${oid}$, submitted_at=NOW() WHERE id=${hid}$",
{"rd": result_json, "oid": operator_id, "hid": human_task_id})
await sor.sqlExe("COMMIT", {})
if original_task_id:
from .agent_loop import _rollback_task_chain
trecs = await sor.R('pipeline_tasks', {'id': original_task_id})
if trecs:
t = trecs[0]
role = getattr(t, 'role', '')
r = await _rollback_task_chain(
sor, pid, original_task_id, role,
comment or "owner 确认不通过,退回重做")
await sor.sqlExe("COMMIT", {})
return True, f"已退回重做: {r.get('new_task_id', '')}"
return True, "已退回重做"
async def complete_human_task(human_task_id, result_data, operator_id=None):
"""处理者提交完成done。校验同机构 + 匹配 assignee。
general 任务 done 后创建 qc_review 的 QC 任务(复用 qc poller → qc_review_run
bug_acceptance 任务不走这里(走 bug_accept
"""
if not human_task_id:
return False, "缺少 human_task_id"
if not operator_id:
return False, "未登录"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT * FROM pipeline_human_tasks WHERE id=${hid}$", {"hid": human_task_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "人类任务不存在"
ht = _rec_to_dict(recs[0])
if ht.get('status') != S_PENDING:
return False, f"任务不在待处理状态 (当前: {ht.get('status')})"
# 同机构校验
pid = ht.get('project_id', '')
proj = await sor.sqlExe(
"SELECT org_id FROM sd_projects WHERE id=${pid}$", {"pid": pid})
await sor.sqlExe("COMMIT", {})
proj_org = getattr(proj[0], 'org_id', '') if proj else ''
user_org = await _get_user_org(sor, operator_id)
if proj_org and user_org != proj_org:
return False, "仅同机构用户可处理该任务"
# 匹配 assigneeassignee_id 指定则须等于本人assignee_role 指定则本人角色须含之
assignee_id = ht.get('assignee_id', '') or ''
assignee_role = ht.get('assignee_role', '') or ''
if assignee_id:
if operator_id != assignee_id:
return False, "该任务未指派给你"
elif assignee_role:
roles = await _get_user_roles(sor, operator_id)
if assignee_role not in roles:
return False, "该任务未指派给你的角色"
task_type = ht.get('task_type', '') or T_GENERAL
result_json = json.dumps(result_data, ensure_ascii=False, default=str) \
if isinstance(result_data, (dict, list)) else str(result_data or '')
if task_type == T_BUG_ACCEPT:
return False, "bug 验收任务请走 bug_accept 接口"
# generaldone + 触发 QC创建 qc_review 任务)
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status='done', result_data=${rd}$, "
"submitted_by=${oid}$, submitted_at=NOW() WHERE id=${hid}$",
{"rd": result_json, "oid": operator_id, "hid": human_task_id})
await sor.sqlExe("COMMIT", {})
# 创建 QC 任务(复用 qc poller → qc_review_run
qid = getID()
qparams = {
"task_kind": "human_task_qc",
"human_task_id": human_task_id,
"title": ht.get('title', '') or '',
"description": (ht.get('description', '') or '')[:2000],
"iteration_id": ht.get('iteration_id', '') or '',
}
# 补 titlepipeline_human_tasks 有 title 列,已在上方 result 里带)
await sor.C('pipeline_tasks', {
'id': qid, 'tenant_id': pid, 'pipeline_id': 'role_task',
'owner_id': 'human_task_qc',
'title': f"人类任务 QC 检查({human_task_id[:8]}",
'state': 'qc_review', 'role': 'agent.qc',
'params': json.dumps(qparams, ensure_ascii=False),
})
await sor.sqlExe("COMMIT", {})
return True, human_task_id
async def qc_human_task(human_task_id, passed, comment=None, operator_id=None):
"""QC 检查人类任务general。passed → qc_status=passed否则 qc_status=rejected + status=pending 退回。"""
if not human_task_id:
return False, "缺少 human_task_id"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT status FROM pipeline_human_tasks WHERE id=${hid}$", {"hid": human_task_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "人类任务不存在"
if passed:
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET qc_status='passed', qc_comment=${c}$ "
"WHERE id=${hid}$", {"c": comment or '', "hid": human_task_id})
else:
# 退回重做status 回 pendingqc_status=rejected
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status='pending', qc_status='rejected', "
"qc_comment=${c}$ WHERE id=${hid}$", {"c": comment or '', "hid": human_task_id})
await sor.sqlExe("COMMIT", {})
return True, human_task_id
async def list_project_human_tasks(project_id, status=None, assignee_id=None):
"""列出项目人类任务(显式任务 + bug 验收,不含冒泡问题)。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
conds = ["project_id=${pid}$"]
params = {"pid": project_id}
if status:
conds.append("status=${st}$")
params["st"] = status
if assignee_id:
conds.append("assignee_id=${aid}$")
params["aid"] = assignee_id
where = " AND ".join(conds)
recs = await sor.sqlExe(
f"SELECT * FROM pipeline_human_tasks WHERE {where} ORDER BY created_at DESC",
params)
await sor.sqlExe("COMMIT", {})
return [_rec_to_dict(r) for r in (recs or [])]
async def has_blocking_human_task(sor, project_id):
"""当前迭代是否有未完成的人类任务(流转门禁用)。
generalstatus=pending 或 status=done 但 qc_status!=passed → 阻塞
bug_acceptancestatus=pending → 阻塞done/rejected = 已验收出结果,不阻塞)
"""
if not project_id:
return False
it = await sor.sqlExe(
"SELECT id FROM sd_iterations WHERE project_id=${pid}$ AND status='in_progress' "
"ORDER BY seq_no DESC LIMIT 1", {"pid": project_id})
await sor.sqlExe("COMMIT", {})
if not it:
return False
iid = getattr(it[0], 'id', '') or ''
if not iid:
return False
recs = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM pipeline_human_tasks "
"WHERE iteration_id=${iid}$ AND ("
" (task_type='general' AND (status='pending' OR (status='done' AND qc_status != 'passed'))) "
" OR (task_type='bug_acceptance' AND status='pending')"
" OR (task_type IN ('requirement_confirmation','design_confirmation') AND status='pending')"
")",
{"iid": iid})
await sor.sqlExe("COMMIT", {})
c = getattr(recs[0], 'c', 0) if recs else 0
return int(c) > 0
async def count_my_human_tasks(user_id, project_id=None):
"""角标 X = pending 问题(agentid=我) + 人类任务(assignee_id=我) + 人类任务(assignee_role∈我角色)。"""
if not user_id:
return 0
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
# ① pending 问题 current_handler_agentid = 我
q_cond = "status='pending' AND current_handler_agentid=${u}$"
q_params = {"u": user_id}
if project_id:
q_cond += " AND tenant_id=${pid}$"
q_params["pid"] = project_id
q = await sor.sqlExe(
f"SELECT COUNT(*) AS c FROM pipeline_agent_questions WHERE {q_cond}", q_params)
await sor.sqlExe("COMMIT", {})
# ② + ③ 人类任务assignee_id=我 或 assignee_role∈我角色
roles = await _get_user_roles(sor, user_id)
ht_cond = "status='pending' AND (assignee_id=${u}$"
ht_params = {"u": user_id}
if roles:
ph = ",".join(f"'{r}'" for r in roles)
ht_cond += f" OR assignee_role IN ({ph})"
ht_cond += ")"
if project_id:
ht_cond += " AND project_id=${pid}$"
ht_params["pid"] = project_id
ht = await sor.sqlExe(
f"SELECT COUNT(*) AS c FROM pipeline_human_tasks WHERE {ht_cond}", ht_params)
await sor.sqlExe("COMMIT", {})
n_q = getattr(q[0], 'c', 0) if q else 0
n_ht = getattr(ht[0], 'c', 0) if ht else 0
return int(n_q) + int(n_ht)
async def list_my_human_todos(user_id, limit=100):
"""跨项目「我的待办」列表union 人类任务 + 冒泡问题,按创建时间倒序。"""
if not user_id:
return []
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
roles = await _get_user_roles(sor, user_id)
role_ph = ",".join(f"'{r}'" for r in roles) if roles else "''"
# 人类任务assignee_id=我 或 assignee_role∈我角色pending
ht = await sor.sqlExe(
f"SELECT id, project_id, iteration_id, bug_id, task_type, assignee_role, "
f"assignee_id, status, qc_status, created_at, submitted_by, result_data, "
f"title, description "
f"FROM pipeline_human_tasks "
f"WHERE status='pending' AND (assignee_id=${{u}}$ OR assignee_role IN ({role_ph})) "
f"ORDER BY created_at DESC LIMIT {limit}", {"u": user_id})
await sor.sqlExe("COMMIT", {})
# 冒泡问题current_handler_agentid=我 或 current_handler_role∈我角色pending
qs = await sor.sqlExe(
f"SELECT id, tenant_id AS project_id, task_id, from_role, question, "
f"problem_type, current_handler_role, current_handler_agentid, created_at "
f"FROM pipeline_agent_questions "
f"WHERE status='pending' AND (current_handler_agentid=${{u}}$ "
f"OR current_handler_role IN ({role_ph})) "
f"ORDER BY created_at DESC LIMIT {limit}", {"u": user_id})
await sor.sqlExe("COMMIT", {})
todos = []
for r in (ht or []):
d = _rec_to_dict(r)
d['source'] = 'human_task'
todos.append(d)
for r in (qs or []):
d = _rec_to_dict(r)
d['source'] = 'question'
todos.append(d)
todos.sort(key=lambda x: str(x.get('created_at') or ''), reverse=True)
return todos[:limit]
async def bug_accept(bug_id, accept, user_id, comment=None):
"""bug 验收accept=True → bug closed + 验收任务 doneaccept=False → bug reopen + 任务 rejected。
校验 user_id == bug.reporter_id。返回 (True, 消息) 或 (False, 错误)。
"""
if not bug_id:
return False, "缺少 bug_id"
if not user_id:
return False, "未登录"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
brecs = await sor.sqlExe(
"SELECT reporter_id, iteration_id, status FROM sd_bugs WHERE id=${bid}$",
{"bid": bug_id})
await sor.sqlExe("COMMIT", {})
if not brecs:
return False, "Bug 不存在"
reporter_id = getattr(brecs[0], 'reporter_id', '') or ''
iteration_id = getattr(brecs[0], 'iteration_id', '') or ''
if reporter_id and user_id != reporter_id:
return False, "仅 Bug 报告人可验收"
if accept:
from . import bug_capability
ok, msg = await bug_capability.close_bug(bug_id, iteration_id, who='human')
if not ok:
return False, f"关闭失败: {msg}"
# 验收任务 → done
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status='done', result_data=${c}$, "
"submitted_by=${u}$, submitted_at=NOW() "
"WHERE bug_id=${bid}$ AND task_type='bug_acceptance' AND status='pending'",
{"c": json.dumps({"accept": True, "comment": comment or ''}, ensure_ascii=False),
"u": user_id, "bid": bug_id})
await sor.sqlExe("COMMIT", {})
return True, "验收通过Bug 已关闭"
else:
from . import bug_capability
ok, msg = await bug_capability.reopen_bug(bug_id, iteration_id, who='human')
if not ok:
return False, f"重开失败: {msg}"
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status='rejected', result_data=${c}$, "
"submitted_by=${u}$, submitted_at=NOW() "
"WHERE bug_id=${bid}$ AND task_type='bug_acceptance' AND status='pending'",
{"c": json.dumps({"accept": False, "comment": comment or ''}, ensure_ascii=False),
"u": user_id, "bid": bug_id})
await sor.sqlExe("COMMIT", {})
return True, "验收不通过Bug 已重新打开"