// 把 runs// 里的真实 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(''); 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(/