feat: 任务实时链路 SSE (#61)

host.py:回合通知摘要成链路条目,增量追加到 <state>/host-traces/<task>.jsonl(上限 5000 行);dispatch/run/goal/delegate 设链路文件
cloud/server.py:GET /tasks/{id}/trace 快照 + /trace/stream SSE(snapshot → entry → done;worktree 在别机时空链路 + 说明)
console:详情链路区,运行中 fetch 流式渲染(EventSource 不能带 X-API-Key 头),终态走快照;DOM 上限 1000 条
单测:test_host_trace 7 + test_task_trace 6;host 49、cloud 525、deploy 14 全过

Co-authored-by: host <host@test.local>
This commit is contained in:
openqjandhost authored and GitHub committed 2026-10-27 02:27:15 +08:00
1 parent 6022f1dada
commit 799f7c3edb
6 files changed
+696

No files matched your search

+106
View File
@@ -137,6 +137,8 @@
<span id="actionMsg" class="muted"></span>
</div>
<pre id="detailBody" style="display:none"></pre>
<h3>实时链路</h3>
<div id="traceBox" class="muted">点任务行查看详情后,这里显示过程链路</div>
<div id="costBox"></div>
<script>
@@ -144,6 +146,10 @@
var $ = function (s) { return document.querySelector(s); };
var KEY_STORE = "codex-console-key";
var currentTask = null;
var traceAbort = null;
var TRACE_KIND = { exec: "命令", file: "文件", call: "调用", tool: "工具", message: "消息", reasoning: "思考", plan: "计划" };
var TRACE_LIMIT = 1000;
var TRACE_TERMINAL = ["completed", "failed", "interrupted", "merged", "merge-failed"];
function esc(s) {
return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;")
@@ -256,7 +262,106 @@ async function loadTasks() {
} catch (e) { $("#taskRows").innerHTML = "<tr><td colspan='7' class='err'>加载失败:" + esc(e.message) + "</td></tr>"; }
}
function stopTrace() {
if (traceAbort) { try { traceAbort.abort(); } catch (e) {} traceAbort = null; }
}
function traceLine(e) {
var k = TRACE_KIND[e.kind] || e.kind || "?";
var body = "";
if (e.kind === "exec") body = esc(e.command || "") + "(exit=" + esc(e.exitCode) + "," + esc(e.durationMs) + "ms,cwd=" + esc(e.cwd || "") + ")";
else if (e.kind === "file") body = esc(e.path || "") + " " + esc(e.change || "") + " " + esc(e.status || "");
else if (e.kind === "message") body = (e.phase ? "[" + esc(e.phase) + "] " : "") + esc(e.text || "");
else if (e.kind === "reasoning") body = esc(e.summary || "");
else if (e.kind === "tool") body = esc(e.server || "") + "/" + esc(e.tool || "") + " " + esc(e.status || "");
else if (e.kind === "call") body = esc(e.name || "");
else if (e.kind === "plan") body = esc(e.text || "");
else body = esc(JSON.stringify(e).slice(0, 300));
return "[" + k + "] " + body;
}
function renderTrace(entries, note, doneStatus) {
var box = $("#traceBox");
var html = "";
if (note) html += "<span class='err'>" + esc(note) + "</span><br>";
if (!entries.length) html += "(暂无链路条目)";
else {
var shown = entries.slice(-TRACE_LIMIT);
if (entries.length > TRACE_LIMIT) html += "(仅显示最近 " + TRACE_LIMIT + " 条,共 " + entries.length + " 条)<br>";
html += shown.map(traceLine).join("<br>");
}
if (doneStatus) html += "<br><span class='muted'>链路已结束(" + esc(doneStatus) + ")</span>";
box.innerHTML = html;
}
async function startTrace(taskId, status) {
stopTrace();
var box = $("#traceBox");
if (TRACE_TERMINAL.indexOf(status) >= 0) {
try {
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/trace", "GET");
renderTrace(d.entries || [], d.note || "", d.status);
} catch (e) { box.innerHTML = "<span class='err'>链路加载失败:" + esc(e.message) + "</span>"; }
return;
}
// 运行中:fetch 流式读 SSE(EventSource 不能带 X-API-Key 头,故不用它)。
var entries = [];
var index = {};
var note = "";
var done = false;
var doneStatus = "";
box.innerHTML = "链路连接中…";
var ctrl = new AbortController();
traceAbort = ctrl;
try {
var headers = {};
var key = getKey();
if (key) headers["X-API-Key"] = key;
var r = await fetch("/tasks/" + encodeURIComponent(taskId) + "/trace/stream", { headers: headers, signal: ctrl.signal });
if (!r.ok) {
var msg = "HTTP " + r.status;
try { var dj = await r.json(); if (dj && dj.error) msg = dj.error; } catch (e) {}
if (r.status === 401) msg += "(key 未填或无效)";
throw new Error(msg);
}
var reader = r.body.getReader();
var decoder = new TextDecoder();
var buf = "";
while (true) {
var chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
var parts = buf.split("\n\n");
buf = parts.pop();
parts.forEach(function (part) {
var line = part.split("\n").filter(function (l) { return l.indexOf("data: ") === 0; }).map(function (l) { return l.slice(6); }).join("\n");
if (!line) return;
try {
var m = JSON.parse(line);
if (m.type === "snapshot") {
entries = m.entries || []; index = {};
entries.forEach(function (e, i) { index[e.id] = i; });
note = m.note || "";
renderTrace(entries, note, "");
} else if (m.type === "entry") {
var e = m.entry;
if (e.id in index) entries[index[e.id]] = e; else { index[e.id] = entries.length; entries.push(e); }
renderTrace(entries, note, "");
} else if (m.type === "done") {
done = true; doneStatus = m.status;
renderTrace(entries, note, doneStatus);
}
} catch (e) {}
});
if (done) break;
}
} catch (e) {
if (e && e.name === "AbortError") return;
box.innerHTML = "<span class='err'>链路流中断:" + esc(e.message) + "</span>";
return;
}
if (done) { loadTasks(); showDetail(taskId); }
}
async function showDetail(taskId) {
stopTrace();
try {
var t = await api("/tasks/" + encodeURIComponent(taskId), "GET");
currentTask = t;
@@ -280,6 +385,7 @@ async function showDetail(taskId) {
$("#detailActions").style.display = "flex";
$("#actionMsg").textContent = "";
$("#costBox").innerHTML = "";
startTrace(taskId, t.status);
} catch (e) { $("#detail").innerHTML = "<span class='err'>加载失败:" + esc(e.message) + "</span>"; }
}