- CloudControlPlaneHandler mixes in OAuthMixin, IdentityMemoryMixin, ProjectMixin, AutomationMixin, FullAutoMixin; shared helpers move to cloud/handler_support.py with server.py re-exports (old import paths keep working, incl. _validate_env_branch for tests). - Method bodies moved verbatim; routers, base plumbing, task/lifecycle, webhooks stay in server.py. No >100-line method body split. - test_handler_split_conformance.py (6 tests) pins resolution, layer bans and method accounting (238 total, 144 + 94, no duplicates). - test_p1_security_server.py: monkeypatch targets follow the defining module (handler_project MAX_PROJECTS_PER_USER, handler_identity relay helpers); assertions unchanged. (cherry picked from commit 92165a2b097126470e4827327d96d592a253adf1)
538 lines
23 KiB
Python
538 lines
23 KiB
Python
"""Handler mixin:AutomationMixin。
|
||
|
||
由 CloudControlPlaneHandler 拆出,方法体逐字搬移;
|
||
self.* 调用(含其它 mixin 的方法与留守的 _send_json / _db 等)
|
||
在混入后的同一类上解析,行为不变。本模块不 import cloud.server。
|
||
"""
|
||
|
||
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 get_automation as _auto_get
|
||
from cloud.automations import list_automations as _auto_list
|
||
from cloud.automations import list_automation_runs as _auto_runs
|
||
from cloud.automations import triage_inbox as _auto_triage
|
||
from cloud.automations import update_automation as _auto_update
|
||
from cloud.lifecycle import bind_automation as _life_bind
|
||
from cloud.lifecycle import get_binding as _life_get_binding
|
||
from cloud.lifecycle import unbind_automation as _life_unbind
|
||
from cloud.marker_tools import parse_first_run_at as _parse_first_run_at
|
||
from cloud.push import (
|
||
create_subscription as _push_create,
|
||
dispatch_event as _push_dispatch,
|
||
)
|
||
from cloud.todos import create_todo as _todo_create
|
||
from cloud.todos import delete_todo as _todo_delete
|
||
from cloud.todos import get_todo as _todo_get
|
||
from cloud.todos import list_todos as _todo_list
|
||
from cloud.todos import update_todo as _todo_update
|
||
from cloud.handler_support import (
|
||
_auth_store as _auth_store,
|
||
_ensure_followups_table as _ensure_followups_table,
|
||
_task_to_dict as _task_to_dict,
|
||
)
|
||
|
||
|
||
class AutomationMixin:
|
||
"""AutomationMixin 领域方法(见模块 docstring)。"""
|
||
|
||
# -- 推送订阅与分发 ---------------------------------------------------
|
||
def _push_create_endpoint(self, user_id, project_id):
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
try:
|
||
sub = _push_create(
|
||
self.server.db_path, project_id, user_id,
|
||
body.get("channel"), body.get("target"),
|
||
body.get("preference") or "instant")
|
||
except ValueError as e:
|
||
self._send_json(400, {"error": str(e)})
|
||
return
|
||
# 审计只记订阅 id 与通道:target 全文不记(Slack webhook
|
||
# URL 本身含 secret 路径,记进审计等于把 secret 落盘)。
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, project_id, "push_subscription_created",
|
||
"subscription_id=%s channel=%s"
|
||
% (sub["id"], sub["channel"]))
|
||
self._send_json(201, sub)
|
||
|
||
def _push_dispatch_endpoint(self, user_id):
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
event_type = body.get("event_type")
|
||
project_id = body.get("project_id")
|
||
if not event_type or not project_id:
|
||
self._send_json(400, {"error": "event_type and project_id required"})
|
||
return
|
||
# P0 安全批后:/admin/push/dispatch 是管理端点,只认
|
||
# CODEX_GLOBAL_ADMINS——该口径已强于 P1 要求的 create_task,
|
||
# 不再降级。被拒与成功都记审计;目标 SSRF 校验在
|
||
# push.dispatch_event / 真实发送层做。
|
||
if not self._is_global_admin(user_id):
|
||
self._audit_admin(user_id, "admin_push_denied",
|
||
"dispatch %s %s" % (event_type, project_id))
|
||
self._send_json(403, {"error": "forbidden"})
|
||
return
|
||
# 分发:sender=None 时用 push 的内置真实发送端
|
||
# (webhook/slack 真 POST,webpush/apns/fcm 仍报缺凭证);
|
||
# 显式注入的 sender 优先于内置端(见 push.dispatch_event)。
|
||
result = _push_dispatch(
|
||
self.server.db_path, event_type, project_id,
|
||
body.get("payload") or {}, sender=None)
|
||
self._audit_admin(user_id, "admin_push_dispatch",
|
||
"dispatch %s %s" % (event_type, project_id))
|
||
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 可选;
|
||
first_run_at 可选(epoch 秒或 ISO8601 字符串,与
|
||
marker 的 parse_first_run_at 同款解析,过去则建完
|
||
即到期);run_once 可选(true 时跑过一次后不再到期)。
|
||
|
||
P1 配额门:创建时先过与建任务同一道配额门(美元配额
|
||
与无价 token cap,超限 402),避免“定义免费建、到期
|
||
无限跑”;单自动化的 spend 上限即创建人配额本身——
|
||
每次到期产出的任务以 created_by=创建人建任务,走
|
||
POST /tasks 的配额门(触发路径若绕过服务端建任务,
|
||
则不在本门覆盖内,见残留)。本服务端没有自动化触发
|
||
端点,触发侧的门不在本次 server.py 改动内。"""
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
if self._reject_if_over_quota(user_id):
|
||
return
|
||
interval = body.get("interval_seconds", 86400)
|
||
if interval is None:
|
||
interval = 86400
|
||
first_run_at = body.get("first_run_at")
|
||
if first_run_at is not None:
|
||
first_run_at = _parse_first_run_at(first_run_at)
|
||
if first_run_at is None:
|
||
self._send_json(400, {"error": "invalid first_run_at"})
|
||
return
|
||
run_once = body.get("run_once", False)
|
||
if not isinstance(run_once, bool):
|
||
self._send_json(400, {"error": "run_once must be boolean"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
auto = _auto_create(
|
||
conn, project_id, body.get("name"),
|
||
body.get("prompt"), user_id, interval,
|
||
body.get("repo_url"),
|
||
first_run_at=first_run_at, run_once=run_once)
|
||
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"]),
|
||
})
|
||
|
||
# -- 项目待办(见 cloud/todos.py)-------------------------
|
||
def _todos_list(self, project_id, query):
|
||
"""列出某项目待办,附全项目各状态计数;query 支持
|
||
status 与 q(标题/描述子串)服务端过滤,计数与
|
||
total 不受过滤影响。"""
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
todos = _todo_list(
|
||
conn, project_id,
|
||
status=query.get("status", [None])[0] or None,
|
||
q=query.get("q", [None])[0] or None)
|
||
# counts / total 是全项目口径,不受过滤影响
|
||
#(与任务列表的 status_counts 同款语义)。
|
||
all_todos = _todo_list(conn, project_id)
|
||
except Exception as exc:
|
||
self._send_json(502, {"error": str(exc)[:500]})
|
||
return
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
counts = {}
|
||
for t in all_todos:
|
||
counts[t["status"]] = counts.get(t["status"], 0) + 1
|
||
self._send_json(200, {
|
||
"project_id": project_id, "todos": todos,
|
||
"counts": counts, "total": len(all_todos)})
|
||
|
||
def _todos_create(self, user_id, project_id):
|
||
"""新建待办:body title 必填;description / status /
|
||
priority / task_id / due_at / sort_order 可选。"""
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
todo = _todo_create(
|
||
conn, project_id, user_id, body.get("title"),
|
||
description=body.get("description"),
|
||
status=body.get("status"),
|
||
priority=body.get("priority"),
|
||
task_id=body.get("task_id"),
|
||
due_at=body.get("due_at"),
|
||
sort_order=body.get("sort_order"))
|
||
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, "todo_created", todo["id"])
|
||
self._send_json(201, todo)
|
||
|
||
def _todos_get(self, project_id, todo_id):
|
||
"""单条待办:行不存在或属于别的项目时一律 404。"""
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
todo = _todo_get(conn, todo_id)
|
||
except Exception as exc:
|
||
self._send_json(502, {"error": str(exc)[:500]})
|
||
return
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
if todo is None or todo["project_id"] != project_id:
|
||
self._send_json(404, {"error": "todo not found"})
|
||
return
|
||
self._send_json(200, todo)
|
||
|
||
def _todos_update(self, user_id, project_id, todo_id):
|
||
"""PATCH 待办:只改 body 里给出的字段;行不存在或
|
||
属于别的项目时一律 404;校验失败 400。"""
|
||
body, err = self._read_json()
|
||
if err:
|
||
self._send_json(400, {"error": err})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
todo = _todo_update(conn, todo_id, body or {})
|
||
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()
|
||
if todo is None or todo["project_id"] != project_id:
|
||
self._send_json(404, {"error": "todo not found"})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, project_id, "todo_updated", todo_id)
|
||
self._send_json(200, todo)
|
||
|
||
def _todos_delete(self, user_id, project_id, todo_id):
|
||
"""DELETE 待办(硬删除):行不存在或属于别的项目
|
||
时一律 404;已取消是软关闭,不走这条。"""
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
todo = _todo_delete(conn, todo_id)
|
||
except Exception as exc:
|
||
self._send_json(502, {"error": str(exc)[:500]})
|
||
return
|
||
finally:
|
||
conn.close()
|
||
if todo is None or todo["project_id"] != project_id:
|
||
self._send_json(404, {"error": "todo not found"})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, project_id, "todo_deleted", todo_id)
|
||
self._send_json(200, todo)
|
||
|
||
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 _automation_get(self, user_id, project_id, automation_id):
|
||
"""GET 单条自动化(轮询用):定义 + 最近产出记录。
|
||
行不存在或属于别的项目时一律 404;产出记录读失败
|
||
时不让整条定义也 502(runs 回空列表 + note)。"""
|
||
conn = self._db()
|
||
try:
|
||
auto = _auto_get(conn, automation_id)
|
||
runs = []
|
||
runs_note = ""
|
||
if auto is not None:
|
||
try:
|
||
runs = _auto_runs(conn, automation_id)
|
||
except Exception:
|
||
runs_note = "runs unavailable"
|
||
finally:
|
||
conn.close()
|
||
if auto is None or auto["project_id"] != project_id:
|
||
self._send_json(404, {"error": "automation not found"})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
binding = _life_get_binding(conn, automation_id)
|
||
finally:
|
||
conn.close()
|
||
self._send_json(200, dict(auto, runs=runs,
|
||
runs_note=runs_note,
|
||
heartbeat_binding=binding))
|
||
|
||
def _automation_bind_get(self, user_id, project_id, automation_id):
|
||
conn = self._db()
|
||
try:
|
||
auto = _auto_get(conn, automation_id)
|
||
binding = _life_get_binding(conn, automation_id)
|
||
finally:
|
||
conn.close()
|
||
if auto is None or auto["project_id"] != project_id:
|
||
self._send_json(404, {"error": "automation not found"})
|
||
return
|
||
self._send_json(200, {"automation_id": automation_id,
|
||
"binding": binding})
|
||
|
||
def _automation_bind(self, user_id, project_id, automation_id):
|
||
"""POST .../automations/{aid}/bind {task_id, mode}:
|
||
把自动化绑到已有线程。mode=heartbeat 表示到期唤醒
|
||
该线程(走 thread/message 投递路);mode=cron 表示
|
||
到期独立跑新任务(绑定只作标记,不唤醒)。目标任务
|
||
必须在同一项目,否则 404(不泄露跨项目存在性)。
|
||
权限 create_task;记 automation_bound 审计。"""
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
auto = _auto_get(conn, automation_id)
|
||
if auto is None or auto["project_id"] != project_id:
|
||
self._send_json(404, {"error": "automation not found"})
|
||
return
|
||
if auto["status"] != "active":
|
||
self._send_json(409, {
|
||
"error": "cancelled automation cannot be bound"})
|
||
return
|
||
target_id = body.get("task_id")
|
||
target = self._fetch_task(target_id) \
|
||
if isinstance(target_id, str) else None
|
||
if target is None:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
target = _task_to_dict(target)
|
||
if target["project_id"] != project_id:
|
||
self._send_json(404, {"error": "task not found"})
|
||
return
|
||
try:
|
||
binding = _life_bind(
|
||
conn, automation_id, project_id, target_id,
|
||
body.get("mode", "heartbeat"))
|
||
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, project_id, "automation_bound",
|
||
"%s:%s" % (automation_id, target_id))
|
||
self._send_json(200, {"automation_id": automation_id,
|
||
"binding": binding})
|
||
|
||
def _automation_unbind(self, user_id, project_id, automation_id):
|
||
conn = self._db()
|
||
try:
|
||
auto = _auto_get(conn, automation_id)
|
||
if auto is None or auto["project_id"] != project_id:
|
||
self._send_json(404, {"error": "automation not found"})
|
||
return
|
||
ok = _life_unbind(conn, automation_id)
|
||
finally:
|
||
conn.close()
|
||
if not ok:
|
||
self._send_json(404, {"error": "no binding"})
|
||
return
|
||
with _auth_store(self.server.db_path) as store:
|
||
store.audit(user_id, project_id, "automation_unbound",
|
||
automation_id)
|
||
self._send_json(200, {"automation_id": automation_id,
|
||
"deleted": True})
|
||
|
||
def _automation_heartbeat(self, user_id, project_id, automation_id):
|
||
"""POST .../automations/{aid}/heartbeat {message?}:
|
||
手动触发一次心跳唤醒——到期触发器(host run-due /
|
||
run_due_automation)调的就是这个口,服务端本身不
|
||
起定时器。无绑定 409;mode=cron 409(cron 不唤醒
|
||
线程);message 缺省用自动化 prompt。投递走
|
||
_deliver_to_thread,与 thread/message 同款复活路,
|
||
配额门同款。记 automation_heartbeat_sent 审计。"""
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
conn = self._db()
|
||
try:
|
||
auto = _auto_get(conn, automation_id)
|
||
binding = _life_get_binding(conn, automation_id)
|
||
finally:
|
||
conn.close()
|
||
if auto is None or auto["project_id"] != project_id:
|
||
self._send_json(404, {"error": "automation not found"})
|
||
return
|
||
if binding is None:
|
||
self._send_json(409, {
|
||
"error": "automation has no heartbeat binding"})
|
||
return
|
||
if binding["mode"] != "heartbeat":
|
||
self._send_json(409, {
|
||
"error": "cron binding does not wake a thread;"
|
||
" use the automation run path"})
|
||
return
|
||
target_row = self._fetch_task(binding["task_id"])
|
||
if target_row is None:
|
||
self._send_json(404, {"error": "bound task not found"})
|
||
return
|
||
target = _task_to_dict(target_row)
|
||
if target["project_id"] != project_id:
|
||
self._send_json(404, {"error": "bound task not found"})
|
||
return
|
||
if self._reject_if_over_quota_for_task(user_id, target):
|
||
return
|
||
message = body.get("message") or auto.get("prompt") or ""
|
||
if not isinstance(message, str) or not message.strip():
|
||
self._send_json(400, {"error": "message required"})
|
||
return
|
||
message = message.strip()
|
||
if len(message) > 20000:
|
||
self._send_json(400, {"error": "message too long"})
|
||
return
|
||
code, payload = self._deliver_to_thread(
|
||
user_id, target, message, "queued",
|
||
audit_action="automation_heartbeat_sent")
|
||
self._send_json(code, dict(payload,
|
||
automation_id=automation_id))
|
||
|
||
def _automation_update(self, user_id, project_id, automation_id):
|
||
"""PATCH 单条自动化(automation_update):改名、改
|
||
prompt、改间隔、改 repo_url,至少给一个字段。注意
|
||
语义边界:更新不重置 last_run_at(改间隔只影响下次
|
||
到期,不立即触发);已取消的定义 409(取消是终态,
|
||
改它必须重建);改名撞同项目 active 名时 400;本
|
||
端点不改“变化时通知”本身——到期产出的任务终态仍
|
||
走 worker 现有 push 派发(见 automations 模块文档)。
|
||
权限 create_task;记 automation_updated 审计。"""
|
||
body, jerr = self._read_json()
|
||
if jerr:
|
||
self._send_json(400, {"error": jerr})
|
||
return
|
||
if body.get("first_run_at") is not None and \
|
||
isinstance(body.get("first_run_at"), str):
|
||
parsed = _parse_first_run_at(body["first_run_at"])
|
||
if parsed is None:
|
||
self._send_json(400, {"error": "invalid first_run_at"})
|
||
return
|
||
body = dict(body, first_run_at=parsed)
|
||
conn = self._db()
|
||
try:
|
||
try:
|
||
ok, auto = _auto_update(conn, automation_id, body)
|
||
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()
|
||
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_updated",
|
||
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)
|