Merge pull request #99 from openqj/feat-p2
feat: P2 控制台与运维批(成员管理、任务筛选、链路下载、线程操作与 skills/MCP、迁移对齐、monitor 告警、备份回滚、Caddy 选项)
This commit is contained in:
22 files changed
+3901
-62
No files matched your search
+467
-17
@@ -90,6 +90,22 @@
|
||||
<div><span id="quotaState" class="muted">未加载</span></div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="adminCard">
|
||||
<h2>管理区 <span class="muted">(仅全局管理员)</span></h2>
|
||||
<div id="adminGate" class="muted">填 key 后判断权限…</div>
|
||||
<div id="adminBody" style="display:none">
|
||||
<div class="bar">
|
||||
<input id="adminUserId" type="text" placeholder="要查配额的用户 id">
|
||||
<button id="btnAdminQuota">查该用户配额</button>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<button id="btnAdminSweep">手动触发 sweep(清理陈旧 running 任务)</button>
|
||||
<button id="btnAdminBackup">手动触发备份</button>
|
||||
</div>
|
||||
<div id="adminResult" class="muted"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>健康状态</h2>
|
||||
<div class="bar">
|
||||
@@ -112,6 +128,20 @@
|
||||
<button id="createProject">新建项目</button>
|
||||
</div>
|
||||
<span id="projectMsg" class="muted"></span>
|
||||
<h3>项目成员</h3>
|
||||
<div class="bar">
|
||||
<input id="inviteUser" type="text" placeholder="成员 user_id 或用户名">
|
||||
<select id="inviteRole">
|
||||
<option value="viewer">viewer(只读)</option>
|
||||
<option value="contributor" selected>contributor(可建任务)</option>
|
||||
<option value="maintainer">maintainer(可批合并)</option>
|
||||
<option value="owner">owner(可管项目)</option>
|
||||
</select>
|
||||
<button id="inviteMember">邀请成员</button>
|
||||
<button id="refreshMembers">刷新成员</button>
|
||||
</div>
|
||||
<div id="memberList" class="muted">先选项目</div>
|
||||
<span id="memberMsg" class="muted"></span>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Workers</h2>
|
||||
@@ -120,6 +150,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>技能与 MCP</h2>
|
||||
<div class="muted">反映宿主租户 home,容器内 home 可能不同;重载只在 schema 枚举里,当前 pin 的二进制拒绝时原样 502;OAuth 只发起登录,不代表登录完成。</div>
|
||||
<div class="bar">
|
||||
<button id="refreshSkills">刷新</button>
|
||||
<span id="skillMsg" class="muted">先选项目</span>
|
||||
</div>
|
||||
<h3>技能</h3>
|
||||
<div id="skillList" class="muted">先选项目</div>
|
||||
<h3>应用</h3>
|
||||
<div id="appList" class="muted">先选项目</div>
|
||||
<h3>MCP 服务</h3>
|
||||
<div id="mcpList" class="muted">先选项目</div>
|
||||
<div class="bar">
|
||||
<button id="btnReloadMcp">重载配置</button>
|
||||
<span id="mcpMsg" class="muted"></span>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<input id="mcpOauthName" type="text" placeholder="MCP 服务名">
|
||||
<button id="btnMcpOauth">发起 OAuth 登录</button>
|
||||
<span id="mcpOauthMsg" class="muted"></span>
|
||||
</div>
|
||||
<div id="mcpOauthBox" class="muted"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>环境 <span class="muted">(选、建、管:当前项目的基准检出)</span></h2>
|
||||
<div class="bar">
|
||||
@@ -198,6 +253,23 @@
|
||||
<h2>任务列表 <span class="muted">(每 10 秒自动刷新当前项目)</span></h2>
|
||||
<div class="bar">
|
||||
<button id="refreshTasks">刷新</button>
|
||||
<select id="taskStatusFilter">
|
||||
<option value="">状态:全部</option>
|
||||
<option value="queued">queued</option>
|
||||
<option value="running">running</option>
|
||||
<option value="completed">completed</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="interrupted">interrupted</option>
|
||||
<option value="merged">merged</option>
|
||||
<option value="merge-failed">merge-failed</option>
|
||||
</select>
|
||||
<select id="taskExecutorFilter">
|
||||
<option value="">执行器:全部</option>
|
||||
<option value="local">local</option>
|
||||
<option value="docker">docker</option>
|
||||
<option value="cloud">cloud</option>
|
||||
</select>
|
||||
<input id="taskSearch" type="text" placeholder="搜索任务 id / prompt">
|
||||
<span id="taskCount" class="muted"></span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
@@ -273,6 +345,22 @@
|
||||
</div>
|
||||
<div id="reviewList" class="muted">点任务行查看详情后,点“刷新队列”查看评审记录</div>
|
||||
<h3>实时链路</h3>
|
||||
<div class="bar">
|
||||
<select id="traceKindFilter">
|
||||
<option value="">链路类型:全部</option>
|
||||
<option value="exec">命令</option>
|
||||
<option value="file">文件</option>
|
||||
<option value="call">调用</option>
|
||||
<option value="tool">工具</option>
|
||||
<option value="message">消息</option>
|
||||
<option value="reasoning">思考</option>
|
||||
<option value="plan">计划</option>
|
||||
<option value="agent-start">子代理开始</option>
|
||||
<option value="agent-done">子代理结束</option>
|
||||
</select>
|
||||
<button id="btnDownloadTrace">下载完整链路</button>
|
||||
<span id="traceMsg" class="muted"></span>
|
||||
</div>
|
||||
<div id="traceBox" class="muted">点任务行查看详情后,这里显示过程链路</div>
|
||||
<div id="costBox"></div>
|
||||
<h3>后续消息</h3>
|
||||
@@ -316,6 +404,19 @@
|
||||
<h3>分诊收件箱</h3>
|
||||
<div class="bar"><button id="btnLoadTriage">刷新分诊</button><span id="triageMsg" class="muted"></span></div>
|
||||
<div id="triageBox" class="muted"></div>
|
||||
<h3>线程</h3>
|
||||
<div id="threadBox" class="muted">点任务行查看详情后,这里显示线程元数据</div>
|
||||
<div class="bar" id="threadActions">
|
||||
<input id="threadRenameInput" type="text" placeholder="新线程名">
|
||||
<button id="btnThreadRename">重命名</button>
|
||||
<button id="btnThreadArchive">归档</button>
|
||||
<button id="btnThreadUnarchive">取消归档</button>
|
||||
<button id="btnThreadCompact">压缩</button>
|
||||
<span id="threadGateNote" class="muted"></span>
|
||||
<span id="threadMsg" class="muted"></span>
|
||||
</div>
|
||||
<div id="threadList" class="muted"></div>
|
||||
<div class="muted">限制:Docker/异机任务线程操作一律 409;merged/merge-failed 不可改;turn/steer 与 review/start 不在本批。</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
@@ -327,6 +428,7 @@ var traceAbort = null;
|
||||
var TRACE_KIND = { exec: "命令", file: "文件", call: "调用", tool: "工具", message: "消息", reasoning: "思考", plan: "计划", "agent-start": "子代理", "agent-done": "子代理" };
|
||||
var TRACE_LIMIT = 1000;
|
||||
var TRACE_TERMINAL = ["completed", "failed", "interrupted", "merged", "merge-failed"];
|
||||
var THREAD_MUTATION_STATUSES = ["completed", "failed", "interrupted"];
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<")
|
||||
@@ -484,6 +586,20 @@ async function decideApproval(approvalId, decision) {
|
||||
loadApprovals(currentTask.id);
|
||||
} catch (e) { $("#approvalMsg").innerHTML = "<span class='err'>决定失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
function workerAgeText(w) {
|
||||
// last_seen 是 epoch 秒;服务端也可能只给距今秒数(旧字段)。
|
||||
// 返回 {text, age}:age 为 null 时无法判断在线状态。
|
||||
var now = Date.now() / 1000;
|
||||
var age = null;
|
||||
if (typeof w.last_seen === "number") age = now - w.last_seen;
|
||||
else if (typeof w.last_seen_seconds_ago === "number") age = w.last_seen_seconds_ago;
|
||||
if (age === null || age < 0) age = null;
|
||||
if (age === null) return { text: "未知", age: null };
|
||||
var s = Math.floor(age);
|
||||
if (s < 60) return { text: s + " 秒前", age: age };
|
||||
if (s < 3600) return { text: Math.floor(s / 60) + " 分钟前", age: age };
|
||||
return { text: Math.floor(s / 3600) + " 小时前", age: age };
|
||||
}
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
var d = await api("/workers", "GET");
|
||||
@@ -499,10 +615,16 @@ async function loadWorkers() {
|
||||
$("#workers").innerHTML = list.map(function (w) {
|
||||
var run = (w.running === undefined || w.running === null) ? "?" : w.running;
|
||||
var cap = (w.capacity === undefined || w.capacity === null) ? "?" : w.capacity;
|
||||
var live = (w.alive === false) ? " / 离线(占用为上次上报值)" : "";
|
||||
// 在线判定:优先服务端 alive(阈值 120 秒,与服务端
|
||||
// alive_threshold_seconds 默认值一致),缺失时按 120 秒自算。
|
||||
var info = workerAgeText(w);
|
||||
var alive = (w.alive !== undefined && w.alive !== null)
|
||||
? !!w.alive
|
||||
: (info.age !== null && info.age <= 120);
|
||||
var live = alive ? " / 在线" : " / 离线(占用为上次上报值)";
|
||||
return esc(w.worker_id || w.id || "?") + " / " + esc(w.executor || "") +
|
||||
" / 占用 " + esc(run) + "/" + esc(cap) + live +
|
||||
" / last_seen " + esc(w.last_seen || w.last_seen_seconds_ago || "");
|
||||
" / 最后心跳 " + esc(info.text);
|
||||
}).join("<br>");
|
||||
} catch (e) { $("#workers").textContent = "加载失败:" + e.message; }
|
||||
}
|
||||
@@ -566,15 +688,33 @@ async function loadEnvironments() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
var pid = $("#projectSelect").value;
|
||||
if (!pid) { $("#taskRows").innerHTML = "<tr><td colspan='9' class='muted'>先选项目</td></tr>"; return; }
|
||||
try {
|
||||
var d = await api("/tasks?project_id=" + encodeURIComponent(pid), "GET");
|
||||
var tasks = d.tasks || [];
|
||||
$("#taskCount").textContent = tasks.length + " 个任务";
|
||||
if (!tasks.length) { $("#taskRows").innerHTML = "<tr><td colspan='9' class='muted'>该项目暂无任务</td></tr>"; return; }
|
||||
$("#taskRows").innerHTML = tasks.slice().reverse().map(function (t) {
|
||||
var tasksCache = [];
|
||||
function filteredTasks() {
|
||||
// 筛选与搜索是纯前端逻辑:10 秒刷新仍拉全量,筛选条件
|
||||
// 不随刷新丢失(不改服务端 /tasks 查询语义)。
|
||||
var st = $("#taskStatusFilter") ? $("#taskStatusFilter").value : "";
|
||||
var ex = $("#taskExecutorFilter") ? $("#taskExecutorFilter").value : "";
|
||||
var q = $("#taskSearch") ? $("#taskSearch").value.trim().toLowerCase() : "";
|
||||
return tasksCache.filter(function (t) {
|
||||
if (st && t.status !== st) return false;
|
||||
if (ex && (t.executor || "") !== ex) return false;
|
||||
if (q) {
|
||||
var hay = ((t.id || "") + " " + (t.prompt || "")).toLowerCase();
|
||||
if (hay.indexOf(q) < 0) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
function renderTasks() {
|
||||
var tasks = filteredTasks();
|
||||
var total = tasksCache.length;
|
||||
$("#taskCount").textContent = tasks.length + " 个任务(共 " + total + " 个)";
|
||||
if (!tasks.length) {
|
||||
$("#taskRows").innerHTML = "<tr><td colspan='9' class='muted'>" +
|
||||
(total ? "没有符合筛选条件的任务" : "该项目暂无任务") + "</td></tr>";
|
||||
return;
|
||||
}
|
||||
$("#taskRows").innerHTML = tasks.slice().reverse().map(function (t) {
|
||||
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>" +
|
||||
@@ -590,6 +730,14 @@ async function loadTasks() {
|
||||
Array.prototype.forEach.call(document.querySelectorAll("#taskRows tr[data-id]"), function (tr) {
|
||||
tr.onclick = function () { showDetail(tr.getAttribute("data-id")); };
|
||||
});
|
||||
}
|
||||
async function loadTasks() {
|
||||
var pid = $("#projectSelect").value;
|
||||
if (!pid) { $("#taskRows").innerHTML = "<tr><td colspan='9' class='muted'>先选项目</td></tr>"; return; }
|
||||
try {
|
||||
var d = await api("/tasks?project_id=" + encodeURIComponent(pid), "GET");
|
||||
tasksCache = d.tasks || [];
|
||||
renderTasks();
|
||||
} catch (e) { $("#taskRows").innerHTML = "<tr><td colspan='9' class='err'>加载失败:" + esc(e.message) + "</td></tr>"; }
|
||||
}
|
||||
|
||||
@@ -612,19 +760,51 @@ function traceLine(e) {
|
||||
else body = esc(JSON.stringify(e).slice(0, 300));
|
||||
return "[" + k + "] " + body;
|
||||
}
|
||||
var lastTraceEntries = [];
|
||||
var lastTraceNote = "";
|
||||
var lastTraceDone = "";
|
||||
function renderTrace(entries, note, doneStatus) {
|
||||
// 全量条目先存下来供“下载完整链路”用;屏上显示仍截断
|
||||
// 最近 TRACE_LIMIT 条,再按类型过滤(过滤不影响下载内容)。
|
||||
lastTraceEntries = entries || [];
|
||||
lastTraceNote = note || "";
|
||||
lastTraceDone = doneStatus || "";
|
||||
var box = $("#traceBox");
|
||||
var html = "";
|
||||
if (note) html += "<span class='err'>" + esc(note) + "</span><br>";
|
||||
if (!entries.length) html += "(暂无链路条目)";
|
||||
var kind = $("#traceKindFilter") ? $("#traceKindFilter").value : "";
|
||||
var pool = kind ? lastTraceEntries.filter(function (e) { return e.kind === kind; }) : lastTraceEntries;
|
||||
if (!pool.length) html += kind ? "(该类型暂无链路条目)" : "(暂无链路条目)";
|
||||
else {
|
||||
var shown = entries.slice(-TRACE_LIMIT);
|
||||
if (entries.length > TRACE_LIMIT) html += "(仅显示最近 " + TRACE_LIMIT + " 条,共 " + entries.length + " 条)<br>";
|
||||
var shown = pool.slice(-TRACE_LIMIT);
|
||||
if (pool.length > TRACE_LIMIT) html += "(仅显示最近 " + TRACE_LIMIT + " 条,共 " + pool.length + " 条)<br>";
|
||||
else if (kind) html += "(按类型 " + esc(kind) + " 过滤,共 " + pool.length + " 条)<br>";
|
||||
html += shown.map(traceLine).join("<br>");
|
||||
}
|
||||
if (doneStatus) html += "<br><span class='muted'>链路已结束(" + esc(doneStatus) + ")</span>";
|
||||
box.innerHTML = html;
|
||||
}
|
||||
async function downloadTrace() {
|
||||
// 下载走同一 /trace 端点全量拉取,不用屏上截断后的条目:
|
||||
// 屏上是最近 1000 条 + 类型过滤,下载必须是完整链路。
|
||||
if (!currentTask) { $("#traceMsg").textContent = "先选一个任务"; return; }
|
||||
try {
|
||||
var d = await api("/tasks/" + encodeURIComponent(currentTask.id) + "/trace", "GET");
|
||||
var entries = d.entries || [];
|
||||
var blob = new Blob([JSON.stringify(
|
||||
{ task_id: currentTask.id, status: d.status, note: d.note || "",
|
||||
count: entries.length, entries: entries }, null, 2)],
|
||||
{ type: "application/json" });
|
||||
var a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "trace-" + currentTask.id.slice(0, 8) + ".json";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
|
||||
$("#traceMsg").innerHTML = "<span class='ok'>已下载完整链路 " + entries.length + " 条</span>";
|
||||
} catch (e) { $("#traceMsg").innerHTML = "<span class='err'>下载失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
var AGENT_STATUS = { running: "进行中", completed: "已完成", failed: "失败", interrupted: "已中断", unknown: "未知" };
|
||||
var AGENT_KIND = { main: "主任务", delegate: "委派子任务", model: "模型子代理" };
|
||||
var AGENT_CALL_SHOWN = 50;
|
||||
@@ -877,6 +1057,11 @@ async function showDetail(taskId) {
|
||||
loadFollowups(taskId);
|
||||
loadPeerMessages(taskId);
|
||||
$("#peerMsg").textContent = "";
|
||||
$("#threadMsg").textContent = "";
|
||||
$("#threadRenameInput").value = "";
|
||||
setThreadGate(t.status);
|
||||
loadThread(taskId);
|
||||
loadThreads(taskId);
|
||||
} catch (e) { $("#detail").innerHTML = "<span class='err'>加载失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
|
||||
@@ -985,6 +1170,136 @@ async function postFollowup(mode) {
|
||||
} catch (e) { $("#followupMsg").innerHTML = "<span class='err'>发送失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
|
||||
function memberErr(e) {
|
||||
var m = e.message || "";
|
||||
if (m.indexOf("forbidden") >= 0) return "没有权限:只有项目 owner 或全局管理员可以管理成员";
|
||||
if (m.indexOf("already_member") >= 0) return "该用户已是成员,改角色请用成员行的下拉框";
|
||||
if (m.indexOf("cannot demote the only owner") >= 0) return "不能把唯一 owner 降为非 owner:请先把另一位成员升为 owner";
|
||||
if (m.indexOf("user not found") >= 0) return "找不到该用户:请核对 user_id 或用户名是否正确";
|
||||
if (m.indexOf("member not found") >= 0) return "该用户不是本项目成员";
|
||||
if (m.indexOf("unknown role") >= 0) return "角色无效:只能是 viewer / contributor / maintainer / owner";
|
||||
if (m.indexOf("project not found") >= 0) return "项目不存在(可能已被删除)";
|
||||
return "成员操作失败:" + m;
|
||||
}
|
||||
async function loadMembers() {
|
||||
var pid = $("#projectSelect").value;
|
||||
var box = $("#memberList");
|
||||
if (!pid) { box.textContent = "先选项目"; return; }
|
||||
try {
|
||||
var d = await api("/projects/" + encodeURIComponent(pid) + "/members", "GET");
|
||||
var list = d.members || [];
|
||||
if (!list.length) { box.textContent = "该项目暂无成员"; return; }
|
||||
box.innerHTML = list.map(function (m) {
|
||||
var label = esc(m.username || "") + (m.username ? "(" + esc(String(m.user_id).slice(0, 8)) + ")" : esc(String(m.user_id).slice(0, 8)));
|
||||
var opts = ["viewer", "contributor", "maintainer", "owner"].map(function (r) {
|
||||
return "<option value='" + r + "'" + (r === m.role ? " selected" : "") + ">" + r + "</option>";
|
||||
}).join("");
|
||||
return "<div style='margin:4px 0'>" + label + " | <select data-member-role='" + esc(m.user_id) + "'>" + opts + "</select></div>";
|
||||
}).join("");
|
||||
Array.prototype.forEach.call(box.querySelectorAll("[data-member-role]"), function (sel) {
|
||||
var prev = sel.value;
|
||||
sel.onchange = async function () {
|
||||
var uid = sel.getAttribute("data-member-role");
|
||||
var role = sel.value;
|
||||
try {
|
||||
await api("/projects/" + encodeURIComponent(pid) + "/members/" + encodeURIComponent(uid), "PATCH", { role: role });
|
||||
prev = role;
|
||||
$("#memberMsg").innerHTML = "<span class='ok'>角色已改为 " + esc(role) + "</span>";
|
||||
} catch (e) {
|
||||
sel.value = prev;
|
||||
$("#memberMsg").innerHTML = "<span class='err'>" + esc(memberErr(e)) + "</span>";
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch (e) { box.innerHTML = "<span class='err'>成员加载失败:" + esc(memberErr(e)) + "</span>"; }
|
||||
}
|
||||
async function inviteMember() {
|
||||
var pid = $("#projectSelect").value;
|
||||
if (!pid) { $("#memberMsg").textContent = "先选项目"; return; }
|
||||
var raw = $("#inviteUser").value.trim();
|
||||
if (!raw) { $("#memberMsg").textContent = "请填成员 user_id 或用户名"; return; }
|
||||
// 输入可能是 user_id 也可能是用户名:服务端优先按 user_id
|
||||
// 查,查不到时前端再按 username 试一次(避免把用户名误当 id 报 404)。
|
||||
var body = { user_id: raw, role: $("#inviteRole").value };
|
||||
try {
|
||||
await api("/projects/" + encodeURIComponent(pid) + "/members", "POST", body);
|
||||
} catch (e) {
|
||||
if (e.message.indexOf("user not found") >= 0) {
|
||||
try {
|
||||
await api("/projects/" + encodeURIComponent(pid) + "/members", "POST", { username: raw, role: $("#inviteRole").value });
|
||||
} catch (e2) { $("#memberMsg").innerHTML = "<span class='err'>" + esc(memberErr(e2)) + "</span>"; return; }
|
||||
} else {
|
||||
$("#memberMsg").innerHTML = "<span class='err'>" + esc(memberErr(e)) + "</span>";
|
||||
return;
|
||||
}
|
||||
}
|
||||
$("#memberMsg").innerHTML = "<span class='ok'>已邀请成员</span>";
|
||||
$("#inviteUser").value = "";
|
||||
loadMembers();
|
||||
}
|
||||
function adminErr(e) {
|
||||
var m = e.message || "";
|
||||
if (m.indexOf("forbidden") >= 0) return "无全局管理员权限:服务端返回 403(CODEX_GLOBAL_ADMINS 未包含当前用户),请联系管理员";
|
||||
if (m.indexOf("user not found") >= 0) return "找不到该用户:请核对用户 id 是否正确";
|
||||
return "管理操作失败:" + m;
|
||||
}
|
||||
async function probeAdmin() {
|
||||
// 权限探针复用已有 /admin/quotas:服务端先验全局管理员
|
||||
// (非管理员 403),通过后才校验 user_id 参数(缺参数 400)。
|
||||
// 故 400 = 是管理员、403 = 不是管理员,不新增任何端点。
|
||||
var gate = $("#adminGate");
|
||||
var body = $("#adminBody");
|
||||
if (!getKey()) { gate.textContent = "未填 key,管理区不可用"; body.style.display = "none"; return; }
|
||||
try {
|
||||
var headers = { "X-API-Key": getKey() };
|
||||
var r = await fetch("/admin/quotas", { headers: headers });
|
||||
if (r.status === 400) {
|
||||
gate.innerHTML = "<span class='ok'>已确认为全局管理员,管理操作可用</span>";
|
||||
body.style.display = "block";
|
||||
} else if (r.status === 403) {
|
||||
gate.textContent = "管理区仅全局管理员可用:当前 key 无全局管理员权限(服务端返回 403)";
|
||||
body.style.display = "none";
|
||||
} else if (r.status === 401) {
|
||||
gate.textContent = "key 无效,管理区不可用(服务端返回 401)";
|
||||
body.style.display = "none";
|
||||
} else {
|
||||
gate.textContent = "管理权限判断失败(HTTP " + r.status + "),管理操作暂不可用";
|
||||
body.style.display = "none";
|
||||
}
|
||||
} catch (e) { gate.textContent = "管理权限判断失败:" + e.message; body.style.display = "none"; }
|
||||
}
|
||||
function fmtUsd(micros) {
|
||||
if (micros === null || micros === undefined) return "不限额";
|
||||
return "$" + (micros / 1000000).toFixed(2);
|
||||
}
|
||||
async function adminQuota() {
|
||||
var uid = $("#adminUserId").value.trim();
|
||||
if (!uid) { $("#adminResult").textContent = "请填要查配额的用户 id"; return; }
|
||||
try {
|
||||
var d = await api("/admin/quotas?user_id=" + encodeURIComponent(uid), "GET");
|
||||
$("#adminResult").innerHTML = "用户 " + esc(uid.slice(0, 8)) + ":本月用量 " + esc(fmtUsd(d.used_usd_micros)) +
|
||||
" / 配额 " + esc(fmtUsd(d.quota_usd_micros)) +
|
||||
(d.remaining_usd_micros !== null && d.remaining_usd_micros !== undefined ? "(剩余 " + esc(fmtUsd(d.remaining_usd_micros)) + ")" : "") +
|
||||
" | 月份 " + esc(d.month || "");
|
||||
} catch (e) { $("#adminResult").innerHTML = "<span class='err'>" + esc(adminErr(e)) + "</span>"; }
|
||||
}
|
||||
async function adminSweep() {
|
||||
if (!confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。")) return;
|
||||
try {
|
||||
var d = await api("/admin/sweep", "POST", {});
|
||||
var swept = d.swept || [];
|
||||
$("#adminResult").innerHTML = "<span class='ok'>sweep 完成,清理 " + swept.length + " 个任务" +
|
||||
(swept.length ? ":" + esc(swept.join("、")) : "") + "</span>";
|
||||
} catch (e) { $("#adminResult").innerHTML = "<span class='err'>" + esc(adminErr(e)) + "</span>"; }
|
||||
}
|
||||
async function adminBackup() {
|
||||
try {
|
||||
var d = await api("/admin/backup", "POST", {});
|
||||
$("#adminResult").innerHTML = "<span class='ok'>备份完成:</span>backend=" + esc(d.backend) +
|
||||
" | 路径 " + esc(d.path) + " | 大小 " + esc(d.size_bytes) + " 字节 | 任务 " + esc(d.task_count) + " 行";
|
||||
} catch (e) { $("#adminResult").innerHTML = "<span class='err'>" + esc(adminErr(e)) + "</span>"; }
|
||||
}
|
||||
|
||||
$("#saveKey").onclick = function () {
|
||||
var v = $("#key").value.trim();
|
||||
if (!v) { $("#keyState").textContent = "key 为空,未保存"; return; }
|
||||
@@ -996,18 +1311,32 @@ $("#saveKey").onclick = function () {
|
||||
loadHealth();
|
||||
loadProjects();
|
||||
loadWorkers();
|
||||
if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); }
|
||||
if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); loadMembers(); }
|
||||
if (getKey()) loadRelayModels();
|
||||
loadQuota();
|
||||
loadGithubRepos();
|
||||
probeAdmin();
|
||||
};
|
||||
$("#clearKey").onclick = function () { localStorage.removeItem(KEY_STORE); setKeyState(); };
|
||||
$("#clearKey").onclick = function () { localStorage.removeItem(KEY_STORE); setKeyState(); probeAdmin(); };
|
||||
$("#refreshHealth").onclick = loadHealth;
|
||||
$("#refreshProjects").onclick = loadProjects;
|
||||
$("#refreshWorkers").onclick = loadWorkers;
|
||||
$("#refreshTasks").onclick = loadTasks;
|
||||
$("#refreshEnvironments").onclick = loadEnvironments;
|
||||
$("#projectSelect").onchange = function () { loadTasks(); loadEnvironments(); };
|
||||
// 合并取舍(P2 rebase 到 97f019d):两侧全留——项目下拉切换时
|
||||
// 同时刷新任务、环境、成员与项目技能;成员、筛选、链路、管理
|
||||
// 绑定与技能加载均保留,任一侧都不丢。
|
||||
$("#projectSelect").onchange = function () { loadTasks(); loadEnvironments(); loadMembers(); loadProjectSkills(); };
|
||||
$("#refreshMembers").onclick = loadMembers;
|
||||
$("#inviteMember").onclick = inviteMember;
|
||||
$("#taskStatusFilter").onchange = renderTasks;
|
||||
$("#taskExecutorFilter").onchange = renderTasks;
|
||||
$("#taskSearch").oninput = renderTasks;
|
||||
$("#traceKindFilter").onchange = function () { renderTrace(lastTraceEntries, lastTraceNote, lastTraceDone); };
|
||||
$("#btnDownloadTrace").onclick = downloadTrace;
|
||||
$("#btnAdminQuota").onclick = adminQuota;
|
||||
$("#btnAdminSweep").onclick = adminSweep;
|
||||
$("#btnAdminBackup").onclick = adminBackup;
|
||||
$("#goalMode").onchange = function () { $("#goalHint").style.display = this.checked ? "block" : "none"; };
|
||||
$("#createProject").onclick = async function () {
|
||||
var name = $("#newProjectName").value.trim();
|
||||
@@ -1218,10 +1547,130 @@ async function postPeerMessage(mode) {
|
||||
} catch (e) { $("#peerMsg").innerHTML = "<span class='err'>发送失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
|
||||
async function loadThread(taskId) {
|
||||
var box = $("#threadBox");
|
||||
try {
|
||||
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/thread", "GET");
|
||||
var th = d.thread || d;
|
||||
var turns = th.turns;
|
||||
if (turns === undefined || turns === null) turns = th.turn_count;
|
||||
if (turns === undefined || turns === null) turns = th.turns_count;
|
||||
box.innerHTML =
|
||||
"id " + esc(th.id || taskId) +
|
||||
" | 名字 " + esc(th.name || th.thread_name || "") +
|
||||
" | 归档 " + esc(th.archived === undefined || th.archived === null ? "" : (th.archived ? "是" : "否")) +
|
||||
" | cwd " + esc(th.cwd || "") +
|
||||
(turns !== undefined && turns !== null ? " | turn 数 " + esc(turns) : "");
|
||||
} catch (e) { box.innerHTML = "<span class='err'>线程加载失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
async function loadThreads(taskId) {
|
||||
var box = $("#threadList");
|
||||
try {
|
||||
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/threads", "GET");
|
||||
var list = d.threads || [];
|
||||
if (!list.length) { box.innerHTML = "暂无其他线程"; return; }
|
||||
box.innerHTML = list.map(function (t) {
|
||||
return esc(t.name || t.thread_name || t.id || "") + " [" + esc(t.id || "") + "]" +
|
||||
(t.archived ? " [已归档]" : "") + (t.cwd ? " " + esc(t.cwd) : "");
|
||||
}).join("<br>");
|
||||
} catch (e) { box.innerHTML = "<span class='err'>线程列表加载失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
function setThreadGate(status) {
|
||||
var ok = THREAD_MUTATION_STATUSES.indexOf(status) >= 0;
|
||||
["btnThreadRename", "btnThreadArchive", "btnThreadUnarchive", "btnThreadCompact"].forEach(function (id) {
|
||||
var b = $("#" + id);
|
||||
if (b) b.disabled = !ok;
|
||||
});
|
||||
$("#threadGateNote").textContent = ok ? "终态任务,线程操作可用" : "仅终态任务可操作线程,当前状态 " + status + ",按钮已置灰";
|
||||
}
|
||||
async function postThreadAction(kind) {
|
||||
if (!currentTask) return;
|
||||
if (THREAD_MUTATION_STATUSES.indexOf(currentTask.status) < 0) {
|
||||
$("#threadMsg").textContent = "仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)";
|
||||
return;
|
||||
}
|
||||
var body = {};
|
||||
if (kind === "rename") {
|
||||
var name = $("#threadRenameInput").value.trim();
|
||||
if (!name) { $("#threadMsg").textContent = "新线程名不能为空"; return; }
|
||||
body = { name: name };
|
||||
}
|
||||
try {
|
||||
var threadPaths = { rename: "/thread/rename", archive: "/thread/archive", unarchive: "/thread/unarchive", compact: "/thread/compact" };
|
||||
var r = await api("/tasks/" + encodeURIComponent(currentTask.id) + threadPaths[kind], "POST", body);
|
||||
$("#threadMsg").innerHTML = "<span class='ok'>" + kind + " 已提交:" + esc(JSON.stringify(r).slice(0, 300)) + "</span>";
|
||||
loadThread(currentTask.id);
|
||||
loadThreads(currentTask.id);
|
||||
} catch (e) { $("#threadMsg").innerHTML = "<span class='err'>" + kind + " 失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
async function loadProjectSkills() {
|
||||
var pid = $("#projectSelect").value;
|
||||
if (!pid) {
|
||||
$("#skillList").textContent = "先选项目";
|
||||
$("#appList").textContent = "先选项目";
|
||||
$("#mcpList").textContent = "先选项目";
|
||||
$("#skillMsg").textContent = "先选项目";
|
||||
return;
|
||||
}
|
||||
$("#skillMsg").textContent = "";
|
||||
try {
|
||||
var s = await api("/projects/" + encodeURIComponent(pid) + "/skills", "GET");
|
||||
var skills = s.skills || [];
|
||||
$("#skillList").innerHTML = skills.length ? skills.map(function (x) {
|
||||
return esc(x.name || x.id || "") + (x.description ? " - " + esc(x.description) : "") + (x.detail ? " - " + esc(x.detail) : "");
|
||||
}).join("<br>") : "暂无技能";
|
||||
} catch (e) { $("#skillList").innerHTML = "<span class='err'>技能加载失败:" + esc(e.message) + "</span>"; }
|
||||
try {
|
||||
var a = await api("/projects/" + encodeURIComponent(pid) + "/apps", "GET");
|
||||
var apps = a.apps || [];
|
||||
$("#appList").innerHTML = apps.length ? apps.map(function (x) {
|
||||
return esc(x.name || x.id || "") + (x.description ? " - " + esc(x.description) : "");
|
||||
}).join("<br>") : "暂无应用";
|
||||
} catch (e) { $("#appList").innerHTML = "<span class='err'>应用加载失败:" + esc(e.message) + "</span>"; }
|
||||
try {
|
||||
var m = await api("/projects/" + encodeURIComponent(pid) + "/mcp-servers", "GET");
|
||||
var servers = m.servers || m.mcp_servers || [];
|
||||
$("#mcpList").innerHTML = servers.length ? servers.map(function (x) {
|
||||
var det = x.detail || x.details || x.state || "";
|
||||
return esc(x.name || x.id || "") + " | 状态 " + esc(x.status || "") + (det ? " | " + esc(det) : "");
|
||||
}).join("<br>") : "暂无 MCP 服务";
|
||||
} catch (e) { $("#mcpList").innerHTML = "<span class='err'>MCP 服务加载失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
$("#btnQueueFollowup").onclick = function () { postFollowup("queued"); };
|
||||
$("#btnDirectFollowup").onclick = function () { postFollowup("direct"); };
|
||||
$("#btnQueuePeerMessage").onclick = function () { postPeerMessage("queued"); };
|
||||
$("#btnDirectPeerMessage").onclick = function () { postPeerMessage("direct"); };
|
||||
$("#btnThreadRename").onclick = function () { postThreadAction("rename"); };
|
||||
$("#btnThreadArchive").onclick = function () { postThreadAction("archive"); };
|
||||
$("#btnThreadUnarchive").onclick = function () { postThreadAction("unarchive"); };
|
||||
$("#btnThreadCompact").onclick = function () { postThreadAction("compact"); };
|
||||
$("#refreshSkills").onclick = loadProjectSkills;
|
||||
$("#btnReloadMcp").onclick = async function () {
|
||||
var pid = $("#projectSelect").value;
|
||||
if (!pid) { $("#mcpMsg").textContent = "先选项目"; return; }
|
||||
try {
|
||||
await api("/projects/" + encodeURIComponent(pid) + "/mcp-servers/reload", "POST", {});
|
||||
$("#mcpMsg").innerHTML = "<span class='ok'>配置已重载</span>";
|
||||
loadProjectSkills();
|
||||
} catch (e) { $("#mcpMsg").innerHTML = "<span class='err'>重载失败:" + esc(e.message) + "</span>"; }
|
||||
};
|
||||
$("#btnMcpOauth").onclick = async function () {
|
||||
var pid = $("#projectSelect").value;
|
||||
var name = $("#mcpOauthName").value.trim();
|
||||
if (!pid) { $("#mcpOauthMsg").textContent = "先选项目"; return; }
|
||||
if (!name) { $("#mcpOauthMsg").textContent = "服务名不能为空"; return; }
|
||||
try {
|
||||
var d = await api("/projects/" + encodeURIComponent(pid) + "/mcp-servers/" + encodeURIComponent(name) + "/oauth-login", "POST", {});
|
||||
var url = d.url || d.auth_url || d.login_url || d.authorization_url || "";
|
||||
var steps = d.steps || d.instructions || "";
|
||||
var html = "登录尚未完成:请在浏览器打开下面的链接或按步骤完成,完成后回到本页刷新 MCP 状态。<br>";
|
||||
if (url) html += "<a href='" + esc(url) + "' target='_blank' rel='noopener'>" + esc(url) + "</a><br>";
|
||||
if (steps) html += esc(Array.isArray(steps) ? steps.join("\n") : String(steps)) + "<br>";
|
||||
html += "<pre>" + esc(JSON.stringify(d, null, 2).slice(0, 4000)) + "</pre>";
|
||||
$("#mcpOauthBox").innerHTML = html;
|
||||
$("#mcpOauthMsg").innerHTML = "<span class='ok'>已返回登录信息,见下方</span>";
|
||||
} catch (e) { $("#mcpOauthMsg").innerHTML = "<span class='err'>发起失败:" + esc(e.message) + "</span>"; }
|
||||
};
|
||||
$("#btnCost").onclick = async function () {
|
||||
if (!currentTask) return;
|
||||
try {
|
||||
@@ -1240,6 +1689,7 @@ loadGithubRepos();
|
||||
// 否则下拉永远只有默认项(此前只在点“保存”时调一次,
|
||||
// 2026-10-27 实测:存过 key 后重开页面模型选不了)。
|
||||
if (getKey()) loadRelayModels();
|
||||
probeAdmin();
|
||||
setInterval(function () { if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); } loadHealth(); loadQuota(); }, 10000);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from cloud.migrations import migrate_sqlite_to_postgres
|
||||
from cloud.migrations import (
|
||||
migrate_sqlite_to_postgres,
|
||||
migration_coverage,
|
||||
)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
@@ -30,6 +33,17 @@ def main(argv=None):
|
||||
print("%s: %d" % (table, count))
|
||||
total += count
|
||||
print("total inserted: %d" % total)
|
||||
# 覆盖报告:只含表名,不含行内容与任何密钥。missing 非空
|
||||
# 即清单与 schema 对齐回归;unmigrated 是目标库无此表、
|
||||
# 本迁移无法迁移的表,须另行处理,不能读成“已迁完”。
|
||||
coverage = migration_coverage()
|
||||
if coverage["missing_from_migration"]:
|
||||
print("WARNING missing_from_migration: %s"
|
||||
% ", ".join(coverage["missing_from_migration"]))
|
||||
print("unmigrated (no PG schema, NOT migrated): %s"
|
||||
% ", ".join(coverage["unmigrated_no_pg_schema"]))
|
||||
print("skipped by design: %s"
|
||||
% ", ".join(coverage["skipped_by_design"]))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+123
-2
@@ -7,9 +7,22 @@
|
||||
- schema 来自 cloud.db.POSTGRES_SCHEMA / init_postgres_schema,
|
||||
本模块不另发明表结构;迁移前先建表(若目标库已有表,
|
||||
CREATE TABLE IF NOT EXISTS 是 no-op)。
|
||||
- P2-ops 对齐:MIGRATION_TABLES 必须等于 POSTGRES_SCHEMA 的
|
||||
表集合(见 migration_coverage 与 test_p2_migrations.py)。
|
||||
以前漏掉的 uploads、task_attachments、task_followups、
|
||||
environments 已补进清单;补进时发现 task_followups 在
|
||||
SQLite 侧比 PG schema 多一列 sender_task_id(peer_messages
|
||||
的运行时 ensure_followups_table 会在 PG 上 ALTER 补列,
|
||||
POSTGRES_SCHEMA 里没有它),迁移时按同样方式先补列再插,
|
||||
否则整批迁移会在该表上撞未知列失败。
|
||||
- kb_fts(SQLite FTS5 虚拟表)不迁移:Postgres 没有 FTS5,
|
||||
KBStore 在 PG 上走 LIKE 分支,kb_docs 的 content 已足够。
|
||||
- sqlite_sequence(AUTOINCREMENT 内部表)不迁移。
|
||||
- UNMIGRATED_TABLES:SQLite 各 store 建出、但 POSTGRES_SCHEMA
|
||||
里没有的表(api_keys、评审、自动化、GitHub App、网关 key
|
||||
等)。它们不是“被跳过”,而是本模块无法迁移——迁移报告
|
||||
(migration_coverage / CLI 输出)逐项列出,调用方不得
|
||||
把“迁移成功”读成“全部数据都过去了”。
|
||||
- 不打印、不返回任何密码或密钥:函数只返回 {表: inserted},
|
||||
CLI 只打印表名与行数。
|
||||
- Postgres 连接参数只走 CLOUD_DB_* 环境变量(见 cloud.db),
|
||||
@@ -17,6 +30,7 @@
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from cloud.db import (
|
||||
@@ -43,10 +57,94 @@ MIGRATION_TABLES = (
|
||||
# (本批后无行按默认额度处理,不再是无限额,但显式
|
||||
# 管理员额度仍需原样迁过去,不能丢)。
|
||||
"user_quotas",
|
||||
# P2-ops 对齐:以下 4 张表在 POSTGRES_SCHEMA 里有、
|
||||
# 原清单里没有,补进后清单与 schema 表集合一致。
|
||||
# uploads / task_attachments:附件包与任务附件;
|
||||
# task_followups:任务间互发消息的后续消息队列;
|
||||
# environments:项目环境(分支/executor)。
|
||||
# 四张表主键均为 TEXT 或复合主键,没有身份列,
|
||||
# 不需要序列拨动(见 SEQUENCE_TABLES)。
|
||||
"uploads",
|
||||
"task_attachments",
|
||||
"task_followups",
|
||||
"environments",
|
||||
)
|
||||
|
||||
_SKIP_TABLES = frozenset({"kb_fts", "sqlite_sequence"})
|
||||
|
||||
# 身份列的表:POSTGRES_SCHEMA 里唯一用
|
||||
# ``BIGINT GENERATED BY DEFAULT AS IDENTITY`` 的 4 张表。
|
||||
# 迁移时显式插 id 不会推序列,迁完必须 setval 到 MAX(id),
|
||||
# 否则后续省略 id 的 INSERT 会撞主键。新增身份列表必须
|
||||
# 同步改这里,test_p2_migrations.py 会拿 schema 实测对齐。
|
||||
SEQUENCE_TABLES = (
|
||||
"audit_log",
|
||||
"summaries",
|
||||
"kb_docs",
|
||||
"cost_records",
|
||||
)
|
||||
|
||||
# SQLite 各 store 建出、但 POSTGRES_SCHEMA 里没有的表:
|
||||
# 本迁移模块无法把它们迁到 Postgres(目标库无此表),
|
||||
# 不是静默跳过——migration_coverage() 与 CLI 逐项报告。
|
||||
# 其中 rate_limit_hits / workers 是易失的限流与心跳数据,
|
||||
# 丢了只影响限流窗口与 worker 可见性,不影响业务数据;
|
||||
# 其余(api_keys、task_reviews、automations、GitHub App、
|
||||
# user_gateway_keys 等)丢了就是真丢,迁库前必须另行
|
||||
# 处理(重建/重绑/重发 key),不能只跑本迁移就宣称迁完。
|
||||
UNMIGRATED_TABLES = (
|
||||
"api_keys",
|
||||
"task_approvals",
|
||||
"task_reviews",
|
||||
"automations",
|
||||
"automation_runs",
|
||||
"github_app_setup_states",
|
||||
"github_app_config",
|
||||
"github_app_installations",
|
||||
"github_fix_review_dedup",
|
||||
"external_identities",
|
||||
"pr_jobs",
|
||||
"rate_limit_hits",
|
||||
"user_gateway_keys",
|
||||
"webhook_deliveries",
|
||||
"workers",
|
||||
)
|
||||
|
||||
|
||||
def schema_tables():
|
||||
"""POSTGRES_SCHEMA 里的表名集合(只认 CREATE TABLE 语句)。"""
|
||||
names = set()
|
||||
for statement in POSTGRES_SCHEMA:
|
||||
m = re.search(r"CREATE TABLE IF NOT EXISTS (\w+)",
|
||||
statement)
|
||||
if m:
|
||||
names.add(m.group(1))
|
||||
return names
|
||||
|
||||
|
||||
def migration_coverage():
|
||||
"""迁移覆盖报告:清单、schema、未迁移项三者一次给全。
|
||||
|
||||
migrated:本次迁移模块会迁的表(= MIGRATION_TABLES);
|
||||
schema_tables:POSTGRES_SCHEMA 里的表;
|
||||
missing_from_migration:schema 里有、清单里没有的表
|
||||
(本批后应为空;非空即对齐回归,调用方应报错
|
||||
而不是继续跑迁移);
|
||||
unmigrated_no_pg_schema:UNMIGRATED_TABLES(目标库
|
||||
无此表,无法迁移,须另行处理);
|
||||
skipped_by_design:kb_fts / sqlite_sequence(设计上不迁)。
|
||||
不含任何密钥或行内容,只含表名。
|
||||
"""
|
||||
schema = schema_tables()
|
||||
return {
|
||||
"migrated": tuple(MIGRATION_TABLES),
|
||||
"schema_tables": tuple(sorted(schema)),
|
||||
"missing_from_migration": tuple(
|
||||
sorted(schema - set(MIGRATION_TABLES))),
|
||||
"unmigrated_no_pg_schema": tuple(UNMIGRATED_TABLES),
|
||||
"skipped_by_design": tuple(sorted(_SKIP_TABLES)),
|
||||
}
|
||||
|
||||
|
||||
def migrate_sqlite_to_postgres(sqlite_path, pg_conn=None, tables=None):
|
||||
"""迁移并返回 {表名: 本次插入行数}。
|
||||
@@ -83,6 +181,26 @@ def migrate_sqlite_to_postgres(sqlite_path, pg_conn=None, tables=None):
|
||||
continue
|
||||
columns = [d[0] for d in src.execute(
|
||||
'SELECT * FROM "%s" LIMIT 0' % table).description]
|
||||
if table == "task_followups" and "sender_task_id" in columns:
|
||||
# SQLite 侧有、POSTGRES_SCHEMA 里没有的列:
|
||||
# 与 peer_messages.ensure_followups_table 同款,
|
||||
# 先查现有列、缺了再 ALTER 补列,已有列是 no-op。
|
||||
# PRAGMA 经适配层翻译成 information_schema 查询,
|
||||
# 取 row[1] 即列名(与 init_postgres_schema 同款)。
|
||||
# 补列失败也不在这里吞掉迁移:INSERT 时若列仍
|
||||
# 缺失会直接报错(大声失败,绝不静默丢列)。
|
||||
try:
|
||||
existing = {row[1] for row in pg_conn.execute(
|
||||
"PRAGMA table_info(task_followups)").fetchall()}
|
||||
except Exception:
|
||||
existing = set()
|
||||
if "sender_task_id" not in existing:
|
||||
try:
|
||||
pg_conn.execute(
|
||||
"ALTER TABLE task_followups"
|
||||
" ADD COLUMN sender_task_id TEXT")
|
||||
except Exception:
|
||||
pass
|
||||
placeholders = ", ".join("?" for _ in columns)
|
||||
col_list = ", ".join('"%s"' % c for c in columns)
|
||||
inserted = 0
|
||||
@@ -96,8 +214,7 @@ def migrate_sqlite_to_postgres(sqlite_path, pg_conn=None, tables=None):
|
||||
inserted += cur.rowcount
|
||||
# 身份列的序列不会因显式插 id 自动前进:迁完把序列
|
||||
# 拨到当前最大 id,否则后续省略 id 的 INSERT 会撞主键。
|
||||
if table in ("audit_log", "summaries", "kb_docs",
|
||||
"cost_records") and "id" in columns:
|
||||
if table in SEQUENCE_TABLES and "id" in columns:
|
||||
try:
|
||||
pg_conn.execute(
|
||||
"SELECT setval(pg_get_serial_sequence(?, ?),"
|
||||
@@ -120,5 +237,9 @@ def migrate_sqlite_to_postgres(sqlite_path, pg_conn=None, tables=None):
|
||||
|
||||
__all__ = [
|
||||
"MIGRATION_TABLES",
|
||||
"SEQUENCE_TABLES",
|
||||
"UNMIGRATED_TABLES",
|
||||
"migration_coverage",
|
||||
"migrate_sqlite_to_postgres",
|
||||
"schema_tables",
|
||||
]
|
||||
+838
@@ -46,6 +46,8 @@ import re
|
||||
import shutil
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
@@ -55,6 +57,7 @@ from decimal import Decimal
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
|
||||
from cloud.auth import ROLES as _MEMBER_ROLES
|
||||
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
|
||||
@@ -164,6 +167,12 @@ _server_log = logging.getLogger("cloud.server")
|
||||
TERMINAL_STATUSES = frozenset(
|
||||
{"completed", "failed", "interrupted", "merged", "merge-failed"}
|
||||
)
|
||||
# 线程改操作(rename/archive/unarchive/compact)只允许这三个终态:
|
||||
# 比 TERMINAL_STATUSES 窄——merged/merge-failed 的 thread 已随任务
|
||||
# 终局,不再改;running/queued 一律 409。
|
||||
THREAD_MUTATION_STATUSES = frozenset(
|
||||
{"completed", "failed", "interrupted"}
|
||||
)
|
||||
# 实时链路(任务过程事件流):host 在回合进行中把 app-server
|
||||
# 通知摘要成条目追加写 jsonl,服务端 tail 文件经 SSE 推送。
|
||||
TRACE_STREAM_MAX_SECONDS = 3600
|
||||
@@ -253,6 +262,211 @@ def _trace_dirs_for_task(server, task):
|
||||
return dirs
|
||||
|
||||
|
||||
# -- 线程 / skills / MCP 元数据操作(经 host.py CLI 透传)--------------
|
||||
# 元数据操作,不跑模型回合:只调 thread/list、thread/read、
|
||||
# thread/name/set、thread/archive、thread/unarchive、
|
||||
# thread/compact/start、skills/list、app/list、
|
||||
# mcpServerStatus/list、config/mcpServer/reload、
|
||||
# mcpServer/oauth/login 这类只读或线程管理方法,不开 turn,
|
||||
# 因此不违反本文件开头“不直接跑模型回合”的原则。
|
||||
#
|
||||
# host.py CLI 契约(与正在加 thread-* 子命令的另一路对齐,见
|
||||
# 各 handler 的 argv 注释):子命令只往 stdout 打一个 JSON
|
||||
# 对象;失败时 exit 非零且 stdout 是 {"error": ...}。本模块
|
||||
# 解析 whole-stdout JSON,解析失败或 host.py 缺失一律 502,
|
||||
# 绝不伪造成功。argv 里的子命令名与参数名是本批的约定,
|
||||
# host.py 侧实现时须与之一致(单测用 stub 脚本按此约定伪造)。
|
||||
def _host_py_path():
|
||||
"""host.py 路径:环境变量 CODEX_HOST_PY 优先,否则取
|
||||
cloud/server.py 所在目录的上一级 host.py(与 worker 的
|
||||
--host-py 缺省规则同款)。"""
|
||||
env_path = os.environ.get("CODEX_HOST_PY")
|
||||
if env_path:
|
||||
return env_path
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(os.path.dirname(here), "host.py")
|
||||
|
||||
|
||||
def _run_host_cli(argv, codex_home, timeout=60):
|
||||
"""调 host.py CLI 子命令,返回 (ok, result_or_error)。
|
||||
|
||||
ok 为 True 时 result 是 stdout 解析出的 JSON 对象;ok 为
|
||||
False 时 result 是可直接放进 502 error 字段的错误文本
|
||||
(CLI 自带的 error 原样透传,解析失败/缺文件/超时则给
|
||||
明确的服务端错误,绝不把 stderr 或原始 key 混进响应)。
|
||||
"""
|
||||
host_py = _host_py_path()
|
||||
if not host_py or not os.path.isfile(host_py):
|
||||
return False, "host.py not found"
|
||||
cmd = [sys.executable, host_py]
|
||||
if codex_home:
|
||||
cmd += ["--codex-home", codex_home]
|
||||
cmd += list(argv)
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True,
|
||||
timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "host.py timed out"
|
||||
except OSError as exc:
|
||||
return False, "cannot run host.py: %s" % exc
|
||||
stdout = (proc.stdout or "").strip()
|
||||
try:
|
||||
parsed = json.loads(stdout) if stdout else None
|
||||
except ValueError:
|
||||
return False, "host.py output is not valid JSON"
|
||||
if proc.returncode != 0:
|
||||
err = None
|
||||
if isinstance(parsed, dict):
|
||||
err = parsed.get("error")
|
||||
return False, str(err) if err else (
|
||||
"host.py failed with exit %d" % proc.returncode)
|
||||
if not isinstance(parsed, dict):
|
||||
return False, "host.py output is not a JSON object"
|
||||
if parsed.get("error"):
|
||||
return False, str(parsed["error"])
|
||||
return True, parsed
|
||||
|
||||
|
||||
def _tenant_safe_project_name(project_id):
|
||||
"""项目 id -> 租户目录安全名,与 worker._tenant_safe_name
|
||||
同规则;映射不出安全名时返回 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 safe
|
||||
|
||||
|
||||
def _tenant_codex_home(server, project_id):
|
||||
"""项目对应的租户 codex-home:tenants_root/<安全项目名>/
|
||||
codex-home。线程持久化在 CODEX_HOME 里,租户按项目分
|
||||
home,因此线程操作必须带这个 home,不能用共享 home。"""
|
||||
safe = _tenant_safe_project_name(project_id)
|
||||
if not safe:
|
||||
return None
|
||||
return os.path.join(_tenants_root_for(server), safe, "codex-home")
|
||||
|
||||
|
||||
def _tenant_work_dir(server, project_id):
|
||||
"""项目对应的租户工作目录:tenants_root/<安全项目名>/work,
|
||||
skills/list 的 cwds 过滤用它(不传 cwd 时 app-server 会
|
||||
默认用自身 cwd,那是错的 home 目录)。"""
|
||||
safe = _tenant_safe_project_name(project_id)
|
||||
if not safe:
|
||||
return None
|
||||
return os.path.join(_tenants_root_for(server), safe, "work")
|
||||
|
||||
|
||||
def _thread_worktree_for_task(server, task):
|
||||
"""本地任务的 worktree 推导:host.py dispatch 有仓库时在
|
||||
<host.py 同级>/work/host-worktrees/<host 任务名> 建 worktree,
|
||||
host 任务名 = 任务 id 的安全字符清洗(与 sandbox 的
|
||||
_host_name 同规则)。无仓库任务的 thread cwd 是 worker 的
|
||||
仓库路径或 host.py 当前目录,服务端表里没有、推不出来,
|
||||
此时按此路径过滤会返回零个 thread,调用方拿到 404——
|
||||
这是诚实结果,不是静默错指。部署可注入
|
||||
server.host_worktrees_dir(/pr 路径同款属性)或环境变量
|
||||
CODEX_HOST_WORKTREES_DIR 覆盖 worktree 根。"""
|
||||
base = getattr(server, "host_worktrees_dir", None)
|
||||
if not base:
|
||||
base = os.environ.get("CODEX_HOST_WORKTREES_DIR")
|
||||
if not base:
|
||||
base = os.path.join(os.path.dirname(_host_py_path()),
|
||||
"work", "host-worktrees")
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(task["id"]))
|
||||
return os.path.join(base, safe)
|
||||
|
||||
|
||||
def _extract_thread_list(result):
|
||||
"""从 thread-list 的 CLI 结果里取 thread 列表:标准形态是
|
||||
ThreadListResponse 的 data;threads 键与 result 包裹是
|
||||
容忍形态(CLI 形状未定前不因键名不同就 502)。取不到
|
||||
列表时返回 None(调用方按 502 处理)。"""
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
for key in ("data", "threads"):
|
||||
value = result.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
inner = result.get("result")
|
||||
if isinstance(inner, dict):
|
||||
for key in ("data", "threads"):
|
||||
value = inner.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _extract_thread(result):
|
||||
"""从 thread-read 的 CLI 结果里取 thread 对象:标准形态是
|
||||
ThreadReadResponse 的 thread;结果本身就是 thread 对象
|
||||
时也接受。取不到时返回 None。"""
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
thread = result.get("thread")
|
||||
if isinstance(thread, dict):
|
||||
return thread
|
||||
if isinstance(result.get("id"), str):
|
||||
return result
|
||||
inner = result.get("result")
|
||||
if isinstance(inner, dict):
|
||||
thread = inner.get("thread")
|
||||
if isinstance(thread, dict):
|
||||
return thread
|
||||
if isinstance(inner.get("id"), str):
|
||||
return inner
|
||||
return None
|
||||
|
||||
|
||||
def _pick_latest_thread(threads):
|
||||
"""cwd 过滤返回多个 thread 时的取用规则:取最新一个非
|
||||
archived。协议说明:Thread 对象本身没有 archived 字段,
|
||||
非归档由 thread/list 的 archived 过滤保证(不传 archived
|
||||
时只回非归档 thread);这里只防御性地跳过显式带
|
||||
archived 真值的条目(CLI 形状若补了该字段时仍正确)。
|
||||
最新 = updatedAt 最大,缺失时退 createdAt,再缺失时按
|
||||
列表原顺序取后者(同秒时后到的更新)。"""
|
||||
best = None
|
||||
best_key = None
|
||||
for idx, item in enumerate(threads):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("archived"):
|
||||
continue
|
||||
ts = item.get("updatedAt")
|
||||
if ts is None:
|
||||
ts = item.get("createdAt")
|
||||
try:
|
||||
ts = float(ts)
|
||||
except (TypeError, ValueError):
|
||||
ts = 0
|
||||
key = (ts, idx)
|
||||
if best_key is None or key > best_key:
|
||||
best = item
|
||||
best_key = key
|
||||
return best
|
||||
|
||||
|
||||
def _extract_result_list(result, keys):
|
||||
"""从 skills/apps/mcp 列表类 CLI 结果里取列表:标准形态
|
||||
是各 Response 的 data;传入的备用键是容忍形态。取不到
|
||||
时返回 None。"""
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
for key in keys:
|
||||
value = result.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
inner = result.get("result")
|
||||
if isinstance(inner, dict):
|
||||
for key in keys:
|
||||
value = inner.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _read_trace_entries(path):
|
||||
"""读 host 写的 jsonl 链路文件:跳过坏行;按 id 去重,后到的
|
||||
完成态覆盖先到的开始态,保首次顺序。文件不存在返回空列表。"""
|
||||
@@ -1648,6 +1862,22 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
_task_part, _rev_part = rest.split("/reviews/", 1)
|
||||
self._update_review_findings(
|
||||
user_id, _task_part, _rev_part.split("/")[0])
|
||||
elif rest.endswith("/threads") and method == "GET":
|
||||
self._task_threads(user_id, rest[: -len("/threads")])
|
||||
elif rest.endswith("/thread") and method == "GET":
|
||||
self._task_thread(user_id, rest[: -len("/thread")], query)
|
||||
elif rest.endswith("/thread/rename") and method == "POST":
|
||||
self._task_thread_rename(
|
||||
user_id, rest[: -len("/thread/rename")])
|
||||
elif rest.endswith("/thread/archive") and method == "POST":
|
||||
self._task_thread_archive(
|
||||
user_id, rest[: -len("/thread/archive")])
|
||||
elif rest.endswith("/thread/unarchive") and method == "POST":
|
||||
self._task_thread_unarchive(
|
||||
user_id, rest[: -len("/thread/unarchive")])
|
||||
elif rest.endswith("/thread/compact") and method == "POST":
|
||||
self._task_thread_compact(
|
||||
user_id, rest[: -len("/thread/compact")])
|
||||
elif rest.endswith("/fork") and method == "POST":
|
||||
self._fork_task(user_id, rest[: -len("/fork")])
|
||||
elif rest.endswith("/approvals") and method == "GET":
|
||||
@@ -2077,6 +2307,170 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
projects.append(item)
|
||||
self._send_json(200, {"projects": projects})
|
||||
|
||||
# -- 项目成员:邀请 / 列表 / 改角色 -------------------------------
|
||||
def _can_manage_members(self, user_id, project_id):
|
||||
"""成员管理权:项目 owner 或全局管理员。
|
||||
|
||||
全局管理员(CODEX_GLOBAL_ADMINS)不一定是项目成员,
|
||||
故不能只用项目内 manage_project 判定;项目 owner 判定
|
||||
复用 check_permission 的 manage_project(仅 owner)。
|
||||
"""
|
||||
if self._is_global_admin(user_id):
|
||||
return True
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
return store.check_permission(
|
||||
user_id, project_id, "manage_project")
|
||||
|
||||
def _set_member_row(self, store, project_id, actor_id, target_id,
|
||||
role):
|
||||
"""写成员行并记审计:owner 走 AuthStore.set_member(内含
|
||||
owner 校验与审计);非成员的全局管理员走直接写 + 同格式
|
||||
审计(set_member 的 owner 校验会挡住全局管理员,故分流)。
|
||||
"""
|
||||
if store.check_permission(actor_id, project_id,
|
||||
"manage_project"):
|
||||
store.set_member(project_id, actor_id, target_id, role)
|
||||
return
|
||||
store.conn.execute(
|
||||
"INSERT OR REPLACE INTO project_members"
|
||||
"(project_id, user_id, role) VALUES(?, ?, ?)",
|
||||
(project_id, target_id, role))
|
||||
store.conn.commit()
|
||||
store.audit(actor_id, project_id, "set_member",
|
||||
"%s:%s" % (target_id, role))
|
||||
|
||||
def _project_members_list(self, user_id, project_id):
|
||||
"""GET /projects/{id}/members:项目成员(read)或全局
|
||||
管理员可读。只返回 user_id / username / role,绝不返回
|
||||
key 哈希等认证字段。"""
|
||||
if not self._project_exists(project_id):
|
||||
self._send_json(404, {"error": "project not found"})
|
||||
return
|
||||
if not self._is_global_admin(user_id):
|
||||
err = self._check_project(user_id, project_id, "read")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT m.user_id, m.role, u.username FROM"
|
||||
" project_members m LEFT JOIN users u"
|
||||
" ON u.id = m.user_id WHERE m.project_id = ?"
|
||||
" ORDER BY u.username, m.user_id",
|
||||
(project_id,)).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
self._send_json(200, {"project_id": project_id, "members": [
|
||||
{"user_id": r["user_id"], "username": r["username"],
|
||||
"role": r["role"]} for r in rows]})
|
||||
|
||||
def _resolve_invite_target(self, body):
|
||||
"""邀请目标解析:优先 user_id,其次 username 查 id;
|
||||
返回 (target_id, error_status, error)。"""
|
||||
target = body.get("user_id")
|
||||
username = body.get("username")
|
||||
if target is not None and not isinstance(target, str):
|
||||
return None, 400, "user_id must be a string"
|
||||
if username is not None and not isinstance(username, str):
|
||||
return None, 400, "username must be a string"
|
||||
if not target and not username:
|
||||
return None, 400, "user_id or username required"
|
||||
conn = self._db()
|
||||
try:
|
||||
if target:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM users WHERE id = ?",
|
||||
(target,)).fetchone()
|
||||
if row is None:
|
||||
return None, 404, "user not found"
|
||||
return row["id"], None, None
|
||||
row = conn.execute(
|
||||
"SELECT id FROM users WHERE username = ?",
|
||||
(username,)).fetchone()
|
||||
if row is None:
|
||||
return None, 404, "user not found"
|
||||
return row["id"], None, None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _project_member_invite(self, user_id, project_id):
|
||||
"""POST /projects/{id}/members:限项目 owner 或全局管理
|
||||
员。已是成员时 409(改角色走 PATCH,不用邀请覆盖);
|
||||
成功记 set_member 审计。role 缺省 contributor。"""
|
||||
if not self._project_exists(project_id):
|
||||
self._send_json(404, {"error": "project not found"})
|
||||
return
|
||||
if not self._can_manage_members(user_id, project_id):
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
body, jerr = self._read_json()
|
||||
if jerr:
|
||||
self._send_json(400, {"error": jerr})
|
||||
return
|
||||
role = body.get("role") or "contributor"
|
||||
if role not in _MEMBER_ROLES:
|
||||
self._send_json(400, {"error": "unknown role"})
|
||||
return
|
||||
target, estatus, eerr = self._resolve_invite_target(body)
|
||||
if eerr:
|
||||
self._send_json(estatus, {"error": eerr})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
existing = store.conn.execute(
|
||||
"SELECT role FROM project_members"
|
||||
" WHERE project_id = ? AND user_id = ?",
|
||||
(project_id, target)).fetchone()
|
||||
if existing is not None:
|
||||
self._send_json(409, {"error": "already_member"})
|
||||
return
|
||||
self._set_member_row(store, project_id, user_id,
|
||||
target, role)
|
||||
self._send_json(201, {"project_id": project_id,
|
||||
"user_id": target, "role": role})
|
||||
|
||||
def _project_member_patch(self, user_id, project_id, target):
|
||||
"""PATCH /projects/{id}/members/{user_id}:限项目 owner
|
||||
或全局管理员。唯一 owner 降为非 owner 时 409(无论降
|
||||
自己还是降他人——否则项目将无 owner 可管);目标非成员
|
||||
时 404。"""
|
||||
if not self._project_exists(project_id):
|
||||
self._send_json(404, {"error": "project not found"})
|
||||
return
|
||||
if not self._can_manage_members(user_id, project_id):
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
body, jerr = self._read_json()
|
||||
if jerr:
|
||||
self._send_json(400, {"error": jerr})
|
||||
return
|
||||
role = body.get("role")
|
||||
if role not in _MEMBER_ROLES:
|
||||
self._send_json(400, {"error": "unknown role"})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
cur = store.conn.execute(
|
||||
"SELECT role FROM project_members"
|
||||
" WHERE project_id = ? AND user_id = ?",
|
||||
(project_id, target)).fetchone()
|
||||
if cur is None:
|
||||
self._send_json(404, {"error": "member not found"})
|
||||
return
|
||||
if cur[0] == "owner" and role != "owner":
|
||||
owners = store.conn.execute(
|
||||
"SELECT COUNT(*) FROM project_members"
|
||||
" WHERE project_id = ? AND role = 'owner'",
|
||||
(project_id,)).fetchone()[0]
|
||||
if owners <= 1:
|
||||
self._send_json(
|
||||
409,
|
||||
{"error": "cannot demote the only owner"})
|
||||
return
|
||||
self._set_member_row(store, project_id, user_id,
|
||||
target, role)
|
||||
self._send_json(200, {"project_id": project_id,
|
||||
"user_id": target, "role": role})
|
||||
|
||||
# -- 项目子资源:记忆 / 知识库 / 成本 -------------------------------
|
||||
def _project_exists(self, project_id):
|
||||
conn = self._db()
|
||||
@@ -2107,6 +2501,22 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
if len(parts) == 1 and method == "PATCH":
|
||||
self._update_project(user_id, project_id)
|
||||
return
|
||||
# /projects/{id}/members:GET 列表 / POST 邀请
|
||||
if len(parts) == 2 and parts[1] == "members":
|
||||
if method == "GET":
|
||||
self._project_members_list(user_id, project_id)
|
||||
elif method == "POST":
|
||||
self._project_member_invite(user_id, project_id)
|
||||
else:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
# /projects/{id}/members/{user_id}:PATCH 改角色
|
||||
if len(parts) == 3 and parts[1] == "members":
|
||||
if method == "PATCH":
|
||||
self._project_member_patch(user_id, project_id, parts[2])
|
||||
else:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
# /projects/{id}/memory
|
||||
if len(parts) == 2 and parts[1] == "memory":
|
||||
if method == "POST":
|
||||
@@ -2257,6 +2667,69 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self._triage_inbox(project_id)
|
||||
return
|
||||
# /projects/{id}/skills GET(项目 read)
|
||||
if len(parts) == 2 and parts[1] == "skills":
|
||||
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._skills_list(project_id)
|
||||
return
|
||||
# /projects/{id}/apps GET(项目 read)
|
||||
if len(parts) == 2 and parts[1] == "apps":
|
||||
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._apps_list(project_id)
|
||||
return
|
||||
# /projects/{id}/mcp-servers GET(项目 read)
|
||||
if len(parts) == 2 and parts[1] == "mcp-servers":
|
||||
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._mcp_servers_list(project_id)
|
||||
return
|
||||
# /projects/{id}/mcp-servers/reload POST(项目 manage_project)
|
||||
if len(parts) == 3 and parts[1] == "mcp-servers" \
|
||||
and parts[2] == "reload":
|
||||
if method != "POST":
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
err = self._check_project(user_id, project_id,
|
||||
"manage_project")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
self._mcp_servers_reload(user_id, project_id)
|
||||
return
|
||||
# /projects/{id}/mcp-servers/{name}/oauth-login POST
|
||||
# (项目 manage_project;name 为空或穿越时 404)
|
||||
if len(parts) == 4 and parts[1] == "mcp-servers" \
|
||||
and parts[3] == "oauth-login":
|
||||
if method != "POST":
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
if not parts[2] or parts[2] in (".", ".."):
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
err = self._check_project(user_id, project_id,
|
||||
"manage_project")
|
||||
if err:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
self._mcp_server_oauth_login(user_id, project_id, parts[2])
|
||||
return
|
||||
self._send_json(404, {"error": "not found"})
|
||||
|
||||
def _memory_create(self, user_id, project_id):
|
||||
@@ -3513,10 +3986,375 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
"review_findings_updated",
|
||||
"%s -> %s" % (task_id, review_id))
|
||||
self._send_json(200, updated)
|
||||
# -- 任务线程操作(经 host.py CLI 透传)----------------------------
|
||||
# 本批限制(P2 切片 C 接线时如实保留,见 cherry-pick 说明):
|
||||
# Docker/异机任务线程操作一律 409(见 _thread_task_guard);
|
||||
# 改操作仅 completed/failed/interrupted,merged/merge-failed
|
||||
# 409(见 THREAD_MUTATION_STATUSES,控制台门控与此一致);
|
||||
# turn/steer 与 review/start 不在本批(review 端点是 P1 已有
|
||||
# 功能,不由本批新增或改动)。
|
||||
def _thread_task(self, user_id, task_id, action):
|
||||
"""取任务并验项目权限:返回 (task, None),或 (None, err),
|
||||
err 为 (status, msg),调用方直接 _send_json。任务不存在
|
||||
404 在先(与 peer 消息同款顺序),权限不足 403。"""
|
||||
row = self._fetch_task(task_id)
|
||||
if row is None:
|
||||
return None, (404, "task not found")
|
||||
task = _task_to_dict(row)
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
if not store.check_permission(
|
||||
user_id, task["project_id"], action):
|
||||
return None, (403, "forbidden")
|
||||
return task, None
|
||||
|
||||
def _thread_task_guard(self, task):
|
||||
"""线程操作的执行器门:只做 local 执行器任务。Docker
|
||||
任务的 thread 在容器内、cloud 任务的 thread 不在本机,
|
||||
本机租户 codex-home 里没有,查了也是错指,一律 409
|
||||
明说,不回空结果假装无 thread。executor 为空(建任务
|
||||
时未指定)时放行:老任务多为 NULL 且实际由 local
|
||||
worker 认领,卡死它们等于端点对老任务不可用;若实际
|
||||
是 Docker 跑的,thread/list 会返回零个而得 404。
|
||||
worktree 在别机时同样 409(与链路的跨机诚实降级同款,
|
||||
但改操作绝不能在错的主机 home 上执行,所以不用 200
|
||||
空结果)。返回 None 表示通过,否则为 (status, msg)。"""
|
||||
executor = task.get("executor")
|
||||
if executor == "docker":
|
||||
return (409, "thread ops are local-executor only;"
|
||||
" docker task threads live inside the container")
|
||||
if executor == "cloud":
|
||||
return (409, "thread ops are local-executor only;"
|
||||
" cloud task threads are not on this host")
|
||||
worktree_host = task.get("worktree_host")
|
||||
if worktree_host and worktree_host != socket.gethostname():
|
||||
return (409, "task worktree is on host %s;"
|
||||
" thread ops must run there" % worktree_host)
|
||||
return None
|
||||
|
||||
def _list_task_threads(self, task):
|
||||
"""对任务调 thread-list:返回 (threads, codex_home, cwd,
|
||||
err),err 为 (status, msg) 或 None。cwd 过滤用任务
|
||||
worktree(见 _thread_worktree_for_task);codex-home
|
||||
用租户 home(见 _tenant_codex_home)。"""
|
||||
codex_home = _tenant_codex_home(self.server,
|
||||
task["project_id"])
|
||||
if codex_home is None:
|
||||
return None, None, None, (
|
||||
502, "cannot resolve tenant codex-home")
|
||||
cwd = _thread_worktree_for_task(self.server, task)
|
||||
ok, result = _run_host_cli(["thread-list", "--cwd", cwd],
|
||||
codex_home)
|
||||
if not ok:
|
||||
return None, None, None, (502, result)
|
||||
threads = _extract_thread_list(result)
|
||||
if threads is None:
|
||||
return None, None, None, (
|
||||
502, "host.py thread-list output has no thread list")
|
||||
return threads, codex_home, cwd, None
|
||||
|
||||
def _resolve_task_thread(self, task):
|
||||
"""单线程端点的解析:thread-list 后按 _pick_latest_thread
|
||||
取最新一个非 archived。返回 (thread, codex_home, cwd,
|
||||
err);零个 thread 时 err 为 (404, "thread not found")。"""
|
||||
threads, codex_home, cwd, err = self._list_task_threads(task)
|
||||
if err is not None:
|
||||
return None, None, None, err
|
||||
thread = _pick_latest_thread(threads)
|
||||
if thread is None:
|
||||
return None, None, None, (404, "thread not found")
|
||||
return thread, codex_home, cwd, None
|
||||
|
||||
def _task_threads(self, user_id, task_id):
|
||||
"""GET /tasks/{id}/threads:thread/list 原样返回列表。
|
||||
读权限用项目 read;零个 thread 时返回空列表(原样),
|
||||
不像单线程端点那样 404——列表端点没有“解析不到”的
|
||||
概念,空就是空。"""
|
||||
task, err = self._thread_task(user_id, task_id, "read")
|
||||
if err is not None:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
guard = self._thread_task_guard(task)
|
||||
if guard is not None:
|
||||
self._send_json(guard[0], {"error": guard[1]})
|
||||
return
|
||||
threads, _home, cwd, err = self._list_task_threads(task)
|
||||
if err is not None:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
self._send_json(200, {"task_id": task_id, "cwd": cwd,
|
||||
"threads": threads})
|
||||
|
||||
def _task_thread(self, user_id, task_id, query):
|
||||
"""GET /tasks/{id}/thread:先 thread/list 解析出 thread,
|
||||
再 thread/read。默认只要元数据(includeTurns 不传);
|
||||
?include_turns=1 时才传 includeTurns。读操作不限制
|
||||
任务状态;执行错误原样透传为 502。"""
|
||||
task, err = self._thread_task(user_id, task_id, "read")
|
||||
if err is not None:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
guard = self._thread_task_guard(task)
|
||||
if guard is not None:
|
||||
self._send_json(guard[0], {"error": guard[1]})
|
||||
return
|
||||
thread, codex_home, cwd, err = self._resolve_task_thread(task)
|
||||
if err is not None:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
argv = ["thread-read", "--thread-id", thread["id"]]
|
||||
include_turns = (query.get("include_turns") or ["0"])[0]
|
||||
if include_turns == "1":
|
||||
argv.append("--include-turns")
|
||||
ok, result = _run_host_cli(argv, codex_home)
|
||||
if not ok:
|
||||
self._send_json(502, {"error": result})
|
||||
return
|
||||
full = _extract_thread(result)
|
||||
if full is None:
|
||||
self._send_json(502, {
|
||||
"error": "host.py thread-read output has no thread"})
|
||||
return
|
||||
self._send_json(200, {"task_id": task_id, "cwd": cwd,
|
||||
"thread_id": thread["id"],
|
||||
"thread": full})
|
||||
|
||||
def _task_thread_mutate(self, user_id, task_id, argv, audit_action,
|
||||
audit_detail, timeout=60):
|
||||
"""线程改操作的公共流程:权限用项目 create_task;只允许
|
||||
终态任务(completed/failed/interrupted),running/queued
|
||||
一律 409;解析 thread 后调对应 CLI,成功记审计。"""
|
||||
task, err = self._thread_task(user_id, task_id, "create_task")
|
||||
if err is not None:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
guard = self._thread_task_guard(task)
|
||||
if guard is not None:
|
||||
self._send_json(guard[0], {"error": guard[1]})
|
||||
return
|
||||
if task["status"] not in THREAD_MUTATION_STATUSES:
|
||||
self._send_json(409, {
|
||||
"error": "thread ops require a terminal task",
|
||||
"status": task["status"]})
|
||||
return
|
||||
thread, codex_home, _cwd, err = self._resolve_task_thread(task)
|
||||
if err is not None:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
ok, result = _run_host_cli(
|
||||
list(argv) + ["--thread-id", thread["id"]],
|
||||
codex_home, timeout=timeout)
|
||||
if not ok:
|
||||
self._send_json(502, {"error": result})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, task["project_id"], audit_action,
|
||||
audit_detail % thread["id"])
|
||||
self._send_json(200, {"task_id": task_id,
|
||||
"thread_id": thread["id"],
|
||||
"result": result})
|
||||
|
||||
def _task_thread_rename(self, user_id, task_id):
|
||||
"""POST /tasks/{id}/thread/rename {name}:thread/name/set。
|
||||
name 必填、非空、200 字内;记 thread_renamed 审计。"""
|
||||
body, jerr = self._read_json()
|
||||
if jerr:
|
||||
self._send_json(400, {"error": jerr})
|
||||
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) > 200:
|
||||
self._send_json(400, {"error": "name too long"})
|
||||
return
|
||||
self._task_thread_mutate(
|
||||
user_id, task_id, ["thread-rename", "--name", name],
|
||||
"thread_renamed", "%s renamed to " + name)
|
||||
|
||||
def _task_thread_archive(self, user_id, task_id):
|
||||
"""POST /tasks/{id}/thread/archive:thread/archive。
|
||||
记 thread_archived 审计。"""
|
||||
self._task_thread_mutate(user_id, task_id, ["thread-archive"],
|
||||
"thread_archived", "%s archived")
|
||||
|
||||
def _task_thread_unarchive(self, user_id, task_id):
|
||||
"""POST /tasks/{id}/thread/unarchive:thread/unarchive。
|
||||
记 thread_unarchived 审计。"""
|
||||
self._task_thread_mutate(user_id, task_id, ["thread-unarchive"],
|
||||
"thread_unarchived", "%s unarchived")
|
||||
|
||||
def _task_thread_compact(self, user_id, task_id):
|
||||
"""POST /tasks/{id}/thread/compact:thread/compact/start。
|
||||
压缩可能耗时,CLI 超时放宽到 120 秒;记 thread_compacted
|
||||
审计。注意:这是线程压缩,不是开新回合,不跑模型
|
||||
轮次之外的 turn(压缩本身由 app-server 内部完成)。"""
|
||||
self._task_thread_mutate(user_id, task_id, ["thread-compact"],
|
||||
"thread_compacted", "%s compacted",
|
||||
timeout=120)
|
||||
|
||||
# -- 项目 skills / apps / MCP(经 host.py CLI 透传)--------------
|
||||
# codex-home 按项目分租户目录,所以这批端点是项目 scope:
|
||||
# 读用项目 read,改用项目 manage_project(owner)。
|
||||
def _project_host_home(self, project_id):
|
||||
"""项目端点的租户 home 与工作目录:返回 (codex_home,
|
||||
work_dir),任一解析不出时返回 (None, None)。注意:这里
|
||||
反映的是宿主租户 home,容器内 home 可能不同,skills/MCP
|
||||
面看到的是宿主侧视图,不代表容器内视图。"""
|
||||
codex_home = _tenant_codex_home(self.server, project_id)
|
||||
work_dir = _tenant_work_dir(self.server, project_id)
|
||||
if codex_home is None or work_dir is None:
|
||||
return None, None
|
||||
return codex_home, work_dir
|
||||
|
||||
def _skills_list(self, project_id):
|
||||
"""GET /projects/{id}/skills:skills/list 原样返回。"""
|
||||
codex_home, work_dir = self._project_host_home(project_id)
|
||||
if codex_home is None:
|
||||
self._send_json(502, {
|
||||
"error": "cannot resolve tenant codex-home"})
|
||||
return
|
||||
ok, result = _run_host_cli(["skills-list", "--cwd", work_dir],
|
||||
codex_home)
|
||||
if not ok:
|
||||
self._send_json(502, {"error": result})
|
||||
return
|
||||
skills = _extract_result_list(result, ("data", "skills"))
|
||||
if skills is None:
|
||||
self._send_json(502, {
|
||||
"error": "host.py skills-list output has no list"})
|
||||
return
|
||||
self._send_json(200, {"project_id": project_id,
|
||||
"skills": skills})
|
||||
|
||||
def _apps_list(self, project_id):
|
||||
"""GET /projects/{id}/apps:app/list 原样返回(schema 里
|
||||
是 AppsListParams/AppsListResponse;项目 scope 下不带
|
||||
threadId,不做按线程的特性门控)。"""
|
||||
codex_home, _work = self._project_host_home(project_id)
|
||||
if codex_home is None:
|
||||
self._send_json(502, {
|
||||
"error": "cannot resolve tenant codex-home"})
|
||||
return
|
||||
ok, result = _run_host_cli(["apps-list"], codex_home)
|
||||
if not ok:
|
||||
self._send_json(502, {"error": result})
|
||||
return
|
||||
apps = _extract_result_list(result, ("data", "apps"))
|
||||
if apps is None:
|
||||
self._send_json(502, {
|
||||
"error": "host.py apps-list output has no list"})
|
||||
return
|
||||
body = {"project_id": project_id, "apps": apps}
|
||||
cursor = result.get("nextCursor")
|
||||
if cursor is not None:
|
||||
body["nextCursor"] = cursor
|
||||
self._send_json(200, body)
|
||||
|
||||
def _mcp_servers_list(self, project_id):
|
||||
"""GET /projects/{id}/mcp-servers:mcpServerStatus/list
|
||||
原样返回(schema 里是 ListMcpServerStatusParams/
|
||||
Response;不带 threadId,走配置发现)。"""
|
||||
codex_home, _work = self._project_host_home(project_id)
|
||||
if codex_home is None:
|
||||
self._send_json(502, {
|
||||
"error": "cannot resolve tenant codex-home"})
|
||||
return
|
||||
ok, result = _run_host_cli(["mcp-servers-list"], codex_home)
|
||||
if not ok:
|
||||
self._send_json(502, {"error": result})
|
||||
return
|
||||
servers = _extract_result_list(result, ("data", "servers"))
|
||||
if servers is None:
|
||||
self._send_json(502, {
|
||||
"error": "host.py mcp-servers-list output has no list"})
|
||||
return
|
||||
body = {"project_id": project_id, "servers": servers}
|
||||
cursor = result.get("nextCursor")
|
||||
if cursor is not None:
|
||||
body["nextCursor"] = cursor
|
||||
self._send_json(200, body)
|
||||
|
||||
def _mcp_servers_reload(self, user_id, project_id):
|
||||
"""POST /projects/{id}/mcp-servers/reload:config/mcpServer/
|
||||
reload。核对结论:该方法在协议 schema 里真实存在——
|
||||
v1 与 v2 的 ClientRequest 方法枚举里都有它,params
|
||||
为 null,没有专用的响应定义(结果通常为空),所以
|
||||
正常调、结果原样透传;只有 CLI 本身报错时才 502,
|
||||
绝不伪造“不支持”也不伪造成功。记审计。"""
|
||||
codex_home, _work = self._project_host_home(project_id)
|
||||
if codex_home is None:
|
||||
self._send_json(502, {
|
||||
"error": "cannot resolve tenant codex-home"})
|
||||
return
|
||||
ok, result = _run_host_cli(["mcp-servers-reload"], codex_home)
|
||||
if not ok:
|
||||
self._send_json(502, {"error": result})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id, "mcp_servers_reloaded",
|
||||
"mcp servers reloaded")
|
||||
self._send_json(200, {"project_id": project_id,
|
||||
"reloaded": True, "result": result})
|
||||
|
||||
def _mcp_server_oauth_login(self, user_id, project_id, name):
|
||||
"""POST /projects/{id}/mcp-servers/{name}/oauth-login:
|
||||
mcpServer/oauth/login。协议返回(McpServerOauthLoginResponse:
|
||||
authorizationUrl 必填、loginId 可空)原样透传给调用方,
|
||||
浏览器里完成登录的 URL/步骤由调用方展示。完成态轮询
|
||||
不做:协议里完成信号是 McpServerOauthLoginCompletedNotification
|
||||
(服务端通知,不是请求/响应轮询),本端点只做到“发起
|
||||
登录并返回授权 URL”这一步,绝不假装登录已完成。
|
||||
记审计时只记服务器名与 loginId,不记授权 URL。"""
|
||||
body, jerr = self._read_json()
|
||||
if jerr:
|
||||
self._send_json(400, {"error": jerr})
|
||||
return
|
||||
argv = ["mcp-server-oauth-login", "--server-name", name]
|
||||
scopes = body.get("scopes")
|
||||
if scopes is not None:
|
||||
if not isinstance(scopes, list) or any(
|
||||
not isinstance(s, str) for s in scopes):
|
||||
self._send_json(400, {
|
||||
"error": "scopes must be a list of strings"})
|
||||
return
|
||||
if scopes:
|
||||
argv += ["--scopes", ",".join(scopes)]
|
||||
timeout_secs = body.get("timeoutSecs")
|
||||
if timeout_secs is not None:
|
||||
if not isinstance(timeout_secs, int) or timeout_secs <= 0:
|
||||
self._send_json(400, {
|
||||
"error": "timeoutSecs must be a positive integer"})
|
||||
return
|
||||
argv += ["--timeout-secs", str(timeout_secs)]
|
||||
codex_home, _work = self._project_host_home(project_id)
|
||||
if codex_home is None:
|
||||
self._send_json(502, {
|
||||
"error": "cannot resolve tenant codex-home"})
|
||||
return
|
||||
ok, result = _run_host_cli(argv, codex_home, timeout=120)
|
||||
if not ok:
|
||||
self._send_json(502, {"error": result})
|
||||
return
|
||||
if not isinstance(result.get("authorizationUrl"), str):
|
||||
self._send_json(502, {
|
||||
"error": "host.py oauth-login output has no"
|
||||
" authorizationUrl"})
|
||||
return
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, project_id,
|
||||
"mcp_server_oauth_login_started",
|
||||
"%s loginId=%s" % (
|
||||
name, result.get("loginId") or ""))
|
||||
response = dict(result)
|
||||
response["project_id"] = project_id
|
||||
response["server_name"] = name
|
||||
self._send_json(200, response)
|
||||
|
||||
def _fork_task(self, user_id, task_id):
|
||||
"""会话分支:从已结束任务的 thread 分叉出新任务。
|
||||
|
||||
|
||||
新任务继承源任务的 executor/模型/强度/权限/计划模式,
|
||||
不继承 goal(分支是普通任务,首轮 prompt 就是这里传的
|
||||
message)。worker 认领后先调 host.py fork(分叉 thread
|
||||
|
||||
@@ -65,7 +65,15 @@ class ConsoleTestCase(unittest.TestCase):
|
||||
# 壳必须引用它实际调的接口,防 JS 与服务端路由漂移。
|
||||
for marker in ("/health", "/projects", "/tasks", "/workers",
|
||||
"X-API-Key", "agentBox", "/agents",
|
||||
"peerMessage", "/messages"):
|
||||
"peerMessage", "/messages",
|
||||
"threadBox", "threadGateNote", "threadList",
|
||||
"THREAD_MUTATION_STATUSES",
|
||||
"/thread", "/threads",
|
||||
"/thread/rename", "/thread/archive",
|
||||
"/thread/unarchive", "/thread/compact",
|
||||
"skillList", "appList", "mcpList", "mcpOauthBox",
|
||||
"/skills", "/apps", "/mcp-servers",
|
||||
"/mcp-servers/reload", "/oauth-login"):
|
||||
self.assertIn(marker, text)
|
||||
|
||||
def test_console_trailing_slash_same_page(self):
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"""P2 切片 A(控制台管理面与筛选)单测。
|
||||
|
||||
只覆盖本切片新增或复用的服务端契约 + 控制台壳标记,不跑
|
||||
浏览器 JS(与 test_console.py 同款:壳只验标记与路由):
|
||||
- 成员端点:非 owner 邀请 403、owner 邀请成功(user_id 与
|
||||
username 两种入参)、重复邀请 409、非法角色 400、未知用户
|
||||
404、成员列表读权限、改角色权限与唯一 owner 防降级、
|
||||
非成员全局管理员可邀请/改角色。
|
||||
- 管理区复用端点:非管理员 sweep 403、管理员配额读成功、
|
||||
探针语义(非管理员 GET /admin/quotas 无参 403,管理员 400)。
|
||||
- 链路全量:写 1200 条链路文件,GET /trace 全量返回(控制台
|
||||
“下载完整链路”依赖的正是该端点,而非屏上截断的 1000 条)。
|
||||
- Workers:心跳后 /workers 返回 epoch 秒 last_seen 与 alive。
|
||||
- 控制台壳含本切片新增标记。
|
||||
|
||||
测试用的 key、用户 id 全是假值;不联网、不碰生产。
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
|
||||
|
||||
def _req(port, method, path, body=None, key=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if key:
|
||||
headers["X-API-Key"] = key
|
||||
conn.request(method, path,
|
||||
json.dumps(body) if body is not None else None, headers)
|
||||
resp = conn.getresponse()
|
||||
raw = resp.read()
|
||||
conn.close()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
data = None
|
||||
return resp.status, data
|
||||
|
||||
|
||||
class P2ConsoleTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.trace_dir = os.path.join(self.tmp.name, "host-traces")
|
||||
os.makedirs(self.trace_dir, exist_ok=True)
|
||||
self.auth = AuthStore(self.db_path)
|
||||
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
||||
self.maint_id, self.maint_key = self.auth.create_user("maint")
|
||||
self.member_id, self.member_key = self.auth.create_user("member")
|
||||
self.admin_id, self.admin_key = self.auth.create_user("admin")
|
||||
self.outsider_id, self.out_key = self.auth.create_user("outsider")
|
||||
self._old_admins = os.environ.get("CODEX_GLOBAL_ADMINS")
|
||||
# 全局管理员:非项目成员的 admin,验“owner 或全局管理员”双通道。
|
||||
os.environ["CODEX_GLOBAL_ADMINS"] = self.admin_id
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.server.trace_dir = self.trace_dir
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
status, proj = _req(self.port, "POST", "/projects",
|
||||
{"name": "demo"}, self.owner_key)
|
||||
self.assertEqual(status, 201, proj)
|
||||
self.project_id = proj["id"]
|
||||
self.auth.set_member(self.project_id, self.owner_id,
|
||||
self.maint_id, "maintainer")
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
try:
|
||||
self.auth.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.tmp.cleanup()
|
||||
if self._old_admins is None:
|
||||
os.environ.pop("CODEX_GLOBAL_ADMINS", None)
|
||||
else:
|
||||
os.environ["CODEX_GLOBAL_ADMINS"] = self._old_admins
|
||||
|
||||
def _members(self, key=None):
|
||||
return _req(self.port, "GET",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
key=key)
|
||||
|
||||
# -- 成员邀请 / 列表 -------------------------------------------
|
||||
def test_non_owner_invite_forbidden(self):
|
||||
# maintainer 不是 owner、不是全局管理员:邀请必须 403,
|
||||
# 且安全门不能因“项目成员”身份放行。
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "viewer"},
|
||||
self.maint_key)
|
||||
self.assertEqual(status, 403, data)
|
||||
|
||||
def test_outsider_invite_forbidden(self):
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "viewer"},
|
||||
self.out_key)
|
||||
self.assertEqual(status, 403, data)
|
||||
|
||||
def test_owner_invite_by_user_id_and_username(self):
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "contributor"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201, data)
|
||||
self.assertEqual(data["role"], "contributor")
|
||||
# 第二个用户走 username 入参(控制台输入框两种都接受)。
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"username": "outsider", "role": "viewer"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201, data)
|
||||
self.assertEqual(data["user_id"], self.outsider_id)
|
||||
|
||||
def test_invite_validation(self):
|
||||
status, _ = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "superuser"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 400)
|
||||
status, _ = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": "no-such-user", "role": "viewer"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 404)
|
||||
status, _ = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"role": "viewer"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def test_invite_duplicate_conflict(self):
|
||||
status, _ = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "viewer"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201)
|
||||
# 已是成员:改角色走 PATCH,重复邀请 409,不静默覆盖。
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "maintainer"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 409, data)
|
||||
|
||||
def test_member_list_read_and_outsider_denied(self):
|
||||
_req(self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "viewer"},
|
||||
self.owner_key)
|
||||
status, data = self._members(self.maint_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
roles = {m["user_id"]: m["role"] for m in data["members"]}
|
||||
self.assertEqual(roles[self.owner_id], "owner")
|
||||
self.assertEqual(roles[self.maint_id], "maintainer")
|
||||
self.assertEqual(roles[self.member_id], "viewer")
|
||||
# 列表不泄露认证字段。
|
||||
for m in data["members"]:
|
||||
self.assertNotIn("api_key_hash", m)
|
||||
self.assertNotIn("key_hash", m)
|
||||
status, _ = self._members(self.out_key)
|
||||
self.assertEqual(status, 403)
|
||||
|
||||
def test_global_admin_can_invite_without_membership(self):
|
||||
# admin 不是项目成员,但凭 CODEX_GLOBAL_ADMINS 可邀请。
|
||||
status, data = _req(
|
||||
self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "contributor"},
|
||||
self.admin_key)
|
||||
self.assertEqual(status, 201, data)
|
||||
|
||||
# -- 改角色 / 唯一 owner 防降级 ---------------------------------
|
||||
def test_patch_role_by_owner_and_non_owner_denied(self):
|
||||
_req(self.port, "POST",
|
||||
"/projects/%s/members" % self.project_id,
|
||||
{"user_id": self.member_id, "role": "viewer"},
|
||||
self.owner_key)
|
||||
status, data = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.member_id),
|
||||
{"role": "maintainer"}, self.maint_key)
|
||||
self.assertEqual(status, 403, data)
|
||||
status, data = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.member_id),
|
||||
{"role": "maintainer"}, self.owner_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["role"], "maintainer")
|
||||
status, _ = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, "ghost"),
|
||||
{"role": "viewer"}, self.owner_key)
|
||||
self.assertEqual(status, 404)
|
||||
|
||||
def test_cannot_demote_only_owner(self):
|
||||
status, data = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.owner_id),
|
||||
{"role": "maintainer"}, self.owner_key)
|
||||
self.assertEqual(status, 409, data)
|
||||
# 先把另一位成员升为 owner,之后降原 owner 才放行。
|
||||
status, _ = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.maint_id),
|
||||
{"role": "owner"}, self.owner_key)
|
||||
self.assertEqual(status, 200)
|
||||
status, data = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.owner_id),
|
||||
{"role": "maintainer"}, self.maint_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
|
||||
def test_global_admin_patch_blocked_by_sole_owner_guard(self):
|
||||
# 全局管理员也不能把唯一 owner 降掉(防项目无 owner 可管)。
|
||||
status, _ = _req(
|
||||
self.port, "PATCH",
|
||||
"/projects/%s/members/%s" % (self.project_id, self.owner_id),
|
||||
{"role": "viewer"}, self.admin_key)
|
||||
self.assertEqual(status, 409)
|
||||
|
||||
# -- 管理区复用端点 ---------------------------------------------
|
||||
def test_admin_probe_and_quota_reuse(self):
|
||||
# 探针语义:控制台凭此区分管理员与否,不新增端点。
|
||||
status, _ = _req(self.port, "GET", "/admin/quotas",
|
||||
key=self.maint_key)
|
||||
self.assertEqual(status, 403)
|
||||
status, _ = _req(self.port, "GET", "/admin/quotas",
|
||||
key=self.admin_key)
|
||||
self.assertEqual(status, 400)
|
||||
status, data = _req(
|
||||
self.port, "GET",
|
||||
"/admin/quotas?user_id=%s" % self.owner_id,
|
||||
key=self.admin_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertIn("used_usd_micros", data)
|
||||
status, _ = _req(
|
||||
self.port, "GET",
|
||||
"/admin/quotas?user_id=%s" % self.owner_id,
|
||||
key=self.maint_key)
|
||||
self.assertEqual(status, 403)
|
||||
|
||||
def test_non_admin_sweep_forbidden(self):
|
||||
status, _ = _req(self.port, "POST", "/admin/sweep", {},
|
||||
self.maint_key)
|
||||
self.assertEqual(status, 403)
|
||||
status, _ = _req(self.port, "POST", "/admin/backup", {},
|
||||
self.maint_key)
|
||||
self.assertEqual(status, 403)
|
||||
|
||||
# -- 链路全量 / Workers ------------------------------------------
|
||||
def test_trace_endpoint_returns_full_entries(self):
|
||||
status, task = _req(
|
||||
self.port, "POST", "/tasks",
|
||||
{"project_id": self.project_id, "prompt": "trace probe"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201, task)
|
||||
task_id = task["id"]
|
||||
path = os.path.join(self.trace_dir, task_id + ".jsonl")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
for i in range(1200):
|
||||
f.write(json.dumps(
|
||||
{"id": "e%d" % i, "kind": "message",
|
||||
"text": "line %d" % i}) + "\n")
|
||||
status, data = _req(
|
||||
self.port, "GET", "/tasks/%s/trace" % task_id,
|
||||
key=self.owner_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
# 服务端不截断:控制台下载按钮拿到的就是这 1200 条全量,
|
||||
# 屏上 1000 条截断只是渲染层行为。
|
||||
self.assertEqual(len(data["entries"]), 1200)
|
||||
|
||||
def test_workers_last_seen_is_epoch_and_alive(self):
|
||||
status, data = _req(
|
||||
self.port, "POST", "/workers/heartbeat",
|
||||
{"worker_id": "w-p2", "executor": "docker",
|
||||
"capacity": 2, "running": 0},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
# 合并适配(P2 rebase 到 97f019d,P1 已合并):P1 起完整
|
||||
# worker 清单只给全局管理员,项目 owner 只能看到聚合
|
||||
# (聚合行为由 P1 的 test_workers_admin_full_member_aggregate_only
|
||||
# 覆盖);本用例验的是完整清单里的 epoch last_seen 与
|
||||
# alive,故用本夹具的全局管理员 admin_key 查,不降门。
|
||||
status, data = _req(self.port, "GET", "/workers",
|
||||
key=self.admin_key)
|
||||
self.assertEqual(status, 200, data)
|
||||
workers = data["workers"]
|
||||
self.assertEqual(len(workers), 1)
|
||||
# 控制台“x 秒/分钟前 + 120 秒在线阈值”的输入:epoch 秒。
|
||||
self.assertIsInstance(workers[0]["last_seen"], (int, float))
|
||||
self.assertLess(abs(workers[0]["last_seen"] - time.time()), 60)
|
||||
self.assertTrue(workers[0]["alive"])
|
||||
|
||||
# -- 控制台壳标记 -------------------------------------------------
|
||||
def test_console_contains_p2_markers(self):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||
conn.request("GET", "/console")
|
||||
resp = conn.getresponse()
|
||||
text = resp.read().decode("utf-8")
|
||||
conn.close()
|
||||
self.assertEqual(resp.status, 200)
|
||||
for marker in ("/members", "inviteMember", "memberList",
|
||||
"adminBody", "/admin/sweep", "/admin/backup",
|
||||
"/admin/quotas", "taskStatusFilter",
|
||||
"taskExecutorFilter", "taskSearch",
|
||||
"btnDownloadTrace", "traceKindFilter",
|
||||
"workerAgeText", "last_seen"):
|
||||
self.assertIn(marker, text, marker)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,203 @@
|
||||
"""P2-ops 迁移对齐:迁移清单必须等于 Postgres schema 表集合,
|
||||
未迁移项必须被显式报告,绝不能静默跳过。
|
||||
|
||||
不用真实 Postgres:迁移本体用一个假 PG 连接跑(只记录
|
||||
INSERT / ALTER,不连库);schema 对齐直接读 POSTGRES_SCHEMA。
|
||||
真实 PG 上的幂等性仍由 test_postgres_integration.py 覆盖
|
||||
(有 CLOUD_TEST_PG_DSN 时才跑)。
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.db import POSTGRES_SCHEMA
|
||||
from cloud.migrations import (
|
||||
MIGRATION_TABLES,
|
||||
SEQUENCE_TABLES,
|
||||
UNMIGRATED_TABLES,
|
||||
migrate_sqlite_to_postgres,
|
||||
migration_coverage,
|
||||
schema_tables,
|
||||
)
|
||||
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.db import POSTGRES_SCHEMA
|
||||
from cloud.migrations import (
|
||||
MIGRATION_TABLES,
|
||||
SEQUENCE_TABLES,
|
||||
UNMIGRATED_TABLES,
|
||||
migrate_sqlite_to_postgres,
|
||||
migration_coverage,
|
||||
schema_tables,
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, rows=None, rowcount=1):
|
||||
self._rows = rows or []
|
||||
self.rowcount = rowcount
|
||||
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
class FakePgConn:
|
||||
"""只记录 SQL 的假 PG 连接:init_postgres_schema 的 DDL
|
||||
直接忽略;PRAGMA 按构造时给的列集合回答(row[1] 为列名,
|
||||
与 init_postgres_schema / 迁移补列代码的取法一致)。"""
|
||||
|
||||
def __init__(self, pragma_columns=None):
|
||||
# pragma_columns: {表名: {列名}};缺省表示“列齐全”。
|
||||
self.pragma_columns = pragma_columns or {}
|
||||
self.statements = []
|
||||
self.inserts = [] # (表名, 列名元组)
|
||||
self.alters = []
|
||||
self.commits = 0
|
||||
|
||||
def execute(self, sql, params=()):
|
||||
self.statements.append(sql)
|
||||
if sql.startswith("PRAGMA table_info("):
|
||||
table = sql[len("PRAGMA table_info("):].rstrip(")")
|
||||
cols = self.pragma_columns.get(table)
|
||||
if cols is None:
|
||||
# 未指定时假装列齐全:projects 含 repo_url,
|
||||
# task_followups 含 sender_task_id。
|
||||
cols = {"repo_url"} if table == "projects" \
|
||||
else {"sender_task_id"}
|
||||
rows = [(i, c, None) for i, c in enumerate(sorted(cols))]
|
||||
return FakeCursor(rows=rows)
|
||||
m = re.search(r'INSERT INTO "(\w+)" \(([^)]*)\)', sql)
|
||||
if m:
|
||||
cols = tuple(c.strip().strip('"')
|
||||
for c in m.group(2).split(","))
|
||||
self.inserts.append((m.group(1), cols))
|
||||
return FakeCursor(rowcount=1)
|
||||
if sql.startswith("ALTER TABLE"):
|
||||
self.alters.append(sql)
|
||||
return FakeCursor(rowcount=0)
|
||||
return FakeCursor(rowcount=0)
|
||||
|
||||
def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
|
||||
def _make_sqlite(path):
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("CREATE TABLE uploads(id TEXT PRIMARY KEY, created_by TEXT,"
|
||||
" created_at REAL, status TEXT)")
|
||||
conn.execute("CREATE TABLE task_attachments(task_id TEXT, bundle_id TEXT,"
|
||||
" rel_path TEXT, size INTEGER,"
|
||||
" PRIMARY KEY(task_id, rel_path))")
|
||||
conn.execute("CREATE TABLE task_followups(id TEXT PRIMARY KEY,"
|
||||
" task_id TEXT, project_id TEXT, body TEXT, mode TEXT,"
|
||||
" status TEXT, created_by TEXT, sender_task_id TEXT,"
|
||||
" created_at REAL, updated_at REAL)")
|
||||
conn.execute("CREATE TABLE 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 REAL, updated_at REAL)")
|
||||
conn.execute("INSERT INTO uploads VALUES(?, ?, ?, ?)",
|
||||
("up1", "u1", 1.0, "done"))
|
||||
conn.execute("INSERT INTO task_attachments VALUES(?, ?, ?, ?)",
|
||||
("t1", "b1", "a.txt", 3))
|
||||
conn.execute("INSERT INTO task_followups VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
("f1", "t1", "p1", "hi", "queued", "pending", "u1",
|
||||
"sender-task", 1.0, 1.0))
|
||||
conn.execute("INSERT INTO environments VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
("e1", "p1", "dev", "main", "docker", "ready", "",
|
||||
"https://example.invalid/r", "u1", 1.0, 1.0))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestMigrationAlignment(unittest.TestCase):
|
||||
def test_migration_tables_equal_schema_tables(self):
|
||||
# 核心对齐断言:以前漏掉的 4 张表补进后,两边集合必须一致。
|
||||
self.assertEqual(set(MIGRATION_TABLES), schema_tables())
|
||||
for table in ("uploads", "task_attachments",
|
||||
"task_followups", "environments", "user_quotas"):
|
||||
self.assertIn(table, MIGRATION_TABLES)
|
||||
|
||||
def test_sequence_tables_equal_identity_tables(self):
|
||||
# 序列拨动清单必须等于 schema 里 IDENTITY 列的表集合:
|
||||
# TEXT 主键的新表不需要拨序列,漏拨或多拨都会在这里变红。
|
||||
identity = set()
|
||||
for statement in POSTGRES_SCHEMA:
|
||||
m = re.search(r"CREATE TABLE IF NOT EXISTS (\w+)", statement)
|
||||
if m and "GENERATED BY DEFAULT AS IDENTITY" in statement:
|
||||
identity.add(m.group(1))
|
||||
self.assertEqual(set(SEQUENCE_TABLES), identity)
|
||||
|
||||
def test_unmigrated_are_real_sqlite_tables_not_in_schema(self):
|
||||
# 未迁移清单不得是凭空编的:每项都必须在 cloud/ 源码里
|
||||
# 真有 CREATE TABLE,且确实不在 PG schema 里;同时不得
|
||||
# 与迁移清单重叠(重叠即“既迁又报未迁”,自相矛盾)。
|
||||
sources = ""
|
||||
for name in os.listdir(REPO_ROOT / "cloud"):
|
||||
if name.endswith(".py"):
|
||||
with open(REPO_ROOT / "cloud" / name) as f:
|
||||
sources += f.read()
|
||||
schema = schema_tables()
|
||||
for table in UNMIGRATED_TABLES:
|
||||
self.assertIn("CREATE TABLE IF NOT EXISTS %s" % table, sources,
|
||||
"%s 不在任何 SQLite DDL 里,清单已过期" % table)
|
||||
self.assertNotIn(table, schema)
|
||||
self.assertNotIn(table, MIGRATION_TABLES)
|
||||
|
||||
def test_coverage_reports_no_missing(self):
|
||||
coverage = migration_coverage()
|
||||
self.assertEqual(coverage["missing_from_migration"], ())
|
||||
self.assertEqual(coverage["unmigrated_no_pg_schema"],
|
||||
tuple(UNMIGRATED_TABLES))
|
||||
self.assertEqual(set(coverage["skipped_by_design"]),
|
||||
{"kb_fts", "sqlite_sequence"})
|
||||
|
||||
|
||||
class TestMigrateNewTables(unittest.TestCase):
|
||||
def test_new_tables_migrated_and_sender_column_added(self):
|
||||
# PG 侧 task_followups 尚无 sender_task_id:迁移必须先
|
||||
# ALTER 补列,再把含该列的行插过去,而不是撞未知列失败。
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = os.path.join(tmp, "src.db")
|
||||
_make_sqlite(src)
|
||||
fake = FakePgConn(pragma_columns={
|
||||
"projects": {"repo_url"},
|
||||
"task_followups": {"id", "task_id", "project_id", "body",
|
||||
"mode", "status", "created_by",
|
||||
"created_at", "updated_at"},
|
||||
})
|
||||
result = migrate_sqlite_to_postgres(src, pg_conn=fake)
|
||||
for table in ("uploads", "task_attachments",
|
||||
"task_followups", "environments"):
|
||||
self.assertEqual(result.get(table), 1, table)
|
||||
self.assertTrue(
|
||||
any("task_followups" in a and "sender_task_id" in a
|
||||
for a in fake.alters),
|
||||
"未发出 sender_task_id 补列 ALTER:%s" % fake.alters)
|
||||
fu_inserts = [cols for t, cols in fake.inserts
|
||||
if t == "task_followups"]
|
||||
self.assertEqual(len(fu_inserts), 1)
|
||||
self.assertIn("sender_task_id", fu_inserts[0])
|
||||
|
||||
def test_sender_column_present_skips_alter(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = os.path.join(tmp, "src.db")
|
||||
_make_sqlite(src)
|
||||
fake = FakePgConn() # 缺省:列齐全
|
||||
migrate_sqlite_to_postgres(src, pg_conn=fake,
|
||||
tables=("task_followups",))
|
||||
# init_postgres_schema 自身会给 projects/tasks 补列,
|
||||
# 这里只断言 task_followups 没有重复补列。
|
||||
self.assertEqual(
|
||||
[a for a in fake.alters if "task_followups" in a], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Skills/apps/MCP server tests: /projects/<id>/skills, /apps,
|
||||
/mcp-servers, /mcp-servers/reload, /mcp-servers/<name>/oauth-login.
|
||||
|
||||
No real host.py CLI, no real codex binary: a stub script written into
|
||||
tmp pretends to be host.py and prints canned JSON per the frozen CLI
|
||||
contract - one JSON object on stdout, nonzero exit plus {"error": ...}
|
||||
on failure. The stub reads a scenario JSON file and appends every
|
||||
invocation to a log file so tests can assert the argv the server
|
||||
passed, including the tenant work dir used as skills/list cwd.
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
|
||||
STUB = r'''import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
def main():
|
||||
argv = sys.argv[1:]
|
||||
cmd = None
|
||||
rest = []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a == "--codex-home":
|
||||
i += 2
|
||||
continue
|
||||
if a.startswith("--codex-home="):
|
||||
i += 1
|
||||
continue
|
||||
if cmd is None:
|
||||
cmd = a
|
||||
i += 1
|
||||
continue
|
||||
rest.append(a)
|
||||
i += 1
|
||||
log = os.environ.get("THREADOPS_STUB_LOG")
|
||||
if log:
|
||||
with open(log, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps({"cmd": cmd, "argv": rest}) + "\n")
|
||||
scenario = {}
|
||||
path = os.environ.get("THREADOPS_STUB_SCENARIO")
|
||||
if path and os.path.exists(path):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
scenario = json.load(f)
|
||||
if scenario.get("fail_cmd") == cmd:
|
||||
print(json.dumps({"error": scenario.get("fail_error", "boom")}))
|
||||
sys.exit(1)
|
||||
if cmd == "skills-list":
|
||||
print(json.dumps({"data": scenario.get("skills", [
|
||||
{"cwd": "/work", "skills": [{"name": "demo-skill"}]},
|
||||
])}))
|
||||
elif cmd == "apps-list":
|
||||
print(json.dumps({"data": [{"id": "app-1", "name": "Demo App"}],
|
||||
"nextCursor": "cur-1"}))
|
||||
elif cmd == "mcp-servers-list":
|
||||
print(json.dumps({"data": [{"name": "srv-1",
|
||||
"runtimeStatus": "running"}]}))
|
||||
elif cmd == "mcp-servers-reload":
|
||||
print(json.dumps({}))
|
||||
elif cmd == "mcp-server-oauth-login":
|
||||
if scenario.get("oauth_no_url"):
|
||||
print(json.dumps({}))
|
||||
else:
|
||||
print(json.dumps({
|
||||
"authorizationUrl": "https://example.invalid/oauth/authorize?x=1",
|
||||
"loginId": "login-123"}))
|
||||
else:
|
||||
print(json.dumps({"error": "unknown subcommand"}))
|
||||
sys.exit(1)
|
||||
|
||||
main()
|
||||
'''
|
||||
|
||||
|
||||
class SkillsMcpTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.auth = AuthStore(self.db_path)
|
||||
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
||||
self.reader_id, self.reader_key = self.auth.create_user("reader")
|
||||
self.outsider_id, self.outsider_key = self.auth.create_user("outsider")
|
||||
self.stub_path = os.path.join(self.tmp.name, "stub_host.py")
|
||||
with open(self.stub_path, "w", encoding="utf-8") as f:
|
||||
f.write(STUB)
|
||||
self.scenario_path = os.path.join(self.tmp.name, "scenario.json")
|
||||
self.log_path = os.path.join(self.tmp.name, "stub.log")
|
||||
self._write_scenario({})
|
||||
self._old_host_py = os.environ.get("CODEX_HOST_PY")
|
||||
os.environ["CODEX_HOST_PY"] = self.stub_path
|
||||
os.environ["THREADOPS_STUB_SCENARIO"] = self.scenario_path
|
||||
os.environ["THREADOPS_STUB_LOG"] = self.log_path
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.server.tenants_root = os.path.join(self.tmp.name, "tenants")
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
status, self.project = self._req(
|
||||
"POST", "/projects", {"name": "demo"}, self.owner_key)
|
||||
self.assertEqual(status, 201, self.project)
|
||||
self.auth.set_member(self.project["id"], self.owner_id,
|
||||
self.reader_id, "viewer")
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
try:
|
||||
self.auth.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if self._old_host_py is None:
|
||||
os.environ.pop("CODEX_HOST_PY", None)
|
||||
else:
|
||||
os.environ["CODEX_HOST_PY"] = self._old_host_py
|
||||
os.environ.pop("THREADOPS_STUB_SCENARIO", None)
|
||||
os.environ.pop("THREADOPS_STUB_LOG", None)
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _write_scenario(self, scenario):
|
||||
with open(self.scenario_path, "w", encoding="utf-8") as f:
|
||||
json.dump(scenario, f)
|
||||
|
||||
def _stub_calls(self, cmd=None):
|
||||
if not os.path.exists(self.log_path):
|
||||
return []
|
||||
calls = []
|
||||
with open(self.log_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
calls.append(json.loads(line))
|
||||
if cmd is not None:
|
||||
calls = [c for c in calls if c["cmd"] == cmd]
|
||||
return calls
|
||||
|
||||
def _req(self, method, path, body=None, key=None):
|
||||
conn = http.client.HTTPConnection(
|
||||
"127.0.0.1", self.port, timeout=10)
|
||||
hdrs = {}
|
||||
payload = None
|
||||
if body is not None:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
hdrs["Content-Type"] = "application/json"
|
||||
if key is not None:
|
||||
hdrs["X-API-Key"] = key
|
||||
try:
|
||||
conn.request(method, path, body=payload, headers=hdrs)
|
||||
resp = conn.getresponse()
|
||||
data = resp.read()
|
||||
finally:
|
||||
conn.close()
|
||||
try:
|
||||
parsed = json.loads(data.decode("utf-8")) if data else None
|
||||
except ValueError:
|
||||
parsed = None
|
||||
return resp.status, parsed
|
||||
|
||||
def _audit_actions(self, project_id):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT action, detail FROM audit_log WHERE project_id = ?",
|
||||
(project_id,)).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [(r[0], r[1]) for r in rows]
|
||||
|
||||
def _pid(self):
|
||||
return self.project["id"]
|
||||
|
||||
def test_unauthorized_without_key(self):
|
||||
status, body = self._req(
|
||||
"GET", "/projects/%s/skills" % self._pid(), None, None)
|
||||
self.assertEqual(status, 401, body)
|
||||
|
||||
def test_forbidden_outsider_reads(self):
|
||||
for suffix in ("skills", "apps", "mcp-servers"):
|
||||
status, body = self._req(
|
||||
"GET", "/projects/%s/%s" % (self._pid(), suffix),
|
||||
None, self.outsider_key)
|
||||
self.assertEqual(status, 403, body)
|
||||
|
||||
def test_project_not_found(self):
|
||||
status, body = self._req(
|
||||
"GET", "/projects/nope/skills", None, self.owner_key)
|
||||
self.assertEqual(status, 404, body)
|
||||
|
||||
def test_skills_list_passthrough_and_cwd(self):
|
||||
status, body = self._req(
|
||||
"GET", "/projects/%s/skills" % self._pid(), None,
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["project_id"], self._pid())
|
||||
self.assertEqual(body["skills"], [
|
||||
{"cwd": "/work", "skills": [{"name": "demo-skill"}]}])
|
||||
calls = self._stub_calls("skills-list")
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertIn("--cwd", calls[0]["argv"])
|
||||
idx = calls[0]["argv"].index("--cwd")
|
||||
expected = os.path.join(
|
||||
self.tmp.name, "tenants", self._pid(), "work")
|
||||
self.assertEqual(calls[0]["argv"][idx + 1], expected)
|
||||
|
||||
def test_reader_can_read_skills(self):
|
||||
status, body = self._req(
|
||||
"GET", "/projects/%s/skills" % self._pid(), None,
|
||||
self.reader_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
|
||||
def test_apps_list_passthrough(self):
|
||||
status, body = self._req(
|
||||
"GET", "/projects/%s/apps" % self._pid(), None, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["apps"], [{"id": "app-1", "name": "Demo App"}])
|
||||
self.assertEqual(body["nextCursor"], "cur-1")
|
||||
|
||||
def test_mcp_servers_list_passthrough(self):
|
||||
status, body = self._req(
|
||||
"GET", "/projects/%s/mcp-servers" % self._pid(), None,
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["servers"], [
|
||||
{"name": "srv-1", "runtimeStatus": "running"}])
|
||||
|
||||
def test_reload_ok_and_audit(self):
|
||||
status, body = self._req(
|
||||
"POST", "/projects/%s/mcp-servers/reload" % self._pid(),
|
||||
{}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertTrue(body["reloaded"])
|
||||
self.assertEqual(body["result"], {})
|
||||
actions = [a for a, _d in self._audit_actions(self._pid())]
|
||||
self.assertIn("mcp_servers_reloaded", actions)
|
||||
|
||||
def test_reload_forbidden_for_reader(self):
|
||||
status, body = self._req(
|
||||
"POST", "/projects/%s/mcp-servers/reload" % self._pid(),
|
||||
{}, self.reader_key)
|
||||
self.assertEqual(status, 403, body)
|
||||
|
||||
def test_reload_cli_failure_502_not_fake_success(self):
|
||||
self._write_scenario(
|
||||
{"fail_cmd": "mcp-servers-reload",
|
||||
"fail_error": "reload rejected"})
|
||||
status, body = self._req(
|
||||
"POST", "/projects/%s/mcp-servers/reload" % self._pid(),
|
||||
{}, self.owner_key)
|
||||
self.assertEqual(status, 502, body)
|
||||
self.assertEqual(body["error"], "reload rejected")
|
||||
self.assertNotIn("reloaded", body)
|
||||
|
||||
def test_oauth_login_passthrough(self):
|
||||
status, body = self._req(
|
||||
"POST",
|
||||
"/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(),
|
||||
{"scopes": ["read", "write"]}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(
|
||||
body["authorizationUrl"],
|
||||
"https://example.invalid/oauth/authorize?x=1")
|
||||
self.assertEqual(body["loginId"], "login-123")
|
||||
self.assertEqual(body["project_id"], self._pid())
|
||||
self.assertEqual(body["server_name"], "srv-1")
|
||||
calls = self._stub_calls("mcp-server-oauth-login")
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertIn("--server-name", calls[0]["argv"])
|
||||
idx = calls[0]["argv"].index("--server-name")
|
||||
self.assertEqual(calls[0]["argv"][idx + 1], "srv-1")
|
||||
self.assertIn("--scopes", calls[0]["argv"])
|
||||
idx = calls[0]["argv"].index("--scopes")
|
||||
self.assertEqual(calls[0]["argv"][idx + 1], "read,write")
|
||||
actions = self._audit_actions(self._pid())
|
||||
self.assertTrue(any(
|
||||
a == "mcp_server_oauth_login_started" and "srv-1" in d
|
||||
for a, d in actions))
|
||||
|
||||
def test_oauth_login_forbidden_for_reader(self):
|
||||
status, body = self._req(
|
||||
"POST",
|
||||
"/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(),
|
||||
{}, self.reader_key)
|
||||
self.assertEqual(status, 403, body)
|
||||
|
||||
def test_oauth_login_validation(self):
|
||||
status, body = self._req(
|
||||
"POST",
|
||||
"/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(),
|
||||
{"scopes": "not-a-list"}, self.owner_key)
|
||||
self.assertEqual(status, 400, body)
|
||||
status, body = self._req(
|
||||
"POST",
|
||||
"/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(),
|
||||
{"timeoutSecs": 0}, self.owner_key)
|
||||
self.assertEqual(status, 400, body)
|
||||
|
||||
def test_oauth_login_cli_failure_502(self):
|
||||
self._write_scenario(
|
||||
{"fail_cmd": "mcp-server-oauth-login",
|
||||
"fail_error": "oauth rejected"})
|
||||
status, body = self._req(
|
||||
"POST",
|
||||
"/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(),
|
||||
{}, self.owner_key)
|
||||
self.assertEqual(status, 502, body)
|
||||
self.assertEqual(body["error"], "oauth rejected")
|
||||
|
||||
def test_oauth_login_missing_url_502(self):
|
||||
self._write_scenario({"oauth_no_url": True})
|
||||
status, body = self._req(
|
||||
"POST",
|
||||
"/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(),
|
||||
{}, self.owner_key)
|
||||
self.assertEqual(status, 502, body)
|
||||
self.assertNotIn("authorizationUrl", body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,430 @@
|
||||
"""Thread ops server tests: GET/POST /tasks/<id>/thread* endpoints.
|
||||
|
||||
No real host.py CLI, no real codex binary: a stub script written into
|
||||
tmp pretends to be host.py and prints canned JSON per the frozen CLI
|
||||
contract - one JSON object on stdout, nonzero exit plus {"error": ...}
|
||||
on failure. The stub reads a scenario JSON file and appends every
|
||||
invocation to a log file so tests can assert the argv the server
|
||||
passed, including the derived --cwd worktree path.
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
|
||||
STUB = r'''import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
def main():
|
||||
argv = sys.argv[1:]
|
||||
cmd = None
|
||||
rest = []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a == "--codex-home":
|
||||
i += 2
|
||||
continue
|
||||
if a.startswith("--codex-home="):
|
||||
i += 1
|
||||
continue
|
||||
if cmd is None:
|
||||
cmd = a
|
||||
i += 1
|
||||
continue
|
||||
rest.append(a)
|
||||
i += 1
|
||||
log = os.environ.get("THREADOPS_STUB_LOG")
|
||||
if log:
|
||||
with open(log, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps({"cmd": cmd, "argv": rest}) + "\n")
|
||||
scenario = {}
|
||||
path = os.environ.get("THREADOPS_STUB_SCENARIO")
|
||||
if path and os.path.exists(path):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
scenario = json.load(f)
|
||||
if scenario.get("fail_cmd") == cmd:
|
||||
print(json.dumps({"error": scenario.get("fail_error", "boom")}))
|
||||
sys.exit(1)
|
||||
if scenario.get("raw_stdout_for") == cmd:
|
||||
print(scenario.get("raw_stdout", ""))
|
||||
return
|
||||
opts = {}
|
||||
j = 0
|
||||
while j < len(rest):
|
||||
a = rest[j]
|
||||
if a.startswith("--") and j + 1 < len(rest) and not rest[j + 1].startswith("--"):
|
||||
opts[a] = rest[j + 1]
|
||||
j += 2
|
||||
elif a.startswith("--"):
|
||||
opts[a] = True
|
||||
j += 1
|
||||
else:
|
||||
j += 1
|
||||
if cmd == "thread-list":
|
||||
print(json.dumps({"data": scenario.get("threads", [
|
||||
{"id": "t-old", "updatedAt": 100, "createdAt": 90},
|
||||
{"id": "t-new", "updatedAt": 200, "createdAt": 190},
|
||||
])}))
|
||||
elif cmd == "thread-read":
|
||||
thread = dict(scenario.get("thread", {"id": "t-new", "updatedAt": 200}))
|
||||
if opts.get("--include-turns"):
|
||||
thread["turns"] = [{"id": "turn-1"}]
|
||||
print(json.dumps({"thread": thread}))
|
||||
elif cmd == "thread-rename":
|
||||
print(json.dumps({"ok": True, "name": opts.get("--name")}))
|
||||
elif cmd in ("thread-archive", "thread-unarchive", "thread-compact"):
|
||||
print(json.dumps({"ok": True}))
|
||||
else:
|
||||
print(json.dumps({"error": "unknown subcommand"}))
|
||||
sys.exit(1)
|
||||
|
||||
main()
|
||||
'''
|
||||
|
||||
|
||||
class ThreadOpsTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.auth = AuthStore(self.db_path)
|
||||
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
||||
self.reader_id, self.reader_key = self.auth.create_user("reader")
|
||||
self.outsider_id, self.outsider_key = self.auth.create_user("outsider")
|
||||
self.stub_path = os.path.join(self.tmp.name, "stub_host.py")
|
||||
with open(self.stub_path, "w", encoding="utf-8") as f:
|
||||
f.write(STUB)
|
||||
self.scenario_path = os.path.join(self.tmp.name, "scenario.json")
|
||||
self.log_path = os.path.join(self.tmp.name, "stub.log")
|
||||
self._write_scenario({})
|
||||
self._old_host_py = os.environ.get("CODEX_HOST_PY")
|
||||
os.environ["CODEX_HOST_PY"] = self.stub_path
|
||||
os.environ["THREADOPS_STUB_SCENARIO"] = self.scenario_path
|
||||
os.environ["THREADOPS_STUB_LOG"] = self.log_path
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.server.tenants_root = os.path.join(self.tmp.name, "tenants")
|
||||
self.server.host_worktrees_dir = os.path.join(
|
||||
self.tmp.name, "worktrees")
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
status, self.project = self._req(
|
||||
"POST", "/projects", {"name": "demo"}, self.owner_key)
|
||||
self.assertEqual(status, 201, self.project)
|
||||
self.auth.set_member(self.project["id"], self.owner_id,
|
||||
self.reader_id, "viewer")
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
try:
|
||||
self.auth.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if self._old_host_py is None:
|
||||
os.environ.pop("CODEX_HOST_PY", None)
|
||||
else:
|
||||
os.environ["CODEX_HOST_PY"] = self._old_host_py
|
||||
os.environ.pop("THREADOPS_STUB_SCENARIO", None)
|
||||
os.environ.pop("THREADOPS_STUB_LOG", None)
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _write_scenario(self, scenario):
|
||||
with open(self.scenario_path, "w", encoding="utf-8") as f:
|
||||
json.dump(scenario, f)
|
||||
|
||||
def _stub_calls(self, cmd=None):
|
||||
if not os.path.exists(self.log_path):
|
||||
return []
|
||||
calls = []
|
||||
with open(self.log_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
calls.append(json.loads(line))
|
||||
if cmd is not None:
|
||||
calls = [c for c in calls if c["cmd"] == cmd]
|
||||
return calls
|
||||
|
||||
def _req(self, method, path, body=None, key=None):
|
||||
conn = http.client.HTTPConnection(
|
||||
"127.0.0.1", self.port, timeout=10)
|
||||
hdrs = {}
|
||||
payload = None
|
||||
if body is not None:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
hdrs["Content-Type"] = "application/json"
|
||||
if key is not None:
|
||||
hdrs["X-API-Key"] = key
|
||||
try:
|
||||
conn.request(method, path, body=payload, headers=hdrs)
|
||||
resp = conn.getresponse()
|
||||
data = resp.read()
|
||||
finally:
|
||||
conn.close()
|
||||
try:
|
||||
parsed = json.loads(data.decode("utf-8")) if data else None
|
||||
except ValueError:
|
||||
parsed = None
|
||||
return resp.status, parsed
|
||||
|
||||
def _create_task(self, **extra):
|
||||
body = {"project_id": self.project["id"], "prompt": "do work"}
|
||||
body.update(extra)
|
||||
status, task = self._req("POST", "/tasks", body, self.owner_key)
|
||||
self.assertEqual(status, 201, task)
|
||||
return task
|
||||
|
||||
def _set_status(self, task_id, status):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute("UPDATE tasks SET status = ? WHERE id = ?",
|
||||
(status, task_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _audit_actions(self, project_id):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT action FROM audit_log WHERE project_id = ?",
|
||||
(project_id,)).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
def test_unauthorized_without_key(self):
|
||||
task = self._create_task(executor="local")
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/thread" % task["id"], None, None)
|
||||
self.assertEqual(status, 401, body)
|
||||
|
||||
def test_forbidden_outsider_read(self):
|
||||
task = self._create_task(executor="local")
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/threads" % task["id"], None,
|
||||
self.outsider_key)
|
||||
self.assertEqual(status, 403, body)
|
||||
|
||||
def test_forbidden_reader_cannot_mutate(self):
|
||||
task = self._create_task(executor="local")
|
||||
self._set_status(task["id"], "completed")
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/thread/archive" % task["id"], {},
|
||||
self.reader_key)
|
||||
self.assertEqual(status, 403, body)
|
||||
|
||||
def test_forbidden_outsider_cannot_mutate(self):
|
||||
# P2 接线新增:非成员改操作同样 403,且不留审计。
|
||||
task = self._create_task(executor="local")
|
||||
self._set_status(task["id"], "completed")
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/thread/archive" % task["id"], {},
|
||||
self.outsider_key)
|
||||
self.assertEqual(status, 403, body)
|
||||
self.assertNotIn("thread_archived",
|
||||
self._audit_actions(self.project["id"]))
|
||||
|
||||
def test_task_not_found(self):
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/nope/thread", None, self.owner_key)
|
||||
self.assertEqual(status, 404, body)
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/nope/thread/archive", {}, self.owner_key)
|
||||
self.assertEqual(status, 404, body)
|
||||
|
||||
def test_docker_task_409(self):
|
||||
task = self._create_task(executor="docker")
|
||||
self._set_status(task["id"], "completed")
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/threads" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 409, body)
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/thread/archive" % task["id"], {},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 409, body)
|
||||
self.assertEqual(self._stub_calls(), [])
|
||||
|
||||
def test_terminal_gate_409_and_read_allowed(self):
|
||||
task = self._create_task(executor="local")
|
||||
self._set_status(task["id"], "running")
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/thread/archive" % task["id"], {},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 409, body)
|
||||
self.assertEqual(body["status"], "running")
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/thread" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
|
||||
def test_merged_task_mutation_409_but_read_ok(self):
|
||||
task = self._create_task(executor="local")
|
||||
self._set_status(task["id"], "merged")
|
||||
status, _body = self._req(
|
||||
"POST", "/tasks/%s/thread/archive" % task["id"], {},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 409)
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/thread" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
|
||||
def test_threads_list_passthrough_and_cwd(self):
|
||||
task = self._create_task(executor="local")
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/threads" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(
|
||||
[t["id"] for t in body["threads"]], ["t-old", "t-new"])
|
||||
expected_cwd = os.path.join(
|
||||
self.tmp.name, "worktrees", task["id"])
|
||||
self.assertEqual(body["cwd"], expected_cwd)
|
||||
calls = self._stub_calls("thread-list")
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertIn("--cwd", calls[0]["argv"])
|
||||
idx = calls[0]["argv"].index("--cwd")
|
||||
self.assertEqual(calls[0]["argv"][idx + 1], expected_cwd)
|
||||
|
||||
def test_thread_latest_non_archived_rule(self):
|
||||
self._write_scenario({"threads": [
|
||||
{"id": "t-old", "updatedAt": 100, "createdAt": 90},
|
||||
{"id": "t-arch", "updatedAt": 300, "createdAt": 290,
|
||||
"archived": True},
|
||||
{"id": "t-new", "updatedAt": 200, "createdAt": 190},
|
||||
]})
|
||||
task = self._create_task(executor="local")
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/thread" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["thread_id"], "t-new")
|
||||
|
||||
def test_thread_tie_break_later_in_list_wins(self):
|
||||
self._write_scenario({"threads": [
|
||||
{"id": "t-a", "updatedAt": 50, "createdAt": 50},
|
||||
{"id": "t-b", "updatedAt": 50, "createdAt": 50},
|
||||
]})
|
||||
task = self._create_task(executor="local")
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/thread" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["thread_id"], "t-b")
|
||||
|
||||
def test_thread_not_found_when_empty(self):
|
||||
self._write_scenario({"threads": []})
|
||||
task = self._create_task(executor="local")
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/thread" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 404, body)
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/threads" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["threads"], [])
|
||||
|
||||
def test_thread_read_include_turns(self):
|
||||
task = self._create_task(executor="local")
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/thread" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertNotIn("turns", body["thread"])
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/thread?include_turns=1" % task["id"],
|
||||
None, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["thread"]["turns"], [{"id": "turn-1"}])
|
||||
calls = self._stub_calls("thread-read")
|
||||
self.assertIn("--include-turns", calls[1]["argv"])
|
||||
self.assertNotIn("--include-turns", calls[0]["argv"])
|
||||
|
||||
def test_rename_ok_and_audit(self):
|
||||
task = self._create_task(executor="local")
|
||||
self._set_status(task["id"], "completed")
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/thread/rename" % task["id"],
|
||||
{"name": "new name"}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["result"]["name"], "new name")
|
||||
self.assertIn("thread_renamed",
|
||||
self._audit_actions(self.project["id"]))
|
||||
calls = self._stub_calls("thread-rename")
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertIn("new name", calls[0]["argv"])
|
||||
|
||||
def test_rename_validation(self):
|
||||
task = self._create_task(executor="local")
|
||||
self._set_status(task["id"], "completed")
|
||||
status, _body = self._req(
|
||||
"POST", "/tasks/%s/thread/rename" % task["id"],
|
||||
{}, self.owner_key)
|
||||
self.assertEqual(status, 400)
|
||||
status, _body = self._req(
|
||||
"POST", "/tasks/%s/thread/rename" % task["id"],
|
||||
{"name": " "}, self.owner_key)
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def test_archive_unarchive_compact_ok_and_audit(self):
|
||||
task = self._create_task(executor="local")
|
||||
self._set_status(task["id"], "failed")
|
||||
for action in ("archive", "unarchive", "compact"):
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/thread/%s" % (task["id"], action),
|
||||
{}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
actions = self._audit_actions(self.project["id"])
|
||||
self.assertIn("thread_archived", actions)
|
||||
self.assertIn("thread_unarchived", actions)
|
||||
self.assertIn("thread_compacted", actions)
|
||||
|
||||
def test_cli_failure_passthrough_502(self):
|
||||
self._write_scenario(
|
||||
{"fail_cmd": "thread-list", "fail_error": "list boom"})
|
||||
task = self._create_task(executor="local")
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/threads" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 502, body)
|
||||
self.assertEqual(body["error"], "list boom")
|
||||
self._write_scenario(
|
||||
{"fail_cmd": "thread-archive", "fail_error": "arch boom"})
|
||||
self._set_status(task["id"], "completed")
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/thread/archive" % task["id"], {},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 502, body)
|
||||
self.assertEqual(body["error"], "arch boom")
|
||||
|
||||
def test_cli_bad_json_502(self):
|
||||
self._write_scenario(
|
||||
{"raw_stdout_for": "thread-list", "raw_stdout": "not json{"})
|
||||
task = self._create_task(executor="local")
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/threads" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 502, body)
|
||||
|
||||
def test_host_py_missing_502(self):
|
||||
os.environ["CODEX_HOST_PY"] = os.path.join(
|
||||
self.tmp.name, "does-not-exist.py")
|
||||
task = self._create_task(executor="local")
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/threads" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 502, body)
|
||||
self.assertIn("host.py", body["error"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+103
-11
@@ -104,6 +104,16 @@ deploy/rollback.sh <host> [user]
|
||||
恢复后同样重启并验 health(60 秒),成功/失败都明说;主机上没有备份包
|
||||
时直接报错退出,不编造。
|
||||
|
||||
### 回滚成功标准:清单里每个端口都通过(P2-ops)
|
||||
|
||||
rollback.sh 与 host_deploy.sh 共用同一份 unit 清单约定:server-1 必含,
|
||||
server-2 以 `systemctl cat` 探测为准(存在才纳入停/起与健康检查),worker
|
||||
是 autoscaler active 时用 autoscaler、否则用 cloud-worker。两脚本改动清单
|
||||
时必须同步改,`deploy/tests/test_p2_ops.py` 会双向核对(只改一处就变红)。
|
||||
|
||||
回滚成功 = 8765 通过,且 server-2 存在时 8766 也通过。只通 8765 不算成功:
|
||||
LB 上游的 server-2 若仍跑故障代码,回滚等于没回完。
|
||||
|
||||
### 回滚边界:只回代码,不回数据库
|
||||
|
||||
数据库迁移是只加列的幂等操作(例如 projects.repo_url、tasks 的 6 列),
|
||||
@@ -128,6 +138,9 @@ sh /opt/codex-app-server/deploy/restore-drill.sh
|
||||
audit_log 行数(行数相等即通过,只比行数,不比对具体内容,不打印任何
|
||||
行内容)→ 比对完立即删掉测试库(trap 保证中途失败也删)。
|
||||
|
||||
范围(P2-ops):演练 PASS 只证明数据库备份可恢复,不证明 worktree /
|
||||
链路 / 上传可恢复——那些不在备份里,见“备份制度”一节。
|
||||
|
||||
安全红线:恢复目标永远是新生成的测试库名,绝不等于生产库名;
|
||||
`restore_postgres` 本身也会拒绝目标库名等于 `CLOUD_DB_NAME`
|
||||
(`allow_production` 绝不在本脚本里设为 True),双重保险。本脚本绝不
|
||||
@@ -165,14 +178,38 @@ journalctl -u monitor.service -f # 看每次检查的输出
|
||||
|
||||
timer 每 5 分钟跑一次,日志进 journal。
|
||||
|
||||
### 监控边界:本轮只有“主机侧检查 + journal 日志”
|
||||
### 监控边界:默认只有“检查 + journal 日志”,告警出口可选(P2-ops)
|
||||
|
||||
monitor.sh 本身不会发消息给用户。推送到 Muse 聊天的告警仍需用户另行
|
||||
决定通道(例如 SSH key 常驻通道让外部 cron 拉 journal,或者一个
|
||||
webhook URL 让 monitor 失败时 POST 过去),在通道定下来之前,不要假装
|
||||
monitor.sh 会通知任何人。手工验证步骤:主机上跑一次 monitor.sh 看 JSON
|
||||
与退出码,再跑一次 --self-test 看 SELF-TEST PASS,最后
|
||||
`systemctl list-timers | grep monitor` 确认 timer 已启用。
|
||||
默认不配告警出口时,monitor.sh 仍然只做检查 + journal 日志,不会发消息
|
||||
给任何人。需要 fail 时通知时,在 monitor.service 的 `Environment=`(或
|
||||
monitor.timer 的运行环境)里二选一配置,默认关闭:
|
||||
|
||||
```ini
|
||||
# 二选一,同时配了只用 webhook:
|
||||
Environment=MONITOR_ALERT_URL=https://example.invalid/hook
|
||||
# 或:
|
||||
Environment=MONITOR_ALERT_MAILTO=ops@example.invalid
|
||||
```
|
||||
|
||||
- 只在 overall=fail 时发,pass 时不发;故障持续期间 timer 每 5 分钟跑
|
||||
一次就会重发,接收端需自行去重(本机不做去重状态文件,免得状态文件
|
||||
本身成为新的故障点)。
|
||||
- 告警内容只有 JSON 检查结果(pass/fail 与 server_mode),不含 health
|
||||
正文、不含任何环境变量、不含密钥;URL 与收件人只走环境变量。
|
||||
- 邮件路径依赖本机 `mail` 命令,没有它时会记一条“告警未发出”到 journal,
|
||||
不会静默假装已通知。
|
||||
|
||||
### 单机模式:MONITOR_SINGLE_HOST=1(P2-ops)
|
||||
|
||||
默认 server 检查要求 server-1 与 server-2 双 active。确认是单机部署时,
|
||||
在 monitor 运行环境里设 `MONITOR_SINGLE_HOST=1`,server 检查只查
|
||||
server-1;否则单机主机的 server 检查会恒 fail,恒 fail 会掩盖真故障。
|
||||
双机主机上绝不要设它(设了就是静默降级)。输出 JSON 的 `server_mode`
|
||||
字段写明本次是 single 还是 dual,降级可见。
|
||||
|
||||
手工验证步骤:主机上跑一次 monitor.sh 看 JSON 与退出码,再跑一次
|
||||
--self-test 看 SELF-TEST PASS,最后 `systemctl list-timers | grep monitor`
|
||||
确认 timer 已启用。--self-test 时内层检查会清掉告警出口,不会误发告警。
|
||||
|
||||
## worker 心跳与 health 的 worker 字段
|
||||
|
||||
@@ -225,17 +262,72 @@ completed 测试任务是直接在 DB 里把测试任务置 completed,不跑
|
||||
- 审计日志是 append-only 的:验证过程的审计条目会留在 audit_log 里,
|
||||
脚本不删审计表——审计就该留痕,这一点不是清理遗漏。
|
||||
|
||||
## 公网入口与 webhook secret(已有文件,不属于本轮新增)
|
||||
## 备份制度:什么可恢复、什么不可恢复(P2-ops,二选一里选了“写清区分”)
|
||||
|
||||
- `caddy/Caddyfile`:只暴露 `/webhooks/*` 与 `/oauth/*`,任务/项目/管理
|
||||
API 对外一律 403;域名与邮箱占位符需部署时替换。
|
||||
不要谎称已做全量备份。本节写清现状;worktree / 链路 / 上传目录纳入
|
||||
备份范围是另一单(需先定量:租户目录大小、备份窗口、恢复演练抽查),
|
||||
不在本批做。
|
||||
|
||||
| 内容 | 在备份里吗 | 恢复手段 |
|
||||
| --- | --- | --- |
|
||||
| 数据库(Postgres 全库) | 是 | pg-backup 每天一次(留 7 份)+ restore-drill 恢复到测试库比对行数 |
|
||||
| 代码(/opt/codex-app-server,不含 work/) | 是 | 每次部署前打备份 tar(留 5 份)+ rollback.sh 恢复 |
|
||||
| 任务 worktree(work/、tenants/ 下各任务目录) | 否 | 无。主机丢了就丢了;数据库里的任务行还在,但 worktree 没了,续跑/链路/附件都会缺 |
|
||||
| 链路文件(host-traces/)与上传文件(附件包) | 否 | 无。链路是过程记录,丢了只影响可观测性;上传附件丢了则任务附件不可下载 |
|
||||
| 运行配置(/etc/codex-app-server/env、unit 文件、nginx/Caddy 配置) | 否(不在代码 tar 与数据库备份里) | 换机时从备份清单手动重贴,见“公网入口”与 new-host-checklist.md |
|
||||
|
||||
后果要说透:只靠现有备份,数据库能恢复、代码能回滚,但一台主机彻底丢了,
|
||||
在跑任务的工作目录与历史链路是回不来的;恢复后需要把受影响的任务标记为
|
||||
失败/重建,而不是假装它们还能续跑。restore-drill.sh 的 PASS 只证明数据库
|
||||
备份可恢复,不证明整机可恢复(脚本头注释同样写明)。
|
||||
|
||||
## 公网入口与 webhook secret
|
||||
|
||||
- `caddy/Caddyfile`(P2-ops:占位符改为 Caddy 原生变量模板):只暴露
|
||||
`/webhooks/*` 与 `/oauth/*`,任务/项目/管理 API 对外一律 403;自动证书
|
||||
走 Caddy(本批二选一里选了 Caddy,certbot 路径未做成模板、只是备选,
|
||||
不要假装两条路都已就绪)。
|
||||
- `setup-webhook-secret.sh`:在主机上生成 webhook secret 并写入 env 文件,
|
||||
绝不打印 secret;已设则不动,避免轮换后 GitHub 端旧 secret 失效。
|
||||
|
||||
### Caddy 模板部署步骤(新机可复现,不申请真实证书也能备好文件)
|
||||
|
||||
```sh
|
||||
# 1. 主机上装 Caddy(包管理器或官方二进制均可),建运行目录:
|
||||
mkdir -p /etc/caddy
|
||||
cp deploy/caddy/Caddyfile /etc/caddy/Caddyfile
|
||||
cp deploy/caddy/caddy.service /etc/systemd/system/caddy.service
|
||||
# 2. 写变量文件(域名与邮箱不是密钥,但也不要写进仓库):
|
||||
cat > /etc/caddy/env <<'EOF'
|
||||
DOMAIN=example.invalid
|
||||
ACME_EMAIL=ops@example.invalid
|
||||
EOF
|
||||
chmod 600 /etc/caddy/env
|
||||
# 3. Caddyfile 里的 {$DOMAIN} / {$ACME_EMAIL} 由 Caddy 运行时从环境变量
|
||||
# 展开;变量未设时 Caddy 启动即报错(fail-fast,不会静默跑成空域名)。
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now caddy
|
||||
# 4. 验证:curl -v https://$DOMAIN/webhooks/github 应被 Caddy 转发
|
||||
# (控制面本身仍只监听 127.0.0.1:8765,不直接对外)。
|
||||
```
|
||||
|
||||
### 当前生产现状(换机需重贴,模板不等于已上线)
|
||||
|
||||
当前生产走宝塔 nginx(api.diante.me 等 vhost 由 BT 面板管理),不是
|
||||
Caddy;BT 若重生成 vhost 会吃掉 location,需从备份贴回(见
|
||||
new-host-checklist.md 第 8 节)。仓库内 Caddy 模板是“新机可复现”的选项,
|
||||
不是“生产已切 Caddy”的声明。备份清单必须另含:BT vhost 配置备份、
|
||||
/etc/caddy/Caddyfile(如已用 Caddy)、caddy.service、unit 文件
|
||||
(codex-cloud-server*.service 等不在 deploy tar 里,改动后须单独 scp,
|
||||
见 codex-cloud-server-2.service 头注释)——这些都不在代码 tar 与数据库
|
||||
备份里,换机时不重贴就等于没备份。
|
||||
|
||||
## 仍未做(不要假装已做)
|
||||
|
||||
- 推送告警通道:monitor 失败时通知到 Muse 聊天的通道还没定(见监控一节)。
|
||||
- 回滚不回数据库:见回滚边界一节;改类型/删列的迁移需要单独评估。
|
||||
- 恢复演练不碰生产库:演练只比行数,不比对具体内容;恢复到生产库的
|
||||
真实灾难恢复流程(停服务、恢复、重启、验 health)还没演练过。
|
||||
- worktree / 链路 / 上传目录未纳入备份:见“备份制度”一节;主机彻底丢了
|
||||
这些回不来,需要另单定量后做。
|
||||
- 生产仍走宝塔 nginx,未切 Caddy:Caddy 模板只是仓库内可部署选项。
|
||||
- CloudExecutor、GUI、Slack 执行、推送实发:与本轮无关,仍是 0 或占位。
|
||||
+20
-6
@@ -1,13 +1,27 @@
|
||||
# 公网入口模板:只暴露 webhook 与 OAuth 回调,任务/项目/管理 API 不对外。
|
||||
# 使用前把 <DOMAIN> 换成你自己的域名,把 <EMAIL> 换成 Let's Encrypt 联系邮箱。
|
||||
# 部署侧在主机上装 Caddy 后,把本文件放到 /etc/caddy/Caddyfile 并 reload。
|
||||
# 公网入口模板(Caddy 自动证书路径,P2-ops 二选一里选了 Caddy,
|
||||
# certbot 路径只是备选、未做成模板)。
|
||||
#
|
||||
# 只暴露 webhook 与 OAuth 回调,任务/项目/管理 API 不对外。
|
||||
#
|
||||
# 变量是 Caddy 原生环境变量展开({$DOMAIN} / {$ACME_EMAIL}),
|
||||
# 不是部署时 sed 替换:Caddy 启动时从进程环境读这两个变量,
|
||||
# 未设时启动即报错(fail-fast,不会静默跑成空域名)。
|
||||
# 部署步骤见 deploy/README.md“公网入口”一节;变量文件示例:
|
||||
# /etc/caddy/env(600):
|
||||
# DOMAIN=example.invalid
|
||||
# ACME_EMAIL=ops@example.invalid
|
||||
# caddy.service 模板(同目录)用 EnvironmentFile 指向该文件。
|
||||
#
|
||||
# 控制面本身仍只监听 127.0.0.1:8765,不直接对外;Caddy 只做反向代理 + TLS。
|
||||
# GitHub webhook 与 OAuth 回调都要求 https 且证书有效,Caddy 的自动证书满足这一点;
|
||||
# 自签名证书不行,GitHub 会拒绝送达,浏览器也会拦截 OAuth 回调。
|
||||
#
|
||||
# 当前生产现状:生产走宝塔 nginx,不是 Caddy;本模板是新机可复现的选项,
|
||||
# 不是“生产已切 Caddy”的声明(见 deploy/README.md)。
|
||||
|
||||
<DOMAIN> {
|
||||
{$DOMAIN} {
|
||||
encode gzip
|
||||
tls <EMAIL>
|
||||
tls {$ACME_EMAIL}
|
||||
|
||||
# GitHub webhook:签名校验在控制面做(GITHUB_WEBHOOK_SECRET),Caddy 只转发。
|
||||
@github path /webhooks/github
|
||||
@@ -18,7 +32,7 @@
|
||||
reverse_proxy @slack 127.0.0.1:8765
|
||||
|
||||
# OAuth 浏览器登录/回调:回调 URL 必须与 OAuth App 里填的完全一致,
|
||||
# 即 https://<DOMAIN>/oauth/<provider>/callback。
|
||||
# 即 https://{$DOMAIN}/oauth/<provider>/callback。
|
||||
@oauth path /oauth/*
|
||||
reverse_proxy @oauth 127.0.0.1:8765
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Caddy 前置代理 unit 模板(与 deploy/caddy/Caddyfile 配对)。
|
||||
#
|
||||
# 注意:unit 文件不在 deploy tar 里(与 codex-cloud-server-2.service
|
||||
# 同款约束),改动后须单独放到 /etc/systemd/system/ 再
|
||||
# daemon-reload + enable + start;只重启 Caddy 不会生效。
|
||||
# EnvironmentFile 指向 /etc/caddy/env(600,部署时按
|
||||
# deploy/README.md“公网入口”一节手写 DOMAIN / ACME_EMAIL,
|
||||
# 域名与邮箱不要写进仓库)。Caddyfile 里的 {$DOMAIN} /
|
||||
# {$ACME_EMAIL} 由 Caddy 运行时从该环境展开。
|
||||
[Unit]
|
||||
Description=Codex public entry (Caddy reverse proxy + automatic TLS)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=caddy
|
||||
Group=caddy
|
||||
EnvironmentFile=/etc/caddy/env
|
||||
ExecStart=/usr/bin/caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
ExecReload=/usr/bin/caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+10
-1
@@ -291,8 +291,17 @@ def transfer(host, user, local, remote, expected_sha):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# 部署前默认跑的测试清单:必须用 discover -s <目录>,
|
||||
# 绝不能用 deploy.tests 这种包路径——包路径会把 deploy 当包
|
||||
# import、盖掉 deploy.py 本体(2026-10-27 教训),导致
|
||||
# 13 个假报错。P2-ops:deploy/tests 以前漏在清单外,
|
||||
# deploy.py 自身的回归(如 ssh 分发)可能带病上线,
|
||||
# 现补进默认清单。
|
||||
DEFAULT_TEST_SUITES = ("tests", "cloud/tests", "deploy/tests")
|
||||
|
||||
|
||||
def run_tests():
|
||||
for suite in ("tests", "cloud/tests"):
|
||||
for suite in DEFAULT_TEST_SUITES:
|
||||
run(["python3", "-m", "unittest", "discover", "-s", suite],
|
||||
cwd=REPO_ROOT)
|
||||
|
||||
|
||||
@@ -133,6 +133,8 @@ worker_unit() {
|
||||
# server-2(8766)是 HA 阶段 2 加的;unit 存在才纳入停/起与 health
|
||||
# 检查,老主机没有它时退回单机行为。2026-10-27 首推教训:脚本只动
|
||||
# server-1 时,LB 上游的 server-2 仍跑旧内存代码,新旧混跑。
|
||||
# 与 rollback.sh 共享的 unit 清单约定(P2-ops):改动清单时两脚本
|
||||
# 同步改,deploy/tests/test_p2_ops.py 会双向核对。
|
||||
SERVER_UNITS="codex-cloud-server"
|
||||
HEALTH_PORTS="8765"
|
||||
if systemctl cat codex-cloud-server-2 >/dev/null 2>&1; then
|
||||
|
||||
@@ -6,8 +6,10 @@ After=network.target codex-cloud-server.service codex-cloud-worker.service
|
||||
Type=oneshot
|
||||
User=codexsvc
|
||||
WorkingDirectory=/opt/codex-app-server
|
||||
# monitor.sh 只做检查、不读 env 文件、不发消息;失败时 systemd 会把
|
||||
# 退出码与输出记进 journal,告警通道见 deploy/README.md(另行决定)。
|
||||
# monitor.sh 只做检查、不读 env 文件;默认不发消息(告警出口默认关闭),
|
||||
# 失败时 systemd 会把退出码与输出记进 journal。可选告警出口
|
||||
# (MONITOR_ALERT_URL / MONITOR_ALERT_MAILTO)见 deploy/README.md:
|
||||
# 配了才在 fail 时发,告警内容只有 pass/fail,不含密钥。
|
||||
ExecStart=/bin/sh /opt/codex-app-server/deploy/monitor.sh
|
||||
|
||||
[Install]
|
||||
|
||||
+67
-7
@@ -31,19 +31,45 @@
|
||||
# JSON),断言 health 那一项为 pass;其它项在非主机机器上 fail 是正常的,
|
||||
# self-test 不因此失败。不要把 --self-test 通过误读成“主机健康”。
|
||||
#
|
||||
# 告警边界:本脚本只做“检查 + journal 日志”(配合 monitor.timer 每 5 分钟
|
||||
# 跑一次),它本身不会发消息给用户。推送到 Muse 聊天的告警仍需用户另行
|
||||
# 决定通道(SSH key 常驻通道或 webhook URL),见 deploy/README.md。
|
||||
# 单机模式(P2-ops):默认 server 检查要求 server-1 与 server-2
|
||||
# 双 active(HA 阶段 2),单机主机上 server-2 不存在会恒 fail,
|
||||
# 恒 fail 会掩盖真故障。确认是单机部署时设 MONITOR_SINGLE_HOST=1,
|
||||
# server 检查只查 server-1;双机主机上绝不要设它(设了就是
|
||||
# 静默降级,冗余丢了也看不见)。输出 JSON 里 server_mode 字段
|
||||
# 写明本次用的是 single 还是 dual,降级可见。
|
||||
#
|
||||
# 告警出口(P2-ops,默认关闭):只在 overall=fail 时发一次,
|
||||
# pass 时不发;故障持续期间 timer 每 5 分钟跑一次就会重发,
|
||||
# 接收端需自行去重(本机不做去重状态文件,免得状态文件本身
|
||||
# 成为新的故障点)。二选一,同时配了只用 webhook:
|
||||
# MONITOR_ALERT_URL=https://example.invalid/hook # webhook POST JSON
|
||||
# MONITOR_ALERT_MAILTO=ops@example.invalid # mail 命令发邮件
|
||||
# 告警内容只有 JSON 检查结果(pass/fail 与 server_mode),
|
||||
# 不含 health 正文、不含任何环境变量、不含密钥;URL 与收件人
|
||||
# 只走环境变量,不写进脚本、不进日志。
|
||||
set -eu
|
||||
|
||||
SERVER_MODE=dual
|
||||
if [ "${MONITOR_SINGLE_HOST:-0}" = "1" ]; then SERVER_MODE=single; fi
|
||||
|
||||
HEALTH_URL=${MONITOR_HEALTH_URL:-http://127.0.0.1:8765/health}
|
||||
APP_DIR=${MONITOR_APP_DIR:-/opt/codex-app-server}
|
||||
DISK_PATH=${MONITOR_DISK_PATH:-/var/lib/codex-app-server}
|
||||
if [ ! -d "$DISK_PATH" ]; then DISK_PATH="/"; fi
|
||||
|
||||
check_server() {
|
||||
# HA 阶段 2:两台 server 都必须 active,任一挂了都算 fail
|
||||
# 默认双机:两台 server 都必须 active,任一挂了都算 fail
|
||||
# (单台 active 时 API 还能用,但冗余已丢,必须可见)。
|
||||
# MONITOR_SINGLE_HOST=1 时只查 server-1(单机部署专用,
|
||||
# 双机主机上设它等于静默降级,见文件头注释)。
|
||||
if [ "$SERVER_MODE" = "single" ]; then
|
||||
if systemctl is-active codex-cloud-server >/dev/null 2>&1; then
|
||||
echo pass
|
||||
else
|
||||
echo fail
|
||||
fi
|
||||
return
|
||||
fi
|
||||
if systemctl is-active codex-cloud-server >/dev/null 2>&1 \
|
||||
&& systemctl is-active codex-cloud-server-2 >/dev/null 2>&1; then
|
||||
echo pass
|
||||
@@ -136,11 +162,36 @@ run_checks() {
|
||||
for v in "$S" "$W" "$H" "$P" "$D" "$K" "$A" "$B"; do
|
||||
if [ "$v" != "pass" ]; then OVERALL=fail; break; fi
|
||||
done
|
||||
printf '{"overall": "%s", "checks": {"server": "%s", "worker": "%s", "health": "%s", "postgres": "%s", "docker_postgres": "%s", "disk": "%s", "app_dir": "%s", "backup": "%s"}}\n' \
|
||||
"$OVERALL" "$S" "$W" "$H" "$P" "$D" "$K" "$A" "$B"
|
||||
LAST_JSON=$(printf '{"overall": "%s", "server_mode": "%s", "checks": {"server": "%s", "worker": "%s", "health": "%s", "postgres": "%s", "docker_postgres": "%s", "disk": "%s", "app_dir": "%s", "backup": "%s"}}' \
|
||||
"$OVERALL" "$SERVER_MODE" "$S" "$W" "$H" "$P" "$D" "$K" "$A" "$B")
|
||||
printf '%s\n' "$LAST_JSON"
|
||||
if [ "$OVERALL" = "pass" ]; then return 0; else return 1; fi
|
||||
}
|
||||
|
||||
send_alert() {
|
||||
# 只在 fail 时由主流程调用。内容就是 LAST_JSON(pass/fail
|
||||
# 与 server_mode),绝不拼 health 正文、环境变量或密钥。
|
||||
if [ -n "${MONITOR_ALERT_URL:-}" ]; then
|
||||
if [ -n "${MONITOR_ALERT_MAILTO:-}" ]; then
|
||||
echo "monitor: webhook 与邮件同时配置,只用 webhook" >&2
|
||||
fi
|
||||
curl -s --max-time 10 -X POST -H 'Content-Type: application/json' \
|
||||
-d "$LAST_JSON" "$MONITOR_ALERT_URL" >/dev/null 2>&1 \
|
||||
|| echo "monitor: webhook 告警发送失败" >&2
|
||||
return 0
|
||||
fi
|
||||
if [ -n "${MONITOR_ALERT_MAILTO:-}" ]; then
|
||||
if command -v mail >/dev/null 2>&1; then
|
||||
printf '%s\n' "$LAST_JSON" \
|
||||
| mail -s "codex monitor FAIL" "$MONITOR_ALERT_MAILTO" \
|
||||
2>/dev/null \
|
||||
|| echo "monitor: 邮件告警发送失败" >&2
|
||||
else
|
||||
echo "monitor: 已配 MAILTO 但本机无 mail 命令,告警未发出" >&2
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "--self-test" ]; then
|
||||
# 起一个 canned health server:只返回固定健康 JSON,不读任何真实服务。
|
||||
SELF_PORT=18999
|
||||
@@ -173,8 +224,11 @@ PY
|
||||
SERVER_PID=$!
|
||||
trap 'kill $SERVER_PID 2>/dev/null || true' EXIT
|
||||
sleep 1
|
||||
# 内层跑完整检查时清掉告警出口:self-test 在非主机上
|
||||
# 其它项必然 fail,不清会在配了告警的环境里误发一条。
|
||||
MONITOR_HEALTH_URL="http://127.0.0.1:$SELF_PORT/health" \
|
||||
HEALTH_URL="http://127.0.0.1:$SELF_PORT/health" \
|
||||
MONITOR_ALERT_URL= MONITOR_ALERT_MAILTO= \
|
||||
sh "$0" > /tmp/monitor-self-test.json 2>/dev/null || true
|
||||
# 注意:上面那行是跑完整检查,health 以外项在非主机上 fail 是正常的;
|
||||
# self-test 只断言两件事:输出是合法 JSON,且 health 解析为 pass。
|
||||
@@ -195,4 +249,10 @@ sys.exit(0 if d["checks"]["health"] == "pass" else 1)
|
||||
fi
|
||||
fi
|
||||
|
||||
run_checks
|
||||
if run_checks; then
|
||||
exit 0
|
||||
fi
|
||||
# 只有 fail 走到这里:默认不配出口时 send_alert 是空操作,
|
||||
# 不会因为没配告警就多报错;配了出口则发一条 fail 告警。
|
||||
send_alert
|
||||
exit 1
|
||||
@@ -4,6 +4,12 @@
|
||||
# 用法(主机上):sh /opt/codex-app-server/deploy/restore-drill.sh
|
||||
# 可选环境变量:BACKUP_DIR(默认 /var/lib/codex-app-server/backups)。
|
||||
#
|
||||
# 演练范围(P2-ops,诚实边界):本演练只证明“数据库备份可恢复”
|
||||
# (行数比对通过)。它不证明 worktree / 链路 / 上传文件可恢复,
|
||||
# 因为那些根本不在备份里(见 deploy/README.md“备份制度”一节):
|
||||
# 代码备份 tar 明确排除 work/,pg-backup 只备数据库。不要把
|
||||
# “恢复演练 PASS”读成“整机可恢复”。
|
||||
#
|
||||
# 步骤:
|
||||
# 1. 跑一次 backup_postgres 生成新鲜备份(.sql)。
|
||||
# 2. 新建一个测试库 codex_control_restore_test_<时间戳>,用
|
||||
|
||||
+53
-14
@@ -10,9 +10,23 @@
|
||||
#
|
||||
# 回滚边界:只回代码,不回数据库(理由见 deploy.sh 头注释与
|
||||
# deploy/README.md:数据库迁移是只加列的幂等操作,老代码仍能读新列)。
|
||||
# 备份包来自 deploy.sh 每次部署前在主机上打的
|
||||
# 备份包来自每次部署前在主机上打的
|
||||
# /opt/codex-app-server-backups/codex-app-server-<时间戳>.tar.gz,
|
||||
# 本脚本恢复其中最新的一份;主机上没有备份包时直接报错退出,不编造。
|
||||
#
|
||||
# 与 host_deploy.sh 共享的约定(P2-ops,两脚本改动时必须同步,
|
||||
# deploy/tests/test_p2_ops.py 会双向核对,见文件尾注释):
|
||||
# - server unit 清单:codex-cloud-server 必含,codex-cloud-server-2
|
||||
# 以 systemctl cat 探测为准(存在才纳入停/起与健康检查);
|
||||
# - worker:autoscaler active 时停/起 autoscaler,否则 cloud-worker
|
||||
# (与 host_deploy.sh 的 worker_unit() 同款);
|
||||
# - 成功标准:清单里每个 server 端口的 health 都通过(8765 必查,
|
||||
# server-2 存在时 8766 也必查),只通 8765 不算回滚成功——
|
||||
# 否则 LB 上游的 server-2 仍跑故障代码,回滚等于没回完。
|
||||
#
|
||||
# 仿真覆盖(仅测试用,生产用默认值):CODEX_ROLLBACK_APP_DIR、
|
||||
# CODEX_ROLLBACK_BACKUP_DIR、CODEX_ROLLBACK_HEALTH_ATTEMPTS、
|
||||
# CODEX_ROLLBACK_HEALTH_INTERVAL。默认值与旧行为完全一致。
|
||||
set -eu
|
||||
|
||||
HOST=${1:-}
|
||||
@@ -33,12 +47,30 @@ ssh_cmd() {
|
||||
|
||||
ssh_cmd "$USER_ARG@$HOST" 'sh -s' <<'HOST_SCRIPT'
|
||||
set -eu
|
||||
APP_DIR=/opt/codex-app-server
|
||||
BACKUP_DIR=/opt/codex-app-server-backups
|
||||
APP_DIR=${CODEX_ROLLBACK_APP_DIR:-/opt/codex-app-server}
|
||||
BACKUP_DIR=${CODEX_ROLLBACK_BACKUP_DIR:-/opt/codex-app-server-backups}
|
||||
HEALTH_ATTEMPTS=${CODEX_ROLLBACK_HEALTH_ATTEMPTS:-12}
|
||||
HEALTH_INTERVAL=${CODEX_ROLLBACK_HEALTH_INTERVAL:-5}
|
||||
|
||||
# 与 host_deploy.sh 共享的 unit 清单约定(见本文件头注释):
|
||||
# server-1 必含,server-2 以 unit 存在为准;改动清单时两脚本同步改。
|
||||
SERVER_UNITS="codex-cloud-server"
|
||||
HEALTH_PORTS="8765"
|
||||
if systemctl cat codex-cloud-server-2 >/dev/null 2>&1; then
|
||||
SERVER_UNITS="$SERVER_UNITS codex-cloud-server-2"
|
||||
HEALTH_PORTS="$HEALTH_PORTS 8766"
|
||||
fi
|
||||
if systemctl is-active -q codex-autoscaler 2>/dev/null; then
|
||||
WORKER_UNIT=codex-autoscaler
|
||||
else
|
||||
WORKER_UNIT=codex-cloud-worker
|
||||
fi
|
||||
|
||||
check_health() {
|
||||
OUT=$(curl -s --max-time 5 http://127.0.0.1:8765/health || true)
|
||||
[ -n "$OUT" ] && printf '%s' "$OUT" | python3 -c '
|
||||
for port in $HEALTH_PORTS; do
|
||||
OUT=$(curl -s --max-time 5 "http://127.0.0.1:$port/health" || true)
|
||||
[ -n "$OUT" ] || return 1
|
||||
if ! printf '%s' "$OUT" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
@@ -46,14 +78,18 @@ except Exception:
|
||||
sys.exit(1)
|
||||
sys.exit(0 if (d.get("ok") is True and d.get("backend") == "postgres"
|
||||
and d.get("db_ok") is True) else 1)
|
||||
'
|
||||
'; then
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
wait_health() {
|
||||
i=0
|
||||
while [ "$i" -lt 12 ]; do
|
||||
while [ "$i" -lt "$HEALTH_ATTEMPTS" ]; do
|
||||
if check_health; then return 0; fi
|
||||
sleep 5
|
||||
sleep "$HEALTH_INTERVAL"
|
||||
i=$((i + 1))
|
||||
done
|
||||
return 1
|
||||
@@ -66,15 +102,18 @@ if [ -z "$BACKUP_FILE" ]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "将恢复备份包:$BACKUP_FILE"
|
||||
systemctl stop codex-cloud-worker codex-cloud-server || true
|
||||
echo "停服务:worker_unit=$WORKER_UNIT server_units=$SERVER_UNITS"
|
||||
systemctl stop "$WORKER_UNIT" $SERVER_UNITS || true
|
||||
find "$APP_DIR" -mindepth 1 -maxdepth 1 ! -name work -exec rm -rf {} +
|
||||
tar -xzf "$BACKUP_FILE" -C /opt
|
||||
chown -R codexsvc:codexsvc "$APP_DIR"
|
||||
systemctl start codex-cloud-server codex-cloud-worker
|
||||
tar -xzf "$BACKUP_FILE" -C "$(dirname "$APP_DIR")"
|
||||
if id codexsvc >/dev/null 2>&1; then
|
||||
chown -R codexsvc:codexsvc "$APP_DIR"
|
||||
fi
|
||||
systemctl start $SERVER_UNITS "$WORKER_UNIT"
|
||||
if wait_health; then
|
||||
echo "回滚成功,health 通过,当前代码为备份包:$BACKUP_FILE"
|
||||
echo "回滚成功,全部端口 health 通过($HEALTH_PORTS),当前代码为备份包:$BACKUP_FILE"
|
||||
exit 0
|
||||
fi
|
||||
echo "回滚后 health 仍失败,需人工介入" >&2
|
||||
echo "回滚后 health 仍失败(需全部端口通过),需人工介入" >&2
|
||||
exit 1
|
||||
HOST_SCRIPT
|
||||
@@ -82,6 +82,33 @@ class TestDeployPure(unittest.TestCase):
|
||||
self.assertIsNone(deploy_mod.live_commit("Warning: something\n"))
|
||||
|
||||
|
||||
class TestDefaultTestSuites(unittest.TestCase):
|
||||
"""P2-ops 回归:部署前默认清单必须含 deploy/tests,
|
||||
且 run_tests() 确实按 discover -s 逐套跑(不是包路径)。"""
|
||||
|
||||
def test_default_suites_include_deploy_tests(self):
|
||||
self.assertIn("deploy/tests", deploy_mod.DEFAULT_TEST_SUITES)
|
||||
self.assertIn("tests", deploy_mod.DEFAULT_TEST_SUITES)
|
||||
self.assertIn("cloud/tests", deploy_mod.DEFAULT_TEST_SUITES)
|
||||
|
||||
def test_run_tests_discovers_every_suite(self):
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, cwd=None, check=True):
|
||||
calls.append(cmd)
|
||||
return subprocess.CompletedProcess(args=cmd, returncode=0,
|
||||
stdout="", stderr="")
|
||||
|
||||
with mock.patch.object(deploy_mod, "run", side_effect=fake_run):
|
||||
deploy_mod.run_tests()
|
||||
discovered = [c[c.index("-s") + 1] for c in calls
|
||||
if "discover" in c]
|
||||
self.assertEqual(discovered, list(deploy_mod.DEFAULT_TEST_SUITES))
|
||||
# 没有任何一套走包路径(deploy.tests 会盖掉 deploy.py)。
|
||||
for c in calls:
|
||||
self.assertNotIn("deploy.tests", c)
|
||||
|
||||
|
||||
class TestBuildRelease(unittest.TestCase):
|
||||
def test_build_from_temp_git_repo(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""P2-ops 回归:monitor 单机开关与告警出口、rollback 与
|
||||
host_deploy 的 unit/健康清单同步。
|
||||
|
||||
全部用假 systemctl / curl / ssh / mail,不碰真实主机与真实服务。
|
||||
告警测试用的 webhook 接收器只监听 127.0.0.1 临时端口。
|
||||
"""
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))
|
||||
DEPLOY_DIR = os.path.join(REPO_ROOT, "deploy")
|
||||
MONITOR_SCRIPT = os.path.join(DEPLOY_DIR, "monitor.sh")
|
||||
ROLLBACK_SCRIPT = os.path.join(DEPLOY_DIR, "rollback.sh")
|
||||
HOST_DEPLOY_SCRIPT = os.path.join(DEPLOY_DIR, "host_deploy.sh")
|
||||
REAL_CURL = shutil.which("curl")
|
||||
|
||||
|
||||
def _write_exe(path, body):
|
||||
with open(path, "w") as f:
|
||||
f.write(body)
|
||||
os.chmod(path, 0o755)
|
||||
|
||||
|
||||
class MonitorSim(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
base = self.tmp.name
|
||||
self.app_dir = os.path.join(base, "app")
|
||||
os.makedirs(self.app_dir)
|
||||
self.backup_dir = os.path.join(base, "backups")
|
||||
os.makedirs(self.backup_dir)
|
||||
with open(os.path.join(self.backup_dir,
|
||||
"cloud-backup-fresh.sql"), "w") as f:
|
||||
f.write("-- fresh\n")
|
||||
self.health_path = os.path.join(base, "health.json")
|
||||
# 健康正文里埋一个哨兵串:告警正文绝不能把它带出去
|
||||
# (告警只能发 LAST_JSON 的 pass/fail,不发 health 正文)。
|
||||
with open(self.health_path, "w") as f:
|
||||
json.dump({"ok": True, "backend": "postgres", "db_ok": True,
|
||||
"worker_ok": True,
|
||||
"note": "sentinel-health-secret"}, f)
|
||||
fake_bin = os.path.join(base, "fakebin")
|
||||
os.makedirs(fake_bin)
|
||||
_write_exe(os.path.join(fake_bin, "systemctl"),
|
||||
"#!/bin/sh\n"
|
||||
"if [ \"$1\" = \"is-active\" ]; then\n"
|
||||
" case \"$2\" in\n"
|
||||
" codex-cloud-server) exit \"$FAKE_SERVER1\" ;;\n"
|
||||
" codex-cloud-server-2) exit \"$FAKE_SERVER2\" ;;\n"
|
||||
" *) exit 1 ;;\n"
|
||||
" esac\n"
|
||||
"fi\n"
|
||||
"exit 0\n")
|
||||
real_curl = REAL_CURL or "/usr/bin/curl"
|
||||
self.curl_log = os.path.join(base, "curl.log")
|
||||
open(self.curl_log, "w").close()
|
||||
_write_exe(os.path.join(fake_bin, "curl"),
|
||||
"#!/bin/sh\n"
|
||||
"echo \"$@\" >> \"$FAKE_CURL_LOG\"\n"
|
||||
"for a in \"$@\"; do\n"
|
||||
" case \"$a\" in\n"
|
||||
" *\"/health\") cat \"$FAKE_HEALTH_JSON\"; exit 0 ;;\n"
|
||||
" esac\n"
|
||||
"done\n"
|
||||
"exec \"%s\" \"$@\"\n" % real_curl)
|
||||
self.fake_bin = fake_bin
|
||||
self.env = dict(os.environ)
|
||||
self.env["PATH"] = fake_bin + os.pathsep + os.environ["PATH"]
|
||||
self.env["MONITOR_HEALTH_URL"] = "http://127.0.0.1:8765/health"
|
||||
self.env["MONITOR_APP_DIR"] = self.app_dir
|
||||
self.env["MONITOR_DISK_PATH"] = base
|
||||
self.env["MONITOR_BACKUP_DIR"] = self.backup_dir
|
||||
self.env["MONITOR_ALERT_URL"] = ""
|
||||
self.env["MONITOR_ALERT_MAILTO"] = ""
|
||||
self.env["FAKE_HEALTH_JSON"] = self.health_path
|
||||
self.env["FAKE_CURL_LOG"] = self.curl_log
|
||||
self.env["FAKE_SERVER1"] = "0" # server-1 active
|
||||
self.env["FAKE_SERVER2"] = "1" # server-2 inactive
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def run_monitor(self, extra_env=None):
|
||||
env = dict(self.env)
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
r = subprocess.run(["sh", MONITOR_SCRIPT],
|
||||
capture_output=True, text=True, env=env)
|
||||
return r, json.loads(r.stdout.strip())
|
||||
|
||||
def test_single_host_passes_server_check(self):
|
||||
_, out = self.run_monitor({"MONITOR_SINGLE_HOST": "1"})
|
||||
self.assertEqual(out["server_mode"], "single")
|
||||
self.assertEqual(out["checks"]["server"], "pass")
|
||||
|
||||
def test_dual_host_fails_server_check_when_server2_down(self):
|
||||
# 默认双机:server-2 不 active 即 fail,不能静默降级。
|
||||
_, out = self.run_monitor()
|
||||
self.assertEqual(out["server_mode"], "dual")
|
||||
self.assertEqual(out["checks"]["server"], "fail")
|
||||
|
||||
def test_webhook_alert_on_fail_only(self):
|
||||
if REAL_CURL is None:
|
||||
self.skipTest("本机无 curl,跳过 webhook 告警测试")
|
||||
received = []
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
received.append(self.rfile.read(length))
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
url = "http://127.0.0.1:%d/hook" % server.server_address[1]
|
||||
r, out = self.run_monitor({"MONITOR_ALERT_URL": url})
|
||||
self.assertNotEqual(r.returncode, 0) # 其它项 fail,整体 fail
|
||||
self.assertEqual(len(received), 1)
|
||||
body = json.loads(received[0].decode())
|
||||
self.assertEqual(body["overall"], "fail")
|
||||
self.assertNotIn("sentinel-health-secret",
|
||||
received[0].decode())
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
def test_alert_default_off(self):
|
||||
# 不配出口:即使整体 fail,monitor 也不应发出任何告警请求。
|
||||
# 行为证据:假 curl 记录了全部调用,其中没有任何 POST
|
||||
# (webhook 告警是唯一的 POST 调用;health 检查是 GET)。
|
||||
r, out = self.run_monitor()
|
||||
self.assertNotEqual(r.returncode, 0)
|
||||
self.assertEqual(out["overall"], "fail")
|
||||
with open(self.curl_log) as f:
|
||||
calls = f.read()
|
||||
self.assertNotIn("POST", calls)
|
||||
self.assertIn("/health", calls)
|
||||
|
||||
def test_mail_alert_uses_mail_command(self):
|
||||
mail_log = os.path.join(self.tmp.name, "mail.log")
|
||||
_write_exe(os.path.join(self.fake_bin, "mail"),
|
||||
"#!/bin/sh\n"
|
||||
"echo \"ARGS: $@\" >> \"%s\"\n"
|
||||
"cat >> \"%s\"\n" % (mail_log, mail_log))
|
||||
r, _ = self.run_monitor(
|
||||
{"MONITOR_ALERT_MAILTO": "ops@example.invalid"})
|
||||
self.assertNotEqual(r.returncode, 0)
|
||||
with open(mail_log) as f:
|
||||
content = f.read()
|
||||
self.assertIn("codex monitor FAIL", content)
|
||||
self.assertIn('"overall": "fail"', content)
|
||||
self.assertNotIn("sentinel-health-secret", content)
|
||||
|
||||
|
||||
class RollbackSim(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
base = self.tmp.name
|
||||
self.app = os.path.join(base, "codex-app-server")
|
||||
os.makedirs(os.path.join(self.app, "work"))
|
||||
with open(os.path.join(self.app, "work", "keep.txt"), "w") as f:
|
||||
f.write("keep\n")
|
||||
with open(os.path.join(self.app, "broken.py"), "w") as f:
|
||||
f.write("x = 0\n")
|
||||
self.backup_dir = os.path.join(base, "backups")
|
||||
os.makedirs(self.backup_dir)
|
||||
# 备份包:顶层目录名必须等于 APP_DIR 的 basename,
|
||||
# rollback 解包到 dirname(APP_DIR) 后正好落回 APP_DIR。
|
||||
src = os.path.join(base, "src", "codex-app-server")
|
||||
os.makedirs(src)
|
||||
with open(os.path.join(src, "restored.py"), "w") as f:
|
||||
f.write("x = 1\n")
|
||||
with open(os.path.join(src, "DEPLOYED_COMMIT"), "w") as f:
|
||||
f.write("backupcommit\n")
|
||||
tarball = os.path.join(self.backup_dir,
|
||||
"codex-app-server-20260101.tar.gz")
|
||||
with tarfile.open(tarball, "w:gz") as tf:
|
||||
for root, dirs, files in os.walk(src):
|
||||
dirs.sort()
|
||||
for name in sorted(files):
|
||||
full = os.path.join(root, name)
|
||||
tf.add(full, arcname=os.path.relpath(full, os.path.join(
|
||||
base, "src")))
|
||||
self.health_dir = os.path.join(base, "health")
|
||||
os.makedirs(self.health_dir)
|
||||
fake_bin = os.path.join(base, "fakebin")
|
||||
os.makedirs(fake_bin)
|
||||
self.log_path = os.path.join(base, "systemctl.log")
|
||||
open(self.log_path, "w").close()
|
||||
_write_exe(os.path.join(fake_bin, "ssh"),
|
||||
"#!/bin/sh\n"
|
||||
"# 假 ssh:忽略目标参数,直接把 heredoc 脚本在本地跑。\n"
|
||||
"exec sh -s\n")
|
||||
_write_exe(os.path.join(fake_bin, "systemctl"),
|
||||
"#!/bin/sh\n"
|
||||
"echo \"$@\" >> \"$FAKE_SYSTEMCTL_LOG\"\n"
|
||||
"if [ \"$1\" = \"cat\" ] && [ \"$2\" = \"codex-cloud-server-2\" ]; then\n"
|
||||
" if [ \"$FAKE_SERVER2_PRESENT\" = \"0\" ]; then exit 1; fi\n"
|
||||
" exit 0\n"
|
||||
"fi\n"
|
||||
"if [ \"$1\" = \"is-active\" ]; then exit 1; fi\n"
|
||||
"exit 0\n")
|
||||
_write_exe(os.path.join(fake_bin, "curl"),
|
||||
"#!/bin/sh\n"
|
||||
"for a in \"$@\"; do\n"
|
||||
" case \"$a\" in\n"
|
||||
" *\":8765/health\") cat \"$FAKE_HEALTH_DIR/8765.json\"; exit 0 ;;\n"
|
||||
" *\":8766/health\") cat \"$FAKE_HEALTH_DIR/8766.json\"; exit 0 ;;\n"
|
||||
" esac\n"
|
||||
"done\n"
|
||||
"exit 1\n")
|
||||
self.env = dict(os.environ)
|
||||
self.env["PATH"] = fake_bin + os.pathsep + os.environ["PATH"]
|
||||
self.env["CODEX_ROLLBACK_APP_DIR"] = self.app
|
||||
self.env["CODEX_ROLLBACK_BACKUP_DIR"] = self.backup_dir
|
||||
self.env["CODEX_ROLLBACK_HEALTH_ATTEMPTS"] = "2"
|
||||
self.env["CODEX_ROLLBACK_HEALTH_INTERVAL"] = "0.1"
|
||||
self.env["FAKE_SYSTEMCTL_LOG"] = self.log_path
|
||||
self.env["FAKE_HEALTH_DIR"] = self.health_dir
|
||||
self.env["FAKE_SERVER2_PRESENT"] = "1"
|
||||
self.env.pop("SSH_PROXY_CMD", None)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def set_health(self, port, ok):
|
||||
with open(os.path.join(self.health_dir, "%s.json" % port), "w") as f:
|
||||
json.dump({"ok": ok, "backend": "postgres", "db_ok": ok}, f)
|
||||
|
||||
def run_rollback(self):
|
||||
return subprocess.run(["sh", ROLLBACK_SCRIPT, "testhost"],
|
||||
capture_output=True, text=True, env=self.env)
|
||||
|
||||
def systemctl_log(self):
|
||||
with open(self.log_path) as f:
|
||||
return f.read()
|
||||
|
||||
def test_success_requires_both_ports(self):
|
||||
self.set_health(8765, True)
|
||||
self.set_health(8766, True)
|
||||
r = self.run_rollback()
|
||||
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
self.assertIn("全部端口 health 通过", r.stdout)
|
||||
log = self.systemctl_log()
|
||||
self.assertIn(
|
||||
"stop codex-cloud-worker codex-cloud-server codex-cloud-server-2",
|
||||
log)
|
||||
self.assertIn(
|
||||
"start codex-cloud-server codex-cloud-server-2 codex-cloud-worker",
|
||||
log)
|
||||
self.assertTrue(os.path.exists(os.path.join(self.app, "restored.py")))
|
||||
self.assertFalse(os.path.exists(os.path.join(self.app, "broken.py")))
|
||||
with open(os.path.join(self.app, "work", "keep.txt")) as f:
|
||||
self.assertEqual(f.read().strip(), "keep")
|
||||
|
||||
def test_server2_health_failure_fails_rollback(self):
|
||||
# 8765 通、8766 不通:只通一端口不算回滚成功。
|
||||
self.set_health(8765, True)
|
||||
self.set_health(8766, False)
|
||||
r = self.run_rollback()
|
||||
self.assertNotEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
self.assertIn("health 仍失败", r.stdout + r.stderr)
|
||||
|
||||
|
||||
class TestUnitListSync(unittest.TestCase):
|
||||
"""两脚本清单同步核对:任一脚本改了清单、另一脚本没跟进,
|
||||
这里就变红,而不是等到回滚时才发现 server-2 没被停/验。"""
|
||||
|
||||
def test_both_scripts_share_server_units_and_ports(self):
|
||||
with open(ROLLBACK_SCRIPT) as f:
|
||||
rollback = f.read()
|
||||
with open(HOST_DEPLOY_SCRIPT) as f:
|
||||
host_deploy = f.read()
|
||||
for script, name in ((rollback, "rollback.sh"),
|
||||
(host_deploy, "host_deploy.sh")):
|
||||
self.assertIn("codex-cloud-server-2", script, name)
|
||||
self.assertIn("8766", script, name)
|
||||
self.assertIn("共享的 unit 清单", script, name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -2065,6 +2065,16 @@ def _resolve_path(base_dir, p):
|
||||
return os.path.join(base_dir, p)
|
||||
|
||||
|
||||
def _thread_cli_result(resp, method):
|
||||
# thread-* 子命令的冻结契约:成功只打 result 内容,失败由调用方
|
||||
# 打 error JSON 并以非零退出;result 为空时打空对象,保持 stdout
|
||||
# 始终是一个 JSON 对象。
|
||||
if not resp or "error" in resp:
|
||||
raise RuntimeError(f"{method} 失败:{resp}")
|
||||
result = resp.get("result")
|
||||
return result if result is not None else {}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--codex-home", default=None)
|
||||
@@ -2143,6 +2153,17 @@ def main():
|
||||
help="评审目标:指定提交 sha(commit)")
|
||||
p.add_argument("--instructions", default=None,
|
||||
help="评审目标:自定义评审指令(custom)")
|
||||
p = sub.add_parser("thread-list"); p.add_argument("--cwd", default=None); p.add_argument("--archived", action="store_true")
|
||||
p = sub.add_parser("thread-read"); p.add_argument("--thread-id", required=True); p.add_argument("--include-turns", action="store_true")
|
||||
p = sub.add_parser("thread-rename"); p.add_argument("--thread-id", required=True); p.add_argument("--name", required=True)
|
||||
p = sub.add_parser("thread-archive"); p.add_argument("--thread-id", required=True)
|
||||
p = sub.add_parser("thread-unarchive"); p.add_argument("--thread-id", required=True)
|
||||
p = sub.add_parser("thread-compact"); p.add_argument("--thread-id", required=True)
|
||||
p = sub.add_parser("skills-list"); p.add_argument("--cwd", default=None)
|
||||
p = sub.add_parser("apps-list")
|
||||
p = sub.add_parser("mcp-servers-list")
|
||||
p = sub.add_parser("mcp-servers-reload")
|
||||
p = sub.add_parser("mcp-server-oauth-login"); p.add_argument("--server-name", required=True); p.add_argument("--scopes", default=None); p.add_argument("--timeout-secs", type=int, default=None)
|
||||
p = sub.add_parser("run"); p.add_argument("--task", required=True); p.add_argument("--prompt", required=True)
|
||||
p = sub.add_parser("interrupt"); p.add_argument("--task", required=True)
|
||||
p = sub.add_parser("delegate")
|
||||
@@ -2592,6 +2613,149 @@ def main():
|
||||
t["status"] = "discarded"
|
||||
save_state(args.state, state)
|
||||
print(f"discarded {args.task}")
|
||||
elif args.cmd == "thread-list":
|
||||
# 只读命令,不 resume;cwd/archived 只在给了时才进 params,
|
||||
# 不传时服务端按自身缺省过滤。
|
||||
try:
|
||||
params = {}
|
||||
if args.cwd:
|
||||
params["cwd"] = args.cwd
|
||||
if args.archived:
|
||||
params["archived"] = True
|
||||
resp = srv.request("thread/list", params, timeout=60)
|
||||
print(json.dumps(_thread_cli_result(resp, "thread/list"),
|
||||
ensure_ascii=False))
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
elif args.cmd == "thread-read":
|
||||
try:
|
||||
srv.resume_thread(args.thread_id)
|
||||
resp = srv.request("thread/read",
|
||||
{"threadId": args.thread_id,
|
||||
"includeTurns": bool(args.include_turns)},
|
||||
timeout=60)
|
||||
print(json.dumps(_thread_cli_result(resp, "thread/read"),
|
||||
ensure_ascii=False))
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
elif args.cmd == "thread-rename":
|
||||
try:
|
||||
srv.resume_thread(args.thread_id)
|
||||
resp = srv.request("thread/name/set",
|
||||
{"threadId": args.thread_id,
|
||||
"name": args.name},
|
||||
timeout=60)
|
||||
print(json.dumps(_thread_cli_result(resp, "thread/name/set"),
|
||||
ensure_ascii=False))
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
elif args.cmd == "thread-archive":
|
||||
# 沿用现有 archive 行为:不 resume,直接调 thread/archive。
|
||||
try:
|
||||
resp = srv.archive_thread(args.thread_id)
|
||||
print(json.dumps(_thread_cli_result(resp, "thread/archive"),
|
||||
ensure_ascii=False))
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
elif args.cmd == "thread-unarchive":
|
||||
try:
|
||||
srv.resume_thread(args.thread_id)
|
||||
resp = srv.request("thread/unarchive",
|
||||
{"threadId": args.thread_id},
|
||||
timeout=60)
|
||||
print(json.dumps(_thread_cli_result(resp, "thread/unarchive"),
|
||||
ensure_ascii=False))
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
elif args.cmd == "thread-compact":
|
||||
try:
|
||||
srv.resume_thread(args.thread_id)
|
||||
resp = srv.request("thread/compact/start",
|
||||
{"threadId": args.thread_id},
|
||||
timeout=120)
|
||||
print(json.dumps(_thread_cli_result(resp, "thread/compact/start"),
|
||||
ensure_ascii=False))
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
elif args.cmd == "skills-list":
|
||||
# skills/mcp 管理面与 thread-* 子命令同款冻结契约:成功
|
||||
# 只打 result 内容,失败打 error JSON 并以非零退出。
|
||||
# skills/list 等方法不带 threadId,不需要 resume。
|
||||
try:
|
||||
params = {}
|
||||
if args.cwd:
|
||||
params["cwds"] = [args.cwd]
|
||||
resp = srv.request("skills/list", params, timeout=60)
|
||||
print(json.dumps(_thread_cli_result(resp, "skills/list"),
|
||||
ensure_ascii=False))
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
elif args.cmd == "apps-list":
|
||||
try:
|
||||
resp = srv.request("app/list", {}, timeout=60)
|
||||
print(json.dumps(_thread_cli_result(resp, "app/list"),
|
||||
ensure_ascii=False))
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
elif args.cmd == "mcp-servers-list":
|
||||
try:
|
||||
resp = srv.request("mcpServerStatus/list", {}, timeout=60)
|
||||
print(json.dumps(_thread_cli_result(resp, "mcpServerStatus/list"),
|
||||
ensure_ascii=False))
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
elif args.cmd == "mcp-servers-reload":
|
||||
try:
|
||||
resp = srv.request("config/mcpServer/reload", None, timeout=60)
|
||||
print(json.dumps(_thread_cli_result(resp, "config/mcpServer/reload"),
|
||||
ensure_ascii=False))
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
elif args.cmd == "mcp-server-oauth-login":
|
||||
try:
|
||||
params = {"name": args.server_name}
|
||||
if args.scopes:
|
||||
params["scopes"] = [s for s in args.scopes.split(",") if s]
|
||||
if args.timeout_secs is not None:
|
||||
params["timeoutSecs"] = args.timeout_secs
|
||||
resp = srv.request("mcpServer/oauth/login", params, timeout=120)
|
||||
print(json.dumps(_thread_cli_result(resp, "mcpServer/oauth/login"),
|
||||
ensure_ascii=False))
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
elif args.cmd == "compact":
|
||||
t = state["tasks"][args.task]
|
||||
# 注意:compact 要求 thread 已在当前 server 里加载,
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import host
|
||||
|
||||
|
||||
class FakeServer:
|
||||
# Fake app-server: records every call in order, never spawns a
|
||||
# real codex binary. request returns {"result": ...} per method,
|
||||
# or an error response when the method is in error_methods.
|
||||
# resume_thread / archive_thread mimic the real Server methods:
|
||||
# they raise RuntimeError on an error response.
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.calls = []
|
||||
self.results = {
|
||||
"thread/list": {"data": [{"id": "th-1"}]},
|
||||
"thread/read": {"thread": {"id": "th-1"}},
|
||||
"thread/name/set": {},
|
||||
"thread/archive": {},
|
||||
"thread/unarchive": {"thread": {"id": "th-1"}},
|
||||
"thread/compact/start": {},
|
||||
}
|
||||
self.error_methods = set()
|
||||
self.resume_error = False
|
||||
self.archive_error = False
|
||||
|
||||
def initialize(self):
|
||||
self.calls.append(("initialize",))
|
||||
return {}
|
||||
|
||||
def close(self):
|
||||
self.calls.append(("close",))
|
||||
|
||||
def request(self, method, params=None, timeout=120):
|
||||
self.calls.append(("request", method, params))
|
||||
if method in self.error_methods:
|
||||
return {"error": {"message": "boom"}}
|
||||
return {"result": self.results.get(method, {})}
|
||||
|
||||
def resume_thread(self, thread_id):
|
||||
self.calls.append(("resume", thread_id))
|
||||
if self.resume_error:
|
||||
raise RuntimeError("thread/resume 失败:boom")
|
||||
return {"thread": {"id": thread_id}}
|
||||
|
||||
def archive_thread(self, thread_id):
|
||||
self.calls.append(("archive", thread_id))
|
||||
if self.archive_error:
|
||||
raise RuntimeError("thread/archive 失败:boom")
|
||||
return {"result": {}}
|
||||
|
||||
|
||||
class ThreadCliTest(unittest.TestCase):
|
||||
def _run(self, fake, cli_args, tasks=None):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path = os.path.join(tmp, "host-state.json")
|
||||
with open(state_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"tasks": tasks or {}, "automations": []}, f)
|
||||
cfg_path = os.path.join(tmp, "cfg.json")
|
||||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"state": state_path}, f)
|
||||
argv = ["host.py", "--config", cfg_path, "--state", state_path]
|
||||
argv += cli_args
|
||||
out = io.StringIO()
|
||||
code = 0
|
||||
with mock.patch.object(host, "Server", lambda **kw: fake):
|
||||
with mock.patch.object(sys, "argv", argv):
|
||||
with redirect_stdout(out):
|
||||
try:
|
||||
host.main()
|
||||
except SystemExit as e:
|
||||
code = e.code if isinstance(e.code, int) else 1
|
||||
return code, out.getvalue(), state_path
|
||||
|
||||
def _calls_before_close(self, fake):
|
||||
return [c for c in fake.calls if c[0] != "close"
|
||||
and c[0] != "initialize"]
|
||||
|
||||
def test_list_no_filters_no_resume(self):
|
||||
fake = FakeServer()
|
||||
code, out, _ = self._run(fake, ["thread-list"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {"data": [{"id": "th-1"}]})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "thread/list", {})])
|
||||
|
||||
def test_list_cwd_and_archived_passthrough(self):
|
||||
fake = FakeServer()
|
||||
code, out, _ = self._run(
|
||||
fake, ["thread-list", "--cwd", "/tmp/work", "--archived"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {"data": [{"id": "th-1"}]})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "thread/list",
|
||||
{"cwd": "/tmp/work", "archived": True})])
|
||||
|
||||
def test_read_defaults_include_turns_false_and_resume_first(self):
|
||||
fake = FakeServer()
|
||||
code, out, _ = self._run(fake, ["thread-read", "--thread-id", "th-1"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {"thread": {"id": "th-1"}})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [
|
||||
("resume", "th-1"),
|
||||
("request", "thread/read",
|
||||
{"threadId": "th-1", "includeTurns": False}),
|
||||
])
|
||||
|
||||
def test_read_include_turns_flag(self):
|
||||
fake = FakeServer()
|
||||
code, out, _ = self._run(
|
||||
fake, ["thread-read", "--thread-id", "th-1", "--include-turns"])
|
||||
self.assertEqual(code, 0)
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls[1], ("request", "thread/read",
|
||||
{"threadId": "th-1",
|
||||
"includeTurns": True}))
|
||||
|
||||
def test_rename_resume_first_and_params(self):
|
||||
fake = FakeServer()
|
||||
code, out, _ = self._run(
|
||||
fake, ["thread-rename", "--thread-id", "th-1",
|
||||
"--name", "new-name"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [
|
||||
("resume", "th-1"),
|
||||
("request", "thread/name/set",
|
||||
{"threadId": "th-1", "name": "new-name"}),
|
||||
])
|
||||
|
||||
def test_archive_no_resume(self):
|
||||
fake = FakeServer()
|
||||
code, out, _ = self._run(
|
||||
fake, ["thread-archive", "--thread-id", "th-1"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("archive", "th-1")])
|
||||
|
||||
def test_unarchive_resume_first(self):
|
||||
fake = FakeServer()
|
||||
code, out, _ = self._run(
|
||||
fake, ["thread-unarchive", "--thread-id", "th-1"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {"thread": {"id": "th-1"}})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [
|
||||
("resume", "th-1"),
|
||||
("request", "thread/unarchive", {"threadId": "th-1"}),
|
||||
])
|
||||
|
||||
def test_compact_resume_first(self):
|
||||
fake = FakeServer()
|
||||
code, out, _ = self._run(
|
||||
fake, ["thread-compact", "--thread-id", "th-1"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [
|
||||
("resume", "th-1"),
|
||||
("request", "thread/compact/start", {"threadId": "th-1"}),
|
||||
])
|
||||
|
||||
def test_method_error_is_error_json_and_nonzero(self):
|
||||
fake = FakeServer()
|
||||
fake.error_methods = {"thread/list"}
|
||||
code, out, _ = self._run(fake, ["thread-list"])
|
||||
self.assertNotEqual(code, 0)
|
||||
parsed = json.loads(out)
|
||||
self.assertEqual(set(parsed.keys()), {"error"})
|
||||
self.assertIn("thread/list", parsed["error"])
|
||||
|
||||
def test_resume_failure_is_error_json_and_nonzero(self):
|
||||
fake = FakeServer()
|
||||
fake.resume_error = True
|
||||
code, out, _ = self._run(
|
||||
fake, ["thread-rename", "--thread-id", "th-1",
|
||||
"--name", "new-name"])
|
||||
self.assertNotEqual(code, 0)
|
||||
parsed = json.loads(out)
|
||||
self.assertEqual(set(parsed.keys()), {"error"})
|
||||
# rename must not reach thread/name/set when resume fails
|
||||
methods = [c[1] for c in fake.calls if c[0] == "request"]
|
||||
self.assertNotIn("thread/name/set", methods)
|
||||
|
||||
def test_old_compact_unchanged(self):
|
||||
fake = FakeServer()
|
||||
tasks = {"t1": {"thread_id": "th-1", "status": "running",
|
||||
"cwd": "/tmp/work"}}
|
||||
code, out, _ = self._run(fake, ["compact", "--task", "t1"],
|
||||
tasks=tasks)
|
||||
self.assertEqual(code, 0)
|
||||
# old human-readable output, not the frozen JSON contract
|
||||
self.assertIn("compact t1:", out)
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [
|
||||
("resume", "th-1"),
|
||||
("request", "thread/compact/start", {"threadId": "th-1"}),
|
||||
])
|
||||
|
||||
def test_old_archive_unchanged(self):
|
||||
fake = FakeServer()
|
||||
tasks = {"t1": {"thread_id": "th-1", "status": "running",
|
||||
"cwd": "/tmp/work"}}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path = os.path.join(tmp, "host-state.json")
|
||||
with open(state_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"tasks": tasks, "automations": []}, f)
|
||||
cfg_path = os.path.join(tmp, "cfg.json")
|
||||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"state": state_path}, f)
|
||||
out = io.StringIO()
|
||||
code = 0
|
||||
with mock.patch.object(host, "Server", lambda **kw: fake):
|
||||
with mock.patch.object(
|
||||
sys, "argv",
|
||||
["host.py", "--config", cfg_path,
|
||||
"--state", state_path,
|
||||
"archive", "--task", "t1"]):
|
||||
with redirect_stdout(out):
|
||||
try:
|
||||
host.main()
|
||||
except SystemExit as e:
|
||||
code = e.code if isinstance(e.code, int) else 1
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("archived t1", out.getvalue())
|
||||
saved = json.load(open(state_path, encoding="utf-8"))
|
||||
self.assertEqual(saved["tasks"]["t1"]["status"], "archived")
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("archive", "th-1")])
|
||||
|
||||
|
||||
class SkillsCliTest(unittest.TestCase):
|
||||
# skills/mcp CLI 子命令:与 thread-* 同款冻结契约(纯 JSON
|
||||
# stdout、失败非零),但不带 threadId、不 resume。
|
||||
|
||||
def _run(self, fake, cli_args):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path = os.path.join(tmp, "host-state.json")
|
||||
with open(state_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"tasks": {}, "automations": []}, f)
|
||||
cfg_path = os.path.join(tmp, "cfg.json")
|
||||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"state": state_path}, f)
|
||||
argv = ["host.py", "--config", cfg_path, "--state", state_path]
|
||||
argv += cli_args
|
||||
out = io.StringIO()
|
||||
code = 0
|
||||
with mock.patch.object(host, "Server", lambda **kw: fake):
|
||||
with mock.patch.object(sys, "argv", argv):
|
||||
with redirect_stdout(out):
|
||||
try:
|
||||
host.main()
|
||||
except SystemExit as e:
|
||||
code = e.code if isinstance(e.code, int) else 1
|
||||
return code, out.getvalue()
|
||||
|
||||
def _calls_before_close(self, fake):
|
||||
return [c for c in fake.calls if c[0] != "close"
|
||||
and c[0] != "initialize"]
|
||||
|
||||
def _fake_with(self, results):
|
||||
fake = FakeServer()
|
||||
fake.results.update(results)
|
||||
return fake
|
||||
|
||||
def test_skills_list_cwd_maps_to_cwds(self):
|
||||
fake = self._fake_with({
|
||||
"skills/list": {"data": [{"cwd": "/tmp/work", "errors": [],
|
||||
"skills": []}]}})
|
||||
code, out = self._run(fake, ["skills-list", "--cwd", "/tmp/work"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {"data": [{"cwd": "/tmp/work",
|
||||
"errors": [],
|
||||
"skills": []}]})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "skills/list",
|
||||
{"cwds": ["/tmp/work"]})])
|
||||
|
||||
def test_skills_list_no_cwd_empty_params(self):
|
||||
fake = self._fake_with({"skills/list": {"data": []}})
|
||||
code, out = self._run(fake, ["skills-list"])
|
||||
self.assertEqual(code, 0)
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "skills/list", {})])
|
||||
|
||||
def test_apps_list(self):
|
||||
fake = self._fake_with({"app/list": {"data": []}})
|
||||
code, out = self._run(fake, ["apps-list"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {"data": []})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "app/list", {})])
|
||||
|
||||
def test_mcp_servers_list(self):
|
||||
fake = self._fake_with({"mcpServerStatus/list": {"data": []}})
|
||||
code, out = self._run(fake, ["mcp-servers-list"])
|
||||
self.assertEqual(code, 0)
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "mcpServerStatus/list", {})])
|
||||
|
||||
def test_mcp_servers_reload(self):
|
||||
fake = self._fake_with({"config/mcpServer/reload": {}})
|
||||
code, out = self._run(fake, ["mcp-servers-reload"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "config/mcpServer/reload",
|
||||
None)])
|
||||
|
||||
def test_oauth_login_full_flags(self):
|
||||
fake = self._fake_with({
|
||||
"mcpServer/oauth/login": {"authorizationUrl": "https://x/login",
|
||||
"loginId": "lg-1"}})
|
||||
code, out = self._run(fake, ["mcp-server-oauth-login",
|
||||
"--server-name", "srv",
|
||||
"--scopes", "a,b",
|
||||
"--timeout-secs", "30"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out), {"authorizationUrl":
|
||||
"https://x/login",
|
||||
"loginId": "lg-1"})
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "mcpServer/oauth/login",
|
||||
{"name": "srv", "scopes": ["a", "b"],
|
||||
"timeoutSecs": 30})])
|
||||
|
||||
def test_oauth_login_minimal(self):
|
||||
fake = self._fake_with({
|
||||
"mcpServer/oauth/login": {"authorizationUrl": "https://x/login"}})
|
||||
code, _ = self._run(fake, ["mcp-server-oauth-login",
|
||||
"--server-name", "srv"])
|
||||
self.assertEqual(code, 0)
|
||||
calls = self._calls_before_close(fake)
|
||||
self.assertEqual(calls, [("request", "mcpServer/oauth/login",
|
||||
{"name": "srv"})])
|
||||
|
||||
def test_method_error_is_error_json_and_nonzero(self):
|
||||
fake = FakeServer()
|
||||
fake.error_methods = {"app/list"}
|
||||
code, out = self._run(fake, ["apps-list"])
|
||||
self.assertNotEqual(code, 0)
|
||||
parsed = json.loads(out)
|
||||
self.assertEqual(set(parsed.keys()), {"error"})
|
||||
self.assertIn("app/list", parsed["error"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user