feat: P1 补齐(自动评审队列、自动化+分诊、并发 caps、运行时审批队列)

This commit is contained in:
host committed 2026-10-27 15:53:50 +08:00
1 parent 29efc6c61f
commit e0cecd70aa
18 files changed
+3474 -27

No files matched your search

+435 -3
View File
@@ -66,6 +66,17 @@ from cloud.peer_messages import deliver_peer_message as _deliver_peer_message
from cloud.peer_messages import ensure_followups_table as _peer_ensure_followups
from cloud.peer_messages import peer_message_prefix as _peer_prefix
from cloud.peer_messages import row_get as _row_get
from cloud.automations import cancel_automation as _auto_cancel
from cloud.automations import create_automation as _auto_create
from cloud.automations import ensure_automations_tables as _auto_ensure
from cloud.automations import list_automations as _auto_list
from cloud.automations import triage_inbox as _auto_triage
from cloud.approvals import create_approval as _appr_create
from cloud.approvals import decide_approval as _appr_decide
from cloud.approvals import ensure_approvals_table as _appr_ensure
from cloud.approvals import expire_overdue as _appr_expire
from cloud.approvals import get_approval as _appr_get
from cloud.approvals import list_approvals as _appr_list
from cloud.kb import KBStore
from cloud.memory import MemoryStore
from cloud.pr import PrSkipped, PrStepError
@@ -1480,8 +1491,27 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._create_peer_message(user_id, rest[: -len("/messages")])
elif rest.endswith("/messages") and method == "GET":
self._list_peer_messages(user_id, rest[: -len("/messages")])
elif rest.endswith("/reviews") and method == "POST":
self._create_review(user_id, rest[: -len("/reviews")])
elif rest.endswith("/reviews") and method == "GET":
self._list_reviews(user_id, rest[: -len("/reviews")])
elif "/reviews/" in rest and rest.endswith("/findings") \
and method == "POST":
_task_part, _rev_part = rest.split("/reviews/", 1)
self._update_review_findings(
user_id, _task_part, _rev_part.split("/")[0])
elif rest.endswith("/fork") and method == "POST":
self._fork_task(user_id, rest[: -len("/fork")])
elif rest.endswith("/approvals") and method == "GET":
self._list_approvals(user_id, rest[: -len("/approvals")])
elif rest.endswith("/approvals") and method == "POST":
self._create_approval(user_id, rest[: -len("/approvals")])
elif rest.endswith("/approve") and method == "POST":
self._decide_approval(
user_id, rest[: -len("/approve")], "approved")
elif rest.endswith("/deny") and method == "POST":
self._decide_approval(
user_id, rest[: -len("/deny")], "denied")
elif rest.endswith("/continue") and method == "POST":
self._continue_task(user_id, rest[: -len("/continue")])
else:
@@ -1974,6 +2004,46 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
subscription_id=parts[3])
self._send_json(200, {"deleted": bool(deleted)})
return
# /projects/{id}/automations
if len(parts) == 2 and parts[1] == "automations":
if method == "POST":
err = self._check_project(user_id, project_id, "create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._automation_create(user_id, project_id)
elif method == "GET":
err = self._check_project(user_id, project_id, "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._automation_list(project_id)
else:
self._send_json(404, {"error": "not found"})
return
# /projects/{id}/automations/{aid}/cancel
if len(parts) == 4 and parts[1] == "automations" \
and parts[3] == "cancel":
if method != "POST":
self._send_json(404, {"error": "not found"})
return
err = self._check_project(user_id, project_id, "create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._automation_cancel(user_id, project_id, parts[2])
return
# /projects/{id}/triage
if len(parts) == 2 and parts[1] == "triage":
if method != "GET":
self._send_json(404, {"error": "not found"})
return
err = self._check_project(user_id, project_id, "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._triage_inbox(project_id)
return
self._send_json(404, {"error": "not found"})
def _memory_create(self, user_id, project_id):
@@ -2998,6 +3068,127 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(200, {
"messages": [_followup_to_dict(r) for r in rows]})
def _create_review(self, user_id, task_id):
"""发起评审:对某任务登记一条评审队列记录(status
requested),不跑模型、不伪造 findings——执行方跑完
后经 /findings 写回。权限:项目 create_task;目标组合
非法时 400,表里不留脏行;记审计 review_requested。"""
from cloud.review import build_review_target
from cloud.review import create_review as _review_create
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
row = self._fetch_task(task_id)
if row is None:
self._send_json(404, {"error": "task not found"})
return
task = _task_to_dict(row)
err = self._check_project(user_id, task["project_id"],
"create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
target_type = body.get("target_type", "uncommittedChanges")
branch = body.get("branch")
sha = body.get("sha")
instructions = body.get("instructions")
for name, value in (("branch", branch), ("sha", sha),
("instructions", instructions)):
if value is not None and not isinstance(value, str):
self._send_json(400, {"error": "%s must be a string" % name})
return
try:
build_review_target(target_type, branch, sha, instructions)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
conn = self._db()
try:
record = _review_create(
conn, task_id, task["project_id"], target_type,
user_id, branch=branch, sha=sha,
instructions=instructions)
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"review_requested",
"%s -> %s" % (task_id, record["id"]))
self._send_json(201, record)
def _list_reviews(self, user_id, task_id):
"""评审队列查看:列出某任务的全部评审记录(含 findings,
未写回的 findings 为 null,如实展示不编造)。权限:
项目 read。"""
from cloud.review import list_reviews as _review_list
row = self._fetch_task(task_id)
if row is None:
self._send_json(404, {"error": "task not found"})
return
task = _task_to_dict(row)
err = self._check_project(user_id, task["project_id"], "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
conn = self._db()
try:
reviews = _review_list(conn, task_id)
finally:
conn.close()
self._send_json(200, {"reviews": reviews})
def _update_review_findings(self, user_id, task_id, review_id):
"""评审结果写回:执行方把 findings 记到指定记录上。
不传 status 时 findings 写回默认落 completed;显式传
failed 可标失败。权限:项目 create_task;记录不属于
该任务时 404;记审计 review_findings_updated。"""
from cloud.review import get_review as _review_get
from cloud.review import update_review as _review_update
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
row = self._fetch_task(task_id)
if row is None:
self._send_json(404, {"error": "task not found"})
return
task = _task_to_dict(row)
err = self._check_project(user_id, task["project_id"],
"create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
findings = body.get("findings")
status = body.get("status")
if findings is None and status is None:
self._send_json(400, {
"error": "findings or status required"})
return
if findings is not None and not isinstance(findings, str):
self._send_json(400, {"error": "findings must be a string"})
return
conn = self._db()
try:
record = _review_get(conn, review_id)
if record is None or record["task_id"] != task_id:
self._send_json(404, {"error": "review not found"})
return
try:
updated = _review_update(conn, review_id,
status=status,
findings=findings)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"review_findings_updated",
"%s -> %s" % (task_id, review_id))
self._send_json(200, updated)
def _fork_task(self, user_id, task_id):
"""会话分支:从已结束任务的 thread 分叉出新任务。
@@ -3180,6 +3371,136 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
store.audit(user_id, task["project_id"], "merge_task", task_id)
self._send_json(200, task)
# -- 运行时审批队列 ------------------------------------------------
def _list_approvals(self, user_id, task_id):
"""列出某任务的审批项(含已决定的,回执一并带出)。
权限:项目 read。列之前先扫荡过期:已到期的 pending
在这里变成 expired(effective decision 为 denied),
列表里看到的就是最终态,不会把已过期项继续显示为
待审批。下游异常不拦:抛给 _dispatch_safe 回 500,
绝不回一条伪造的空列表或成功。"""
row = self._fetch_task(task_id)
if row is None:
self._send_json(404, {"error": "task not found"})
return
task = _task_to_dict(row)
with _auth_store(self.server.db_path) as store:
if not store.check_permission(user_id, task["project_id"],
"read"):
self._send_json(403, {"error": "forbidden"})
return
conn = self._db()
try:
_appr_ensure(conn)
_appr_expire(conn)
items = _appr_list(conn, task_id=task_id)
finally:
conn.close()
self._send_json(200, {"approvals": items})
def _create_approval(self, user_id, task_id):
"""给某任务建一条待审批项(系统入口:未来 host hook 与
手动补录都走这里,实时 elicitation 本片未接,见
approvals 模块 docstring 的诚实边界)。
权限:项目 create_task(建审批项是写动作,与后续消息
同级)。kind 缺省 elicitation;summary 必填;
timeout_seconds 只接受 60 到 86400 秒整数,缺省 3600,
超出范围回 400。成功记审计 approval_created。"""
row = self._fetch_task(task_id)
if row is None:
self._send_json(404, {"error": "task not found"})
return
task = _task_to_dict(row)
with _auth_store(self.server.db_path) as store:
if not store.check_permission(user_id, task["project_id"],
"create_task"):
self._send_json(403, {"error": "forbidden"})
return
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
kind = body.get("kind", "elicitation")
summary = body.get("summary")
detail = body.get("detail")
if detail is not None and not isinstance(
detail, (dict, list, str)):
self._send_json(400, {"error": "detail must be an object"})
return
timeout = body.get("timeout_seconds", 3600)
if not isinstance(timeout, int) or isinstance(timeout, bool) \
or timeout < 60 or timeout > 86400:
self._send_json(
400, {"error": "timeout_seconds must be 60..86400"})
return
conn = self._db()
try:
try:
item = _appr_create(
conn, task_id, task["project_id"], kind=kind,
summary=summary, detail=detail,
requested_by=body.get("requested_by"),
timeout_seconds=timeout)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"approval_created",
"%s %s" % (item["id"], item["kind"]))
self._send_json(201, {"approval": item})
def _decide_approval(self, user_id, prefix, decision):
"""批准或拒绝一条审批项:prefix 形如
<task_id>/approvals/<approval_id>(路由从 /approve
或 /deny 后缀剥出来的剩余部分)。
权限:项目 create_task(决定是写动作)。审批项不属于
该任务时回 404,不跨任务决定。已终态(批准、拒绝或
过期)时回 409 并带回执,原决定绝不被覆盖。成功记
审计 approval_decided,回执里有 decided_by 与
decided_at。"""
parts = prefix.split("/approvals/")
if len(parts) != 2 or not parts[0] or not parts[1]:
self._send_json(404, {"error": "not found"})
return
task_id, approval_id = parts
row = self._fetch_task(task_id)
if row is None:
self._send_json(404, {"error": "task not found"})
return
task = _task_to_dict(row)
with _auth_store(self.server.db_path) as store:
if not store.check_permission(user_id, task["project_id"],
"create_task"):
self._send_json(403, {"error": "forbidden"})
return
conn = self._db()
try:
_appr_ensure(conn)
_appr_expire(conn)
existing = _appr_get(conn, approval_id)
if existing is None or existing["task_id"] != task_id:
self._send_json(404, {"error": "approval not found"})
return
status, item = _appr_decide(
conn, approval_id, decision, user_id)
finally:
conn.close()
if status == "already_decided":
self._send_json(409, {"error": "already decided",
"approval": item})
return
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"approval_decided",
"%s %s by %s" % (approval_id, decision, user_id))
self._send_json(200, {"approval": item})
def _continue_task(self, user_id, task_id):
"""旧任务接回来继续会话:终端任务(completed/failed/
interrupted/merge-failed)带一条新消息 + 全部选项重排队,
@@ -3566,8 +3887,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
def _workers_heartbeat(self, user_id):
"""worker 心跳:body 含 worker_id(必填,缺了 400)与可选
executor/capacity。心跳本身就是注册:worker_id 不存在时
自动注册,绝不 404。"""
executor/capacity/running。心跳本身就是注册:worker_id
不存在时自动注册,绝不 404。running 是 worker 上报的
当前占用槽数(启动中占位也算);未给时保留旧值。"""
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
@@ -3584,10 +3906,20 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
except (TypeError, ValueError):
self._send_json(400, {"error": "capacity must be an integer"})
return
running = body.get("running")
if running is not None:
try:
running = int(running)
except (TypeError, ValueError):
self._send_json(400, {"error": "running must be an integer"})
return
if running < 0:
self._send_json(400, {"error": "running must be >= 0"})
return
try:
worker = _registry_heartbeat(
self.server.db_path, worker_id,
executor=executor, capacity=capacity)
executor=executor, capacity=capacity, running=running)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
@@ -4534,6 +4866,106 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(200, result)
# -- webhooks(签名认证,不走 X-API-Key)---------------------------
# -- 自动化与分诊收件箱(P1 第 2 项)---------------------------
# 权限口径与其它项目级写操作同款:创建/取消用 create_task
# (不是 manage_project,因为自动化是项目成员的日常编排,
# 不是改项目归属/仓库这类 owner 专属操作;评审/审批等写
# 操作同样用 create_task);读用 read。状态变更记审计。
# 失败口径:校验失败 400;模块/数据库层意外失败时回 502
# 透传错误原文(截断 500 字),绝不回 201 假装建成功——
# 注意 _dispatch_safe 的兜底是 500,这里显式 502 优先于它。
def _automation_create(self, user_id, project_id):
"""创建自动化定义:只登记定义与到期状态,不跑模型、
不另起调度器(执行器后续走 claim 门,见 automations
模块文档)。body:name、prompt 必填;interval_seconds
可选(默认 86400,最小 60);repo_url 可选。"""
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
interval = body.get("interval_seconds", 86400)
if interval is None:
interval = 86400
conn = self._db()
try:
try:
auto = _auto_create(
conn, project_id, body.get("name"),
body.get("prompt"), user_id, interval,
body.get("repo_url"))
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
except Exception as exc:
self._send_json(502, {"error": str(exc)[:500]})
return
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, project_id, "automation_created",
auto["id"])
self._send_json(201, auto)
def _automation_list(self, project_id):
"""列出某项目全部自动化定义(含已取消),附到期计数。"""
conn = self._db()
try:
try:
autos = _auto_list(conn, project_id)
except Exception as exc:
self._send_json(502, {"error": str(exc)[:500]})
return
conn.commit()
finally:
conn.close()
self._send_json(200, {
"project_id": project_id,
"automations": autos,
"due_count": sum(1 for a in autos if a["due"]),
})
def _automation_cancel(self, user_id, project_id, automation_id):
"""取消自动化(软删除):行不存在或属于别的项目时一
律 404(不泄露跨项目存在性);已取消时 409。"""
conn = self._db()
try:
try:
ok, auto = _auto_cancel(conn, automation_id)
except Exception as exc:
self._send_json(502, {"error": str(exc)[:500]})
return
finally:
conn.close()
if auto is None or auto["project_id"] != project_id:
self._send_json(404, {"error": "automation not found"})
return
if not ok:
self._send_json(409, {
"error": "automation already cancelled"})
return
with _auth_store(self.server.db_path) as store:
store.audit(user_id, project_id, "automation_cancelled",
automation_id)
self._send_json(200, auto)
def _triage_inbox(self, project_id):
"""分诊收件箱(只读):到期自动化、失败/中断任务、
待处理后续消息、最近自动化产出。见 automations 模块
triage_inbox 的字段说明。"""
conn = self._db()
try:
try:
_auto_ensure(conn)
_ensure_followups_table(conn)
inbox = _auto_triage(conn, project_id)
except Exception as exc:
self._send_json(502, {"error": str(exc)[:500]})
return
conn.commit()
finally:
conn.close()
self._send_json(200, inbox)
def _handle_github_webhook(self):
"""GitHub webhook:验签 -> delivery 去重 -> 解析 -> 执行建任务。