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
+90
-4
@@ -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>
|
||||
Reference in new issue
Block a user