Files
codex-app-server/cloud/server.py
T

1320 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""云端控制面 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 migrate_tasks_table(conn):
"""幂等迁移:给老库 tasks 表补新列,不丢数据。
新列:result_text、error_text、actual_model、started_at、
finished_at、child_pid。PRAGMA table_info 查现有列,缺哪个
补哪个;已迁移的库再跑一次是 no-op。worker 写执行结果、
server 读任务时都依赖这些列存在。
"""
cols = {row[1] for row in conn.execute(
"PRAGMA table_info(tasks)").fetchall()}
new_cols = {
"result_text": "TEXT",
"error_text": "TEXT",
"actual_model": "TEXT",
"started_at": "REAL",
"finished_at": "REAL",
"child_pid": "INTEGER",
}
for name, coltype in new_cols.items():
if name not in cols:
conn.execute(
"ALTER TABLE tasks ADD COLUMN %s %s" % (name, coltype))
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))"
)
migrate_tasks_table(conn)
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 _row_get(row, name):
"""sqlite Row 的容错取值:老库行缺列时返回 None 不炸。"""
try:
return row[name]
except (KeyError, IndexError, TypeError):
return None
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"],
"result_text": _row_get(row, "result_text"),
"error_text": _row_get(row, "error_text"),
"actual_model": _row_get(row, "actual_model"),
"started_at": _row_get(row, "started_at"),
"finished_at": _row_get(row, "finished_at"),
"child_pid": _row_get(row, "child_pid"),
}
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 == "/metrics" and method == "GET":
self._metrics(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})
def _metrics(self, user_id):
"""可观测性:任务计数、成功率、平均耗时、fallback、成本合计。
权限:任意项目成员即可读(不要求 owner),但只统计调用者
是成员的项目——不是全库数字,避免跨项目信息泄露。
成本真数与估算分开:cost_records 的 source 列区分。
"""
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 project_id, status, started_at, finished_at"
" FROM tasks"
).fetchall()
by_status = {}
durations = []
for row in rows:
if row["project_id"] not in member_ids:
continue
by_status[row["status"]] = by_status.get(row["status"], 0) + 1
if row["started_at"] and row["finished_at"]:
durations.append(row["finished_at"] - row["started_at"])
total = sum(by_status.values())
done = by_status.get("completed", 0) + by_status.get("merged", 0)
failed = by_status.get("failed", 0) + by_status.get("merge-failed", 0)
finished = done + failed
try:
cost_rows = conn.execute(
"SELECT project_id, source, total_tokens, usd_micros,"
" fallback_count FROM cost_records"
).fetchall()
except sqlite3.OperationalError:
cost_rows = []
actual_tokens = estimated_tokens = 0
actual_usd = estimated_usd = 0
fallback_total = 0
for row in cost_rows:
if row["project_id"] not in member_ids:
continue
if row["source"] == "actual":
actual_tokens += row["total_tokens"] or 0
actual_usd += row["usd_micros"] or 0
else:
estimated_tokens += row["total_tokens"] or 0
estimated_usd += row["usd_micros"] or 0
fallback_total += row["fallback_count"] or 0
finally:
conn.close()
self._send_json(200, {
"total_tasks": total,
"by_status": by_status,
"success_rate": (done / finished) if finished else None,
"avg_duration_seconds": (
sum(durations) / len(durations)) if durations else None,
"fallback_total": fallback_total,
"cost": {
"actual_tokens": actual_tokens,
"estimated_tokens": estimated_tokens,
"actual_usd_micros": actual_usd,
"estimated_usd_micros": estimated_usd,
},
})
# -- 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()