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:
1 parent
6022f1dada
commit
799f7c3edb
6 files changed
+696
No files matched your search
@@ -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, "&").replace(/</g, "<")
|
||||
@@ -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>"; }
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user