feat: P1 补齐(自动评审队列、自动化+分诊、并发 caps、运行时审批队列)
This commit is contained in:
1 parent
29efc6c61f
commit
e0cecd70aa
18 files changed
+3474
-27
No files matched your search
+435
-3
@@ -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 去重 -> 解析 -> 执行建任务。
|
||||
|
||||
|
||||
Reference in new issue
Block a user