1320 lines
52 KiB
Python
1320 lines
52 KiB
Python
"""云端控制面 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()
|