Files
codex-app-server/cloud/server.py
T
host 7c46ac8e5b feat: 协调员任务(并行子任务、等齐、合流汇总)
建任务时传 coordinator_parts(分工列表,1-20 项,分工名唯一)即为协调员任务:本身不跑模型回合,worker 认领后一次建齐子任务(普通任务,并行被各 worker 认领),等全部终态后合流成汇总报告写回。子任务带 parent_task_id 与 coordinator_part,控制台可用 parent_task_id 查到;协调员重跑按分工名复用已完成子任务、失败/中断的回 queued 重跑;取消与超时尽力取消未终态子任务。子任务的配额/key/执行器门照常生效,不构成绕过。协调员任务拒绝与 goal/plan/验收命令同用。控制台:任务列表 [协调]/[协调子任务] 标记、详情分工与子任务区、新建表单子任务 JSON 输入。新单测 26 个。
2026-10-27 15:45:17 +08:00

4869 lines
209 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 已接通(见 sandbox.py),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 hashlib
import html
import json
import logging
import os
import re
import shutil
import socket
import sqlite3
import threading
import time
import uuid
from collections import deque
from contextlib import contextmanager
from decimal import Decimal
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, quote, urlparse
from cloud.auth import AuthStore
from cloud.coordinator import parse_parts as _coord_parse_parts
from cloud.coordinator import validate_parts as _coord_validate_parts
from cloud.cost import CostStore
from cloud.db import connect as _db_connect
from cloud.db import create_database as _create_database
from cloud.db import init_postgres_schema as _init_postgres_schema
from cloud.db import PostgresIntegrityError as _PgIntegrityError
from cloud.db import PostgresOperationalError as _PgOperationalError
from cloud.peer_messages import deliver_peer_message as _deliver_peer_message
from cloud.peer_messages import ensure_followups_table as _peer_ensure_followups
from cloud.peer_messages import peer_message_prefix as _peer_prefix
from cloud.peer_messages import row_get as _row_get
from cloud.kb import KBStore
from cloud.memory import MemoryStore
from cloud.pr import PrSkipped, PrStepError
from cloud.uploads import (
BUNDLE_TTL_SECONDS,
MAX_RAW_BYTES,
parse_multipart,
parse_paths_field,
save_bundle,
upload_root as _upload_root,
validate_bundle,
)
from cloud.pr_exec import (enqueue_pr_job, execute_task_pr,
find_open_pr_job)
from cloud.workers import (
heartbeat_worker as _registry_heartbeat,
list_workers as _registry_list,
)
from cloud.oauth import (
BindError,
OAuthError,
OAuthStateStore,
bind_external_identity,
build_authorization_url,
default_userinfo_client,
find_bound_user,
handle_oauth_callback,
list_bindings,
map_external_identity_to_local_user_with_key,
start_oauth_login,
unbind_external_identity,
)
from cloud.relay import RelayError as _RelayError
from cloud.relay import get_relay_models as _get_relay_models
from cloud.repos import validate_repo_url
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.github_app import (
DEFAULT_APP_NAME,
DEFAULT_APP_URL,
AppError,
SetupStateStore,
build_app_manifest,
claim_fix_review,
exchange_manifest_code,
get_app_config,
list_installations,
parse_installation_event,
public_app_status,
record_installation_repos,
remove_installation,
remove_installation_repos,
save_app_config,
set_fix_review_task_id,
)
from cloud.webhooks import (
WebhookDeliveryStore,
parse_github_check_run,
parse_github_pr_comment,
parse_slack_command,
verify_github_signature_any,
verify_slack_signature,
)
# PR 事件推送失败时记日志(systemd 下进 journal);handler 的
# log_message 被重写为静默(测试输出干净),所以不用它。
_server_log = logging.getLogger("cloud.server")
TERMINAL_STATUSES = frozenset(
{"completed", "failed", "interrupted", "merged", "merge-failed"}
)
# 实时链路(任务过程事件流):host 在回合进行中把 app-server
# 通知摘要成条目追加写 jsonl,服务端 tail 文件经 SSE 推送。
TRACE_STREAM_MAX_SECONDS = 3600
TRACE_STREAM_POLL_SECONDS = 1.0
TRACE_DEFAULT_DIR = "/opt/codex-app-server/work/host-traces"
def _trace_file_name(task_id):
"""云任务 id -> 链路文件名:与 cloud.sandbox 的 _host_name
同款清洗(只保留 [A-Za-z0-9_.-],其余替 -),两边算出同一名字。"""
return re.sub(r"[^A-Za-z0-9_.-]", "-", str(task_id))
def _trace_dir_for(server):
"""链路目录解析:server 属性(单测注入)> 环境变量 > 默认路径。"""
d = getattr(server, "trace_dir", None)
if d:
return str(d)
return os.environ.get("CODEX_TRACE_DIR") or TRACE_DEFAULT_DIR
TENANTS_DEFAULT_ROOT = "/var/lib/codex-app-server/tenants"
def _tenants_root_for(server):
"""租户根目录:server 属性(单测注入)> 环境变量 > 默认路径。"""
d = getattr(server, "tenants_root", None)
if d:
return str(d)
return os.environ.get("CODEX_TENANTS_ROOT") or TENANTS_DEFAULT_ROOT
def _tenant_trace_dir(server, project_id):
"""项目对应的租户链路目录(租户 Docker 任务的链路文件落在这里)。
目录名清洗与 worker._tenant_safe_name 同规则;项目 id 映射
不出安全名时返回 None(只读共享目录)。
"""
if not project_id:
return None
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(project_id)).strip(".")
if not safe or safe in (".", ".."):
return None
return os.path.join(_tenants_root_for(server), safe, "work",
"host-traces")
def _trace_dirs_for_task(server, task):
"""任务的链路目录列表:租户目录优先、共享目录兜底。
任务可能在不同执行器上跑过(重试换执行器),两个目录的
文件都读、按 id / (thread, id) 去重合并;通常只有一个目录
里有文件,行为与以前完全一致。
"""
dirs = []
tenant = _tenant_trace_dir(server, task.get("project_id"))
if tenant:
dirs.append(tenant)
shared = _trace_dir_for(server)
if shared not in dirs:
dirs.append(shared)
return dirs
def _read_trace_entries(path):
"""读 host 写的 jsonl 链路文件:跳过坏行;按 id 去重,后到的
完成态覆盖先到的开始态,保首次顺序。文件不存在返回空列表。"""
entries = []
index = {}
try:
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except ValueError:
continue
if not isinstance(entry, dict) or "id" not in entry:
continue
eid = entry["id"]
if eid in index:
entries[index[eid]] = entry
else:
index[eid] = len(entries)
entries.append(entry)
except OSError:
return []
return entries
def _read_trace_entries_multi(paths):
"""多文件合并读(flat trace 用):按 id 去重,后到的覆盖先到的,
保首次顺序;文件不存在视为空。"""
entries = []
index = {}
for path in paths:
for e in _read_trace_entries(path):
if e["id"] in index:
entries[index[e["id"]]] = e
else:
index[e["id"]] = len(entries)
entries.append(e)
return entries
AGENT_CALL_LIMIT = 1000
def _read_agent_entries(path):
"""读链路文件供子代理聚合:按 (thread, id) 去重,后到的完成态
覆盖先到的开始态,保首次顺序;坏行与无 id 行跳过;文件不
存在为空。同一 item id 在不同线程出现时不互相覆盖(模型内
子代理与主任务各有自己的线程)。"""
entries = []
index = {}
try:
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except ValueError:
continue
if not isinstance(entry, dict) or "id" not in entry:
continue
key = (entry.get("thread") or "", entry["id"])
if key in index:
entries[index[key]] = entry
else:
index[key] = len(entries)
entries.append(entry)
except OSError:
return []
return entries
def _read_agent_entries_multi(paths):
"""多文件合并读(agents 聚合用):按 (thread, id) 去重,后到的
覆盖先到的,保首次顺序;文件不存在视为空。"""
entries = []
index = {}
for path in paths:
for e in _read_agent_entries(path):
key = (e.get("thread") or "", e["id"])
if key in index:
entries[index[key]] = e
else:
index[key] = len(entries)
entries.append(e)
return entries
def _latest_ts(values):
vals = [v for v in values if isinstance(v, (int, float))]
return max(vals) if vals else None
vals = [v for v in values if isinstance(v, (int, float))]
return max(vals) if vals else None
def _agent_status_from_task(status):
return {"queued": "running", "running": "running",
"completed": "completed", "merged": "completed",
"failed": "failed", "merge-failed": "failed",
"interrupted": "interrupted"}.get(status, "unknown")
def _build_agent_chain(task, trace_dirs):
"""任务 → 子代理链:主任务 + 模型内子代理(同文件多线程)+
委派子任务(父链路里的 agent-start/done 标记 + 子链路文件)。
trace_dirs 是 _trace_dirs_for_task 给的目录列表(租户优先、
共享兜底);父链路与子链路文件都在这些目录里找、合并读。
状态诚实口径:委派子任务以 agent-done 标记为准,无标记即
进行中——委派进程与父任务相互独立,父任务结束不代表子任务
停;模型内子代理无明确结束信号,父任务运行中即进行中,
否则未知。无 thread 的旧条目一律归主任务。
"""
parent_paths = [os.path.join(d, _trace_file_name(task["id"]) + ".jsonl")
for d in trace_dirs]
entries = _read_agent_entries_multi(parent_paths)
calls = [e for e in entries
if e.get("kind") not in ("agent-start", "agent-done")]
main_thread = None
for e in calls:
if e.get("thread"):
main_thread = e["thread"]
break
root_calls = [e for e in calls
if not e.get("thread") or e.get("thread") == main_thread]
agents = [{
"id": "main", "name": "主任务", "kind": "main", "parent": None,
"status": _agent_status_from_task(task.get("status")),
"prompt": None, "trace": None,
"calls": root_calls[:AGENT_CALL_LIMIT],
"updated_ts": _latest_ts([e.get("ts") for e in root_calls]),
}]
task_running = task.get("status") in ("queued", "running")
threads = {}
for e in calls:
t = e.get("thread")
if t and t != main_thread:
threads.setdefault(t, []).append(e)
for t, es in threads.items():
agents.append({
"id": t, "name": "模型子代理 " + t[:12], "kind": "model",
"parent": "main",
"status": "running" if task_running else "unknown",
"prompt": None, "trace": None,
"calls": es[:AGENT_CALL_LIMIT],
"updated_ts": _latest_ts([e.get("ts") for e in es]),
})
starts = {}
dones = {}
for e in entries:
if e.get("kind") == "agent-start" and e.get("name"):
starts[e["name"]] = e
elif e.get("kind") == "agent-done" and e.get("name"):
dones[e["name"]] = e
for name, s in starts.items():
trace_name = s.get("trace") or dones.get(name, {}).get("trace")
child_calls = []
if trace_name:
child_paths = [os.path.join(d, os.path.basename(trace_name))
for d in trace_dirs]
child_calls = [e for e in _read_agent_entries_multi(child_paths)
if e.get("kind") not in
("agent-start", "agent-done")]
done = dones.get(name)
status = "running"
if done and done.get("status") in (
"completed", "failed", "interrupted"):
status = done["status"]
agents.append({
"id": "delegate:" + name, "name": name, "kind": "delegate",
"parent": "main", "status": status,
"prompt": s.get("prompt"), "trace": trace_name,
"calls": child_calls[:AGENT_CALL_LIMIT],
"updated_ts": _latest_ts(
[e.get("ts") for e in child_calls] +
[s.get("ts"), done.get("ts") if done else None]),
})
return agents
DEFAULT_SWEEP_THRESHOLD_SECONDS = 300
# 限流默认值:每 IP / 每 API key 每分钟 120 次;环境变量
# CLOUD_RATE_LIMIT_PER_MINUTE / CLOUD_RATE_LIMIT_WINDOW_SECONDS 可调。
# 限流默认内存滑动窗口;Postgres 下改走 DB 共享计数(多 server
# 共用,见 DbRateLimiter),并发下可能多放行少量请求(非原子计数)。
DEFAULT_RATE_LIMIT_PER_MINUTE = 120
DEFAULT_RATE_LIMIT_WINDOW_SECONDS = 60
TASK_STATUS_KEYS = (
"queued", "running", "completed", "failed",
"interrupted", "merged", "merge-failed",
)
def _json_default(obj):
"""json 兜底:Postgres 的 SUM(bigint) 返回 NUMERIC,psycopg2
映成 Decimal,不处理它在 _send_json 里直接炸 TypeError、请求
线程死掉、客户端只看到连接断开(2026-10-26 项目成本按天实弹
就是这么断的)。整数值 Decimal 转 int,非整数转 float;其它
类型照旧抛 TypeError,不静默吞掉真正的类型错误。"""
if isinstance(obj, Decimal):
return int(obj) if obj == obj.to_integral_value() else float(obj)
raise TypeError("Object of type %s is not JSON serializable"
% obj.__class__.__name__)
def _rate_limit_from_env(name, default):
try:
value = int(os.environ.get(name) or default)
except (TypeError, ValueError):
return default
return value if value > 0 else default
class RateLimiter:
"""内存滑动窗口限流:每 key 每窗口最多 per_minute 次。
只在 allow() 内部持一把短锁做 deque 修剪与计数,不持锁做
任何 I/O,不会把正常请求串行化。key 由调用方给(本 server
传“key:<API key 的 sha256>”或“ip:<客户端 IP>”,原始 API key
绝不存进限流结构里)。窗口与阈值可在实例上改(测试注入小
阈值验证 429 就靠改这两个属性)。
"""
def __init__(self, per_minute=DEFAULT_RATE_LIMIT_PER_MINUTE,
window_seconds=DEFAULT_RATE_LIMIT_WINDOW_SECONDS,
time_fn=None):
self.per_minute = per_minute
self.window_seconds = window_seconds
self._time_fn = time_fn or time.time
self._lock = threading.Lock()
self._hits = {}
def allow(self, key):
now = self._time_fn()
window = self.window_seconds
limit = self.per_minute
with self._lock:
queue = self._hits.get(key)
if queue is None:
queue = deque()
self._hits[key] = queue
cutoff = now - window
while queue and queue[0] <= cutoff:
queue.popleft()
if len(queue) >= limit:
return False
queue.append(now)
# 键数兜底:长期运行的 server 不应让 _hits 无限增长;
# 超过 10000 个 key 时清掉最久未用的空队列(不影响计数)。
if len(self._hits) > 10000:
for old_key in list(self._hits):
if not self._hits[old_key]:
del self._hits[old_key]
break
return True
class DbRateLimiter:
"""DB 共享滑动窗口限流(HA 阶段 2,多 server 共用计数)。
与 RateLimiter 同接口(allow(key),per_minute/window_seconds
属性可改,测试注入小阈值验证 429 的路子不变)。Postgres 下多
server 共享同一张表,计数跨进程;SQLite 下与内存版等价(单测用)。
并发下计数与写入非原子,可能多放行少量请求——限流是 abuse
缓解,不是精确配额。表与索引懒建(workers 表同款模式),
不进 POSTGRES_SCHEMA。列名用 hit_key(key 在部分方言里是
保留字,避开它)。
结构要点(2026-10-27 死锁修复):allow() 只做 INSERT + 窗口内
COUNT,不再 DELETE——旧实现每请求在同一事务里 DELETE 旧行 +
INSERT + COUNT,控制台并发加载 + 双 server 下两个事务在
rate_limit_hits 表上死锁,未捕获的异常直接断连、nginx 报
502。过期行改由后台 sweep_rate_limit_hits() 定期清(见下),
请求路径上只剩 INSERT + COUNT,不存在 DELETE↔DELETE/INSERT
的死锁形状;COUNT 本身按窗口过滤,过期行残留不影响计数
正确性,只影响表大小。
"""
def __init__(self, db_path, per_minute=DEFAULT_RATE_LIMIT_PER_MINUTE,
window_seconds=DEFAULT_RATE_LIMIT_WINDOW_SECONDS,
time_fn=None):
self.db_path = str(db_path)
self.per_minute = per_minute
self.window_seconds = window_seconds
self._time_fn = time_fn or time.time
self._ensure_lock = threading.Lock()
self._ensured = False
def _ensure_table(self, conn):
"""建表与索引:每实例只做一次(首个 allow 时),不是每请求
都跑 DDL——DDL 也会拿锁,没必要让每个请求都经过它。"""
if self._ensured:
return
with self._ensure_lock:
if self._ensured:
return
conn.execute(
"CREATE TABLE IF NOT EXISTS rate_limit_hits("
"hit_key TEXT, ts REAL)")
conn.execute(
"CREATE INDEX IF NOT EXISTS rate_limit_hits_key_ts"
" ON rate_limit_hits(hit_key, ts)")
conn.commit()
self._ensured = True
def allow(self, key):
now = self._time_fn()
window = self.window_seconds
limit = self.per_minute
conn = _db_connect(self.db_path)
try:
self._ensure_table(conn)
conn.execute(
"INSERT INTO rate_limit_hits(hit_key, ts) VALUES(?, ?)",
(key, now))
row = conn.execute(
"SELECT COUNT(*) FROM rate_limit_hits"
" WHERE hit_key = ? AND ts > ?",
(key, now - window)).fetchone()
conn.commit()
return row[0] <= limit
finally:
conn.close()
def _make_rate_limiter(db_path):
"""建限流器:Postgres 下用 DB 共享计数(多 server 共用),
否则用内存版(单机与单测)。后端只看环境变量,与其它分发一致。"""
per_minute = _rate_limit_from_env(
"CLOUD_RATE_LIMIT_PER_MINUTE", DEFAULT_RATE_LIMIT_PER_MINUTE)
window_seconds = _rate_limit_from_env(
"CLOUD_RATE_LIMIT_WINDOW_SECONDS",
DEFAULT_RATE_LIMIT_WINDOW_SECONDS)
if _db_backend_name() == "postgres":
return DbRateLimiter(db_path, per_minute=per_minute,
window_seconds=window_seconds)
return RateLimiter(per_minute=per_minute,
window_seconds=window_seconds)
RATE_LIMIT_SWEEP_INTERVAL_SECONDS = 60
# Postgres advisory lock key:两个 server 各起一个 sweeper 时,只
# 有拿到锁的那个真正 DELETE,另一个直接跳过——清理者之间也不
# 会互相死锁。本项目其它地方不用 advisory lock,不会撞 key。
RATE_LIMIT_SWEEP_ADVISORY_KEY = 20261027
def sweep_rate_limit_hits(db_path, window_seconds, now=None):
"""删过期 hit 行:只由后台定期调用,不在请求路径上。
返回 True 表示本次真正执行了删除(表为空时也算),False
表示 Postgres 下没拿到 advisory 锁、另一台 server 正在清,
本次跳过。清理是纯卫生(防表无限增长):allow() 的 COUNT
本身按窗口过滤,过期行残留不影响计数,所以清理失败或
跳过都不影响限流正确性,只影响表大小。
"""
now = time.time() if now is None else now
conn = _db_connect(db_path)
try:
conn.execute(
"CREATE TABLE IF NOT EXISTS rate_limit_hits("
"hit_key TEXT, ts REAL)")
conn.execute(
"CREATE INDEX IF NOT EXISTS rate_limit_hits_key_ts"
" ON rate_limit_hits(hit_key, ts)")
if _db_backend_name() == "postgres":
row = conn.execute(
"SELECT pg_try_advisory_xact_lock(?)",
(RATE_LIMIT_SWEEP_ADVISORY_KEY,)).fetchone()
if not row or not row[0]:
return False
conn.execute(
"DELETE FROM rate_limit_hits WHERE ts <= ?",
(now - window_seconds,))
conn.commit()
return True
finally:
conn.close()
def start_rate_limit_sweeper(limiter, db_path):
"""给 server 主进程起后台清理线程:每 60 秒清一次过期行。
只对 DbRateLimiter 起(内存版在 allow() 里自行修剪,不需要)。
线程 daemon、异常自愈(记 warning 后继续下一轮),绝不因
清理失败拖垮 server。调用方只在 main() 里调一次,测试不调。
"""
if not isinstance(limiter, DbRateLimiter):
return None
def _loop():
while True:
time.sleep(RATE_LIMIT_SWEEP_INTERVAL_SECONDS)
try:
sweep_rate_limit_hits(db_path, limiter.window_seconds)
except Exception as exc: # noqa: BLE001 -- 清理是卫生操作
_server_log.warning("rate limit sweep failed: %s", exc)
thread = threading.Thread(target=_loop, daemon=True,
name="rate-limit-sweeper")
thread.start()
return thread
def _db_backend_name():
"""当前数据库后端名:环境变量,未设则 sqlite;绝不返回连接串。"""
return (os.environ.get("CLOUD_DB_BACKEND") or "sqlite").strip().lower()
def _deployed_commit():
"""当前上线代码的 commit:deploy.py 部署时在包根写的 DEPLOYED_COMMIT。
文件不存在、读失败、内容为空时返回 None(老包、本地开发都走这支),
health 绝不因此失败。commit 是公开的版本标识,不是密钥。
路径取 cloud/ 的父目录(即仓库根),不依赖当前工作目录。"""
path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"DEPLOYED_COMMIT")
try:
with open(path, encoding="utf-8") as f:
value = f.read().strip()
except Exception:
return None
return value or None
def _worker_heartbeat_status(server=None):
"""读 worker 运维心跳,返回 (age_seconds_or_None, worker_ok)。
优先读 workers 注册表(多机前提):所有 worker 每轮都往
workers 表写 last_seen(见 worker._registry_heartbeat),而文件
心跳只在本机存在——多 server 下 server 读不到别机 worker 的
文件,注册表是唯一跨机可靠的心跳源。表不存在、无行、查库
失败时回落文件心跳(单机旧行为与单测保持兼容)。
文件心跳路径:测试注入优先(server.worker_heartbeat_file),
其次环境变量 CLOUD_WORKER_HEARTBEAT_FILE,未设则默认
./worker-heartbeat.json——与 worker 的默认路径保持一致,主机
部署时两边都要指到同一个主机路径(见 deploy/README)。
阈值:server.worker_heartbeat_max_age_seconds(测试注入)或
环境变量 CLOUD_WORKER_HEARTBEAT_MAX_AGE_SECONDS,默认 180 秒。
文件不存在、读失败、JSON 非法、last_seen 非数字时一律返回
(None, False),不抛异常、不影响 health 本身的 200。
"""
max_age = 180
if server is not None:
override = getattr(server, "worker_heartbeat_max_age_seconds", None)
if override is not None:
try:
max_age = float(override)
except (TypeError, ValueError):
max_age = 180
if max_age == 180:
try:
max_age = float(os.environ.get(
"CLOUD_WORKER_HEARTBEAT_MAX_AGE_SECONDS") or 180)
except (TypeError, ValueError):
max_age = 180
db_path = getattr(server, "db_path", None) if server is not None else None
if db_path:
try:
conn = _connect(db_path)
try:
row = conn.execute(
"SELECT MAX(last_seen) FROM workers").fetchone()
finally:
conn.close()
if row is not None and row[0] is not None:
age = time.time() - float(row[0])
return round(age, 1), bool(age <= max_age)
except Exception:
pass
path = None
if server is not None:
path = getattr(server, "worker_heartbeat_file", None)
if not path:
path = os.environ.get("CLOUD_WORKER_HEARTBEAT_FILE") or \
"./worker-heartbeat.json"
try:
with open(path, encoding="utf-8") as f:
payload = json.load(f)
last_seen = float(payload.get("last_seen"))
except Exception:
return None, False
age = time.time() - last_seen
return round(age, 1), bool(age <= max_age)
def _connect(db_path):
"""每个请求新建一个连接;后端按 CLOUD_DB_BACKEND 分发。
cloud.db.connect() 本身已按环境变量分发:sqlite 时是
WAL + busy_timeout 的 SQLite 连接;postgres 时是 psycopg
适配连接(db_path 被忽略,连接参数只走 CLOUD_DB_*
环境变量,驱动缺失时明确报错,绝不回落)。因此 server
自身的表与 AuthStore / MemoryStore 等沿用 connect() 的
调用点在 PG 模式下同样走 Postgres,无需逐一改写。
"""
return _db_connect(str(db_path))
def migrate_tasks_table(conn):
"""幂等迁移:给老库 tasks 表补新列,不丢数据。
新列:result_text、error_text、actual_model、started_at、
finished_at、child_pid、executor、pr_url、pr_number,以及
goal、verify_command、goal_max_rounds(goal 模式用)、
goal_mode(新 goal 语义:1 时 prompt 即目标,无验收命令、
不限轮次;老行 NULL 按 0 计)。
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",
"executor": "TEXT",
"pr_url": "TEXT",
"pr_number": "INTEGER",
"goal": "TEXT",
"verify_command": "TEXT",
"goal_max_rounds": "INTEGER",
"goal_mode": "INTEGER",
# HA 阶段 1:认领任务的 worker 所在主机名;/pr 据此判断
# worktree 在本机(同步跑)还是别机(排 pr 任务给该主机)。
"worktree_host": "TEXT",
# 任务级选项(console 选模型/强度/权限用):NULL = 跟随
# worker 配置默认值;model 不做白名单(中转目录会变,
# 下拉只是建议值),effort/approval 由 _create_task 校验。
"model": "TEXT",
"effort": "TEXT",
"approval": "TEXT",
# 计划模式(只读)与分支来源:plan=1 时 worker 用
# read-only 沙箱;forked_from 记源任务 id(非分支为 NULL)。
"plan": "INTEGER",
"forked_from": "TEXT",
# 环境:任务绑定的环境 id(NULL = 不指定环境,走原有
# 项目仓库路径);环境本身是项目仓库的一个命名基准检出
# (分支 + 执行器),任务 worktree 从它建,不改任务隔离。
"environment_id": "TEXT",
# 续跑消息:旧任务继续会话时本轮要说的话(NULL = 没有待
# 续跑消息)。worker 认领后把 prompt 换成它跑一轮,跑完
# 清掉;任务详情里照常显示原 prompt,续跑消息只作本轮输入。
"resume_message": "TEXT",
# 任务列表显示 turn 用:最近一轮的 turn_id 与
# root_turn_id(worker 写回时只在新值非空时覆盖)。
"turn_id": "TEXT",
"root_turn_id": "TEXT",
# 协调员:coordinator_parts 记分工 JSON(普通任务为 NULL,
# worker 认领后按它建并行子任务、等齐、合流);parent_task_id
# 与 coordinator_part 记在子任务行上(父任务 id 与分工名,
# 续跑按分工名复用已有子任务,不重复建)。
"coordinator_parts": "TEXT",
"parent_task_id": "TEXT",
"coordinator_part": "TEXT",
}
for name, coltype in new_cols.items():
if name not in cols:
conn.execute(
"ALTER TABLE tasks ADD COLUMN %s %s" % (name, coltype))
def migrate_projects_table(conn):
"""幂等迁移:给老库 projects 表补 repo_url 列,不丢数据。
与 migrate_tasks_table 同款写法:PRAGMA table_info 查现有列
(Postgres 适配层把它翻译成 information_schema 查询,调用方
取 row[1] 即列名),缺 repo_url 时 ALTER TABLE 补列,已迁移
的库再跑一次是 no-op。SQLite 与 Postgres 两支都走它。
"""
cols = {row[1] for row in conn.execute(
"PRAGMA table_info(projects)").fetchall()}
if "repo_url" not in cols:
conn.execute("ALTER TABLE projects ADD COLUMN repo_url TEXT")
if "auto_pr" not in cols:
conn.execute("ALTER TABLE projects ADD COLUMN auto_pr INTEGER")
def _upload_dir_for(server):
"""附件暂存根目录:server 属性(单测注入)> 环境变量 > 默认路径
(与 cloud.uploads.upload_root 同款解析,worker 侧也用它)。"""
d = getattr(server, "upload_dir", None)
if d:
return str(d)
return _upload_root()
def _ensure_uploads_tables(conn):
"""幂等建附件包与任务附件表:server init_db 调它,老库第一次
跑到这里时自动补表,不丢数据。uploads 记包的归属与状态
(pending/used,任务绑定时置 used,防一包多用);
task_attachments 记任务绑定的每个文件(相对路径 + 大小),
worker 派发时按 bundle_id 找到暂存目录。"""
conn.execute(
"CREATE TABLE IF NOT EXISTS uploads("
"id TEXT PRIMARY KEY, "
"created_by TEXT, "
"created_at DOUBLE PRECISION, "
"status TEXT)"
)
conn.execute(
"CREATE TABLE IF NOT EXISTS task_attachments("
"task_id TEXT, "
"bundle_id TEXT, "
"rel_path TEXT, "
"size INTEGER, "
"PRIMARY KEY(task_id, rel_path))"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_attachments_task"
" ON task_attachments(task_id)")
def _attachments_map(conn, task_ids):
"""一批任务的附件相对路径(按路径排序):{task_id: [rel_path]}。
任务列表接口用一次查询取全量,不按任务逐条查。"""
out = {tid: [] for tid in task_ids}
if not task_ids:
return out
placeholders = ", ".join("?" * len(task_ids))
rows = conn.execute(
"SELECT task_id, rel_path FROM task_attachments"
" WHERE task_id IN (%s) ORDER BY rel_path" % placeholders,
tuple(task_ids),
).fetchall()
for row in rows:
tid = row[0] if isinstance(row, tuple) else row["task_id"]
rel = row[1] if isinstance(row, tuple) else row["rel_path"]
if tid in out:
out[tid].append(rel)
return out
def _ensure_followups_table(conn):
"""幂等建后续消息表:委托给 peer_messages(两边同一份 DDL,
server init_db、各 followup 接口与 worker 投递路径共用)。
老库第一次跑到这里时自动补表补列,不丢数据。DOUBLE
PRECISION 在 SQLite 与 Postgres 下都可用(SQLite 按 NUMERIC
亲和处理)。"""
_peer_ensure_followups(conn)
def _followup_to_dict(row):
return {
"id": row["id"],
"task_id": row["task_id"],
"project_id": row["project_id"],
"body": row["body"],
"mode": row["mode"],
"status": row["status"],
"created_by": row["created_by"],
"sender_task_id": _row_get(row, "sender_task_id"),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def _ensure_environments_table(conn):
"""幂等建环境表:server init_db 与各环境接口都可调它,老库
第一次跑到这里时自动补表,不丢数据。类型写法与 followups
表一致(DOUBLE PRECISION 在 SQLite 与 Postgres 下都可用)。
环境 = 项目仓库的一个命名基准检出:repo_url 是创建时从
项目快照来的(之后项目改 repo_url 不影响已建环境),branch
为空表示默认分支,executor 为空表示不限执行器(任务创建时
若指定执行器须与它一致,否则继承它)。状态机:creating
(已记录、等 worker 准备检出)-> ready / failed;archive
后新任务不能再选它,旧任务结果不受影响。检出目录本身不
存绝对路径(worker 按 repos_dir / 租户目录推导,见 worker
的 _env_host_dir),避免 server 与 worker 机器路径不一致。
"""
conn.execute(
"CREATE TABLE IF NOT EXISTS environments("
"id TEXT PRIMARY KEY, "
"project_id TEXT, "
"name TEXT, "
"branch TEXT, "
"executor TEXT, "
"status TEXT, "
"error_text TEXT, "
"repo_url TEXT, "
"created_by TEXT, "
"created_at DOUBLE PRECISION, "
"updated_at DOUBLE PRECISION)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_env_project"
" ON environments(project_id, status)")
# 同项目未归档环境名唯一:用部分唯一索引(SQLite 与 Postgres
# 都支持 WHERE 子句),归档过的名字可重用——全表 UNIQUE
# 会挡住重用,与 _create_environment 的 409 规则矛盾。
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_env_name"
" ON environments(project_id, name)"
" WHERE status != 'archived'")
def _ensure_quotas_table(conn):
"""幂等建配额表:server init_db 与配额查询都可调它,老库
第一次跑到这里时自动补表,不丢数据。quota_usd_micros 为
NULL 表示不限额(与 0 区分:0 是一分钱也不能花);金额
单位与 cost_records 一致,都是 USD 微单位(1 美元 =
1_000_000),避免浮点。"""
conn.execute(
"CREATE TABLE IF NOT EXISTS user_quotas("
"user_id TEXT PRIMARY KEY, "
"quota_usd_micros BIGINT, "
"updated_at DOUBLE PRECISION)")
_ENV_BRANCH_RE = re.compile(r"^[A-Za-z0-9_.\-/]+$")
def _validate_env_branch(branch):
"""环境分支名校验:空/None 表示默认分支;非空时只允许
字母数字与 _ . - /,且不能以 - 开头、不能含 ..、不能以
.lock 结尾、长度不超过 200——git ref 名的保守子集,防
分支名被拼进 shell 或越出目录。返回规范化后的分支或 None,
非法时抛 ValueError(调用方转 400)。"""
if branch is None:
return None
if not isinstance(branch, str):
raise ValueError("branch must be a string")
branch = branch.strip()
if not branch:
return None
if len(branch) > 200 or not _ENV_BRANCH_RE.match(branch) \
or branch.startswith("-") or ".." in branch \
or branch.endswith(".lock"):
raise ValueError("branch is not a valid git branch name")
return branch
def _environment_to_dict(row):
return {
"id": row["id"],
"project_id": row["project_id"],
"name": row["name"],
"branch": _row_get(row, "branch"),
"executor": _row_get(row, "executor"),
"status": row["status"],
"error_text": _row_get(row, "error_text"),
"repo_url": _row_get(row, "repo_url"),
"created_by": row["created_by"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def _is_postgres_backend():
backend = os.environ.get("CLOUD_DB_BACKEND") or "sqlite"
return str(backend).strip().lower() in ("postgres", "postgresql")
def init_db(db_path):
"""创建 server 自己的 projects/tasks 表(AuthStore 的表由其自建)。
Postgres 模式:建 cloud.db.POSTGRES_SCHEMA 的全部表(其中
projects/tasks 已含 migrate_tasks_table 的 6 列),再跑一次
migrate_tasks_table——PRAGMA 翻译后在 PG 上是 no-op,老库
迁移仍走同一函数。SQLite 模式:原行为不变。
"""
conn = _connect(db_path)
try:
if _is_postgres_backend():
_init_postgres_schema(conn)
migrate_tasks_table(conn)
migrate_projects_table(conn)
_ensure_followups_table(conn)
_ensure_environments_table(conn)
_ensure_quotas_table(conn)
_ensure_uploads_tables(conn)
conn.commit()
return
conn.execute(
"CREATE TABLE IF NOT EXISTS projects("
"id TEXT PRIMARY KEY, "
"name TEXT, "
"owner_id TEXT, "
"created_at REAL, "
"repo_url TEXT, "
"auto_pr INTEGER)"
)
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)
migrate_projects_table(conn)
_ensure_followups_table(conn)
_ensure_environments_table(conn)
_ensure_quotas_table(conn)
_ensure_uploads_tables(conn)
# worker 每轮 poll 都按 status 取任务(见 load_tasks 的
# WHERE status IN (...)),无索引时是全表扫描;任务表随
# 时间变大后索引是必须的。IF NOT EXISTS 保证老库幂等。
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_tasks_status"
" ON tasks(status)")
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, attachments=None):
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"),
"executor": _row_get(row, "executor"),
"pr_url": _row_get(row, "pr_url"),
"pr_number": _row_get(row, "pr_number"),
"goal": _row_get(row, "goal"),
"verify_command": _row_get(row, "verify_command"),
"goal_max_rounds": _row_get(row, "goal_max_rounds"),
"goal_mode": _row_get(row, "goal_mode") or 0,
"attachments": list(attachments or []),
"resume_message": _row_get(row, "resume_message"),
"worktree_host": _row_get(row, "worktree_host"),
"model": _row_get(row, "model"),
"effort": _row_get(row, "effort"),
"approval": _row_get(row, "approval"),
"plan": _row_get(row, "plan") or 0,
"forked_from": _row_get(row, "forked_from"),
"environment_id": _row_get(row, "environment_id"),
"turn_id": _row_get(row, "turn_id"),
"root_turn_id": _row_get(row, "root_turn_id"),
"coordinator_parts": (
_coord_parse_parts(_row_get(row, "coordinator_parts"))
if _row_get(row, "coordinator_parts") else None),
"parent_task_id": _row_get(row, "parent_task_id"),
"coordinator_part": _row_get(row, "coordinator_part"),
}
def _project_to_dict(row):
return {
"id": row["id"],
"name": row["name"],
"owner_id": row["owner_id"],
"created_at": row["created_at"],
# 老库行缺 repo_url 列时 _row_get 给 None,不炸(列本身由
# migrate_projects_table 在 init_db 时补,老行读到也是 None)。
"repo_url": _row_get(row, "repo_url"),
# auto_pr 默认 0(关闭):老行缺列时 None 按 0 计,绝不因
# 迁移就把老项目默认改成自动推。
"auto_pr": _row_get(row, "auto_pr") or 0,
}
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, default=_json_default).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()
# 发头即算已响应:之后 wfile 写失败(客户端已断)也不应再
# 尝试回 500——响应一旦开始就不能再发第二个状态行。
self._responded = True
self.wfile.write(body)
def _send_html(self, status, html_body):
body = html_body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self._responded = True
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_safe("GET")
def do_POST(self):
self._dispatch_safe("POST")
def do_DELETE(self):
self._dispatch_safe("DELETE")
def do_PATCH(self):
# PATCH 之前没有入口(BaseHTTPRequestHandler 默认回 501),
# 项目补仓库是第一个 PATCH 路由;与其它方法一样走 _dispatch。
self._dispatch_safe("PATCH")
def _dispatch_safe(self, method):
"""分发兜底:_dispatch 里任何未捕获异常都回 500 JSON,而不是
让请求线程直接断连(2026-10-27 死锁事件就是这么变成前端
“HTTP 502”的:异常逃出 handle_one_request 后连接无响应,
nginx 只能报 502,客户端拿不到任何可读错误)。
500 体只写固定 internal_error,不把堆栈泄漏给客户端;
堆栈走 _server_log.exception 进服务端日志。已开始响应
(SSE 流、_send_json/_send_html 已发头)时不再二次发送,
只记日志——响应一旦开始就不能再发第二个状态行。
_responded 每请求重置(同一连接 keep-alive 复用 handler
对象时不串请求)。
"""
self._responded = False
try:
self._dispatch(method)
except Exception:
_server_log.exception("unhandled error in %s %s",
method, self.path)
if not self._responded:
try:
self._send_json(500, {"error": "internal_error"})
except Exception:
pass
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":
# 健康检查不走限流与认证;db_ok 只做一次轻量查询,
# 绝不返回连接串、密码或任何凭据。
db_ok = False
try:
conn = _connect(self.server.db_path)
try:
conn.execute("SELECT 1").fetchone()
db_ok = True
finally:
conn.close()
except Exception:
db_ok = False
# worker 字段:worker_ok 只表示“worker 心跳新鲜”,worker
# 不 ok 不代表 server 挂了,所以 health 本身仍返回 200,
# 调用方看 worker_ok 字段判断,不要把 health 改成 503
# 破坏已有调用(monitor.sh 也是按字段判断,不按状态码)。
age, worker_ok = _worker_heartbeat_status(self.server)
self._send_json(200, {
"ok": True, "service": "cloud-control-plane",
"backend": _db_backend_name(), "db_ok": db_ok,
"worker_last_seen_seconds_ago": age,
"worker_ok": worker_ok,
# 上线证据:health 通过不再单独证明新代码,
# 部署验真必须同时核对 commit 等于期望值(见 deploy.py)。
"commit": _deployed_commit(),
})
return
if path == "/console" and method == "GET":
# 命令台 v1:只出静态壳,不走鉴权与限流(与 /health 同款
# pre-auth 待遇)。壳里没有任何数据;数据一律凭用户在
# 浏览器里填的 X-API-Key 调接口,key 只存在用户浏览器
# localStorage。页面文件与 server.py 同目录,改动随部署走。
try:
here = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(here, "console.html"),
"r", encoding="utf-8") as f:
self._send_html(200, f.read())
except OSError:
self._send_json(404, {"error": "console not found"})
return
if path.startswith("/oauth/"):
# OAuth 登录/回调是 pre-auth 端点(用户还没本站 API key,
# 这正是 OAuth 要解决的问题),靠 state + client_secret
# 认证,不走 X-API-Key;不要把它当成无认证后门。
# 绑定端点例外:绑定必须知道绑到哪个已有账号,所以在
# 处理器内验 X-API-Key(无 key 401),分发仍走这支。
bind = self._oauth_bind_route(path)
if bind is not None:
provider, action = bind
if method == "POST" and action == "bind_initiate":
self._oauth_bind_initiate(provider)
elif method == "GET" and action == "bindings":
self._oauth_bindings_get(provider)
elif method == "DELETE" and action == "bindings":
self._oauth_bindings_delete(provider)
else:
self._send_json(404, {"error": "not found"})
return
if method == "GET" and path.endswith("/login"):
self._oauth_login(path)
elif method == "GET" and path.endswith("/callback"):
# 真实浏览器流程:provider 重定向到这里,query 带
# code + state(不带 client_id,靠 state 记录取回)。
self._oauth_callback_get(path)
elif method == "POST" and path.endswith("/callback"):
self._oauth_callback(path)
else:
self._send_json(404, {"error": "not found"})
return
if path.startswith("/users/me/keys"):
# API keys 管理:全部只操作认证用户自己的 key。
if method == "GET" and path == "/users/me/keys":
self._user_keys_list()
elif method == "POST" and path == "/users/me/keys":
self._user_keys_create()
elif method == "POST" and \
path == "/users/me/keys/revoke-others":
self._user_keys_revoke_others()
elif method == "DELETE" and \
path.startswith("/users/me/keys/"):
key_id = path[len("/users/me/keys/"):]
if key_id and "/" not in key_id:
self._user_keys_revoke(key_id)
else:
self._send_json(404, {"error": "not found"})
else:
self._send_json(404, {"error": "not found"})
return
if path.startswith("/github-app/"):
# GitHub App 一键安装:setup/state 与 status 须持本站 key
# (在处理器内验);setup 表单页与 callback 靠 setup
# state 凭证(一次性、1 小时),不走 X-API-Key(浏览器
# 回调带不了请求头,state 就是凭证)。
if method == "POST" and path == "/github-app/setup/state":
self._github_app_setup_state()
elif method == "GET" and path == "/github-app/setup":
self._github_app_setup_page()
elif method == "GET" and path == "/github-app/callback":
self._github_app_callback()
elif method == "GET" and path == "/github-app/status":
self._github_app_status()
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
# 限流在认证之前、健康/OAuth/webhook 之后:超限返回 429,
# 不是 401(不要把“请求太频繁”记成“认证失败”)。
# key:有 X-API-Key 时按 key 的 sha256 计(原始 key 不存),
# 无 key 时按客户端 IP 计。
limiter = getattr(self.server, "rate_limiter", None)
if limiter is None:
limiter = _make_rate_limiter(self.server.db_path)
self.server.rate_limiter = limiter
# 测试注入:server.rate_limit_per_minute / rate_limit_window_seconds
# 改动后即时生效,不用重启 server。
if getattr(self.server, "rate_limit_per_minute", None) is not None:
limiter.per_minute = self.server.rate_limit_per_minute
if getattr(self.server, "rate_limit_window_seconds", None) is not None:
limiter.window_seconds = self.server.rate_limit_window_seconds
api_key_header = self.headers.get("X-API-Key")
if api_key_header:
limit_key = "key:" + hashlib.sha256(
api_key_header.encode("utf-8")).hexdigest()
else:
limit_key = "ip:" + (self.client_address[0]
if self.client_address else "unknown")
if not limiter.allow(limit_key):
self._send_json(429, {"error": "rate_limited"})
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 == "/workers" and method == "GET":
self._workers_list(user_id)
elif path == "/workers/heartbeat" and method == "POST":
self._workers_heartbeat(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 == "/relay/models" and method == "GET":
self._relay_models(user_id)
elif path == "/github/repos" and method == "GET":
self._github_repos_list(user_id)
elif path.startswith("/projects/"):
self._dispatch_project(user_id, method, path, query)
elif path == "/environments" and method == "POST":
self._create_environment(user_id)
elif path == "/environments" and method == "GET":
self._list_environments(user_id, query)
elif path.startswith("/environments/"):
rest = path[len("/environments/"):]
if "/" not in rest and method == "GET":
self._get_environment(user_id, rest)
elif rest.endswith("/archive") and method == "POST":
self._archive_environment(user_id, rest[: -len("/archive")])
else:
self._send_json(404, {"error": "not found"})
elif path == "/users/me/quota" and method == "GET":
self._user_quota_self(user_id)
elif path == "/tasks" and method == "POST":
self._create_task(user_id)
elif path == "/uploads" and method == "POST":
self._create_upload(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/backup" and method == "POST":
self._admin_backup(user_id)
elif path == "/admin/quotas" and method == "GET":
self._admin_quota_get(user_id, query)
elif path == "/admin/quotas" and method == "POST":
self._admin_quota_set(user_id)
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("/trace/stream") and method == "GET":
self._task_trace_stream(
user_id, rest[: -len("/trace/stream")])
elif rest.endswith("/trace") and method == "GET":
self._task_trace(user_id, rest[: -len("/trace")])
elif rest.endswith("/agents") and method == "GET":
self._task_agents(user_id, rest[: -len("/agents")])
elif rest.endswith("/cancel") and method == "POST":
self._cancel_task(user_id, rest[: -len("/cancel")])
elif rest.endswith("/merge") and method == "POST":
self._merge_task(user_id, rest[: -len("/merge")])
elif rest.endswith("/pr") and method == "POST":
self._task_pr(user_id, rest[: -len("/pr")])
elif rest.endswith("/retry") and method == "POST":
self._retry_task(user_id, rest[: -len("/retry")])
elif rest.endswith("/heartbeat") and method == "POST":
self._heartbeat_task(user_id, rest[: -len("/heartbeat")])
elif rest.endswith("/followups") and method == "POST":
self._create_followup(user_id, rest[: -len("/followups")])
elif rest.endswith("/followups") and method == "GET":
self._list_followups(user_id, rest[: -len("/followups")])
elif rest.endswith("/messages") and method == "POST":
self._create_peer_message(user_id, rest[: -len("/messages")])
elif rest.endswith("/messages") and method == "GET":
self._list_peer_messages(user_id, rest[: -len("/messages")])
elif rest.endswith("/fork") and method == "POST":
self._fork_task(user_id, rest[: -len("/fork")])
elif rest.endswith("/continue") and method == "POST":
self._continue_task(user_id, rest[: -len("/continue")])
else:
self._send_json(404, {"error": "not found"})
else:
self._send_json(404, {"error": "not found"})
# -- 环境 ----------------------------------------------------------
def _fetch_environment(self, env_id):
conn = self._db()
try:
return conn.execute(
"SELECT * FROM environments WHERE id = ?", (env_id,)
).fetchone()
finally:
conn.close()
def _create_environment(self, user_id):
"""建环境:只记录意图(status=creating),检出由 worker
在下一轮准备(clone + checkout 分支),准备好后 status
变 ready,失败变 failed 并带 error_text——创建本身是同步
返回的记录,准备是异步的,与任务 queued->running 同款语义。
同项目内未归档环境名唯一(409),避免选环境时分不清;
归档过的名字可重用。项目没有 repo_url 时 400:环境就是
仓库的基准检出,没有仓库就没有环境。"""
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
project_id = body.get("project_id")
if not project_id or not isinstance(project_id, str):
self._send_json(400, {"error": "project_id required"})
return
name = body.get("name")
if not name or not isinstance(name, str) or not name.strip():
self._send_json(400, {"error": "name required"})
return
name = name.strip()
if len(name) > 100:
self._send_json(400, {"error": "name too long"})
return
executor = body.get("executor")
if executor is not None and executor not in ("local", "docker"):
self._send_json(400, {
"error": "executor must be local or docker"})
return
try:
branch = _validate_env_branch(body.get("branch"))
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
err = self._check_project(user_id, project_id, "create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
now = time.time()
env_id = uuid.uuid4().hex
conn = self._db()
try:
proj = conn.execute(
"SELECT repo_url FROM projects WHERE id = ?",
(project_id,)).fetchone()
if proj is None:
self._send_json(404, {"error": "project not found"})
return
repo_url = _row_get(proj, "repo_url")
if not repo_url:
self._send_json(400, {
"error": "project has no repo_url, "
"environment needs a repo"})
return
existing = conn.execute(
"SELECT id FROM environments WHERE project_id = ?"
" AND name = ? AND status != 'archived'",
(project_id, name)).fetchone()
if existing:
self._send_json(409, {
"error": "environment name already exists"
" in this project"})
return
try:
conn.execute(
"INSERT INTO environments(id, project_id, name,"
" branch, executor, status, error_text, repo_url,"
" created_by, created_at, updated_at)"
" VALUES(?, ?, ?, ?, ?, 'creating', NULL, ?, ?,"
" ?, ?)",
(env_id, project_id, name, branch, executor,
repo_url, user_id, now, now))
conn.commit()
except (sqlite3.IntegrityError, _PgIntegrityError):
self._send_json(409, {
"error": "environment name already exists"
" in this project"})
return
row = conn.execute(
"SELECT * FROM environments WHERE id = ?", (env_id,)
).fetchone()
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, project_id, "environment_created",
env_id)
self._send_json(201, _environment_to_dict(row))
def _list_environments(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
err = self._check_project(user_id, project_id, "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
include_archived = query.get("include_archived") == ["1"]
conn = self._db()
try:
if include_archived:
rows = conn.execute(
"SELECT * FROM environments WHERE project_id = ?"
" ORDER BY created_at, id",
(project_id,)).fetchall()
else:
rows = conn.execute(
"SELECT * FROM environments WHERE project_id = ?"
" AND status != 'archived'"
" ORDER BY created_at, id",
(project_id,)).fetchall()
finally:
conn.close()
self._send_json(200, {
"environments": [_environment_to_dict(r) for r in rows]})
def _get_environment(self, user_id, env_id):
row = self._fetch_environment(env_id)
if row is None:
self._send_json(404, {"error": "environment not found"})
return
env = _environment_to_dict(row)
err = self._check_project(user_id, env["project_id"], "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._send_json(200, env)
def _archive_environment(self, user_id, env_id):
"""归档环境:已归档时幂等返回 200;有 queued/running
任务正在用它时 409,绝不让进行中的任务失去基准检出。
归档只改状态,不删检出目录(已完成任务的 worktree 独立,
检出留着可查;磁盘清理另行手动做,不在归档里顺手删)。"""
row = self._fetch_environment(env_id)
if row is None:
self._send_json(404, {"error": "environment not found"})
return
env = _environment_to_dict(row)
err = self._check_project(
user_id, env["project_id"], "create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
if env["status"] == "archived":
self._send_json(200, env)
return
now = time.time()
conn = self._db()
try:
using = conn.execute(
"SELECT id FROM tasks WHERE environment_id = ?"
" AND status IN ('queued', 'running') LIMIT 1",
(env_id,)).fetchone()
if using:
self._send_json(409, {
"error": "environment is in use by a queued or"
" running task"})
return
conn.execute(
"UPDATE environments SET status = 'archived',"
" updated_at = ? WHERE id = ?",
(now, env_id))
conn.commit()
row = conn.execute(
"SELECT * FROM environments WHERE id = ?", (env_id,)
).fetchone()
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, env["project_id"],
"environment_archived", env_id)
self._send_json(200, _environment_to_dict(row))
# -- 项目 ----------------------------------------------------------
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
# repo_url 可选:不传时为 None(老行为不变);传了则与 worker
# 共用 cloud.repos.validate_repo_url 的同一套校验,不合法时
# 400 且不建项目,绝不把带凭证的 URL 存进库。
repo_url = body.get("repo_url")
if repo_url is not None:
try:
repo_url = validate_repo_url(repo_url)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
# auto_pr 可选,默认 0(关闭):只接受 0/1(布尔 True/False
# 按 1/0 计),其它值 400 且不建项目。本轮没有现成的
# PATCH /projects/{id} 路由,auto_pr 只能在创建时指定,
# 建后改它需直接改库或重建项目(见 README)。
auto_pr_raw = body.get("auto_pr", 0)
if isinstance(auto_pr_raw, bool):
auto_pr = 1 if auto_pr_raw else 0
elif isinstance(auto_pr_raw, int) and auto_pr_raw in (0, 1):
auto_pr = auto_pr_raw
else:
self._send_json(400, {"error": "auto_pr must be 0 or 1"})
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,"
" repo_url, auto_pr) VALUES(?, ?, ?, ?, ?, ?)",
(project_id, name, user_id, now, repo_url, auto_pr),
)
conn.commit()
project = {
"id": project_id,
"name": name,
"owner_id": user_id,
"created_at": now,
"repo_url": repo_url,
"auto_pr": auto_pr,
}
except (sqlite3.IntegrityError, _PgIntegrityError):
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 _update_project(self, user_id, project_id):
"""PATCH /projects/{id}:项目 owner 可改 repo_url(建后补仓
库,解“建项目时没填仓库、之后建环境一直 400”的死结)。
repo_url 缺 key 时 400;null 或空串表示清空;字符串走与
创建时同一套 validate_repo_url 校验(带凭证的 URL 拒绝)。
只改 projects 行,不动已建环境的快照 repo_url——已建环境
仍用建环境时的仓库,新环境才用新值。改完记 update_project
审计。"""
err = self._check_project(user_id, project_id, "manage_project")
if err:
self._send_json(err[0], {"error": err[1]})
return
body, jerr = self._read_json()
if jerr:
self._send_json(400, {"error": jerr})
return
if "repo_url" not in body:
self._send_json(400, {"error": "repo_url required"})
return
repo_url = body.get("repo_url")
if repo_url is not None and repo_url != "":
if not isinstance(repo_url, str):
self._send_json(400, {"error": "repo_url must be a string"})
return
try:
repo_url = validate_repo_url(repo_url)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
else:
repo_url = None
conn = self._db()
try:
row = conn.execute(
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
" FROM projects WHERE id = ?",
(project_id,)).fetchone()
if row is None:
self._send_json(404, {"error": "project not found"})
return
conn.execute(
"UPDATE projects SET repo_url = ? WHERE id = ?",
(repo_url, project_id))
conn.commit()
row = conn.execute(
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
" FROM projects WHERE id = ?",
(project_id,)).fetchone()
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, project_id, "update_project",
repo_url or "(cleared)")
self._send_json(200, {"project": _project_to_dict(row)})
def _github_repos_list(self, user_id):
"""GET /github/repos:列出本服务 GitHub App 安装记录里的仓
库(installation_repositories 事件同步进 github_app_
installations 表),给控制台建项目/补仓库时的下拉菜单用。
任何已认证用户可列(只含仓库名,不含密钥);选了哪个仓库
只影响调用者自己有管理权的项目(PATCH 仍按 owner 校验)。
手填 URL 的能力不变——没进 App 安装的公开仓库仍可手动填。"""
conn = self._db()
try:
rows = list_installations(conn)
finally:
conn.close()
repos = [{
"installation_id": r["installation_id"],
"account_login": r["account_login"],
"repo_full_name": r["repo_full_name"],
"clone_url": "https://github.com/%s.git" % r["repo_full_name"],
} for r in rows]
self._send_json(200, {"repos": repos})
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, repo_url, auto_pr"
" 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
if len(parts) == 1 and method == "PATCH":
self._update_project(user_id, project_id)
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,"
" COALESCE(SUM(cached_input_tokens),0) AS cached,"
" COALESCE(SUM(CASE WHEN source = 'actual'"
" AND usd_micros IS NULL THEN total_tokens"
" ELSE 0 END),0) AS unpriced"
" FROM cost_records WHERE project_id = ? AND day = ?"
" GROUP BY day",
(project_id, day)).fetchone()
if row:
daily = [{"day": row["day"],
"total_tokens": int(row["t"]),
"usd_micros": int(row["u"]),
"turns": int(row["c"]),
"fallback_count": int(row["f"]),
"cached_input_tokens": int(row["cached"]),
"unpriced_actual_tokens": int(row["unpriced"])}]
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,"
" COALESCE(SUM(cached_input_tokens),0) AS cached,"
" COALESCE(SUM(CASE WHEN source = 'actual'"
" AND usd_micros IS NULL THEN total_tokens"
" ELSE 0 END),0) AS unpriced"
" FROM cost_records WHERE project_id = ? AND day >= ?"
" GROUP BY day ORDER BY day",
(project_id, cutoff)).fetchall()
daily = [{"day": r["day"],
"total_tokens": int(r["t"]),
"usd_micros": int(r["u"]),
"turns": int(r["c"]),
"fallback_count": int(r["f"]),
"cached_input_tokens": int(r["cached"]),
"unpriced_actual_tokens": int(r["unpriced"])}
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 _relay_models(self, user_id):
# 中转可用模型列表:服务端凭环境变量里的 relay key 现取
# 现回,只把模型 id 交给调用者。key 绝不进响应、日志、
# 缓存(见 cloud/relay.py)。单测可经 server.relay_fetcher
# 注入假取数函数;中转挂了或未配 key 时 502,不编造列表。
fetcher = getattr(self.server, "relay_fetcher", None)
try:
models, cached, updated_at = _get_relay_models(fetcher=fetcher)
except _RelayError as exc:
self._send_json(502, {"error": str(exc)})
return
self._send_json(200, {
"models": models, "cached": cached, "updated_at": updated_at})
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": int(r["turns"]),
"total_tokens": int(r["tokens"]),
"fallback_count": int(r["fallbacks"])} for r in rows],
"fallback_events": [
{"task_id": r["task_id"], "project_id": r["project_id"],
"model": r["model"],
"fallback_count": int(r["fallback_count"])}
for r in fb_rows],
})
# -- 任务 ----------------------------------------------------------
# -- 任务附件 ------------------------------------------------------
def _create_upload(self, user_id):
"""上传一包附件,返回 upload_id(建任务时绑定)。
权限:任何持有效 key 的用户都可上传(包本身不挂项目,
归属在建任务时校验);包不直接进任务,必须经 _create_task
绑定才生效。413 预检在读 body 之前按 Content-Length 卡,
防止超大 body 先占内存;文件级上限由 validate_bundle 卡。
"""
content_type = self.headers.get("Content-Type", "")
if "multipart/form-data" not in content_type:
self._send_json(400, {
"error": "Content-Type 必须是 multipart/form-data"})
return
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
length = 0
if length > MAX_RAW_BYTES:
self._send_json(413, {"error": "upload too large"})
return
raw = self._read_body()
try:
fields, files = parse_multipart(raw, content_type)
paths = parse_paths_field(fields, files)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
if not files:
self._send_json(400, {"error": "no files uploaded"})
return
if len(paths) != len(files):
self._send_json(400, {
"error": "附件路径与文件数量不一致"})
return
err = validate_bundle(paths, [len(data) for _name, data in files])
if err:
self._send_json(400, {"error": err})
return
root = _upload_dir_for(self.server)
os.makedirs(root, exist_ok=True)
bundle_id, saved = save_bundle(
root, paths, [data for _name, data in files])
conn = self._db()
try:
conn.execute(
"INSERT INTO uploads(id, created_by, created_at, status)"
" VALUES(?, ?, ?, 'pending')",
(bundle_id, user_id, time.time()),
)
conn.commit()
finally:
conn.close()
self._send_json(201, {
"upload_id": bundle_id,
"files": [{"path": path, "size": size}
for path, size in saved],
})
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
# executor 可选:None 表示任意 worker 可认领;指定时只能是
# local/docker/cloud,非法值 400 且不建任务。
executor = body.get("executor")
if executor is not None and executor not in (
"local", "docker", "cloud"):
self._send_json(400, {
"error": "executor must be local, docker or cloud"})
return
# 任务级选项:model 不做白名单(中转目录会变,下拉只是
# 建议值);effort 必须是 codex 的 effort 档位;approval
# 是 console 三档(ask/auto/full),非法值 400 且不建任务。
# 三者都缺省(NULL)时 worker 走配置默认值,行为与过去一致。
model = body.get("model")
if model is not None:
if not isinstance(model, str) or not model.strip():
self._send_json(400, {
"error": "model must be a non-empty string"})
return
model = model.strip()
effort = body.get("effort")
if effort is not None and effort not in (
"low", "medium", "high", "xhigh", "max"):
self._send_json(400, {
"error": "effort must be low, medium, high, xhigh or max"})
return
approval = body.get("approval")
if approval is not None and approval not in ("ask", "auto", "full"):
self._send_json(400, {
"error": "approval must be ask, auto or full"})
return
# 计划模式:只读沙箱,不改任何文件;与“完全访问”
# (danger-full-access)语义直接冲突,拒绝建任务。
plan = 1 if body.get("plan") in (True, 1) else 0
if plan and approval == "full":
self._send_json(400, {
"error": "plan 模式与 approval=full 冲突(只读 vs 完全访问)"})
return
# goal 模式分新旧两种语义(2026-10-27 起 console 只发新语义):
# 新语义 goal_mode=1:prompt 本身就是目标,不需要专用 goal
# 文本、不需要验收命令、不设轮次上限(goal_max_rounds 存 0,
# worker 走 host.py goal 循环的模型自决分支);传了
# verify_command 是语义冲突,400。旧语义(verify_command
# 非空)保留:goal 文本缺省用 prompt,轮次缺省 3,非法值
# 400 且不建任务;老客户端与已建的旧 goal 任务继续可用。
goal_mode = 1 if body.get("goal_mode") in (True, 1) else 0
if goal_mode:
if plan:
self._send_json(400, {
"error": "plan 模式与 goal 模式冲突(只读 vs 改文件)"})
return
if body.get("verify_command"):
self._send_json(400, {
"error": "goal 模式不需要验收命令(达标由模型宣告)"})
return
goal = prompt
verify_command = None
goal_max_rounds = 0
else:
goal = body.get("goal")
if goal is not None and not isinstance(goal, str):
self._send_json(400, {"error": "goal must be a string"})
return
verify_command = body.get("verify_command")
if verify_command is not None and not isinstance(
verify_command, str):
self._send_json(400, {
"error": "verify_command must be a string"})
return
# 计划模式与 goal 模式也冲突:goal 循环就是要改文件
# 直到验测通过,只读沙箱下它永远做不成。
if plan and verify_command:
self._send_json(400, {
"error": "plan 模式与 goal 模式冲突(只读 vs 改文件)"})
return
try:
goal_max_rounds = int(body.get("goal_max_rounds", 3))
except (TypeError, ValueError):
self._send_json(400, {
"error": "goal_max_rounds must be an integer"})
return
if goal_max_rounds < 1:
self._send_json(400, {
"error": "goal_max_rounds must be >= 1"})
return
# 协调员:coordinator_parts 非空时任务本身不跑模型回合,
# worker 认领后按分工建并行子任务、等齐、合流;与 goal/
# plan/验收命令语义冲突(那些都是“本任务跑模型”的意思),
# 同用时 400 且不建任务。
coord_parts = None
if body.get("coordinator_parts") is not None:
coord_parts, coord_err = _coord_validate_parts(
body.get("coordinator_parts"))
if coord_err:
self._send_json(400, {"error": coord_err})
return
if goal_mode or plan or verify_command:
self._send_json(400, {
"error": "协调员任务不跑模型回合,不能与"
" goal/plan/验收命令同用"})
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
if self._reject_if_over_quota(user_id):
return
# 环境绑定:environment_id 非空时必须是本项目、已 ready
# 的环境;creating/failed/archived 一律拒绝并把状态写进
# 报错(creating 时用户就知道该等 worker 准备好再建任务,
# 而不是任务排队后才在 worker 侧失败)。执行器规则:
# 环境 executor 非空且任务 executor 已指定时必须一致;
# 任务 executor 未指定时继承环境 executor(认领过滤靠它,
# 否则 local 任务可能被 docker worker 认领、反之亦然);
# cloud 执行器没有环境检出路径,一律拒绝。
environment_id = body.get("environment_id")
if environment_id is not None:
if not isinstance(environment_id, str) \
or not environment_id.strip():
self._send_json(400, {
"error": "environment_id must be a non-empty string"})
return
environment_id = environment_id.strip()
env_row = self._fetch_environment(environment_id)
if env_row is None:
self._send_json(404, {"error": "environment not found"})
return
env = _environment_to_dict(env_row)
if env["project_id"] != project_id:
self._send_json(400, {
"error": "environment belongs to another project"})
return
if env["status"] != "ready":
self._send_json(400, {
"error": "environment not ready: %s" % env["status"]})
return
if executor == "cloud":
self._send_json(400, {
"error": "environment does not support cloud executor"})
return
if env["executor"] and executor \
and env["executor"] != executor:
self._send_json(400, {
"error": "executor does not match environment executor"})
return
if executor is None and env["executor"]:
executor = env["executor"]
# 附件绑定(upload_id 由 POST /uploads 发出):包必须存在、
# 归属当前用户、未使用、24 小时内、暂存目录还在;cloud
# 执行器没有 worktree 可 seed,直接拒绝。校验在 INSERT
# 之前做,绑定写与任务 INSERT 同一事务,失败不留半截。
bundle_files = []
upload_id = body.get("upload_id")
if upload_id is not None:
if not isinstance(upload_id, str) or not upload_id:
self._send_json(400, {
"error": "upload_id must be a non-empty string"})
return
if executor == "cloud":
self._send_json(400, {
"error": "附件不支持 cloud 执行器"})
return
conn0 = self._db()
try:
brow = conn0.execute(
"SELECT * FROM uploads WHERE id = ?",
(upload_id,),
).fetchone()
finally:
conn0.close()
if brow is None:
self._send_json(400, {"error": "upload_id 不存在"})
return
if brow["created_by"] != user_id:
self._send_json(403, {"error": "forbidden"})
return
if brow["status"] == "used":
self._send_json(409, {"error": "附件包已使用"})
return
if time.time() - brow["created_at"] > BUNDLE_TTL_SECONDS:
self._send_json(400, {"error": "附件包已过期"})
return
bundle_dir = os.path.join(
_upload_dir_for(self.server), upload_id)
if not os.path.isdir(bundle_dir):
self._send_json(400, {"error": "附件暂存目录缺失"})
return
for dirpath, _dirnames, filenames in os.walk(bundle_dir):
for fn in sorted(filenames):
full = os.path.join(dirpath, fn)
rel = os.path.relpath(full, bundle_dir)
bundle_files.append((rel, os.path.getsize(full)))
bundle_files.sort()
if not bundle_files:
self._send_json(400, {"error": "附件包为空"})
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, _attachments_map(
conn, [row["id"]])[row["id"]]))
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, executor,"
" goal, verify_command, goal_max_rounds,"
" model, effort, approval, plan, goal_mode,"
" environment_id, coordinator_parts,"
" parent_task_id, coordinator_part)"
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?,"
" ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(task_id, project_id, prompt, idempotency_key,
json.dumps(depends_on), priority, user_id, now, now,
executor, goal, verify_command, goal_max_rounds,
model, effort, approval, plan, goal_mode,
environment_id,
json.dumps(coord_parts) if coord_parts else None,
None, None),
)
for rel_path, size in bundle_files:
conn.execute(
"INSERT INTO task_attachments"
"(task_id, bundle_id, rel_path, size)"
" VALUES(?, ?, ?, ?)",
(task_id, upload_id, rel_path, size),
)
if upload_id is not None:
conn.execute(
"UPDATE uploads SET status = 'used' WHERE id = ?",
(upload_id,),
)
conn.commit()
except (sqlite3.IntegrityError, _PgIntegrityError):
# 并发下同幂等键抢建:回读已有任务,不建重复。
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, _attachments_map(
conn, [row["id"]])[row["id"]]))
return
raise
row = conn.execute(
"SELECT * FROM tasks WHERE id = ?", (task_id,)
).fetchone()
task = _task_to_dict(
row, [rel_path for rel_path, _size in bundle_files])
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
parent_task_id = None
parent_ids = query.get("parent_task_id")
if parent_ids:
parent_task_id = parent_ids[0]
conn = self._db()
try:
if parent_task_id:
rows = conn.execute(
"SELECT * FROM tasks WHERE project_id = ?"
" AND parent_task_id = ?"
" ORDER BY created_at, id",
(project_id, parent_task_id),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM tasks WHERE project_id = ?"
" ORDER BY created_at, id",
(project_id,),
).fetchall()
amap = _attachments_map(conn, [r["id"] for r in rows])
finally:
conn.close()
self._send_json(200, {"tasks": [
_task_to_dict(r, amap[r["id"]]) 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
conn = self._db()
try:
atts = _attachments_map(conn, [task_id])[task_id]
finally:
conn.close()
task = _task_to_dict(row, atts)
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 _trace_snapshot(self, task):
"""当前链路快照 (entries, note):worktree 在别机时读不到
文件,返回空链路 + 说明(多机诚实降级,不谎报无过程)。
租户目录与共享目录都读、合并(租户 Docker 任务的链路
文件只在租户目录里,旧版只读共享目录会误报为空)。"""
worktree_host = task.get("worktree_host")
if worktree_host and worktree_host != socket.gethostname():
return [], ("链路文件在 worktree 主机 %s 上,本机读不到"
% worktree_host)
paths = [os.path.join(d, _trace_file_name(task["id"]) + ".jsonl")
for d in _trace_dirs_for_task(self.server, task)]
return _read_trace_entries_multi(paths), ""
def _task_trace(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
entries, note = self._trace_snapshot(task)
self._send_json(200, {"task_id": task_id, "status": task["status"],
"entries": entries, "note": note})
def _task_agents(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
worktree_host = task.get("worktree_host")
if worktree_host and worktree_host != socket.gethostname():
self._send_json(200, {"task_id": task_id, "status": task["status"],
"agents": [],
"note": ("链路文件在 worktree 主机 %s 上,本机读不到"
% worktree_host)})
return
agents = _build_agent_chain(
task, _trace_dirs_for_task(self.server, task))
self._send_json(200, {"task_id": task_id, "status": task["status"],
"agents": agents, "note": ""})
def _task_trace_stream(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
entries, note = self._trace_snapshot(task)
try:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.end_headers()
except (BrokenPipeError, ConnectionResetError):
return
self._responded = True
# SSE 无 Content-Length,done 后靠关连接界定流结束;注意
# send_header("Connection", "keep-alive") 会把
# close_connection 改回 False,所以必须在 end_headers 之后
# 再置 True(CPython send_header 的 Connection 分支行为)。
self.close_connection = True
sent = {}
def _emit(obj):
try:
self.wfile.write(
("data: " + json.dumps(obj, ensure_ascii=False,
default=_json_default) +
"\n\n").encode("utf-8"))
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
return False
return True
if not _emit({"type": "snapshot", "task_id": task_id,
"status": task["status"], "entries": entries,
"note": note}):
return
for entry in entries:
sent[entry["id"]] = json.dumps(entry, sort_keys=True,
ensure_ascii=False)
if task["status"] in TERMINAL_STATUSES:
_emit({"type": "done", "status": task["status"]})
return
deadline = time.time() + TRACE_STREAM_MAX_SECONDS
try:
while time.time() < deadline:
time.sleep(TRACE_STREAM_POLL_SECONDS)
row = self._fetch_task(task_id)
if row is None:
_emit({"type": "done", "status": "deleted"})
return
task = _task_to_dict(row)
entries, _note = self._trace_snapshot(task)
for entry in entries:
blob = json.dumps(entry, sort_keys=True,
ensure_ascii=False)
if sent.get(entry["id"]) != blob:
sent[entry["id"]] = blob
if not _emit({"type": "entry", "entry": entry}):
return
if task["status"] in TERMINAL_STATUSES:
_emit({"type": "done", "status": task["status"]})
return
except (BrokenPipeError, ConnectionResetError):
return
_emit({"type": "done", "status": task["status"],
"reason": "timeout"})
def _create_followup(self, user_id, task_id):
"""给进行中任务发后续消息:排队 / 直接发送。
语义(用户定稿):排队进队尾,不打断本轮,本轮结束
后按序每条再跑一轮;直接发送也不中断本轮——消息立即
送达(链路里可见),本轮跑完后立刻以它开下一轮,且
优先于排队中的消息。v1 只做运行时(含排队中)的任务:
已终态的任务 409;goal 任务 409(goal 循环自有轮次)。
权限与取消同款:项目 create_task(写操作,不能只读)。
"""
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
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
text = body.get("body")
if not text or not isinstance(text, str) or not text.strip():
self._send_json(400, {"error": "body required"})
return
text = text.strip()
if len(text) > 20000:
self._send_json(400, {"error": "body too long"})
return
mode = body.get("mode", "queued")
if mode not in ("queued", "direct"):
self._send_json(400, {
"error": "mode must be queued or direct"})
return
if task["status"] not in ("queued", "running"):
self._send_json(409, {
"error": "后续消息仅支持进行中任务",
"status": task["status"],
})
return
if task.get("verify_command"):
self._send_json(409, {"error": "goal 任务暂不支持后续消息"})
return
now = time.time()
conn = self._db()
try:
_ensure_followups_table(conn)
fu_id = uuid.uuid4().hex
conn.execute(
"INSERT INTO task_followups(id, task_id, project_id,"
" body, mode, status, created_by, created_at,"
" updated_at) VALUES(?, ?, ?, ?, ?, 'pending', ?, ?, ?)",
(fu_id, task_id, task["project_id"], text, mode,
user_id, now, now),
)
conn.commit()
fu = _followup_to_dict(
conn.execute(
"SELECT * FROM task_followups WHERE id = ?",
(fu_id,),
).fetchone()
)
finally:
conn.close()
if mode == "direct":
self._append_direct_trace(task, fu_id, text)
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"followup_created", "%s:%s" % (task_id, fu_id))
self._send_json(201, fu)
def _append_direct_trace(self, task, fu_id, text):
"""直接发送的消息立即进链路:往 host 的 jsonl 里追加一条
message 条目(phase=followup-direct),运行中的链路流
下一轮 poll 就能看到它。只尽力而为:worktree 在别机时
本机文件不是 worker 写的那份,跳过(与 _trace_snapshot
的多机降级同规则);写失败也绝不让 POST 失败。"""
try:
worktree_host = task.get("worktree_host")
if worktree_host and worktree_host != socket.gethostname():
return
# 续写已有链路文件所在的目录:租户 Docker 任务的链路
# 文件在租户目录里,续写共享目录会把一条任务的链路
# 拆成两处;还没有任何链路文件时默认写共享目录。
d = _trace_dir_for(self.server)
tenant = _tenant_trace_dir(self.server, task.get("project_id"))
if tenant and os.path.exists(os.path.join(
tenant, _trace_file_name(task["id"]) + ".jsonl")):
d = tenant
os.makedirs(d, exist_ok=True)
entry = {"kind": "message", "id": "fu-" + fu_id,
"ts": int(time.time() * 1000),
"phase": "followup-direct", "text": text[:300]}
with open(os.path.join(
d, _trace_file_name(task["id"]) + ".jsonl"),
"a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
pass
def _list_followups(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
conn = self._db()
try:
_ensure_followups_table(conn)
rows = conn.execute(
"SELECT * FROM task_followups WHERE task_id = ?"
" ORDER BY created_at, id",
(task_id,),
).fetchall()
finally:
conn.close()
self._send_json(200, {
"followups": [_followup_to_dict(r) for r in rows]})
def _create_peer_message(self, user_id, task_id):
"""任务间互发消息(API 发送方):以 from_task_id 任务的名义
给目标任务发消息。投递语义与 worker 路径共用 deliver 层,
同样不中断目标任务:运行中/排队中排成后续消息,终态则
续跑复活。权限:调用方须对目标项目有 create_task、对
发送任务项目有 read(防冒名);from_task_id 必填——用户
给任务发消息走 /followups,这里只做任务对任务。"""
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
target_row = self._fetch_task(task_id)
if target_row is None:
self._send_json(404, {"error": "task not found"})
return
target = _task_to_dict(target_row)
with _auth_store(self.server.db_path) as store:
if not store.check_permission(
user_id, target["project_id"], "create_task"):
self._send_json(403, {"error": "forbidden"})
return
from_task_id = body.get("from_task_id")
if not from_task_id or not isinstance(from_task_id, str):
self._send_json(400, {"error": "from_task_id required"})
return
sender_row = self._fetch_task(from_task_id)
if sender_row is None:
self._send_json(404, {"error": "sender task not found"})
return
sender = _task_to_dict(sender_row)
with _auth_store(self.server.db_path) as store:
if not store.check_permission(
user_id, sender["project_id"], "read"):
self._send_json(403, {"error": "forbidden"})
return
text = body.get("body")
if not text or not isinstance(text, str) or not text.strip():
self._send_json(400, {"error": "body required"})
return
text = text.strip()
if len(text) > 20000:
self._send_json(400, {"error": "body too long"})
return
mode = body.get("mode", "queued")
if mode not in ("queued", "direct"):
self._send_json(400, {
"error": "mode must be queued or direct"})
return
conn = self._db()
try:
_ensure_followups_table(conn)
status, detail = _deliver_peer_message(
conn, from_task_id, task_id, text, mode)
finally:
conn.close()
if status == "rejected":
self._send_json(409, {"error": detail})
return
if status == "delivered" and mode == "direct":
# 直接发送的链路可见性与用户后续消息同款:消息立即
# 进链路,但当前轮仍跑完才开下一轮,不中断。
self._append_direct_trace(
target, detail, _peer_prefix(from_task_id) + text)
with _auth_store(self.server.db_path) as store:
store.audit(user_id, target["project_id"],
"peer_message_sent",
"%s -> %s (%s)" % (from_task_id, task_id, status))
self._send_json(201, {
"from_task_id": from_task_id, "to_task_id": task_id,
"mode": mode, "delivery": status, "followup_id": (
detail if status == "delivered" else None)})
def _list_peer_messages(self, user_id, task_id):
"""列出某任务发出的互发消息(收到的消息在 /followups 里,
带 sender_task_id)。只含排成后续消息的那部分;复活投递
的那部分没有 followup 行,查审计 peer_message_sent 与目标
任务的续跑轮次。权限与 followup 列表同款:项目 read。"""
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
conn = self._db()
try:
_ensure_followups_table(conn)
rows = conn.execute(
"SELECT * FROM task_followups WHERE sender_task_id = ?"
" ORDER BY created_at, id",
(task_id,),
).fetchall()
finally:
conn.close()
self._send_json(200, {
"messages": [_followup_to_dict(r) for r in rows]})
def _fork_task(self, user_id, task_id):
"""会话分支:从已结束任务的 thread 分叉出新任务。
新任务继承源任务的 executor/模型/强度/权限/计划模式,
不继承 goal(分支是普通任务,首轮 prompt 就是这里传的
message)。worker 认领后先调 host.py fork(分叉 thread
历史 + 拷 worktree 文件),再以 message 跑首轮。
只能从终态任务分支(进行中的 thread 正在变,分叉快照
不可信);message 必填——分支没有首条消息就没有方向。
"""
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
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 self._reject_if_over_quota(user_id):
return
message = body.get("message")
if not message or not isinstance(message, str) \
or not message.strip():
self._send_json(400, {"error": "message required"})
return
message = message.strip()
if len(message) > 20000:
self._send_json(400, {"error": "message too long"})
return
if task["status"] not in TERMINAL_STATUSES:
self._send_json(409, {
"error": "只能从已结束的任务分支",
"status": task["status"],
})
return
now = time.time()
new_id = uuid.uuid4().hex
conn = self._db()
try:
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" idempotency_key, depends_on, priority, created_by,"
" created_at, updated_at, last_heartbeat, executor,"
" goal, verify_command, goal_max_rounds,"
" model, effort, approval, plan, forked_from)"
" VALUES(?, ?, ?, 'queued', NULL, '[]', ?, ?, ?, ?,"
" NULL, ?, NULL, NULL, ?, ?, ?, ?, ?, ?)",
(new_id, task["project_id"], message, task["priority"],
user_id, now, now, task["executor"],
task["goal_max_rounds"] or 3, task["model"],
task["effort"], task["approval"], task["plan"] or 0,
task_id),
)
# 分支带走附件清单(worktree 本身已含这些文件,分支
# 不再 seed;清单只作展示,bundle 删了也不影响分支)。
conn.execute(
"INSERT INTO task_attachments"
"(task_id, bundle_id, rel_path, size)"
" SELECT ?, bundle_id, rel_path, size"
" FROM task_attachments WHERE task_id = ?",
(new_id, task_id),
)
conn.commit()
new_task = _task_to_dict(
conn.execute(
"SELECT * FROM tasks WHERE id = ?", (new_id,)
).fetchone(),
_attachments_map(conn, [new_id])[new_id],
)
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"], "task_forked",
"%s -> %s" % (task_id, new_id))
self._send_json(201, new_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()
)
# 取消后 worker 不会再跑它:暂存包没人 seed 了,顺手删掉
# (任务终局后 worker 侧也会删,两边删同一目录都容忍
# 不存在;删失败不挡取消本身)。
try:
brow = conn.execute(
"SELECT bundle_id FROM task_attachments"
" WHERE task_id = ? LIMIT 1",
(task_id,),
).fetchone()
if brow is not None:
bundle_id = brow[0]
shutil.rmtree(
os.path.join(
_upload_dir_for(self.server), bundle_id),
ignore_errors=True)
except Exception:
pass
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 _merge_task(self, user_id, task_id):
"""把云端任务标记为已合并:只补状态,不执行合并本身。
本接口不做 git 合并。自动 PR 的正确顺序是:任务
completed -> 调本接口改状态 -> 调 POST /tasks/{id}/pr
推分支建 PR -> 再在 GitHub 上合 PR 或本地 host.py merge。
本地 host.py merge 成功后会删分支和 worktree,此后调
PR 会跳过 no_worktree,所以 PR 必须在本地 git merge
之前做,而不是之后。规则:任务必须存在(404)、调用方
必须是项目 maintainer 以上(approve_merge,403)、任务
必须是 completed(其它状态一律 409,含已 merged 的重复
调用,避免二次合并混淆)。成功后写审计 merge_task。
"""
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"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
if task["status"] != "completed":
self._send_json(409, {
"error": "only completed tasks can be marked merged",
"status": task["status"],
})
return
now = time.time()
conn = self._db()
try:
conn.execute(
"UPDATE tasks SET status = 'merged', 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"], "merge_task", task_id)
self._send_json(200, task)
def _continue_task(self, user_id, task_id):
"""旧任务接回来继续会话:终端任务(completed/failed/
interrupted/merge-failed)带一条新消息 + 全部选项重排队,
worker 认领后在同一会话线程上再跑一轮(不是新任务、不
是分支)。新消息存在 resume_message 列,worker 跑完清掉;
原 prompt 不动,详情里仍显示原 prompt。
规则:任务必须存在(404)、调用方须有 create_task 权限
(与后续消息同级:这本质上是“给旧会话发消息”)、任务
必须是终端且非 merged(queued/running 请用后续消息,
merged 的 worktree 已删、请分支,各 409)。选项与新建
任务同款校验:model(空串 = 跟随配置)、effort、approval、
plan、goal_mode;不传的选项沿用任务原值。冲突与新建
一致:plan + approval=full、plan + goal_mode 各 400。
goal_mode=1 时同时把任务转成新 goal 语义(goal=新消息、
verify_command 清空、轮次 0),worker 走 goal-resume
在旧线程上开新目标循环;goal_mode 不传时 goal 任务沿用
原语义续跑。状态回 queued 时上一轮 result/error 清空
(与重试一致,否则排队中的任务还挂着旧结果)。
并发下用 CAS(WHERE status IN 终端四态),没改到行
说明状态已变,返回 409 而不是假装成功。写审计
task_continued。
"""
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 ("queued", "running"):
self._send_json(409, {
"error": "进行中任务请用后续消息继续",
"status": task["status"],
})
return
if task["status"] == "merged":
self._send_json(409, {
"error": "已合并任务不能继续(worktree 已删),请分支",
"status": task["status"],
})
return
if task["status"] not in TERMINAL_STATUSES:
self._send_json(409, {
"error": "only terminal tasks can be continued",
"status": task["status"],
})
return
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
message = body.get("message")
if not isinstance(message, str) or not message.strip():
self._send_json(400, {"error": "message 必填"})
return
message = message.strip()
if len(message) > 20000:
self._send_json(400, {"error": "message too long"})
return
model = body.get("model")
if model is not None:
if not isinstance(model, str):
self._send_json(400, {
"error": "model must be a string"})
return
model = model.strip() or None
effort = body.get("effort")
if effort is not None and effort not in (
"low", "medium", "high", "xhigh", "max"):
self._send_json(400, {
"error": "effort must be low, medium, high, xhigh or max"})
return
approval = body.get("approval")
if approval is not None and approval not in (
"ask", "auto", "full"):
self._send_json(400, {
"error": "approval must be ask, auto or full"})
return
plan = None
if "plan" in body:
plan = 1 if body["plan"] in (True, 1) else 0
goal_mode = None
if "goal_mode" in body:
goal_mode = 1 if body["goal_mode"] in (True, 1) else 0
plan_eff = plan if plan is not None else (task["plan"] or 0)
goal_eff = goal_mode if goal_mode is not None \
else (task["goal_mode"] or 0)
approval_eff = approval if approval is not None \
else task["approval"]
if plan_eff and approval_eff == "full":
self._send_json(400, {
"error": "plan 模式与 approval=full 冲突(只读 vs 完全访问)"})
return
if plan_eff and goal_eff:
self._send_json(400, {
"error": "plan 模式与 goal 模式冲突"})
return
sets = ["status = 'queued'", "resume_message = ?",
"result_text = NULL", "error_text = NULL",
"actual_model = NULL", "updated_at = ?"]
vals = [message, time.time()]
if model is not None or "model" in body:
sets.append("model = ?")
vals.append(model)
if effort is not None:
sets.append("effort = ?")
vals.append(effort)
if approval is not None:
sets.append("approval = ?")
vals.append(approval)
if plan is not None:
sets.append("plan = ?")
vals.append(plan)
if goal_mode is not None:
sets.append("goal_mode = ?")
vals.append(goal_mode)
if goal_mode == 1:
sets.append("goal = ?")
vals.append(message)
sets.append("verify_command = NULL")
sets.append("goal_max_rounds = 0")
vals.append(task_id)
conn = self._db()
try:
cur = conn.execute(
"UPDATE tasks SET %s WHERE id = ? AND status IN"
" ('completed', 'failed', 'interrupted', 'merge-failed')"
% ", ".join(sets),
tuple(vals),
)
conn.commit()
if cur.rowcount != 1:
self._send_json(409, {
"error": "任务状态已变化,请刷新后重试",
"status": task["status"],
})
return
new_row = 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"], "task_continued",
task_id)
self._send_json(200, _task_to_dict(new_row))
def _retry_task(self, user_id, task_id):
"""把终态失败任务重新排队:只改状态,不直接执行。
规则:任务必须存在(404)、调用方必须是项目 maintainer
以上(approve_merge,403——重跑要再花模型钱,不能让
只有读权限或普通贡献者随便点)、任务必须是 failed /
interrupted / merge-failed(其它状态一律 409:running
本来就在跑,queued 本来就在等,completed/merged 已有
结果,重跑应是新任务而不是覆盖结果)。
成功后状态回 queued,上一轮的 result_text/error_text/
actual_model 清空(否则新一轮的结果会和旧结果混在一起
无法分辨);prompt、模型、优先级、依赖原样保留,依赖
已完成时重跑任务会立刻就绪。写审计 task_retried。
更新用 CAS(WHERE status IN (...)),避免与正在完成的
回合互相覆盖:若 CAS 没改到行,说明状态已变,返回
409 而不是假装成功。
"""
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"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
if self._reject_if_over_quota(user_id):
return
if task["status"] not in ("failed", "interrupted", "merge-failed"):
self._send_json(409, {
"error": "only failed/interrupted tasks can be retried",
"status": task["status"],
})
return
now = time.time()
conn = self._db()
try:
cur = conn.execute(
"UPDATE tasks SET status = 'queued', result_text = NULL,"
" error_text = NULL, actual_model = NULL, updated_at = ?"
" WHERE id = ? AND status IN"
" ('failed', 'interrupted', 'merge-failed')",
(now, task_id),
)
conn.commit()
if cur.rowcount != 1:
self._send_json(409, {
"error": "task status changed, retry aborted",
"status": task["status"],
})
return
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"], "task_retried", task_id)
self._send_json(200, task)
# -- 租户配额与计费 ----------------------------------------------
# 计费口径:只算 cost_records 里 usd_micros 非空的实际成本
# 行(estimated 行 usd 为 NULL,SUM 自动跳过,不算已花
# 的钱);归属按任务创建者 created_by,不按项目——一个
# 用户在多个项目里花的钱都算进他自己的月度账单,项目维
# 度的拆分由 _project_cost 另行提供。窗口是 UTC 自然月,
# 与 cost_records.day(UTC 日期)同口径。
def _quota_month(self):
from datetime import datetime, timezone
return datetime.now(timezone.utc).strftime("%Y-%m")
def _quota_status(self, user_id):
"""返回 (quota_usd_micros|None, used_usd_micros, month)。
quota 为 None 表示不限额:老用户与未设配额的用户默认
不限额,行为与过去一致,不会因为上了配额功能就突然
被挡住。"""
month = self._quota_month()
conn = self._db()
try:
_ensure_quotas_table(conn)
conn.commit()
# cost_records 由 CostStore 懒建:全新库上它还不存在,
# 直接 JOIN 会报 no such table——先实例化一次建表
# (与 _project_cost 同款做法),再跑配额查询。
_cost_store = CostStore(self.server.db_path)
try:
_cost_store._conn.close()
except Exception:
pass
row = conn.execute(
"SELECT quota_usd_micros FROM user_quotas"
" WHERE user_id = ?",
(user_id,)).fetchone()
quota = row[0] if row is not None else None
used = conn.execute(
"SELECT COALESCE(SUM(c.usd_micros), 0)"
" FROM cost_records c JOIN tasks t ON c.task_id = t.id"
" WHERE t.created_by = ? AND c.day LIKE ?",
(user_id, month + "%")).fetchone()[0]
return quota, int(used or 0), month
finally:
conn.close()
def _quota_payload(self, user_id, quota, used, month):
return {"user_id": user_id, "month": month,
"quota_usd_micros": quota, "used_usd_micros": used,
"remaining_usd_micros": (quota - used)
if quota is not None else None}
def _check_quota(self, user_id):
"""配额准入:放行返回 None;超限返回 (quota, used,
month),调用方一律回 402——这是钱用完了,不是请求
太快,所以不用 429。注意:只做建任务/分支/重跑时的
准入校验;跑起来的回合中途超限不会被掐断(worker
侧没有按金额中途停转的钩子,那是下一步的事)。"""
quota, used, month = self._quota_status(user_id)
if quota is not None and used >= quota:
return quota, used, month
return None
def _reject_if_over_quota(self, user_id):
blocked = self._check_quota(user_id)
if blocked is not None:
quota, used, month = blocked
self._send_json(402, {"error": "quota_exceeded",
"month": month,
"quota_usd_micros": quota,
"used_usd_micros": used})
return True
return False
def _is_global_maintainer(self, user_id):
"""全局管理判定:沿用 _admin_backup 的口径——在任一
项目里是 maintainer/owner 即算(没有单设的全局管理
员表;没有任何项目角色的用户无权改任何人的配额)。"""
conn = self._db()
try:
row = conn.execute(
"SELECT COUNT(*) FROM project_members"
" WHERE user_id = ? AND role IN ('maintainer', 'owner')",
(user_id,)).fetchone()
return (row[0] if row is not None else 0) > 0
finally:
conn.close()
def _user_exists(self, target):
conn = self._db()
try:
return conn.execute(
"SELECT 1 FROM users WHERE id = ?",
(target,)).fetchone() is not None
finally:
conn.close()
def _user_quota_self(self, user_id):
quota, used, month = self._quota_status(user_id)
self._send_json(200,
self._quota_payload(user_id, quota, used, month))
def _admin_quota_get(self, user_id, query):
if not self._is_global_maintainer(user_id):
self._send_json(403, {"error": "forbidden"})
return
target = (query.get("user_id") or [None])[0]
if not target or not isinstance(target, str):
self._send_json(400, {"error": "user_id required"})
return
if not self._user_exists(target):
self._send_json(404, {"error": "user not found"})
return
quota, used, month = self._quota_status(target)
self._send_json(200,
self._quota_payload(target, quota, used, month))
def _admin_quota_set(self, user_id):
if not self._is_global_maintainer(user_id):
self._send_json(403, {"error": "forbidden"})
return
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
target = body.get("user_id")
if not target or not isinstance(target, str):
self._send_json(400, {"error": "user_id required"})
return
if not self._user_exists(target):
self._send_json(404, {"error": "user not found"})
return
quota = body.get("quota_usd_micros")
if quota is not None:
try:
quota = int(quota)
except (TypeError, ValueError):
self._send_json(400, {
"error": "quota_usd_micros must be an integer or null"})
return
if quota < 0:
self._send_json(400, {
"error": "quota_usd_micros must be >= 0"})
return
now = time.time()
conn = self._db()
try:
cur = conn.execute(
"UPDATE user_quotas SET quota_usd_micros = ?,"
" updated_at = ? WHERE user_id = ?",
(quota, now, target))
if cur.rowcount != 1:
conn.execute(
"INSERT INTO user_quotas(user_id, quota_usd_micros,"
" updated_at) VALUES(?, ?, ?)",
(target, quota, now))
conn.commit()
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, None, "quota_set",
"%s:%s" % (target, quota))
quota, used, month = self._quota_status(target)
self._send_json(200,
self._quota_payload(target, quota, used, month))
# -- worker 池 -----------------------------------------------------
def _workers_list(self, user_id):
"""列出注册 worker:任何已认证用户可读(worker 是基础设施
信息,不属于某个项目的数据,不按项目成员过滤)。"""
workers = _registry_list(self.server.db_path)
self._send_json(200, {"workers": workers})
def _workers_heartbeat(self, user_id):
"""worker 心跳:body 含 worker_id(必填,缺了 400)与可选
executor/capacity。心跳本身就是注册:worker_id 不存在时
自动注册,绝不 404。"""
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
worker_id = body.get("worker_id")
if not worker_id or not isinstance(worker_id, str):
self._send_json(400, {"error": "worker_id required"})
return
executor = body.get("executor")
capacity = body.get("capacity")
if capacity is not None:
try:
capacity = int(capacity)
except (TypeError, ValueError):
self._send_json(400, {"error": "capacity must be an integer"})
return
try:
worker = _registry_heartbeat(
self.server.db_path, worker_id,
executor=executor, capacity=capacity)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
worker["alive"] = True
self._send_json(200, worker)
# -- 自动 PR(默认关闭,显式触发)----------------------------------
def _notify_pr_event(self, project_id, event_type, payload):
"""PR 事件推送:best-effort,失败只记日志,绝不破坏 PR 响应。
review-requested = PR 建成功(PR 在等评审);
merge-failed = 已 merged 任务的落盘(push/建 PR)失败,
任务状态保持 merged、可重调 /pr——事件说的是落盘失败,
不是状态变 merge-failed(云端 merge 端点只改状态,
没有真合并可失败)。
"""
try:
result = _push_dispatch(
self.server.db_path, event_type, project_id, payload)
except Exception as exc: # noqa: BLE001 -- 通知失败绝不炸 PR
_server_log.warning("pr notify %s failed: %s", event_type, exc)
return
if result.get("errors"):
_server_log.warning("pr notify %s errors: %s",
event_type, result["errors"])
def _task_pr(self, user_id, task_id):
"""给已 merged 的任务建 PR(见 cloud/pr.py 的跳过规则)。
权限:项目 maintainer 以上(approve_merge,403);任务
不存在 404;非 merged 状态 409;跳过时返回 200 +
pr_skipped 与 reason,任务状态保持 merged 不变;push/pr
创建失败时返回 502 并写清哪一步失败,任务同样保持 merged。
成功时把 pr_url/pr_number 写回任务并记审计 pr_created。
推送:成功时发 review-requested,落盘失败时发 merge-failed
(均为 best-effort,不影响 PR 响应;跳过时不发)。
HA 阶段 1:任务的 worktree_host 与本机不同时,不直接跑,
而是排 pr 任务给该主机(返回 200 + pr_queued,同一任务
未完成的任务去重),由该主机上的 local worker 执行;
worktree 在本机(或未记录)时同步跑,与旧行为一致。
App-token:项目 repo_url 能解析出 github.com 仓库名且
库里有该仓库的 App 安装记录时,现换 installation token
走 App 路径(响应里 auth_via=app),否则走原来的 gh +
PAT 路径(auth_via=pat);换 token 任一步失败都回落
PAT,不因此报错。token 只在本次请求内存里出现。
"""
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"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
if task["status"] != "merged":
self._send_json(409, {
"error": "only merged tasks can create a PR",
"status": task["status"],
})
return
# HA 阶段 1:worktree 在别机时不直接跑,排 pr 任务给该主机;
# 本机(或 worktree_host 未记录的老任务)走同步路。
worktree_host = task.get("worktree_host")
if worktree_host and worktree_host != socket.gethostname():
conn = self._db()
try:
existing = find_open_pr_job(conn, task_id)
if existing is not None:
job_id = existing["id"]
else:
job_id = enqueue_pr_job(
conn, task_id, task["project_id"], worktree_host)
finally:
conn.close()
self._send_json(200, {
"task_id": task_id, "status": task["status"],
"pr_queued": True, "pr_job_id": job_id,
"worktree_host": worktree_host,
})
return
try:
result = execute_task_pr(
self.server.db_path, task_id, user_id,
worktrees_root=getattr(
self.server, "host_worktrees_dir", None),
git_runner=getattr(self.server, "pr_git_runner", None),
gh_runner=getattr(self.server, "pr_gh_runner", None),
gh_which=getattr(self.server, "pr_gh_which", None),
app_token_client=getattr(
self.server, "pr_app_token_client", None),
github_api=getattr(self.server, "pr_github_api", None))
except PrSkipped as exc:
self._send_json(200, {
"task_id": task_id, "status": task["status"],
"pr_skipped": True, "reason": exc.reason,
})
return
except PrStepError as exc:
# merge-failed 事件已在 execute_task_pr 内发出,这里只映射响应。
self._send_json(502, {
"error": exc.message, "step": exc.step,
"task_id": task_id, "status": task["status"],
})
return
conn = self._db()
try:
task = _task_to_dict(
conn.execute(
"SELECT * FROM tasks WHERE id = ?",
(task_id,)).fetchone())
finally:
conn.close()
self._send_json(200, dict(task, pr_url=result["pr_url"],
pr_number=result["pr_number"],
branch=result["branch"],
auth_via=result["auth_via"]))
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 _admin_backup(self, user_id):
"""POST /admin/backup:备份当前后端数据库,返回路径与规模信息。
权限:调用方须在至少一个项目里是 maintainer 以上(全局
管理端点没有单个项目上下文,按“任一项目 maintainer+”
判定;无此角色返回 403)。返回只含 backend、备份文件
路径、文件大小与任务行数,绝不返回数据库密码、备份文件
内容或任何凭据。out_dir 可由 body 指定,否则走环境变量
CLOUD_BACKUP_DIR,未设则用 cloud-backups。
"""
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
conn = self._db()
try:
member_rows = conn.execute(
"SELECT role FROM project_members WHERE user_id = ?",
(user_id,),
).fetchall()
finally:
conn.close()
role_levels = {"viewer": 0, "contributor": 1,
"maintainer": 2, "owner": 3}
is_maintainer = any(
role_levels.get(row[0], 0) >= 2 for row in member_rows)
if not is_maintainer:
self._send_json(403, {"error": "forbidden"})
return
out_dir = body.get("out_dir") or os.environ.get(
"CLOUD_BACKUP_DIR") or "cloud-backups"
if not isinstance(out_dir, str) or not out_dir:
self._send_json(400, {"error": "out_dir must be a string"})
return
backend = _db_backend_name()
try:
if backend == "postgres":
from cloud.backup import backup_postgres as _backup_pg
dest = _backup_pg(out_dir, keep=3)
else:
from cloud.backup import backup_db as _backup_sqlite
dest = _backup_sqlite(self.server.db_path, out_dir, keep=3)
size_bytes = os.path.getsize(dest)
conn = self._db()
try:
task_count = conn.execute(
"SELECT COUNT(*) FROM tasks").fetchone()[0]
finally:
conn.close()
except Exception as exc:
self._send_json(500, {"error": "backup failed: %s" % (exc,)})
return
self._send_json(200, {
"backend": backend, "path": dest,
"size_bytes": size_bytes, "task_count": task_count,
})
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()
# 按状态分计数:七个状态键恒存在(缺失为 0),调用方
# 不用再猜某个状态是“零”还是“没统计”。
by_status = {key: 0 for key in TASK_STATUS_KEYS}
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, _PgOperationalError):
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_clients(self, provider):
"""回调用的 http/userinfo 客户端:测试注入优先,生产走真实路径。
server 属性 oauth_http_client / oauth_userinfo_client 由
create_server 置为 None(生产)或由测试注入 fake;http_client
已注入(fake)而 userinfo 未注入时不走真实 userinfo(fake
token 自带身份字段时不需要它,走真实端点反而会用假 token
撞真实 GitHub)。生产(两者都未注入)时 userinfo 走真实端点。
"""
http_client = getattr(self.server, "oauth_http_client", None)
userinfo_client = getattr(self.server, "oauth_userinfo_client", None)
if userinfo_client is None and http_client is None:
userinfo_client = default_userinfo_client(provider)
return http_client, userinfo_client
def _oauth_finish(self, provider, code, state, client_id, redirect_uri):
"""回调共用后半段:验 state -> 换 token -> 取身份 -> 分叉。
成功返回 6 元组
``(local_id, created, api_key_plain, rotated, identity, is_bind)``;
失败抛 OAuthError(调用方按 JSON/HTML 分别呈现,绝不把
access_token、client_secret、旧 API key 写进任何响应),
绑定冲突抛 BindError(调用方按 409 呈现)。
client_id / redirect_uri 为 None 时由 handle_oauth_callback
从 state 记录取回(真实浏览器回调只带 code + state)。
分叉规则(顺序即优先级):
1. state 带 bind_user_id -> 绑定分支:把身份绑到该本站
账号,返回 is_bind=True、api_key_plain=None(绑定成功页
绝不显示任何 key);冲突抛 BindError;
2. 身份已绑定到某本站账号 -> 登录落到被绑账号,并按
OAuth 登录规矩签发附加 key(created=False、rotated
恒为 False),与 oauth 账号登录同一语义,不发明新语义;
3. 否则走原来的确定性 oauth 账号映射(新建/复用 + 签发
附加 key)。
"""
store = OAuthStateStore(self.server.db_path)
try:
http_client, userinfo_client = self._oauth_clients(provider)
identity = handle_oauth_callback(
store, provider, code, state, client_id, redirect_uri,
http_client=http_client, userinfo_client=userinfo_client)
finally:
try:
store.conn.close()
except Exception:
pass
with _auth_store(self.server.db_path) as auth:
bind_user_id = identity.get("bind_user_id")
if bind_user_id:
binding = bind_external_identity(
auth, provider, identity, bind_user_id)
auth.audit(bind_user_id, "", "bind_external",
"%s:%s" % (provider, identity.get("external_id")))
identity["already_bound"] = binding["already"]
return (bind_user_id, False, None, False, identity, True)
bound_user_id = find_bound_user(auth, provider, identity)
if bound_user_id is not None:
_key_id, api_key_plain = auth.create_api_key(
bound_user_id, "oauth:%s" % provider)
auth.audit(bound_user_id, "", "oauth_login",
"%s:%s" % (provider, identity.get("external_id")))
auth.audit(bound_user_id, "", "api_key_issued", provider)
return (bound_user_id, False, api_key_plain, False, identity,
False)
local_id, created, api_key_plain, rotated = \
map_external_identity_to_local_user_with_key(
auth, provider, identity)
auth.audit(local_id, "", "oauth_login",
"%s:%s" % (provider, identity.get("external_id")))
if not created:
auth.audit(local_id, "", "api_key_issued", provider)
return local_id, created, api_key_plain, rotated, identity, False
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:
self._send_json(400, {"error": "code and state required"})
return
try:
local_id, created, api_key_plain, rotated, identity, is_bind = \
self._oauth_finish(provider, code, state, client_id,
redirect_uri)
except BindError as e:
self._send_json(409, {"error": str(e), "reason": e.reason})
return
except OAuthError as e:
self._send_json(400, {"error": str(e), "reason": e.reason})
return
if is_bind:
# 绑定成功:绝不返回任何 key(绑定页的 completer 未必是
# 发起人,key 一旦上页就可能落到别人手里);发起人用
# 自己的 key 调 GET /oauth/{provider}/bindings 核对。
self._send_json(200, {"linked": True, "provider": provider,
"login": identity.get("login"),
"external_id": identity.get("external_id"),
"user_id": local_id,
"already_bound":
identity.get("already_bound", False)})
return
# api_key 明文只在这里出现一次:调用方须立即保存,
# 之后任何接口都不再返回它;登录签发的是附加 key,旧 key
# 仍可继续使用(多 key;不用的旧 key 可在 keys 管理里撤销)。
self._send_json(200, {"user_id": local_id, "created": created,
"provider": provider,
"login": identity.get("login"),
"email": identity.get("email"),
"api_key": api_key_plain,
"api_key_rotated": rotated})
def _oauth_error_page(self, reason, message):
"""GET 回调的 400 错误页:只含 reason 与提示,不含任何密钥、
token 或旧 API key(message 来自 OAuthError,本身不含密钥,
仍做 HTML 转义)。"""
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>OAuth login failed</title></head><body>"
"<h1>OAuth login failed</h1>"
"<p>reason: %s</p><p>%s</p>"
"<p>Please restart login from the login page.</p>"
"</body></html>"
% (html.escape(str(reason or "error")),
html.escape(str(message))))
def _oauth_success_page(self, provider, local_id, created, login,
api_key_plain, rotated):
"""GET 回调的成功页:只显示 login、user_id、created 与新 API key
一次;绝不显示 access_token、client_secret、旧 API key。
rotated 只为向后兼容保留(登录不再轮换,恒为 False):
新 key 是附加 key,旧 key 仍可继续使用,页面不再写“旧 key
已失效”,而是说明多 key 与撤销入口。
"""
rotation_note = (
"<p>Your previous API key has been rotated and no longer works.</p>"
if rotated else
"<p>Previous keys still work (multi-key). Revoke unused keys "
"via the API keys management endpoints.</p>"
if not created else "")
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>OAuth login succeeded</title></head><body>"
"<h1>OAuth login succeeded</h1>"
"<p>provider: %s</p>"
"<p>login: %s</p>"
"<p>user_id: %s</p>"
"<p>new user: %s</p>"
"%s"
"<p>Your API key (shown only once, save it now):</p>"
"<p><code>%s</code></p>"
"<p>Please save it immediately. It will not be shown again "
"after you leave or refresh this page."
"</p>"
"<p>Use it as the X-API-Key header for API calls.</p>"
"</body></html>"
% (html.escape(str(provider)),
html.escape(str(login)),
html.escape(str(local_id)),
"yes" if created else "no",
rotation_note,
html.escape(str(api_key_plain))))
def _oauth_bind_success_page(self, provider, login):
"""绑定成功页:只显示 provider + 外部 login + 成功提示。
绝不显示任何 key,也不显示本站 user_id:回调页的 completer
未必是绑定发起人,key 与内部 id 一旦上页就可能落到别人
手里;发起人用自己的 key 调 GET /oauth/{provider}/bindings
核对绑定。
"""
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>OAuth binding succeeded</title></head><body>"
"<h1>OAuth binding succeeded</h1>"
"<p>provider: %s</p>"
"<p>login: %s</p>"
"<p>Your external identity is now bound to your account.</p>"
"<p>You can verify it from your bindings list.</p>"
"</body></html>"
% (html.escape(str(provider)),
html.escape(str(login))))
def _oauth_callback_get(self, path):
provider = self._oauth_provider(path)
if provider is None:
self._send_html(404, self._oauth_error_page(
"not_found", "unknown oauth path"))
return
query = parse_qs(urlparse(self.path).query)
code = (query.get("code") or [None])[0]
state = (query.get("state") or [None])[0]
client_id = (query.get("client_id") or [None])[0]
redirect_uri = (query.get("redirect_uri") or [None])[0]
if not code or not state:
self._send_html(400, self._oauth_error_page(
"missing_params", "code and state required"))
return
try:
local_id, created, api_key_plain, rotated, identity, is_bind = \
self._oauth_finish(provider, code, state, client_id,
redirect_uri)
except BindError as e:
self._send_html(409, self._oauth_error_page(
e.reason or "bind_conflict", str(e)))
return
except OAuthError as e:
self._send_html(400, self._oauth_error_page(
e.reason or "oauth_failed", str(e)))
return
if is_bind:
self._send_html(200, self._oauth_bind_success_page(
provider, identity.get("login")))
return
self._send_html(200, self._oauth_success_page(
provider, local_id, created, identity.get("login"),
api_key_plain, rotated))
# -- OAuth 绑定(已有账号绑外部身份)------------------------------
def _oauth_bind_route(self, path):
"""绑定相关路径解析:返回 (provider, action) 或 None。
POST /oauth/{provider}/bind/initiate -> (provider, "bind_initiate")
GET|DELETE /oauth/{provider}/bindings -> (provider, "bindings")
其它 /oauth/ 路径返回 None,走原来的登录/回调分发。
"""
parts = [p for p in path.split("/") if p]
if len(parts) == 4 and parts[0] == "oauth" \
and parts[2] == "bind" and parts[3] == "initiate":
return parts[1], "bind_initiate"
if len(parts) == 3 and parts[0] == "oauth" \
and parts[2] == "bindings":
return parts[1], "bindings"
return None
def _oauth_bind_initiate(self, provider):
"""绑定起点:须持本站 key(与登录不同,绑定必须知道绑到谁)。
body 须含 client_id + redirect_uri(与登录同款);state 里
带 bind_user_id,回调时靠它走绑定分支。只返回授权 URL,
不直接跳转;跳转由调用方决定。
"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
client_id = body.get("client_id")
redirect_uri = body.get("redirect_uri")
scope = body.get("scope")
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 = store.create_state(
provider, redirect_uri, client_id=client_id,
bind_user_id=user_id)
url = build_authorization_url(
provider, client_id, redirect_uri, scope=scope, state=state)
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
self._send_json(200, {"provider": provider, "state": state,
"authorization_url": url})
def _oauth_bindings_get(self, provider):
"""列出调用方自己的绑定(只看得到自己的,别人的看不到)。"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
with _auth_store(self.server.db_path) as auth:
bindings = list_bindings(auth, user_id)
self._send_json(200, {"provider": provider, "bindings": bindings})
def _oauth_bindings_delete(self, provider):
"""解绑调用方自己在该 provider 上的绑定:本来就没有返回 404。
解绑后该身份登录恢复原来的 oauth 账号映射(旧 oauth 账号
若存在,key 仍可用,不会被解绑删掉)。
"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
with _auth_store(self.server.db_path) as auth:
ok = unbind_external_identity(auth, provider, user_id)
if ok:
auth.audit(user_id, "", "unbind_external", provider)
if not ok:
self._send_json(404, {"error": "no binding for provider"})
return
self._send_json(200, {"unbound": True, "provider": provider})
# -- GitHub App 一键安装 ------------------------------------------
def _github_app_public_base(self):
base = os.environ.get("GITHUB_APP_PUBLIC_BASE") or ""
return base.rstrip("/") or None
def _github_app_error_page(self, reason, message):
"""App 流程错误页:只含 reason 与提示,不含任何 secret、
pem、code、state(message 来自流程错误,本身不含密钥,
仍做 HTML 转义)。"""
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>GitHub App setup failed</title></head><body>"
"<h1>GitHub App setup failed</h1>"
"<p>reason: %s</p><p>%s</p>"
"</body></html>"
% (html.escape(str(reason or "error")),
html.escape(str(message))))
def _github_app_setup_state(self):
"""签发 setup state:须持本站 key(只有操作方能发起建 App)。
要求环境变量 GITHUB_APP_PUBLIC_BASE(公网域名,如
https://api.diante.me):manifest 里的 hook / 回调地址
都由它拼出。返回 {state, setup_url},setup_url 是用户
在浏览器里打开的表单页;state 一次性、1 小时过期。
"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
base = self._github_app_public_base()
if not base:
self._send_json(400, {
"error": "GITHUB_APP_PUBLIC_BASE not configured"})
return
body, err = self._read_json()
if err:
body = {}
org = (body or {}).get("org")
store = SetupStateStore(self.server.db_path)
try:
state = store.create_state()
finally:
try:
store.conn.close()
except Exception:
pass
setup_url = base + "/github-app/setup?state=" + state
if org:
setup_url += "&org=" + quote(str(org))
self._send_json(200, {"state": state, "setup_url": setup_url})
def _github_app_setup_page(self):
"""建 App 表单页:公开页,但 state 必须是操作方签发的。
无 state 或 state 未签发时不渲染表单(state 是回调的
唯一凭证,公开页自己现发 state 等于谁都能覆盖 App 配置)。
表单按 GitHub manifest 文档 POST 到 github.com 的建 App
页(个人账号 /settings/apps/new,组织账号
/organizations/{org}/settings/apps/new),manifest JSON
由 JS 填进隐藏字段;用户在 GitHub 页确认 Create 后跳回
本站 /github-app/callback。
"""
query = parse_qs(urlparse(self.path).query)
state = (query.get("state") or [None])[0]
org = (query.get("org") or [None])[0]
if not state:
self._send_html(400, self._github_app_error_page(
"missing_state",
"setup state required: ask the operator to issue one"))
return
base = self._github_app_public_base()
if not base:
self._send_html(400, self._github_app_error_page(
"not_configured", "GITHUB_APP_PUBLIC_BASE not configured"))
return
manifest = build_app_manifest(
base,
app_name=os.environ.get("GITHUB_APP_NAME") or DEFAULT_APP_NAME,
app_url=os.environ.get("GITHUB_APP_URL") or DEFAULT_APP_URL,
public=os.environ.get("GITHUB_APP_PUBLIC") == "1")
manifest_json = json.dumps(manifest).replace("<", "\\u003c")
if org:
action = ("https://github.com/organizations/%s"
"/settings/apps/new?state=%s"
% (quote(str(org)),
quote(state)))
else:
action = ("https://github.com/settings/apps/new?state=%s"
% quote(state))
perms = ", ".join("%s: %s" % kv
for kv in manifest["default_permissions"].items())
events = ", ".join(manifest["default_events"])
page = (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>Create GitHub App</title></head><body>"
"<h1>Create GitHub App</h1>"
"<p>App name: %s</p>"
"<p>Webhook URL: %s</p>"
"<p>Permissions: %s</p>"
"<p>Events: %s</p>"
"<p>Click Submit to create the app on GitHub. GitHub will "
"redirect back here to finish setup.</p>"
"<form action=\"%s\" method=\"post\">"
"<input type=\"hidden\" name=\"manifest\" id=\"manifest\">"
"<input type=\"submit\" value=\"Create GitHub App\">"
"</form>"
"<script>document.getElementById(\"manifest\").value = "
"JSON.stringify(%s);</script>"
"</body></html>"
% (html.escape(manifest["name"]),
html.escape(manifest["hook_attributes"]["url"]),
html.escape(perms), html.escape(events),
html.escape(action), manifest_json))
self._send_html(200, page)
def _github_app_callback(self):
"""建 App 回调:验 setup state -> 换 code -> 存配置。
state 一次性、1 小时过期;换 code 失败或响应缺字段时
报错误页,不存任何东西。成功页只显示 App 身份(slug、
id、主页)与安装链接,绝不显示 webhook_secret 与 pem
(两者只在库里,server 进程内用)。
"""
query = parse_qs(urlparse(self.path).query)
code = (query.get("code") or [None])[0]
state = (query.get("state") or [None])[0]
if not code or not state:
self._send_html(400, self._github_app_error_page(
"missing_params", "code and state required"))
return
store = SetupStateStore(self.server.db_path)
try:
ok = store.consume_state(state)
finally:
try:
store.conn.close()
except Exception:
pass
if not ok:
self._send_html(400, self._github_app_error_page(
"invalid_state", "setup state invalid or expired"))
return
http_client = getattr(self.server, "github_app_exchange_client",
None)
try:
config = exchange_manifest_code(code, http_client=http_client)
except AppError as e:
self._send_html(400, self._github_app_error_page(
e.reason or "exchange_failed", str(e)))
return
conn = self._db()
try:
save_app_config(conn, config)
finally:
conn.close()
slug = config.get("slug") or ""
page = (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>GitHub App created</title></head><body>"
"<h1>GitHub App created</h1>"
"<p>App: %s</p>"
"<p>App ID: %s</p>"
"<p>Install it on your repositories:</p>"
"<p><a href=\"https://github.com/apps/%s/installations/new\">"
"Install this GitHub App</a></p>"
"<p>After installing, webhooks from the app are verified "
"automatically. API actions (push, PR) still use the "
"existing PAT until the App-token phase; you may remove "
"the manual webhook once the app is installed.</p>"
"</body></html>"
% (html.escape(slug),
html.escape(str(config.get("app_id"))),
html.escape(slug)))
self._send_html(200, page)
def _github_app_status(self):
"""App 配置与安装状态:须持本站 key;绝不返回 secret/pem。"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
conn = self._db()
try:
status = public_app_status(conn)
status["installations"] = list_installations(conn)
finally:
conn.close()
self._send_json(200, status)
# -- API keys 管理 ---------------------------------------------------
def _user_keys_list(self):
"""列出自己的 key:primary(id "legacy")+ 附加 key。
只返回 id / label / 后 4 位提示 / 创建 / 最近使用 / 撤销
时间,绝不返回明文与哈希;只看得到自己的(认证用户)。
"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
with _auth_store(self.server.db_path) as auth:
keys = auth.list_api_keys(user_id)
self._send_json(200, {"keys": keys})
def _user_keys_create(self):
"""给自己签发一个附加 key:明文只在本次响应里出现一次。
body 可含 label(字符串,缺省 "api-key");新 key 立刻
可用,与 primary 及已有附加 key 互不影响。
"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
body, err = self._read_json()
if err:
body = {}
label = (body or {}).get("label")
if not isinstance(label, str) or not label:
label = "api-key"
with _auth_store(self.server.db_path) as auth:
key_id, api_key_plain = auth.create_api_key(user_id, label)
self._send_json(201, {"id": key_id, "label": label,
"hint": api_key_plain[-4:],
"api_key": api_key_plain})
def _user_keys_revoke(self, key_id):
"""撤销自己的一个 key:附加 key 记 revoked_at,primary
(id "legacy")清哈希;无此 key 或已撤销返回 404。
撤销自己的最后一个 key 后须重新登录拿新 key(登录总能
签发,不会把自己锁死在账号外)。
"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
with _auth_store(self.server.db_path) as auth:
ok = auth.revoke_api_key(user_id, key_id)
if not ok:
self._send_json(404, {"error": "key not found"})
return
self._send_json(200, {"revoked": True, "id": key_id})
def _user_keys_revoke_others(self):
"""撤销除本次请求 key 之外的全部自己的 key(附加 + primary),
返回撤销数。用于“这台设备之外的 key 全作废”;本次请求
的 key 本身不受影响。"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
api_key = self.headers.get("X-API-Key") or ""
keep_hash = hashlib.sha256(api_key.encode()).hexdigest()
with _auth_store(self.server.db_path) as auth:
count = auth.revoke_other_api_keys(user_id, keep_hash)
self._send_json(200, {"revoked_count": count})
# -- 推送订阅与分发 ---------------------------------------------------
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
# 分发:sender=None 时用 push 的内置真实发送端
# (webhook/slack 真 POST,webpush/apns/fcm 仍报缺凭证);
# 显式注入的 sender 优先于内置端(见 push.dispatch_event)。
result = _push_dispatch(
self.server.db_path, event_type, project_id,
body.get("payload") or {}, sender=None)
self._send_json(200, result)
# -- webhooks(签名认证,不走 X-API-Key)---------------------------
def _handle_github_webhook(self):
"""GitHub webhook:验签 -> delivery 去重 -> 解析 -> 执行建任务。
顺序是故意的:secret 未配 503、签名无效 401(两者都在
去重之前,避免未验签的重复送达污染去重表);同一
X-GitHub-Delivery 第二次送达直接回 200 duplicate,不
重复建任务、不重复记审计。验签接受两个 secret:App
webhook secret(库里,App 装好后)与 env 里
GITHUB_WEBHOOK_SECRET(手动 webhook,过渡期共存;App
装好、手动 webhook 删掉后自然不再被命中)。
/fix-review 解析成功后在控制面建一个 queued 任务(prompt
含 repo、PR 号、评论原文、作者),由 worker 认领执行——
webhook 本身不跑模型。任务归属:按项目名 == repo
full_name 匹配项目;没有匹配项目时不凭空建项目、不建
任务,返回 task_id 为 None 并说明原因(worker 的 repo
仍是配置里的 repo,不是任意 GitHub 仓库的自动克隆,
见 README)。check_run / check_suite 只做通知解析,
返回解析结果而不建任务、不改云端任务状态(CI 状态回填
由 pr-checks 主动拉取)。installation / installation_repositories
事件记安装记录(created/added 增行、removed 减行、deleted
清安装),不建任务。手动 webhook 与 App webhook 共存时同一评论
会送两遍(delivery_id 不同):/fix-review 另有内容去重
(repo + PR + 评论 id),只建一次任务。
"""
raw = self._read_body()
app_secret = None
conn = self._db()
try:
config = get_app_config(conn)
if config is not None:
app_secret = config.get("webhook_secret")
finally:
conn.close()
secrets = [s for s in
(app_secret, os.environ.get("GITHUB_WEBHOOK_SECRET"))
if s]
if not secrets:
self._send_json(503, {"error": "github webhook secret not configured"})
return
signature = self.headers.get("X-Hub-Signature-256")
if not verify_github_signature_any(secrets, 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
event = self.headers.get("X-GitHub-Event")
delivery_id = self.headers.get("X-GitHub-Delivery")
repository = payload.get("repository") or {}
repo_name = repository.get("full_name")
store = WebhookDeliveryStore(self.server.db_path)
try:
if not store.claim(delivery_id, event=event, repo=repo_name):
self._send_json(200, {
"duplicate": True, "delivery_id": delivery_id,
})
return
finally:
try:
store.close()
except Exception:
pass
# installation / installation_repositories 事件:记安装记录,
# 不建任务。后者是安装后增减仓库的同步事件(added 增行、
# removed 减行),与 installation 事件共用同一解析与动作
# 分发;new_permissions_accepted 无需改行。
if event in ("installation", "installation_repositories"):
inst = parse_installation_event(payload)
if inst is None:
self._send_json(200, {"result": None})
return
conn = self._db()
try:
action = inst["action"]
if action == "created":
record_installation_repos(
conn, inst["installation_id"],
inst["account_login"], inst["repos"])
elif action == "added":
record_installation_repos(
conn, inst["installation_id"],
inst["account_login"], inst["repos"])
elif action == "removed":
remove_installation_repos(
conn, inst["installation_id"], inst["repos"])
elif action == "deleted":
remove_installation(conn, inst["installation_id"])
# new_permissions_accepted:权限已接受,无需改行。
finally:
conn.close()
with _auth_store(self.server.db_path) as auth:
auth.audit("system", "", "github_app_installation",
"%s:%s" % (inst["installation_id"],
inst["action"]))
self._send_json(200, {
"result": "installation_recorded",
"installation_id": inst["installation_id"],
"action": inst["action"],
"repos": inst["repos"],
})
return
# check_run / check_suite:只解析,不建任务、不改状态。
check = parse_github_check_run(payload)
if check is not None:
self._send_json(200, check)
return
# issue_comment 与 pull_request_review_comment:后者把
# pull_request.number 归一化成 parser 要的 issue.number。
result = parse_github_pr_comment(payload)
if result is None and payload.get("pull_request") and \
payload.get("comment"):
normalized = dict(payload)
normalized["issue"] = {
"number": (payload["pull_request"] or {}).get("number")}
result = parse_github_pr_comment(normalized)
if result is None:
self._send_json(200, {"result": None})
return
# /fix-review 执行:建 queued 任务(不跑模型,worker 认领)。
conn = self._db()
try:
project_row = None
if repo_name:
project_row = conn.execute(
"SELECT id FROM projects WHERE name = ?",
(repo_name,),
).fetchone()
if project_row is None:
self._send_json(200, dict(
result, task_id=None, project_id=None,
error="no matching project for repo"))
return
project_id = project_row[0]
# 内容去重:手动 webhook 与 App webhook 共存时同一评论
# 送两遍(delivery_id 不同),只建一次任务;重复送达
# 回读当初的 task_id,不重复记审计。
comment_id = (payload.get("comment") or {}).get("id")
is_new, existing_task_id = claim_fix_review(
conn, repo_name, result.get("pr_number"), comment_id)
if not is_new:
self._send_json(200, dict(
result, task_id=existing_task_id,
project_id=project_id, duplicate=True))
return
task_id = uuid.uuid4().hex
now = time.time()
prompt = (
"[webhook /fix-review] repo=%s pr=%s author=%s\n%s"
% (result.get("repo"), result.get("pr_number"),
result.get("author"), result.get("comment")))
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', ?, '[]', 0, 'system',"
" ?, ?, NULL)",
(task_id, project_id, prompt,
"webhook:%s" % delivery_id if delivery_id else None,
now, now),
)
conn.commit()
except (sqlite3.IntegrityError, _PgIntegrityError):
# 并发/去重兜底:同 delivery 已建过任务时回读它,
# 不建重复任务、不重复记审计。
row = conn.execute(
"SELECT id FROM tasks WHERE project_id = ?"
" AND idempotency_key = ?",
(project_id,
"webhook:%s" % delivery_id if delivery_id else None),
).fetchone()
if row:
self._send_json(200, dict(
result, task_id=row[0], project_id=project_id,
duplicate=True))
return
raise
set_fix_review_task_id(
conn, repo_name, result.get("pr_number"), comment_id,
task_id)
finally:
conn.close()
with _auth_store(self.server.db_path) as auth:
auth.audit("system", project_id, "webhook_fix_review", task_id)
self._send_json(200, dict(
result, task_id=task_id, project_id=project_id))
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,部署步骤见 cloud/README.md)。
"""
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
# 限流器:Postgres 下 DB 共享计数(多 server 共用),否则内存
# 滑动窗口;默认值与环境变量覆盖见 _make_rate_limiter;测试可
# 直接改 server.rate_limit_per_minute / window_seconds。
server.rate_limiter = _make_rate_limiter(db_path)
server.rate_limit_per_minute = None
server.rate_limit_window_seconds = None
# OAuth 回调注入点:生产为 None(走真实换 token + 真实 userinfo),
# 测试可注入 fake http_client / userinfo_client;绝不注入真实 secret。
server.oauth_http_client = None
server.oauth_userinfo_client = None
# 自动 PR 注入点:生产为 None(走真实 git/gh),测试可注入
# fake git/gh runner 与 worktree 根;绝不注入真实凭证。
server.pr_git_runner = None
server.pr_gh_runner = None
server.pr_gh_which = None
server.host_worktrees_dir = None
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)
start_rate_limit_sweeper(server.rate_limiter, args.db)
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()