"""云端控制面 HTTP server(首版)。 零第三方依赖,只用 http.server + sqlite3 + 标准库。 分工与边界(以实际代码为准,不夸大): - 本 server 只做控制面:项目/任务的创建、查询、取消、心跳、 stale 任务回收,以及 GitHub/Slack webhook 的验签与解析。 - 本版不直接跑模型回合。任务状态机与 host.py 对齐: queued -> running -> completed/failed/interrupted/merged/merge-failed, 其中 completed/merged 由 worker/控制面后续调用方更新(例如 worker 调 host.py 跑完回合后回写),本 server 不提供“跑模型”端点。 - 其它已就绪模块(queue 的 order_tasks/ready_tasks、sandbox 的执行器、 memory 的 MemoryStore、kb 的 KBStore、models 的 ModelRouter、 cost 的 CostStore、webhooks 的 NotificationService)本版尚未接成 HTTP 端点,留给 worker 接线时调用;本 server 只接入其中两处: AuthStore(认证/权限/审计)与 webhooks 的验签/解析函数。 - DockerExecutor/CloudExecutor 目前仍是占位(见 sandbox.py), 本 server 不假装已接通容器或云端执行。 导入规则:只用绝对包导入(from cloud.auth import ...)。绝不要把 cloud/ 目录加进 sys.path:标准库已有 queue 模块,会和 cloud/queue.py 撞名,导致 stdlib queue 被错误模块覆盖。 状态库:单一 SQLite 文件。AuthStore 用 users/project_members/ audit_log 三张表;本 server 用 projects/tasks 两张表,表名不冲突。 并发:ThreadingHTTPServer 多线程处理请求,每个请求内新建 SQLite 连接(server 表用新连接,AuthStore 每次请求新建一个实例),绝不在 多线程 handler 间共用一个连接。 启动: python3 -m cloud.server [--host 127.0.0.1] [--port 8765] [--db <路径>] 后端由 CLOUD_DB_BACKEND 选(sqlite 默认;postgres 目前只是骨架, 驱动缺失时启动即明确报错,见 cloud/db.py)。 测试用 create_server(db_path, host, port) 只返回 ThreadingHTTPServer 实例,不自动 serve。 """ import argparse import json import os import sqlite3 import time import uuid from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse from cloud.auth import AuthStore from cloud.cost import CostStore from cloud.db import connect as _db_connect from cloud.db import create_database as _create_database from cloud.kb import KBStore from cloud.memory import MemoryStore from cloud.oauth import ( OAuthError, OAuthStateStore, handle_oauth_callback, map_external_identity_to_local_user, start_oauth_login, ) from cloud.push import ( create_subscription as _push_create, delete_subscription as _push_delete, dispatch_event as _push_dispatch, list_subscriptions as _push_list, ) from cloud.webhooks import ( parse_github_pr_comment, parse_slack_command, verify_github_signature, verify_slack_signature, ) TERMINAL_STATUSES = frozenset( {"completed", "failed", "interrupted", "merged", "merge-failed"} ) DEFAULT_SWEEP_THRESHOLD_SECONDS = 300 def _connect(db_path): """每个请求新建一个连接;走 cloud.db 的 WAL + busy_timeout。 单机 SQLite 的上限仍在:WAL + 有限重试只能缓解多线程写冲突, 高并发要换 Postgres(见 cloud/README.md)。 """ return _db_connect(str(db_path)) def init_db(db_path): """创建 server 自己的 projects/tasks 表(AuthStore 的表由其自建)。""" conn = _connect(db_path) try: conn.execute( "CREATE TABLE IF NOT EXISTS projects(" "id TEXT PRIMARY KEY, " "name TEXT, " "owner_id TEXT, " "created_at REAL)" ) conn.execute( "CREATE TABLE IF NOT EXISTS tasks(" "id TEXT PRIMARY KEY, " "project_id TEXT, " "prompt TEXT, " "status TEXT, " "idempotency_key TEXT, " "depends_on TEXT, " "priority INTEGER, " "created_by TEXT, " "created_at REAL, " "updated_at REAL, " "last_heartbeat REAL, " "UNIQUE(project_id, idempotency_key))" ) conn.commit() finally: conn.close() @contextmanager def _auth_store(db_path): """每请求一个 AuthStore 实例(自带新连接),用完即关闭。""" store = AuthStore(db_path) try: yield store finally: try: store.conn.close() except Exception: pass def _task_to_dict(row): depends_on = row["depends_on"] try: depends_on = json.loads(depends_on) if depends_on else [] except (TypeError, ValueError): depends_on = [] if not isinstance(depends_on, list): depends_on = [depends_on] return { "id": row["id"], "project_id": row["project_id"], "prompt": row["prompt"], "status": row["status"], "idempotency_key": row["idempotency_key"], "depends_on": depends_on, "priority": row["priority"], "created_by": row["created_by"], "created_at": row["created_at"], "updated_at": row["updated_at"], "last_heartbeat": row["last_heartbeat"], } def _project_to_dict(row): return { "id": row["id"], "name": row["name"], "owner_id": row["owner_id"], "created_at": row["created_at"], } def sweep_stale_tasks(db_path, threshold_seconds=300): """把 running 且心跳过期的任务改为 interrupted,返回被扫任务 id 列表。 心跳取 last_heartbeat,无心跳则用 updated_at;早于 now - threshold_seconds 即过期。审计记一条 "sweep_interrupted" (user_id 记 "system",因为回收不是某个用户的操作)。 独立可单测,不依赖 HTTP。 """ now = time.time() cutoff = now - threshold_seconds conn = _connect(db_path) try: conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT id, project_id FROM tasks WHERE status = 'running'" " AND COALESCE(last_heartbeat, updated_at) < ?", (cutoff,), ).fetchall() swept = [] for row in rows: cur = conn.execute( "UPDATE tasks SET status = 'interrupted', updated_at = ?" " WHERE id = ? AND status = 'running'", (now, row["id"]), ) if cur.rowcount: swept.append(row["id"]) conn.commit() stale = [(row["id"], row["project_id"]) for row in rows if row["id"] in swept] finally: conn.close() with _auth_store(db_path) as store: for task_id, project_id in stale: store.audit("system", project_id, "sweep_interrupted", task_id) return swept class CloudControlPlaneHandler(BaseHTTPRequestHandler): """控制面请求处理:除 /health 与 /webhooks/* 外一律要求 X-API-Key。""" protocol_version = "HTTP/1.1" # -- 通用工具 ------------------------------------------------------ def log_message(self, *args): # 测试输出保持干净 pass def _send_json(self, status, obj): body = json.dumps(obj).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _read_body(self): try: length = int(self.headers.get("Content-Length") or 0) except ValueError: length = 0 if length <= 0: return b"" return self.rfile.read(length) def _read_json(self): """返回 (obj, error):空 body 视为 {};非法 JSON 返回 error。""" raw = self._read_body() if not raw: return {}, None try: obj = json.loads(raw.decode("utf-8")) except (ValueError, UnicodeDecodeError): return None, "invalid json" if not isinstance(obj, dict): return None, "body must be a JSON object" return obj, None def _db(self): conn = _connect(self.server.db_path) conn.row_factory = sqlite3.Row return conn def _authenticate(self): """X-API-Key -> user_id;无 key 或 key 错返回 None。""" api_key = self.headers.get("X-API-Key") if not api_key: return None with _auth_store(self.server.db_path) as store: return store.authenticate(api_key) # -- 入口 ---------------------------------------------------------- def do_GET(self): self._dispatch("GET") def do_POST(self): self._dispatch("POST") def do_DELETE(self): self._dispatch("DELETE") def _dispatch(self, method): parsed = urlparse(self.path) path = parsed.path if len(path) > 1 and path.endswith("/"): path = path.rstrip("/") query = parse_qs(parsed.query) if path == "/health" and method == "GET": self._send_json(200, {"ok": True, "service": "cloud-control-plane"}) return if path.startswith("/oauth/"): # OAuth 登录/回调是 pre-auth 端点(用户还没本站 API key, # 这正是 OAuth 要解决的问题),靠 state + client_secret # 认证,不走 X-API-Key;不要把它当成无认证后门。 if method == "GET" and path.endswith("/login"): self._oauth_login(path) elif method == "POST" and path.endswith("/callback"): self._oauth_callback(path) else: self._send_json(404, {"error": "not found"}) return if path.startswith("/webhooks/"): # webhook 靠签名认证(外部服务无法持 X-API-Key),不走 API key。 if method != "POST": self._send_json(404, {"error": "not found"}) return if path == "/webhooks/github": self._handle_github_webhook() elif path == "/webhooks/slack": self._handle_slack_webhook() else: self._send_json(404, {"error": "not found"}) return user_id = self._authenticate() if user_id is None: self._send_json(401, {"error": "unauthorized"}) return if path == "/projects" and method == "POST": self._create_project(user_id) elif path == "/projects" and method == "GET": self._list_projects(user_id) elif path == "/models" and method == "GET": self._models_overview(user_id) elif path.startswith("/projects/"): self._dispatch_project(user_id, method, path, query) elif path == "/tasks" and method == "POST": self._create_task(user_id) elif path == "/tasks" and method == "GET": self._list_tasks(user_id, query) elif path == "/admin/sweep" and method == "POST": self._admin_sweep() elif path == "/admin/push/dispatch" and method == "POST": self._push_dispatch_endpoint(user_id) elif path.startswith("/tasks/"): rest = path[len("/tasks/"):] if "/" not in rest and method == "GET": self._get_task(user_id, rest) elif rest.endswith("/cost") and method == "GET": self._task_cost(user_id, rest[: -len("/cost")]) elif rest.endswith("/cancel") and method == "POST": self._cancel_task(user_id, rest[: -len("/cancel")]) elif rest.endswith("/heartbeat") and method == "POST": self._heartbeat_task(user_id, rest[: -len("/heartbeat")]) else: self._send_json(404, {"error": "not found"}) else: self._send_json(404, {"error": "not found"}) # -- 项目 ---------------------------------------------------------- def _create_project(self, user_id): body, err = self._read_json() if err: self._send_json(400, {"error": err}) return name = body.get("name") if not name or not isinstance(name, str): self._send_json(400, {"error": "name required"}) return project_id = body.get("id") or uuid.uuid4().hex if not isinstance(project_id, str): self._send_json(400, {"error": "id must be a string"}) return now = time.time() conn = self._db() try: existing = conn.execute( "SELECT id FROM projects WHERE id = ?", (project_id,) ).fetchone() if existing: self._send_json(409, {"error": "project exists"}) return conn.execute( "INSERT INTO projects(id, name, owner_id, created_at)" " VALUES(?, ?, ?, ?)", (project_id, name, user_id, now), ) conn.commit() project = { "id": project_id, "name": name, "owner_id": user_id, "created_at": now, } except sqlite3.IntegrityError: self._send_json(409, {"error": "project exists"}) return finally: conn.close() # AuthStore.create_project 只写成员表(owner 角色),项目记录 # 本身在上面的 projects 表;两者配合才算一个完整项目。 with _auth_store(self.server.db_path) as store: store.create_project(project_id, user_id) store.audit(user_id, project_id, "create_project", name) self._send_json(201, project) def _list_projects(self, user_id): conn = self._db() try: member_ids = { row["project_id"] for row in conn.execute( "SELECT project_id FROM project_members WHERE user_id = ?", (user_id,), ).fetchall() } rows = conn.execute( "SELECT id, name, owner_id, created_at FROM projects" " ORDER BY created_at, id" ).fetchall() finally: conn.close() projects = [_project_to_dict(r) for r in rows if r["id"] in member_ids] self._send_json(200, {"projects": projects}) # -- 项目子资源:记忆 / 知识库 / 成本 ------------------------------- def _project_exists(self, project_id): conn = self._db() try: row = conn.execute( "SELECT id FROM projects WHERE id = ?", (project_id,) ).fetchone() return row is not None finally: conn.close() def _check_project(self, user_id, project_id, action): """返回 None 表示通过;否则返回 (status, error) 先行响应。""" if not self._project_exists(project_id): return 404, "project not found" with _auth_store(self.server.db_path) as store: if not store.check_permission(user_id, project_id, action): return 403, "forbidden" return None def _dispatch_project(self, user_id, method, path, query): rest = path[len("/projects/"):] parts = rest.split("/") project_id = parts[0] if parts else "" if not project_id: self._send_json(404, {"error": "not found"}) return # /projects/{id}/memory if len(parts) == 2 and parts[1] == "memory": 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._memory_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._memory_list(project_id, query) else: self._send_json(404, {"error": "not found"}) return # /projects/{id}/memory/{mid}/supersede if len(parts) == 4 and parts[1] == "memory" and parts[3] == "supersede": 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._memory_supersede(user_id, project_id, parts[2]) return # /projects/{id}/kb/index if len(parts) == 3 and parts[1] == "kb" and parts[2] == "index": 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._kb_index(user_id, project_id) return # /projects/{id}/kb/search if len(parts) == 3 and parts[1] == "kb" and parts[2] == "search": 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._kb_search(project_id, query) return # /projects/{id}/cost if len(parts) == 2 and parts[1] == "cost": 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._project_cost(project_id, query) return # /projects/{id}/push/subscriptions if len(parts) == 3 and parts[1] == "push" and parts[2] == "subscriptions": 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._push_create_endpoint(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._send_json(200, { "subscriptions": _push_list( self.server.db_path, project_id, user_id=user_id)}) else: self._send_json(404, {"error": "not found"}) return # /projects/{id}/push/subscriptions/{sid} DELETE if len(parts) == 4 and parts[1] == "push" and parts[2] == "subscriptions": if method != "DELETE": 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 subs = _push_list(self.server.db_path, project_id) mine = [s for s in subs if s["id"] == parts[3]] if not mine: self._send_json(404, {"error": "subscription not found"}) return # 只能删自己的订阅,项目 owner 可删任意(owner 角色检查)。 with _auth_store(self.server.db_path) as auth: is_owner = auth.check_permission( user_id, project_id, "manage_project") if mine[0]["user_id"] != user_id and not is_owner: self._send_json(403, {"error": "forbidden"}) return deleted = _push_delete(self.server.db_path, subscription_id=parts[3]) self._send_json(200, {"deleted": bool(deleted)}) return self._send_json(404, {"error": "not found"}) def _memory_create(self, user_id, project_id): body, err = self._read_json() if err: self._send_json(400, {"error": err}) return goal = body.get("goal") conclusion = body.get("conclusion") if not goal or not isinstance(goal, str): self._send_json(400, {"error": "goal required"}) return if not conclusion or not isinstance(conclusion, str): self._send_json(400, {"error": "conclusion required"}) return store = MemoryStore(self.server.db_path) try: mid = store.add_summary( project_id, body.get("thread_id") or "", goal, conclusion, files_changed=body.get("files_changed") or [], open_issues=body.get("open_issues") or [], ) finally: try: store.conn.close() except Exception: pass with _auth_store(self.server.db_path) as auth: auth.audit(user_id, project_id, "create_memory", str(mid)) self._send_json(201, {"id": mid, "project_id": project_id}) def _memory_list(self, project_id, query): try: top_k = int((query.get("top_k") or ["5"])[0]) except (TypeError, ValueError): self._send_json(400, {"error": "top_k must be an integer"}) return store = MemoryStore(self.server.db_path) try: summaries = store.list_summaries(project_id) context = store.build_prompt_context(project_id, top_k=top_k) finally: try: store.conn.close() except Exception: pass self._send_json(200, { "project_id": project_id, "summaries": summaries[:top_k], "prompt_context": context, }) def _memory_supersede(self, user_id, project_id, mid_raw): try: mid = int(mid_raw) except (TypeError, ValueError): self._send_json(400, {"error": "id must be an integer"}) return conn = self._db() try: row = conn.execute( "SELECT project_id FROM summaries WHERE id = ?", (mid,) ).fetchone() finally: conn.close() if row is None or row["project_id"] != project_id: self._send_json(404, {"error": "summary not found"}) return store = MemoryStore(self.server.db_path) try: store.deprecate(mid) finally: try: store.conn.close() except Exception: pass with _auth_store(self.server.db_path) as auth: auth.audit(user_id, project_id, "supersede_memory", str(mid)) self._send_json(200, {"id": mid, "status": "deprecated"}) def _kb_index(self, user_id, project_id): body, err = self._read_json() if err: self._send_json(400, {"error": err}) return path = body.get("path") content = body.get("content") if not path or not isinstance(path, str): self._send_json(400, {"error": "path required"}) return if content is None or not isinstance(content, str): self._send_json(400, {"error": "content required"}) return store = KBStore(self.server.db_path) try: doc_id = store.index_file( project_id, path, content, kind=body.get("kind") or "file") except ValueError as e: self._send_json(400, {"error": str(e)}) return finally: try: store.conn.close() except Exception: pass with _auth_store(self.server.db_path) as auth: auth.audit(user_id, project_id, "kb_index", path) self._send_json(201, {"id": doc_id, "project_id": project_id, "path": path}) def _kb_search(self, project_id, query): q = (query.get("q") or [""])[0] if not q: self._send_json(400, {"error": "q required"}) return try: top_k = int((query.get("top_k") or ["5"])[0]) except (TypeError, ValueError): self._send_json(400, {"error": "top_k must be an integer"}) return store = KBStore(self.server.db_path) try: results = store.search(project_id, q, top_k=top_k) finally: try: store.conn.close() except Exception: pass self._send_json(200, {"project_id": project_id, "query": q, "results": results}) def _project_cost(self, project_id, query): store = CostStore(self.server.db_path) try: overall = store.report_by_project(project_id) conn = store._conn conn.row_factory = sqlite3.Row days = (query.get("days") or [None])[0] day = (query.get("day") or [None])[0] daily = [] if day: row = conn.execute( "SELECT day, COALESCE(SUM(total_tokens),0) AS t," " COALESCE(SUM(usd_micros),0) AS u, COUNT(*) AS c," " COALESCE(SUM(fallback_count),0) AS f" " FROM cost_records WHERE project_id = ? AND day = ?" " GROUP BY day", (project_id, day)).fetchone() if row: daily = [{"day": row["day"], "total_tokens": row["t"], "usd_micros": row["u"], "turns": row["c"], "fallback_count": row["f"]}] elif days is not None: try: n = int(days) except (TypeError, ValueError): self._send_json(400, {"error": "days must be an integer"}) return from datetime import date, timedelta cutoff = (date.today() - timedelta(days=max(n - 1, 0)) ).isoformat() rows = conn.execute( "SELECT day, COALESCE(SUM(total_tokens),0) AS t," " COALESCE(SUM(usd_micros),0) AS u, COUNT(*) AS c," " COALESCE(SUM(fallback_count),0) AS f" " FROM cost_records WHERE project_id = ? AND day >= ?" " GROUP BY day ORDER BY day", (project_id, cutoff)).fetchall() daily = [{"day": r["day"], "total_tokens": r["t"], "usd_micros": r["u"], "turns": r["c"], "fallback_count": r["f"]} for r in rows] finally: try: store._conn.close() except Exception: pass self._send_json(200, {"project_id": project_id, "overall": overall, "daily": daily}) # -- 任务成本与模型总览 ---------------------------------------------- def _task_cost(self, user_id, task_id): 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 auth: if not auth.check_permission(user_id, task["project_id"], "read"): self._send_json(403, {"error": "forbidden"}) return store = CostStore(self.server.db_path) try: report = store.report_by_task(task_id) finally: try: store._conn.close() except Exception: pass self._send_json(200, {"task_id": task_id, "project_id": task["project_id"], **report}) def _models_overview(self, user_id): # 优先级列表来自环境变量 MODELS_PRIORITY(逗号分隔),未设时 # 用默认列表;这只是控制面的默认配置,不是 provider 的承诺。 raw = os.environ.get("MODELS_PRIORITY", "") priority = [m.strip() for m in raw.split(",") if m.strip()] or [ "gpt-6.1-sol", "gpt-6-sol", "gpt-5.6-sol"] # 只统计调用者是成员的项目,避免跨项目泄露成本。 conn = self._db() try: member_ids = [r["project_id"] for r in conn.execute( "SELECT project_id FROM project_members WHERE user_id = ?", (user_id,)).fetchall()] finally: conn.close() if not member_ids: self._send_json(200, {"priority_list": priority, "model_stats": [], "fallback_events": []}) return placeholders = ",".join("?" for _ in member_ids) store = CostStore(self.server.db_path) try: conn = store._conn conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT model, COUNT(*) AS turns," " COALESCE(SUM(total_tokens),0) AS tokens," " COALESCE(SUM(fallback_count),0) AS fallbacks" " FROM cost_records WHERE project_id IN (%s)" " GROUP BY model ORDER BY model" % placeholders, member_ids).fetchall() fb_rows = conn.execute( "SELECT task_id, project_id, model, fallback_count" " FROM cost_records WHERE project_id IN (%s)" " AND fallback_count > 0" " ORDER BY created_at DESC LIMIT 50" % placeholders, member_ids).fetchall() finally: try: store._conn.close() except Exception: pass self._send_json(200, { "priority_list": priority, "model_stats": [ {"model": r["model"], "turns": r["turns"], "total_tokens": r["tokens"], "fallback_count": r["fallbacks"]} for r in rows], "fallback_events": [ {"task_id": r["task_id"], "project_id": r["project_id"], "model": r["model"], "fallback_count": r["fallback_count"]} for r in fb_rows], }) # -- 任务 ---------------------------------------------------------- def _create_task(self, user_id): body, err = self._read_json() if err: self._send_json(400, {"error": err}) return project_id = body.get("project_id") prompt = body.get("prompt") if not project_id or not isinstance(project_id, str): self._send_json(400, {"error": "project_id required"}) return if not prompt or not isinstance(prompt, str): self._send_json(400, {"error": "prompt required"}) return depends_on = body.get("depends_on") or [] if isinstance(depends_on, str): depends_on = [depends_on] if not isinstance(depends_on, list): self._send_json(400, {"error": "depends_on must be a list"}) return try: priority = int(body.get("priority", 0) or 0) except (TypeError, ValueError): self._send_json(400, {"error": "priority must be an integer"}) return idempotency_key = body.get("idempotency_key") if idempotency_key is not None and not isinstance(idempotency_key, str): self._send_json(400, {"error": "idempotency_key must be a string"}) return with _auth_store(self.server.db_path) as store: if not store.check_permission(user_id, project_id, "create_task"): self._send_json(403, {"error": "forbidden"}) return now = time.time() conn = self._db() try: if idempotency_key is not None: row = conn.execute( "SELECT * FROM tasks WHERE project_id = ?" " AND idempotency_key = ?", (project_id, idempotency_key), ).fetchone() if row: self._send_json(200, _task_to_dict(row)) return task_id = uuid.uuid4().hex try: conn.execute( "INSERT INTO tasks(id, project_id, prompt, status," " idempotency_key, depends_on, priority, created_by," " created_at, updated_at, last_heartbeat)" " VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL)", (task_id, project_id, prompt, idempotency_key, json.dumps(depends_on), priority, user_id, now, now), ) conn.commit() except sqlite3.IntegrityError: # 并发下同幂等键抢建:回读已有任务,不建重复。 row = conn.execute( "SELECT * FROM tasks WHERE project_id = ?" " AND idempotency_key = ?", (project_id, idempotency_key), ).fetchone() if row: self._send_json(200, _task_to_dict(row)) return raise row = conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,) ).fetchone() task = _task_to_dict(row) finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, project_id, "create_task", task_id) self._send_json(201, task) def _list_tasks(self, user_id, query): project_ids = query.get("project_id") project_id = project_ids[0] if project_ids else None if not project_id: self._send_json(400, {"error": "project_id required"}) return with _auth_store(self.server.db_path) as store: if not store.check_permission(user_id, project_id, "read"): self._send_json(403, {"error": "forbidden"}) return conn = self._db() try: rows = conn.execute( "SELECT * FROM tasks WHERE project_id = ?" " ORDER BY created_at, id", (project_id,), ).fetchall() finally: conn.close() self._send_json(200, {"tasks": [_task_to_dict(r) for r in rows]}) def _fetch_task(self, task_id): conn = self._db() try: return conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,) ).fetchone() finally: conn.close() def _get_task(self, user_id, task_id): 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 self._send_json(200, task) def _cancel_task(self, user_id, task_id): 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 if task["status"] in TERMINAL_STATUSES: self._send_json(400, {"error": "task already terminal"}) return now = time.time() conn = self._db() try: conn.execute( "UPDATE tasks SET status = 'interrupted', updated_at = ?" " WHERE id = ?", (now, task_id), ) conn.commit() task = _task_to_dict( conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,) ).fetchone() ) finally: conn.close() with _auth_store(self.server.db_path) as store: store.audit(user_id, task["project_id"], "cancel_task", task_id) self._send_json(200, task) def _heartbeat_task(self, user_id, task_id): 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 now = time.time() new_status = "running" if task["status"] == "queued" else task["status"] conn = self._db() try: conn.execute( "UPDATE tasks SET status = ?, last_heartbeat = ?," " updated_at = ? WHERE id = ?", (new_status, now, now, task_id), ) conn.commit() task = _task_to_dict( conn.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,) ).fetchone() ) finally: conn.close() self._send_json(200, task) # -- 管理 ---------------------------------------------------------- def _admin_sweep(self): body, err = self._read_json() if err: self._send_json(400, {"error": err}) return threshold = body.get( "threshold_seconds", DEFAULT_SWEEP_THRESHOLD_SECONDS) try: threshold = int(threshold) except (TypeError, ValueError): self._send_json(400, {"error": "threshold_seconds must be an integer"}) return swept = sweep_stale_tasks(self.server.db_path, threshold) self._send_json(200, {"swept": swept}) # -- OAuth(pre-auth,靠 state + secret,不走 X-API-Key)----------- def _oauth_provider(self, path): parts = [p for p in path.split("/") if p] if len(parts) != 3 or parts[0] != "oauth": return None return parts[1] def _oauth_login(self, path): provider = self._oauth_provider(path) if provider is None: self._send_json(404, {"error": "not found"}) return query = parse_qs(urlparse(self.path).query) client_id = (query.get("client_id") or [None])[0] redirect_uri = (query.get("redirect_uri") or [None])[0] scope = (query.get("scope") or [None])[0] if not client_id or not redirect_uri: self._send_json(400, {"error": "client_id and redirect_uri required"}) return store = OAuthStateStore(self.server.db_path) try: state, url = start_oauth_login( store, provider, client_id, redirect_uri, scope=scope) except OAuthError as e: self._send_json(400, {"error": str(e), "reason": e.reason}) return except ValueError as e: self._send_json(400, {"error": str(e)}) return finally: try: store.conn.close() except Exception: pass # 只返回 URL,不直接跳转到任何登录页;跳转由调用方决定。 self._send_json(200, {"provider": provider, "state": state, "authorization_url": url}) def _oauth_callback(self, path): provider = self._oauth_provider(path) if provider is None: self._send_json(404, {"error": "not found"}) return body, err = self._read_json() if err: self._send_json(400, {"error": err}) return code = body.get("code") state = body.get("state") client_id = body.get("client_id") redirect_uri = body.get("redirect_uri") if not code or not state or not client_id or not redirect_uri: self._send_json(400, {"error": "code, state, client_id and redirect_uri required"}) return store = OAuthStateStore(self.server.db_path) try: identity = handle_oauth_callback( store, provider, code, state, client_id, redirect_uri) except OAuthError as e: self._send_json(400, {"error": str(e), "reason": e.reason}) return finally: try: store.conn.close() except Exception: pass with _auth_store(self.server.db_path) as auth: local_id, created = map_external_identity_to_local_user( auth, provider, identity) auth.audit(local_id, "", "oauth_login", "%s:%s" % (provider, identity.get("external_id"))) self._send_json(200, {"user_id": local_id, "created": created, "provider": provider, "login": identity.get("login"), "email": identity.get("email")}) # -- 推送订阅与分发 --------------------------------------------------- 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 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 # 分发者须是该项目的成员(至少可读),否则不能往别人项目里推。 with _auth_store(self.server.db_path) as auth: if not auth.check_permission(user_id, project_id, "read"): self._send_json(403, {"error": "forbidden"}) return # HTTP 层无可注入 sender:webhook/slack 会报“无 sender”, # webpush/apns/fcm 会报缺凭证——都不静默成功。真实发送端 # 由 worker 注入 sender 后调 push.dispatch_event。 result = _push_dispatch( self.server.db_path, event_type, project_id, body.get("payload") or {}, sender=None) self._send_json(200, result) # -- webhooks(签名认证,不走 X-API-Key)--------------------------- def _handle_github_webhook(self): raw = self._read_body() secret = os.environ.get("GITHUB_WEBHOOK_SECRET") if not secret: self._send_json(503, {"error": "github webhook secret not configured"}) return signature = self.headers.get("X-Hub-Signature-256") if not verify_github_signature(secret, raw, signature): self._send_json(401, {"error": "unauthorized"}) return try: payload = json.loads(raw.decode("utf-8")) if raw else {} except (ValueError, UnicodeDecodeError): self._send_json(400, {"error": "invalid json"}) return if not isinstance(payload, dict): self._send_json(400, {"error": "body must be a JSON object"}) return # 只解析不执行:/fix-review 的真正执行由控制面后续调 host.py 完成。 result = parse_github_pr_comment(payload) if result is None: self._send_json(200, {"result": None}) else: self._send_json(200, result) def _handle_slack_webhook(self): raw = self._read_body() secret = os.environ.get("SLACK_SIGNING_SECRET") if not secret: self._send_json(503, {"error": "slack webhook secret not configured"}) return signature = self.headers.get("X-Slack-Signature") timestamp = self.headers.get("X-Slack-Request-Timestamp") if not verify_slack_signature(secret, timestamp, raw, signature): self._send_json(401, {"error": "unauthorized"}) return # 只解析不执行任务:Slack 斜杠命令的执行由控制面后续决定。 content_type = self.headers.get("Content-Type") or "" text = "" if "application/x-www-form-urlencoded" in content_type: parsed = parse_qs(raw.decode("utf-8", "replace")) values = parsed.get("text") text = values[0] if values else "" elif raw: try: payload = json.loads(raw.decode("utf-8")) if isinstance(payload, dict): text = payload.get("text", "") or "" else: text = raw.decode("utf-8", "replace") except (ValueError, UnicodeDecodeError): text = raw.decode("utf-8", "replace") self._send_json(200, parse_slack_command(text)) def create_server(db_path, host="127.0.0.1", port=8765, backend=None): """建 server 实例(不自动 serve,供测试与 __main__ 共用)。 backend 为 None 时读环境变量 CLOUD_DB_BACKEND,未设则默认 sqlite(旧行为不变)。选 postgres 时在建表之前就调用工厂: 驱动缺失则初始化即抛明确错误(文案含 psycopg),启动即失败, 绝不在运行时中途才炸、也绝不静默回落到 SQLite;Postgres 目前只是骨架,即使驱动已装也尚未接通,见 cloud/db.py 与 README。 """ db_path = str(db_path) # 后端校验必须在 init_db 之前:postgres 骨架不能先建 SQLite 表 # 再失败,那会假装 sqlite 已可用。 _create_database(backend=backend, db_path=db_path) init_db(db_path) # 确保 AuthStore 的三张表也存在,后续请求才能直接查 project_members。 with _auth_store(db_path): pass server = ThreadingHTTPServer((host, port), CloudControlPlaneHandler) server.db_path = db_path server.daemon_threads = True return server def main(argv=None): parser = argparse.ArgumentParser(description="cloud control plane") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8765) parser.add_argument("--db", default="cloud-control-plane.db") parser.add_argument("--db-backend", default=None, help="sqlite(默认)或 postgres;未给则读 " "CLOUD_DB_BACKEND 环境变量") args = parser.parse_args(argv) server = create_server(args.db, args.host, args.port, backend=args.db_backend) print( "cloud-control-plane listening on %s:%d db=%s" % (args.host, server.server_address[1], args.db), flush=True, ) try: server.serve_forever() except KeyboardInterrupt: pass if __name__ == "__main__": main()