feat: 命令台云端环境——选、建、管 + 任务绑定环境
环境是项目仓库的命名基准检出(项目 + 仓库快照 + 分支 + 执行器), 不是官方 Codex 云端环境(无公开调用口,不宣称能建它)。 - DB:environments 表(creating/ready/failed/archived,部分唯一索引 保证同项目未归档名唯一、归档名可重用)+ tasks.environment_id, server/worker/db 三处迁移同步(含 #67 的 goal_mode,不丢新语义)。 - API:POST/GET /environments、GET /environments/{id}、 POST /environments/{id}/archive;任务创建选环境须本项目已 ready、 执行器一致或继承、cloud 执行器拒绝。 - Worker:run_all 每轮开头准备 creating 环境(真实 git clone + checkout 分支,幂等、无 preparing 卡死态,准备期归档 CAS 不覆盖); 环境任务派发时 task[repo] 指到环境检出(租户 Docker 容器内路径 + envs 整目录挂载),任务仍一任务一 worktree。 - Console:环境卡(列表/新建/归档,未 ready 置灰)+ 新建任务环境下拉 + 任务列表 [环境] 标记;公网 nginx 须同批代理 /environments。 - 单测:新增 test_task_environments.py 14 个;本地实弹(真实 git clone octocat/Hello-World,不跑模型)项目→环境→任务→归档全通。
This commit is contained in:
1 parent
c86b450851
commit
3d45edc8b4
6 files changed
+1224
-11
No files matched your search
+327
-3
@@ -452,6 +452,10 @@ def migrate_tasks_table(conn):
|
||||
# read-only 沙箱;forked_from 记源任务 id(非分支为 NULL)。
|
||||
"plan": "INTEGER",
|
||||
"forked_from": "TEXT",
|
||||
# 环境:任务绑定的环境 id(NULL = 不指定环境,走原有
|
||||
# 项目仓库路径);环境本身是项目仓库的一个命名基准检出
|
||||
# (分支 + 执行器),任务 worktree 从它建,不改任务隔离。
|
||||
"environment_id": "TEXT",
|
||||
}
|
||||
for name, coltype in new_cols.items():
|
||||
if name not in cols:
|
||||
@@ -510,6 +514,85 @@ def _followup_to_dict(row):
|
||||
}
|
||||
|
||||
|
||||
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'")
|
||||
|
||||
|
||||
_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")
|
||||
@@ -530,6 +613,7 @@ def init_db(db_path):
|
||||
migrate_tasks_table(conn)
|
||||
migrate_projects_table(conn)
|
||||
_ensure_followups_table(conn)
|
||||
_ensure_environments_table(conn)
|
||||
conn.commit()
|
||||
return
|
||||
conn.execute(
|
||||
@@ -559,6 +643,7 @@ def init_db(db_path):
|
||||
migrate_tasks_table(conn)
|
||||
migrate_projects_table(conn)
|
||||
_ensure_followups_table(conn)
|
||||
_ensure_environments_table(conn)
|
||||
# worker 每轮 poll 都按 status 取任务(见 load_tasks 的
|
||||
# WHERE status IN (...)),无索引时是全表扫描;任务表随
|
||||
# 时间变大后索引是必须的。IF NOT EXISTS 保证老库幂等。
|
||||
@@ -630,6 +715,7 @@ def _task_to_dict(row):
|
||||
"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"),
|
||||
}
|
||||
|
||||
|
||||
@@ -927,6 +1013,18 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._relay_models(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 == "/tasks" and method == "POST":
|
||||
self._create_task(user_id)
|
||||
elif path == "/tasks" and method == "GET":
|
||||
@@ -969,6 +1067,190 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
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()
|
||||
@@ -1599,6 +1881,46 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
if not store.check_permission(user_id, project_id, "create_task"):
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
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"]
|
||||
now = time.time()
|
||||
conn = self._db()
|
||||
try:
|
||||
@@ -1618,13 +1940,15 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
" 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)"
|
||||
" model, effort, approval, plan, goal_mode,"
|
||||
" environment_id)"
|
||||
" 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),
|
||||
model, effort, approval, plan, goal_mode,
|
||||
environment_id),
|
||||
)
|
||||
conn.commit()
|
||||
except (sqlite3.IntegrityError, _PgIntegrityError):
|
||||
|
||||
Reference in new issue
Block a user