"""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)