Files
agent-loop/demo/tools/build-viewer.js
2026-11-12 15:29:12 +08:00

98 lines
5.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 把 runs/<run>/ 里的真实 trace 整理成回放页面需要的数据,并算出“证据”。
// 用法:node tools/build-viewer.js → 生成 ../explainers/real-run-replay.html
const fs = require('node:fs');
const path = require('node:path');
const DEMO = path.join(__dirname, '..');
const RUNS = path.join(DEMO, 'runs');
const OUT = path.join(DEMO, '..', 'explainers', 'real-run-replay.html');
const SECRET = 'LOCAL-ONLY-SECRET';
const readJsonl = (f) => (fs.existsSync(f) ? fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []);
function build(runName) {
const dir = path.join(RUNS, runName);
const meta = JSON.parse(fs.readFileSync(path.join(dir, 'meta.json'), 'utf8'));
let recs = fs.readdirSync(path.join(dir, 'trace')).flatMap((f) => readJsonl(path.join(dir, 'trace', f)));
recs.sort((a, b) => a.t - b.t);
const t0 = recs[0].t;
// 路径里的本机用户目录对观众没有意义,换成相对路径
const strip = (s) => s.split(dir + '/').join('').split(dir).join('<run>');
recs = JSON.parse(strip(JSON.stringify(recs)));
recs.forEach((r) => { r.s = (r.t - t0) / 1000; });
const session = JSON.parse(strip(JSON.stringify(readJsonl(path.join(dir, 'session.jsonl')))));
session.forEach((e) => { e.s = (e.at - t0) / 1000; });
const end = recs[recs.length - 1].s;
// ---- 证据 ----
const workers = [];
for (const r of recs) {
if (r.kind === 'worker_spawn') workers.push({ pid: r.worker_pid, start: r.s, end: null, signal: null, reason: r.reason, models: 0, tools: 0 });
if (r.kind === 'worker_exit') { const w = workers.find((x) => x.pid === r.worker_pid); if (w) { w.end = r.s; w.signal = r.signal; } }
if (r.role === 'cloud-worker' && r.kind === 'model_request') { const w = workers.find((x) => x.pid === r.pid); if (w) w.models++; }
if (r.role === 'cloud-worker' && r.kind === 'tool_roundtrip') { const w = workers.find((x) => x.pid === r.pid); if (w) w.tools++; }
}
workers.forEach((w) => { if (w.end == null) w.end = end; });
const workerAlive = workers.reduce((a, w) => a + (w.end - w.start), 0);
const reqs = recs.filter((r) => r.kind === 'model_request');
const resps = recs.filter((r) => r.kind === 'model_response');
const kill = recs.find((r) => r.kind === 'inject_kill');
let killEv = null;
if (kill) {
const killedReq = reqs.filter((r) => r.pid === kill.worker_pid).pop();
const nextReq = reqs.find((r) => r.s > kill.s);
killEv = {
at: kill.s, pid: kill.worker_pid, after_seq: kill.after_seq,
lostRequest: !!killedReq && !resps.some((x) => x.pid === kill.worker_pid && x.s > killedReq.s),
nextPid: nextReq?.pid, nextAt: nextReq?.s,
identical: !!(killedReq && nextReq && JSON.stringify(killedReq.request) === JSON.stringify(nextReq.request)),
bytes: killedReq?.request_bytes,
};
}
const offline = recs.find((r) => r.kind === 'device_offline');
const onlineAgain = offline && recs.find((r) => r.kind === 'device_online' && r.s > offline.s);
let offEv = null;
if (offline && onlineAgain) {
const firstExec = recs.find((r) => r.kind === 'tool_exec' && r.s > onlineAgain.s);
const park = recs.find((r) => r.kind === 'worker_park');
offEv = {
from: offline.s, to: onlineAgain.s,
workersDuring: workers.filter((w) => w.start < onlineAgain.s && w.end > (park ? park.s + 0.1 : offline.s)).length,
parkedTool: park ? recs.find((r) => r.kind === 'dispatch_offline')?.tool : null,
parkAt: park?.s,
resumeMs: firstExec ? Math.round((firstExec.s - onlineAgain.s) * 1000) : null,
};
}
const gatewayText = reqs.map((r) => JSON.stringify(r.request)).join('\n');
const uploaded = [];
for (const r of recs.filter((x) => x.kind === 'tool_exec')) {
if (r.tool === 'read_file') uploaded.push(`文件 ${r.input.path}`);
if (r.tool === 'run_command') uploaded.push(`命令输出 \`${r.input.command}\``);
}
const jobs = recs.filter((r) => r.kind === 'job_start').map((j) => {
const x = recs.find((r) => r.kind === 'job_exit' && r.job_id === j.job_id);
return { id: j.job_id, start: j.s, end: x ? x.s : end, code: x?.exit_code, pid: j.child_pid };
});
const replies = recs.filter((r) => r.kind === 'session_append' && r.type === 'reply_to_user');
const answeredDuringJob = jobs.length && replies.some((r) => r.s > jobs[0].start + 0.5 && r.s < jobs[0].end);
const evidence = {
wall: end, workerCount: workers.length, workerAlive, workers,
gateway: { count: reqs.length, bytes: reqs.reduce((a, r) => a + r.request_bytes, 0), ms: resps.reduce((a, r) => a + r.ms, 0), secretInGateway: gatewayText.includes(SECRET) },
secretInSession: JSON.stringify(session).includes(SECRET),
uploaded,
kill: killEv, offline: offEv, jobs, answeredDuringJob,
toolExecs: recs.filter((r) => r.kind === 'tool_exec').length,
sessionEvents: session.length,
};
return { name: runName, meta, recs, session, evidence, finalDiff: fs.readFileSync(path.join(dir, 'final.diff'), 'utf8'), end };
}
const runs = fs.readdirSync(RUNS).filter((d) => fs.existsSync(path.join(RUNS, d, 'meta.json'))).sort().map(build);
const tpl = fs.readFileSync(path.join(__dirname, 'viewer-template.html'), 'utf8');
const data = JSON.stringify(runs).replace(/</g, '\\u003c');
fs.writeFileSync(OUT, tpl.replace('/*__DATA__*/null', data));
console.log(`写入 ${OUT},${runs.length} 次运行,${(fs.statSync(OUT).size / 1024).toFixed(0)} KB`);
for (const r of runs) console.log(r.name, JSON.stringify({ ...r.evidence, workers: r.evidence.workers.length }));