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:
host committed 2026-10-27 04:07:54 +08:00
1 parent c86b450851
commit 3d45edc8b4
6 files changed
+1224 -11

No files matched your search

+90 -4
View File
@@ -118,9 +118,30 @@
</div>
</div>
<section class="card">
<h2>环境 <span class="muted">(选、建、管:当前项目的基准检出)</span></h2>
<div class="bar">
<button id="refreshEnvironments">刷新</button>
<span id="envCount" class="muted"></span>
</div>
<div id="envList" class="muted">先选项目</div>
<div class="bar">
<input id="newEnvName" type="text" placeholder="新环境名">
<input id="newEnvBranch" type="text" placeholder="分支(可选,空=默认分支)">
<select id="newEnvExecutor">
<option value="">执行器:不限</option>
<option value="local">local</option>
<option value="docker">docker</option>
</select>
<button id="createEnvironment">新建环境</button>
</div>
<span id="envMsg" class="muted"></span>
</section>
<section class="card">
<h2>新建任务</h2>
<div class="bar">
<select id="envSelect"><option value="">环境:不指定(走项目仓库)</option></select>
<select id="executor">
<option value="">executor:任意 worker</option>
<option value="local">local</option>
@@ -300,6 +321,51 @@ async function loadWorkers() {
} catch (e) { $("#workers").textContent = "加载失败:" + e.message; }
}
async function loadEnvironments() {
var pid = $("#projectSelect").value;
var sel = $("#envSelect");
var cur = sel.value;
if (!pid) {
$("#envList").textContent = "先选项目";
$("#envCount").textContent = "";
sel.innerHTML = "<option value=''>环境:不指定(走项目仓库)</option>";
return;
}
try {
var d = await api("/environments?project_id=" + encodeURIComponent(pid), "GET");
var envs = d.environments || [];
$("#envCount").textContent = envs.length + " 个环境";
if (!envs.length) { $("#envList").textContent = "该项目暂无环境(先建一个,或不指定环境直接建任务)"; }
else {
$("#envList").innerHTML = envs.map(function (e) {
var btn = e.status === "archived" ? "" : " <button data-env-archive='" + esc(e.id) + "'>归档</button>";
var err = e.status === "failed" && e.error_text ? " / 失败原因:" + esc(e.error_text) : "";
return esc(e.name) + "(" + esc(shortId(e.id)) + ") / 分支 " + esc(e.branch || "默认分支") +
" / 执行器 " + esc(e.executor || "不限") + " / 状态 " + esc(e.status) + err + btn + "<br>";
}).join("");
Array.prototype.forEach.call(document.querySelectorAll("[data-env-archive]"), function (b) {
b.onclick = async function () {
if (!confirm("确定归档该环境吗?归档后新任务不能再选它,进行中的任务不受影响(有进行中任务在用它时归档会被拒绝)。")) return;
try { await api("/environments/" + encodeURIComponent(b.getAttribute("data-env-archive")) + "/archive", "POST", {}); loadEnvironments(); }
catch (e) { $("#envMsg").innerHTML = "<span class='err'>归档失败:" + esc(e.message) + "</span>"; }
};
});
}
// 任务创建的环境下拉:只列未归档环境,未 ready 的置灰(creating
// 时可看到它在准备,ready 后才能选);刷新时尽量保留已选值。
sel.innerHTML = "<option value=''>环境:不指定(走项目仓库)</option>";
envs.forEach(function (e) {
var o = document.createElement("option");
o.value = e.id; o.textContent = e.name + "(" + e.status + ")";
if (e.status !== "ready") o.disabled = true;
sel.appendChild(o);
});
if (cur && Array.prototype.some.call(sel.options, function (o) { return o.value === cur && !o.disabled; })) sel.value = cur;
} catch (e) {
$("#envList").innerHTML = "<span class='err'>加载失败:" + esc(e.message) + "</span>";
}
}
async function loadTasks() {
var pid = $("#projectSelect").value;
if (!pid) { $("#taskRows").innerHTML = "<tr><td colspan='7' class='muted'>先选项目</td></tr>"; return; }
@@ -312,7 +378,7 @@ async function loadTasks() {
var pr = t.pr_url ? "<a href='" + esc(t.pr_url) + "' target='_blank' rel='noopener'>#" + esc(t.pr_number) + "</a>" : "";
return "<tr data-id='" + esc(t.id) + "' class='" + (currentTask && currentTask.id === t.id ? "selected" : "") + "'>" +
"<td>" + esc(shortId(t.id)) + "</td>" +
"<td>" + (t.plan ? "[计划] " : "") + (t.forked_from ? "[分支] " : "") + ((t.goal_mode || t.verify_command) ? "[目标] " : "") + esc(String(t.prompt || "").slice(0, 60)) + "</td>" +
"<td>" + (t.plan ? "[计划] " : "") + (t.forked_from ? "[分支] " : "") + ((t.goal_mode || t.verify_command) ? "[目标] " : "") + (t.environment_id ? "[环境 " + esc(shortId(t.environment_id)) + "] " : "") + esc(String(t.prompt || "").slice(0, 60)) + "</td>" +
"<td class='st-" + esc(t.status) + "'>" + esc(t.status) + "</td>" +
"<td>" + esc(t.executor || "") + "</td>" +
"<td>" + esc(t.worktree_host || "") + "</td>" +
@@ -507,7 +573,7 @@ $("#saveKey").onclick = function () {
loadHealth();
loadProjects();
loadWorkers();
if ($("#projectSelect").value) loadTasks();
if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); }
if (getKey()) loadRelayModels();
};
$("#clearKey").onclick = function () { localStorage.removeItem(KEY_STORE); setKeyState(); };
@@ -515,7 +581,8 @@ $("#refreshHealth").onclick = loadHealth;
$("#refreshProjects").onclick = loadProjects;
$("#refreshWorkers").onclick = loadWorkers;
$("#refreshTasks").onclick = loadTasks;
$("#projectSelect").onchange = loadTasks;
$("#refreshEnvironments").onclick = loadEnvironments;
$("#projectSelect").onchange = function () { loadTasks(); loadEnvironments(); };
$("#goalMode").onchange = function () { $("#goalHint").style.display = this.checked ? "block" : "none"; };
$("#createProject").onclick = async function () {
var name = $("#newProjectName").value.trim();
@@ -530,12 +597,31 @@ $("#createProject").onclick = async function () {
loadProjects();
} catch (e) { $("#projectMsg").innerHTML = "<span class='err'>新建失败:" + esc(e.message) + "</span>"; }
};
$("#createEnvironment").onclick = async function () {
var pid = $("#projectSelect").value;
var name = $("#newEnvName").value.trim();
if (!pid) { $("#envMsg").textContent = "先选项目"; return; }
if (!name) { $("#envMsg").textContent = "环境名必填"; return; }
var body = { project_id: pid, name: name };
var br = $("#newEnvBranch").value.trim();
if (br) body.branch = br;
var ex = $("#newEnvExecutor").value;
if (ex) body.executor = ex;
try {
await api("/environments", "POST", body);
$("#envMsg").innerHTML = "<span class='ok'>环境已建,worker 准备中(creating → ready,失败会在列表里写原因)</span>";
$("#newEnvName").value = ""; $("#newEnvBranch").value = "";
loadEnvironments();
} catch (e) { $("#envMsg").innerHTML = "<span class='err'>新建失败:" + esc(e.message) + "</span>"; }
};
$("#createTask").onclick = async function () {
var pid = $("#projectSelect").value;
var prompt = $("#prompt").value.trim();
if (!pid) { $("#taskMsg").textContent = "先选项目"; return; }
if (!prompt) { $("#taskMsg").textContent = "prompt 必填"; return; }
var body = { project_id: pid, prompt: prompt };
var envId = $("#envSelect").value;
if (envId) body.environment_id = envId;
var ex = $("#executor").value;
if (ex) body.executor = ex;
var model = $("#modelCustom").value.trim() || $("#modelSelect").value;
@@ -602,7 +688,7 @@ loadWorkers();
// 否则下拉永远只有默认项(此前只在点“保存”时调一次,
// 2026-10-27 实测:存过 key 后重开页面模型选不了)。
if (getKey()) loadRelayModels();
setInterval(function () { if ($("#projectSelect").value) loadTasks(); loadHealth(); }, 10000);
setInterval(function () { if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); } loadHealth(); }, 10000);
</script>
</body>
</html>