first commit
This commit is contained in:
97
demo/tools/build-viewer.js
Normal file
97
demo/tools/build-viewer.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// 把 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 }));
|
||||
Reference in New Issue
Block a user