Cloud dispatch pool + auto PR (opt-in, skip clearly)
Worker pool: workers table + register/heartbeat (heartbeat is
registration, no 404), GET /workers with alive (120s default),
POST /workers/heartbeat (X-API-Key, missing worker_id 400).
Tasks gain executor (local/docker/cloud, None = any worker);
worker claim filters by executor in pick_next and in the CAS
WHERE clause, CAS semantics unchanged; CloudExecutor _name
now correctly returns cloud, not local.
CloudExecutor: generic HTTP runner interface (endpoint from
explicit arg or CLOUD_RUNNER_ENDPOINT, auth header name only,
token value only from env into the request header, never into
logs/body/errors); no endpoint configured = explicit failure,
never silent downgrade. Vendor adapters (ECS/Cloud Run) are
out of scope, documented as such.
Auto PR: projects.auto_pr (0/1, create-time only, no PATCH
route exists); POST /tasks/{id}/pr (maintainer+ via
approve_merge) only for merged tasks (else 409); skip reasons
in order: auto_pr_disabled, no_worktree, no_remote, no_gh,
gh_not_authenticated — all return 200 with pr_skipped, task
stays merged, no push attempted; push/pr-create failure
returns 502 with step, task stays merged; success writes
pr_url/pr_number and audits pr_created. No default auto-push
from the worker. Remote URLs in errors are scrubbed of
userinfo; gh credentials are managed by gh itself, never
stored by the control plane.
Tests: host 27 pass, cloud 323 pass (8 skipped are
environment-dependent real-Postgres tests).
This commit is contained in:
1 parent
d3e588e4b3
commit
306cdf0d0c
8 files changed
+1256
-34
No files matched your search
+176
-7
@@ -59,6 +59,11 @@ from cloud.db import PostgresIntegrityError as _PgIntegrityError
|
||||
from cloud.db import PostgresOperationalError as _PgOperationalError
|
||||
from cloud.kb import KBStore
|
||||
from cloud.memory import MemoryStore
|
||||
from cloud.pr import PrSkipped, PrStepError, create_task_pr
|
||||
from cloud.workers import (
|
||||
heartbeat_worker as _registry_heartbeat,
|
||||
list_workers as _registry_list,
|
||||
)
|
||||
from cloud.oauth import (
|
||||
OAuthError,
|
||||
OAuthStateStore,
|
||||
@@ -227,6 +232,9 @@ def migrate_tasks_table(conn):
|
||||
"started_at": "REAL",
|
||||
"finished_at": "REAL",
|
||||
"child_pid": "INTEGER",
|
||||
"executor": "TEXT",
|
||||
"pr_url": "TEXT",
|
||||
"pr_number": "INTEGER",
|
||||
}
|
||||
for name, coltype in new_cols.items():
|
||||
if name not in cols:
|
||||
@@ -246,6 +254,8 @@ def migrate_projects_table(conn):
|
||||
"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 _is_postgres_backend():
|
||||
@@ -275,7 +285,8 @@ def init_db(db_path):
|
||||
"name TEXT, "
|
||||
"owner_id TEXT, "
|
||||
"created_at REAL, "
|
||||
"repo_url TEXT)"
|
||||
"repo_url TEXT, "
|
||||
"auto_pr INTEGER)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS tasks("
|
||||
@@ -346,6 +357,9 @@ def _task_to_dict(row):
|
||||
"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"),
|
||||
}
|
||||
|
||||
|
||||
@@ -358,6 +372,9 @@ def _project_to_dict(row):
|
||||
# 老库行缺 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -569,6 +586,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
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":
|
||||
@@ -595,6 +616,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
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("/heartbeat") and method == "POST":
|
||||
self._heartbeat_task(user_id, rest[: -len("/heartbeat")])
|
||||
else:
|
||||
@@ -626,6 +649,18 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
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:
|
||||
@@ -637,8 +672,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT INTO projects(id, name, owner_id, created_at,"
|
||||
" repo_url) VALUES(?, ?, ?, ?, ?)",
|
||||
(project_id, name, user_id, now, repo_url),
|
||||
" repo_url, auto_pr) VALUES(?, ?, ?, ?, ?, ?)",
|
||||
(project_id, name, user_id, now, repo_url, auto_pr),
|
||||
)
|
||||
conn.commit()
|
||||
project = {
|
||||
@@ -647,6 +682,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
"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"})
|
||||
@@ -671,7 +707,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
).fetchall()
|
||||
}
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, owner_id, created_at, repo_url"
|
||||
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
|
||||
" FROM projects ORDER BY created_at, id"
|
||||
).fetchall()
|
||||
finally:
|
||||
@@ -1095,6 +1131,14 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
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
|
||||
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"})
|
||||
@@ -1120,10 +1164,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
conn.execute(
|
||||
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||||
" idempotency_key, depends_on, priority, created_by,"
|
||||
" created_at, updated_at, last_heartbeat)"
|
||||
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL)",
|
||||
" created_at, updated_at, last_heartbeat, executor)"
|
||||
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?)",
|
||||
(task_id, project_id, prompt, idempotency_key,
|
||||
json.dumps(depends_on), priority, user_id, now, now),
|
||||
json.dumps(depends_on), priority, user_id, now, now,
|
||||
executor),
|
||||
)
|
||||
conn.commit()
|
||||
except (sqlite3.IntegrityError, _PgIntegrityError):
|
||||
@@ -1270,6 +1315,124 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
store.audit(user_id, task["project_id"], "merge_task", task_id)
|
||||
self._send_json(200, task)
|
||||
|
||||
# -- 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 _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。
|
||||
"""
|
||||
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
|
||||
conn = self._db()
|
||||
try:
|
||||
prow = conn.execute(
|
||||
"SELECT auto_pr FROM projects WHERE id = ?",
|
||||
(task["project_id"],)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
auto_pr = 0
|
||||
if prow is not None:
|
||||
try:
|
||||
auto_pr = prow["auto_pr"] or 0
|
||||
except (KeyError, IndexError, TypeError):
|
||||
auto_pr = 0
|
||||
try:
|
||||
result = create_task_pr(
|
||||
task_id, auto_pr,
|
||||
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))
|
||||
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:
|
||||
self._send_json(502, {
|
||||
"error": exc.message, "step": exc.step,
|
||||
"task_id": task_id, "status": task["status"],
|
||||
})
|
||||
return
|
||||
now = time.time()
|
||||
conn = self._db()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET pr_url = ?, pr_number = ?,"
|
||||
" updated_at = ? WHERE id = ?",
|
||||
(result["pr_url"], result["pr_number"], 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"], "pr_created",
|
||||
result["pr_url"])
|
||||
self._send_json(200, dict(task, pr_url=result["pr_url"],
|
||||
pr_number=result["pr_number"],
|
||||
branch=result["branch"]))
|
||||
|
||||
def _heartbeat_task(self, user_id, task_id):
|
||||
row = self._fetch_task(task_id)
|
||||
if row is None:
|
||||
@@ -1857,6 +2020,12 @@ def create_server(db_path, host="127.0.0.1", port=8765, backend=None):
|
||||
# 测试可注入 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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user