Files
AgentLab/scripts/check-trace.mjs
T
ThearchyHelios eddeded2a4 全栈安全加固、性能提速与架构演进:连接池与沙箱止血、双路混合检索、多版本归档及前端分包治理
- 第一批(安全加固与生命周期止血):隔离测试宿主机密钥与 .env 泄漏;修复 SQLite 取消查询挂死、:memory: 连接池报错与 EngineCache 世代竞态;补齐运行与评测删除时的 Checkpointer 线程、Artifact 磁盘文件与无主附件回收;加固三套沙箱路径越界、进程组清理与 Seatbelt 数据目录隔离,封堵 SSRF IPv4-mapped IPv6 元数据绕过与响应体 OOM;修复推理模型多轮 reasoning_content、结构化输出 Token 漏计与缓存折扣计费
- 第二批(数据库/集群/问数提速与首屏分包):补齐核心表索引与 SQLite WAL 调优,列表接口按需 defer 大列,收敛启动扫描并打通跨进程 slot 槽位唤醒;优化跨源全局选表、哈希桶数字绑定、流式 CSV 导入及多方言分析算子;剥离首屏静态引入的 @xyflow/react 与全局样式(首屏主包降至 461.59 kB),补充流式 rAF 攒批、Store 精细订阅与表单无障碍绑定
- 第三批(架构演进与工程自动化闭环):记录库超挂接阈值历史版本自动增量归档至单库多表(解除 SQLite 10 库 ATTACH 硬上限);重构知识库为词面 BM25 与全集合归一化向量矩阵真·双路独立召回并支持游标分批重建;升级多方言感知 SQL 模板词法分析(E'...' / $$...$$ / q'[...]' / # / 嵌套注释)与 Decimal / bool / NaN 参数校验,消除 ledger 层对 api 路由层的反向依赖;拆分 3101 行 CaliberDrafts 页面为 7 个领域子模块,消除检查脚本本机路径硬编码并新增 GitHub Actions CI 门禁与三批回归测试套件
2026-11-13 14:51:36 +08:00

705 lines
46 KiB
JavaScript
Raw 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.
// 运行航迹(run/trace.ts)和图推导(run/derive.ts)的回归检查。
//
// 和 check-decode 一样用**真实运行**导出的事件跑(fixtures.json),不用构造的样本:
// 要守的恰恰是真实事件的脏细节——审批恢复会对同一节点重发 node.started、
// 老运行的事件没有 ts、协作成员的 end 事件晚发。夹具里没有图,下面按节点 id
// 搭了和那次运行一致的最小图;分支、容错、取消这几种夹具里没有的结局才用构造的。
//
// 转译借 vite dev server(它 serve 的就是应用实际运行的那份)。
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273。对别的实例(比如一份沙箱拷贝)跑时
// 带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-trace.mjs
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { createTsLoader, ROOT } from './lib/env.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const root = `${ROOT}/`
const fixtures = JSON.parse(
readFileSync(join(ROOT, 'frontend/src/run/__tests__/fixtures.json'), 'utf8'))
const load = await createTsLoader(WEB)
let trace, derive, decode
try {
trace = await import(await load('/src/run/trace.ts'))
derive = await import(await load('/src/run/derive.ts'))
decode = await import(await load('/src/run/decode.ts'))
} catch (e) {
console.error(`✗ 加载前端运行态模块失败(${WEB}):\n ${e.message}`)
process.exit(1)
}
const { emptyTrace, foldEvent, finalizeTrace, project, isSettled } = trace
const { topology, activeEdgesOf, heldEdgesOf, walkedEdges, applyDerived } = derive
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
/**
* 一节一节地跑:某一节里抛了异常,只记成这一节失败,接着跑下一节,
* 不让一处卡住把后面的检查一起吞掉。这里是纯函数的检查:
* 某种事件让翻译层直接抛了异常,也只算这一节
*/
async function section(name, fn) {
console.log(`\n=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
}
}
/** store 里的那条路:折一条、推导一次,终态时收尾 */
const run = (events, graph) => events.reduce((t, ev) => {
const folded = foldEvent(t, ev)
if (!graph) return folded
return isSettled(folded.phase) ? finalizeTrace(folded, graph) : applyDerived(folded, graph)
}, emptyTrace())
const upto = (events, seq) => events.filter((e) => e.seq <= seq)
/** decodeRun 的步骤树摊平:收尾要递归到子步骤,断言也得看到每一行 */
const flat = (steps) => steps.flatMap(function walk(s) { return [s, ...(s.children ?? []).flatMap(walk)] })
const g = (nodes, edges) => ({
nodes: nodes.map((n) => (typeof n === 'string' ? { id: n } : n)),
edges: edges.map(([source, target, sourceHandle]) => ({ source, target, sourceHandle: sourceHandle ?? null })),
})
const ms = (x) => `${Math.round(x)}ms`
const LATER = Date.now() + 86_400_000
// 各夹具对应的图(按那次运行的节点 id 搭的)
const G = {
db: g(['input', 'query_kpi', 'output'], [['input', 'query_kpi'], ['query_kpi', 'output']]),
// 人工审批带通过 / 驳回两个出口,这次走的是通过:驳回那条下游就是"分支落空"
human: g([{ id: 'in', type: 'input' }, { id: 'h', type: 'human' }, 'out', 'rejected_note'],
[['in', 'h'], ['h', 'out', 'approved'], ['h', 'rejected_note', 'rejected']]),
// 驳回 → 改写 → 再审:rewrite → review 是回边
loop_approve: g([{ id: 'start', type: 'input' }, 'draft', { id: 'review', type: 'human' }, 'rewrite', 'done'],
[['start', 'draft'], ['draft', 'review'], ['review', 'done', 'approved'],
['review', 'rewrite', 'rejected'], ['rewrite', 'review']]),
fanout: g([{ id: 'start', type: 'input' }, 'leg0', 'leg1', 'leg2', 'merge', 'out'],
[['start', 'leg0'], ['start', 'leg1'], ['start', 'leg2'],
['leg0', 'merge'], ['leg1', 'merge'], ['leg2', 'merge'], ['merge', 'out']]),
// 循环条件写错了,循环节点当场失败:循环体和 done 出口之后的都被堵住
failed: g([{ id: 'start', type: 'input' }, 'init_state', { id: 'loop_sum', type: 'loop' }, 'add_one', 'result'],
[['start', 'init_state'], ['init_state', 'loop_sum'], ['loop_sum', 'add_one', 'body'],
['add_one', 'loop_sum'], ['loop_sum', 'result', 'done']]),
think: g([{ id: 'start', type: 'input' }, { id: 'each', type: 'loop' }, 'handle', 'collect', 'done'],
[['start', 'each'], ['each', 'handle', 'body'], ['handle', 'collect'], ['collect', 'each'],
['each', 'done', 'done']]),
}
await section('段', async () => {
const t = run(fixtures.db, G.db)
const q = t.nodes.query_kpi
const started = fixtures.db.find((e) => e.type === 'node.started' && e.node_id === 'query_kpi')
const finished = fixtures.db.find((e) => e.type === 'node.finished' && e.node_id === 'query_kpi')
check('一个节点一段,起止取事件的 ts', q?.segments.length === 1
&& q.segments[0].start === started.ts * 1000 && q.segments[0].end === finished.ts * 1000,
q?.segments.map((s) => `${s.kind}:${s.status}`).join(','))
check('节点跑完是 done、次数 1', q?.state === 'done' && q.count === 1, `${q?.state} ×${q?.count}`)
check('工具调用计数、跑完没有挂着的', q?.tools === 1 && q.toolsRunning === 0)
check('相位是成功', t.phase === 'succeeded', t.phase)
const p = project(t, LATER)
check('完成 3 / 共 3', p.nodesDone === 3 && p.nodesTotal === 3, `${p.nodesDone}/${p.nodesTotal}`)
check('跑完了没有东西还开着', !Object.values(t.nodes).some((n) => n.segments.some((s) => s.end == null)))
check('llm.token 不改航迹引用',
foldEvent(t, { seq: 999, type: 'llm.token', node_id: 'query_kpi', ts: 1, data: { delta: 'x' } }) === t)
check('收过的 seq 再来一遍原样返回', foldEvent(t, fixtures.db[3]) === t)
})
await section('并行度', async () => {
const t = run(fixtures.fanout, G.fanout)
const peak = Math.max(...t.parallelSeries.map(([, v]) => v))
check('三路同时在跑,峰值 3', peak === 3, `峰值 ${peak}`)
const at = (seq) => fixtures.fanout.find((e) => e.seq === seq).ts * 1000
const mid = project(t, at(9) + 1)
check('三路都开跑后投影出 3 路并行', mid.parallelNow === 3, `${mid.parallelNow}`)
check('那一刻三路都是 running',
['leg0', 'leg1', 'leg2'].every((id) => mid.nodes[id]?.state === 'running'),
['leg0', 'leg1', 'leg2'].map((id) => mid.nodes[id]?.state).join(','))
const later = project(t, at(11) + 1)
check('先跑完的那路落成 done,并行度降到 2',
later.nodes.leg2?.state === 'done' && later.parallelNow === 2,
`${later.nodes.leg2?.state} / ${later.parallelNow}`)
check('跑完后此刻并行度是 0', project(t, LATER).parallelNow === 0)
check('回放到开始之前:什么都没跑', project(t, at(1) - 1).nodes.leg0?.state === 'idle')
})
await section('defer 汇合', async () => {
// 汇合节点等其余任务都跑完再跑一次。一路回来了、其余还在跑时它是"待汇合"
const e = fixtures.fanout
const s3 = run(upto(e, 3), G.fanout)
check('入口跑完、三路还没开始:三路都在排队',
['leg0', 'leg1', 'leg2'].every((id) => s3.nodes[id]?.state === 'queued'),
['leg0', 'leg1', 'leg2'].map((id) => s3.nodes[id]?.state).join(','))
const s4 = run(upto(e, 4), G.fanout)
check('开跑的那路不再排队', s4.nodes.leg0?.state === 'running' && s4.nodes.leg1?.state === 'queued')
check('上游一路都没回来时汇合节点不排队', (s4.nodes.merge?.state ?? 'idle') === 'idle')
const s11 = run(upto(e, 11), G.fanout)
check('一路回来了:汇合节点待汇合', s11.nodes.merge?.state === 'queued', s11.nodes.merge?.state)
check('待汇合时指向它的边不算活跃(它还没在跑)',
!activeEdgesOf(s11, G.fanout).some((id) => id.includes('merge')))
const s16 = run(upto(e, 16), G.fanout)
check('汇合开跑后三条入边都在流', activeEdgesOf(s16, G.fanout).filter((id) => id.endsWith('|merge|')).length === 3,
activeEdgesOf(s16, G.fanout).join(' '))
const done = run(e, G.fanout)
check('汇合节点只跑了一次', done.nodes.merge?.count === 1, `×${done.nodes.merge?.count}`)
})
await section('墙钟 / 执行 / 等人', async () => {
const e = fixtures.human
const t = run(e, G.human)
const ts = (type) => e.find((x) => x.type === type).ts * 1000
const wall = e[e.length - 1].ts * 1000 - e[0].ts * 1000
const wait = ts('run.resumed') - ts('run.interrupted')
const p = project(t, LATER)
check('墙钟 = 第一条到最后一条', Math.abs(p.elapsedMs - wall) < 1, `${ms(p.elapsedMs)} vs ${ms(wall)}`)
check('等人 = 中断到恢复', Math.abs(p.waitMs - wait) < 1, `${ms(p.waitMs)} vs ${ms(wait)}`)
check('执行 = 墙钟 − 等人', Math.abs(p.activeMs - (wall - wait)) < 1,
`${ms(p.activeMs)} vs ${ms(wall - wait)}`)
check('不用 run.finished.duration_ms 的那个 12ms', p.elapsedMs > 500)
const h = t.nodes.h
check('审批恢复后的重放不算第二次执行', h?.count === 1, `×${h?.count}`)
check('等人节点一行三段:跑 → 等 → 接着跑',
h?.segments.map((s) => s.kind).join(',') === 'run,wait,run' && h.segments[2].resumed === true,
h?.segments.map((s) => `${s.kind}:${s.status}`).join(','))
const waiting = run(upto(e, 6), G.human)
check('停在审批上:相位 waiting、记下等的是谁', waiting.phase === 'waiting' && waiting.waitingNodeId === 'h')
check('等待节点的入边没有光点', activeEdgesOf(waiting, G.human).length === 0,
activeEdgesOf(waiting, G.human).join(' '))
check('入边改记成停住的边(闸门)', heldEdgesOf(waiting, G.human).join(' ') === 'in|h|',
heldEdgesOf(waiting, G.human).join(' '))
const later = project(waiting, ts('run.interrupted') + 5000)
check('等的时候墙钟和等人一起涨,执行不涨',
later.waitMs >= 4999 && later.elapsedMs - later.waitMs < 50,
`墙钟 ${ms(later.elapsedMs)} · 等人 ${ms(later.waitMs)} · 执行 ${ms(later.activeMs)}`)
check('回放到等待中间:h 是 waiting',
project(t, ts('run.interrupted') + 1).nodes.h?.state === 'waiting')
})
await section('等待审批时来了一条质询(approval.inquiry)', async () => {
// 审批人在批准前就报告提问:服务端在运行停在审批上时追加一条 approval.inquiry(node_id 为空),运行本身没动。
// 它不能把相位从等待里带出来(标题变成「正在执行」、工具栏挂出必然 409 的「停止」),也不能把等待的节点、
// 等待的那段时长收掉;恢复之后照常接着走
const e = fixtures.human
const before = upto(e, 6)
const last = before[before.length - 1]
const inquiry = {
seq: last.seq + 1, type: 'approval.inquiry', node_id: null, ts: last.ts + 0.5,
data: { question: '本月销售额是否扣除了退款?', quote: null, report: 'write', unit: 'u3', approval_id: 'ap-1',
answer_text: '已扣除退款。', answer_doc: null, ok: true, limit: null, error: null,
inquiry_artifacts: ['a'.repeat(64)], queries: [{ alias: 'Q1', artifact: 'a'.repeat(64), sql: 'SELECT 1', row_count: 1,
truncated: false, source: 'shop' }], model: 'verify-mini', usage: { calls: 2 } },
}
const asked = [...before, inquiry]
const t = run(asked, G.human)
check('相位仍是 waiting,等的还是 h', t.phase === 'waiting' && t.waitingNodeId === 'h' && t.nodes.h?.state === 'waiting',
`${t.phase} · ${t.waitingNodeId} · ${t.nodes.h?.state}`)
check('decodePhase 同样是 waiting', decode.decodePhase(asked) === 'waiting', decode.decodePhase(asked))
check('等待那一段没有被收掉', t.nodes.h?.segments.map((s) => `${s.kind}:${s.end == null ? 'open' : 'closed'}`).join(',') === 'run:closed,wait:open',
t.nodes.h?.segments.map((s) => `${s.kind}:${s.status}`).join(','))
check('等待节点的入边仍是闸门、没有光点', activeEdgesOf(t, G.human).length === 0 && heldEdgesOf(t, G.human).join(' ') === 'in|h|',
`流动 ${activeEdgesOf(t, G.human).join(' ')} · 闸门 ${heldEdgesOf(t, G.human).join(' ')}`)
const later = project(t, inquiry.ts * 1000 + 5000)
check('质询不算执行:之后墙钟和等待一起涨,执行时长不涨',
later.waitMs >= 5000 && later.elapsedMs - later.waitMs < 50,
`墙钟 ${ms(later.elapsedMs)} · 等待 ${ms(later.waitMs)} · 执行 ${ms(later.activeMs)}`)
const steps = decode.decodeRun(asked)
check('右栏:审批那一行仍在等待', decode.isAwaitingHuman(steps)
&& flat(steps).some((s) => s.kind === 'human' && s.nodeId === 'h' && s.status === 'waiting'),
flat(steps).map((s) => `${s.kind}:${s.status}`).join(','))
check('右栏:质询单独成一行,挂在顶层,不当成未识别的事件', steps.some((s) => s.code === 'approval_inquiry' && s.status === 'done')
&& !flat(steps).some((s) => s.title === '未识别的事件'), steps.map((s) => s.title).join(' | '))
// 恢复之后:后面的事件序号往后挪一位,接在质询后面
const resumed = [...asked, ...e.filter((x) => x.seq > 6).map((x) => ({ ...x, seq: x.seq + 1 }))]
const done = run(resumed, G.human)
check('恢复后照常跑完:h 只执行一次,一行三段(跑 → 等 → 接着跑)', done.phase === 'succeeded' && done.nodes.h?.count === 1
&& done.nodes.h.segments.map((s) => s.kind).join(',') === 'run,wait,run',
`${done.phase} · ×${done.nodes.h?.count} · ${done.nodes.h?.segments.map((s) => s.kind).join(',')}`)
const p = project(done, LATER)
const wait = (e.find((x) => x.type === 'run.resumed').ts - e.find((x) => x.type === 'run.interrupted').ts) * 1000
check('等待时长照旧是中断到恢复(质询那一刻不切断等待)', Math.abs(p.waitMs - wait) < 1, `${ms(p.waitMs)} vs ${ms(wait)}`)
let same = true
let where = ''
let tt = emptyTrace()
for (let i = 0; i < resumed.length; i++) {
tt = foldEvent(tt, resumed[i])
const expect = decode.decodePhase(resumed.slice(0, i + 1))
if (tt.phase !== expect) { same = false; where = `#${resumed[i].seq}: ${tt.phase} vs ${expect}` }
}
check('每个前缀上 trace.phase === decodePhase(含质询那一条)', same, where)
})
await section('循环轮次', async () => {
const e = fixtures.think
const t = run(e, G.think)
const each = t.nodes.each
check('走 done 出口不再 +1:3 项跑完是第 3 轮', each?.iteration === 3, `第 ${each?.iteration} 轮`)
check('总项数取 edge.taken.total', each?.iterTotal === 3, `${each?.iterTotal}`)
check('循环走完了', each?.loopDone === true && each.state === 'done')
check('循环体一行三段,标号 1 2 3',
t.nodes.handle?.segments.filter((s) => s.kind === 'run').map((s) => s.iteration).join(',') === '1,2,3',
t.nodes.handle?.segments.map((s) => s.iteration).join(','))
const mid = run(upto(e, 47), G.think)
check('两轮之间循环节点是"容器进行中",不是打勾',
mid.nodes.each?.state === 'running' && mid.nodes.each.looping === true, mid.nodes.each?.state)
check('容器进行中时指向它的边不流动', !activeEdgesOf(mid, G.think).some((id) => id.includes('|each|')))
check('第二轮时轮次是 2', mid.nodes.each?.iteration === 2)
check('用量从 llm.end 累加、终态以后端总数校正',
t.tokensIn === 177 && t.tokensOut === 922, `${t.tokensIn} / ${t.tokensOut}`)
})
await section('循环回边', async () => {
const topo = topology(G.loop_approve)
check('认出改写连回审批那条是回边', topo.back.size === 1 && topo.back.has('rewrite|review|'),
[...topo.back].join(' '))
check('去掉回边后拓扑序成立', topo.order.indexOf('review') < topo.order.indexOf('rewrite')
&& topo.order.indexOf('draft') < topo.order.indexOf('review'), topo.order.join(' → '))
check('层级:审批在第 2 层', topo.rank.review === 2, JSON.stringify(topo.rank))
const t = run(fixtures.loop_approve, G.loop_approve)
check('审了三轮就是三次,重放不算', t.nodes.review?.count === 3, `×${t.nodes.review?.count}`)
check('改写两次', t.nodes.rewrite?.count === 2)
const walked = walkedEdges(t, G.loop_approve)
check('走过的边包含回边和两条出口', walked.size === 5, [...walked].join(' '))
})
await section('失败后下游被阻断', async () => {
const t = run(fixtures.failed, G.failed)
check('失败的是循环节点', t.failedNodeId === 'loop_sum' && t.nodes.loop_sum.state === 'failed')
check('循环体和 done 出口之后都是 blocked',
t.nodes.add_one?.state === 'blocked' && t.nodes.result?.state === 'blocked',
`${t.nodes.add_one?.state} / ${t.nodes.result?.state}`)
check('跑过的上游不受影响', t.nodes.init_state?.state === 'done')
check('相位是失败,没有活跃的边', t.phase === 'failed' && activeEdgesOf(t, G.failed).length === 0)
})
await section('on_error=continue', async () => {
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1000 + seq / 10, data })
const base = [
ev(1, 'run.started', null, { nodes: 3 }),
ev(2, 'node.started', 'a'), ev(3, 'node.finished', 'a', { duration_ms: 10 }),
ev(4, 'node.started', 'b'), ev(5, 'node.failed', 'b', { error: '接口超时', duration_ms: 20 }),
]
const tolerant = g(['a', { id: 'b', data: { config: { on_error: 'continue' } } }, 'c'], [['a', 'b'], ['b', 'c']])
const strict = g(['a', 'b', 'c'], [['a', 'b'], ['b', 'c']])
const mid = run(base, tolerant)
check('容错失败后下游照常排队', mid.nodes.c?.state === 'queued', mid.nodes.c?.state)
const ok = run([...base, ev(6, 'node.started', 'c'), ev(7, 'node.finished', 'c'),
ev(8, 'run.finished', null, { usage: {} })], tolerant)
check('跑完:失败的那个仍是 failed,下游 done,不传播阻断',
ok.phase === 'succeeded' && ok.nodes.b.state === 'failed' && ok.nodes.c.state === 'done')
const bad = run([...base, ev(6, 'run.failed', null, { error: '接口超时' })], strict)
check('不容错的失败:下游 blocked', bad.nodes.c?.state === 'blocked', bad.nodes.c?.state)
check('没有 on_error=continue 时失败节点不放行', mid.nodes.c && run(base, strict).nodes.c?.state !== 'queued')
})
await section('分支落空', async () => {
const t = run(fixtures.human, G.human)
check('审批走了通过:驳回那条下游是 unreached', t.nodes.rejected_note?.state === 'unreached',
t.nodes.rejected_note?.state)
check('走过的边只有入口和通过那条',
[...walkedEdges(t, G.human)].sort().join(' ') === 'h|out|approved in|h|',
[...walkedEdges(t, G.human)].join(' '))
const loop = run(fixtures.think, G.think)
check('循环跑完所有节点都走到了', !Object.values(loop.nodes).some((n) => n.state === 'unreached'))
})
await section('终态清扫', async () => {
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 2000 + seq, data })
const team = [
ev(1, 'run.started', null, { nodes: 3 }),
ev(2, 'node.started', 'in'), ev(3, 'node.finished', 'in'),
ev(4, 'node.started', 'team'),
ev(5, 'log', 'team', { level: 'info', round: 0, agents: ['甲', '乙'], done: false }),
ev(6, 'agent.step.start', 'team', { agent: '甲', round: 0 }),
ev(7, 'agent.step.start', 'team', { agent: '乙', round: 0 }),
]
const gr = g([{ id: 'in', type: 'input' }, { id: 'team', type: 'supervisor' }, 'out'], [['in', 'team'], ['team', 'out']])
const live = run(team, gr)
check('两个成员在跑时并行度是 2', live.parallelSeries.at(-1)[1] === 2)
check('调度段是推算的', live.nodes.team.segments.some((s) => s.kind === 'dispatch' && s.estimated))
const stop = run([...team, ev(8, 'run.cancelled', null)], gr)
check('取消:节点收成 cancelled', stop.phase === 'cancelled' && stop.nodes.team.state === 'cancelled',
stop.nodes.team.state)
check('取消:成员段一个不剩地收掉', !stop.nodes.team.segments.some((s) => s.end == null))
check('取消:没有活跃的边', activeEdgesOf(stop, gr).length === 0)
check('取消:没走到的是 unreached,不是 blocked', stop.nodes.out?.state === 'unreached', stop.nodes.out?.state)
check('取消后并行度归零', stop.parallelSeries.at(-1)[1] === 0)
const down = run([...team, ev(8, 'log', null, { level: 'warn', code: 'server_shutdown', message: '服务关停' })], gr)
check('服务重启:挂起,不是失败', down.phase === 'suspended' && down.nodes.team.state === 'suspended')
check('挂起不推"没走到"——还能接着跑', (down.nodes.out?.state ?? 'idle') === 'idle')
const back = run([...team, ev(8, 'log', null, { level: 'warn', code: 'server_shutdown' }),
ev(9, 'run.resumed', null, {}), ev(10, 'run.started', null, { resumed: true }),
ev(11, 'node.started', 'team')], gr)
check('挂起后接着跑:重新开跑、算第二次执行', back.phase === 'running'
&& back.nodes.team.state === 'running' && back.nodes.team.count === 2)
check('接着跑之后结束时刻清掉', back.endedAt == null)
const lost = run([...team, { seq: 0, type: 'stream.end', node_id: null, ts: 2008, data: { status: 'interrupted', pending: false } }], gr)
check('对账:interrupted 且没有待审批 = 挂起', lost.phase === 'suspended' && lost.nodes.team.state === 'suspended')
const human = fixtures.human
const held = run([...upto(human, 6), { seq: 0, type: 'stream.end', node_id: null, ts: 0,
data: { status: 'interrupted', pending: true } }], G.human)
check('对账:有待审批就还是在等人', held.phase === 'waiting' && held.nodes.h.state === 'waiting')
const gone = run([...upto(human, 6), ev(99, 'run.cancelled', null)], G.human)
check('等人时取消:等待节点收成 cancelled、等人段闭合',
gone.nodes.h.state === 'cancelled' && !gone.nodes.h.segments.some((s) => s.end == null) && gone.waitingSince == null)
const fail = run([...team, ev(8, 'run.failed', null, { error: '超时' })], gr)
check('失败但没有节点报错(超时):在跑的收成 cancelled', fail.nodes.team.state === 'cancelled'
&& fail.failedNodeId == null, fail.nodes.team.state)
})
await section('老数据没有 ts', async () => {
for (const name of ['loop_approve', 'supervisor']) {
let t
try { t = run(fixtures[name]) } catch (e) { check(`${name} 折叠不抛错`, false, e.message); continue }
check(`${name} 标成无时间戳`, t.timed === false)
const p = project(t, Date.now())
check(`${name} 投影出结局`, p.phase === 'succeeded', p.phase)
check(`${name} 时长只来自 duration_ms、是有限值`, Number.isFinite(p.elapsedMs) && p.elapsedMs > 0
&& p.waitMs === 0, `${ms(p.elapsedMs)}`)
check(`${name} 段按顺序排、没有 NaN`, Object.values(t.nodes).every((n) => n.segments.every((s) =>
Number.isFinite(s.start) && (s.end == null || Number.isFinite(s.end)))))
}
const sup = run(fixtures.supervisor)
const researcher = sup.nodes.research_supervisor.segments.find((s) => s.kind === 'member')
check('成员段按 duration 定宽', researcher && researcher.end - researcher.start === 6660,
researcher && `${researcher.end - researcher.start}`)
check('等人节点在无 ts 时也只算一次', sup.nodes.human_review?.count === 1)
})
await section('相位和右栏同一个来源', async () => {
let same = true
let where = ''
for (const [name, events] of Object.entries(fixtures)) {
let t = emptyTrace()
for (let i = 0; i < events.length; i++) {
t = foldEvent(t, events[i])
const expect = decode.decodePhase(events.slice(0, i + 1))
if (t.phase !== expect) { same = false; where = `${name}#${events[i].seq}: ${t.phase} vs ${expect}` }
}
}
check('每个前缀上 trace.phase === decodePhase', same, where)
const upToWait = upto(fixtures.human, 6)
check('停在审批上的相位是 waiting', decode.decodePhase(upToWait) === 'waiting')
check('服务强杀后对账:interrupted 且没有待审批是 suspended',
decode.decodePhase(upto(fixtures.db, 7), { status: 'interrupted', pending: false }) === 'suspended')
// store 写回 run.status 的是 runStatusOf(phase),挂起写的是 suspended。消费方照
// decodePhase(events, {status: run.status}) 传进来,得认得这个值,不然强杀的运行一直转圈
const cut = upto(fixtures.db, 7)
check('run.status 写回的 suspended 也认得', decode.decodePhase(cut, { status: 'suspended' }) === 'suspended',
decode.decodePhase(cut, { status: 'suspended' }))
check('decodeRun 按 suspended 收尾:没有还在转的行',
!flat(decode.decodeRun(cut, { status: 'suspended' })).some((s) => s.status === 'running'),
flat(decode.decodeRun(cut, { status: 'suspended' })).map((s) => `${s.kind}:${s.status}`).join(','))
// 人工审批:右栏在 human.requested 就把宿主节点标成等你,画布得同一时刻变,
// 不能等到 run.interrupted(并行时它要等整个超步跑完才来)。恢复后的重放不算新一轮
let same2 = true
let where2 = ''
for (const [name, events] of Object.entries(fixtures)) {
for (let i = 0; i < events.length; i++) {
if (!['human.requested', 'human.resolved', 'run.interrupted', 'run.resumed'].includes(events[i].type)) continue
const cutAt = events.slice(0, i + 1)
const t = run(cutAt)
const onCanvas = Object.values(t.nodes).filter((n) => n.state === 'waiting').map((n) => n.id).sort().join()
const inPanel = decode.decodeRun(cutAt).filter((s) => s.kind === 'node' && s.status === 'waiting')
.map((s) => s.nodeId).sort().join()
if (events[i].type !== 'run.resumed' && onCanvas !== inPanel) {
same2 = false; where2 = `${name}#${events[i].seq}: 画布 [${onCanvas}] 右栏 [${inPanel}]`
}
}
}
check('审批事件之后画布和右栏认定的"在等人"是同一批节点', same2, where2)
})
await section('并行时 human.requested 先到', async () => {
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 6000 + seq / 10, data })
const gr = g([{ id: 'in', type: 'input' }, { id: 'h', type: 'human' }, 'x', 'ok'],
[['in', 'h'], ['in', 'x'], ['h', 'ok', 'approved']])
const asked = [
ev(1, 'run.started', null, { nodes: 4 }),
ev(2, 'node.started', 'in'), ev(3, 'node.finished', 'in'),
ev(4, 'node.started', 'h'), ev(5, 'node.started', 'x'),
ev(6, 'human.requested', 'h', { kind: 'human_node', node_id: 'h', mode: 'approve', title: '确认' }),
]
const t = run(asked, gr)
check('另一路还在跑:审批节点已经是 waiting', t.nodes.h?.state === 'waiting', t.nodes.h?.state)
check('相位还是 running(整条流水线没停)', t.phase === 'running' && decode.decodePhase(asked) === 'running', t.phase)
check('右栏同一时刻也是等你', decode.decodeRun(asked).find((s) => s.nodeId === 'h')?.status === 'waiting')
check('指向它的边不流动、改记成闸门',
!activeEdgesOf(t, gr).includes('in|h|') && heldEdgesOf(t, gr).includes('in|h|') && activeEdgesOf(t, gr).includes('in|x|'),
`流动 ${activeEdgesOf(t, gr).join(' ')} · 闸门 ${heldEdgesOf(t, gr).join(' ')}`)
check('它不算并行里在跑的那几个', t.parallelSeries.at(-1)[1] === 1, `${t.parallelSeries.at(-1)[1]}`)
const paused = run([...asked, ev(7, 'node.finished', 'x'),
ev(8, 'run.interrupted', 'h', { payload: { node_id: 'h' } })], gr)
check('run.interrupted 来了不再开第二段等待',
paused.nodes.h.segments.map((s) => s.kind).join(',') === 'run,wait', paused.nodes.h.segments.map((s) => s.kind).join(','))
const resumed = run([...asked, ev(7, 'node.finished', 'x'), ev(8, 'run.interrupted', 'h', { payload: { node_id: 'h' } }),
ev(9, 'run.resumed', null, {}), ev(10, 'run.started', null, { resumed: true }),
ev(11, 'node.started', 'h'),
ev(12, 'human.requested', 'h', { kind: 'human_node', node_id: 'h', mode: 'approve', title: '确认' })], gr)
check('恢复后重放的 human.requested 不把它打回等待', resumed.nodes.h.state === 'running' && resumed.nodes.h.count === 1,
`${resumed.nodes.h.state} ×${resumed.nodes.h.count}`)
const again = run(upto(fixtures.loop_approve, 75))
check('驳回后真的第二轮审批:human.requested 一到就是等待', again.nodes.review?.state === 'waiting', again.nodes.review?.state)
})
await section('画布的节点形状(type 是渲染器 card,类型在 data.nodeType)', async () => {
// store 传进 derive 的是 FlowNode。以前只读 type,拿到的全是 'card',分支、循环、
// 审批的出口路由一概不认,没选的出口后面全显示成排队
const fn = (id, nodeType, config = {}) => ({ id, type: 'card', data: { nodeType, label: id, config } })
const edge = (id, source, target, sourceHandle = null) => ({ id, source, target, sourceHandle })
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 7000 + seq / 10, data })
const flow = {
nodes: [fn('in', 'input'), fn('br', 'branch'), fn('a', 'llm'), fn('b', 'llm'), fn('out', 'output')],
// 分支命中 yes → a;default → b;a 之后也连到 b(b 另有来路)
edges: [edge('e1', 'in', 'br'), edge('e2', 'br', 'a', 'yes'), edge('e3', 'br', 'b', 'default'),
edge('e4', 'a', 'b'), edge('e5', 'b', 'out')],
}
const spec = { nodes: flow.nodes.map((n) => ({ id: n.id, type: n.data.nodeType, data: { config: n.data.config } })),
edges: flow.edges }
const head = [
ev(1, 'run.started', null, { nodes: 5 }), ev(2, 'node.started', 'in'), ev(3, 'node.finished', 'in'),
ev(4, 'node.started', 'br'), ev(5, 'edge.taken', 'br', { branch: 'yes' }), ev(6, 'node.finished', 'br'),
]
const mid = run(head, flow)
check('分支没选的出口:目标不排队', (mid.nodes.b?.state ?? 'idle') === 'idle', mid.nodes.b?.state)
check('选中的出口:目标排队', mid.nodes.a?.state === 'queued', mid.nodes.a?.state)
check('FlowNode 和 GraphSpec 推出同一个结果',
JSON.stringify(derive.deriveStates(mid, flow)) === JSON.stringify(derive.deriveStates(mid, spec)),
`${JSON.stringify(derive.deriveStates(mid, flow))} vs ${JSON.stringify(derive.deriveStates(mid, spec))}`)
// 画布上随手拖出来、还没连线的节点也没有入边:入口得按类型认,不能退到"没有入边的"
const loose = { nodes: [...flow.nodes, fn('draft', 'llm')], edges: flow.edges }
check('入口按 data.nodeType 认', topology(loose).entries.join() === 'in', topology(loose).entries.join())
const all = run([...head, ev(7, 'node.started', 'a'), ev(8, 'node.finished', 'a'),
ev(9, 'node.started', 'b'), ev(10, 'node.finished', 'b'),
ev(11, 'node.started', 'out'), ev(12, 'node.finished', 'out'),
ev(13, 'run.finished', null, { usage: {} })], flow)
const walked = walkedEdges(all, flow)
check('b 是从 a 那路到的:分支没选的出口不算走过', !walked.has('e3') && walked.has('e2') && walked.has('e4'),
[...walked].join(' '))
const loopG = {
nodes: [fn('s', 'input'), fn('lp', 'loop', { max_iterations: 5 }), fn('body', 'llm'), fn('after', 'output')],
edges: [edge('l1', 's', 'lp'), edge('l2', 'lp', 'body', 'body'), edge('l3', 'body', 'lp'),
edge('l4', 'lp', 'after', 'done')],
}
const lp = run([ev(1, 'run.started', null, { nodes: 4 }), ev(2, 'node.started', 's'), ev(3, 'node.finished', 's'),
ev(4, 'node.started', 'lp'), ev(5, 'edge.taken', 'lp', { branch: 'body', iteration: 0, total: 3 }),
ev(6, 'node.finished', 'lp'), ev(7, 'node.started', 'body')], loopG)
check('循环还在 body 里转:done 出口的目标不排队', (lp.nodes.after?.state ?? 'idle') === 'idle', lp.nodes.after?.state)
const hg = {
nodes: [fn('i', 'input'), fn('h', 'human'), fn('ok', 'output'), fn('no', 'output')],
edges: [edge('h1', 'i', 'h'), edge('h2', 'h', 'ok', 'approved'), edge('h3', 'h', 'no', 'rejected')],
}
const hv = run([ev(1, 'run.started', null, { nodes: 4 }), ev(2, 'node.started', 'i'), ev(3, 'node.finished', 'i'),
ev(4, 'node.started', 'h'),
ev(5, 'node.finished', 'h', { preview: { approved: true, __decision__: 'approved' } })], hg)
check('审批通过:驳回出口的目标不排队、通过出口的排队',
(hv.nodes.no?.state ?? 'idle') === 'idle' && hv.nodes.ok?.state === 'queued',
`${hv.nodes.no?.state} / ${hv.nodes.ok?.state}`)
})
await section('失败后接着跑', async () => {
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 8000 + seq / 10, data })
const failed = [
ev(1, 'run.started', null, { nodes: 2 }),
ev(2, 'node.started', 'a'), ev(3, 'node.failed', 'a', { error: '模型 id 写错了' }),
ev(4, 'run.failed', null, { error: '模型 id 写错了' }),
]
const gr = g(['a', 'b'], [['a', 'b']])
check('失败时记下是谁', run(failed, gr).failedNodeId === 'a')
const cont = [...failed, ev(5, 'run.resumed', null, {}), ev(6, 'run.started', null, { resumed: true })]
check('接着跑一开始,上一回合的失败节点就清掉', run(cont, gr).failedNodeId == null, run(cont, gr).failedNodeId)
const ok = run([...cont, ev(7, 'node.started', 'a'), ev(8, 'node.finished', 'a'),
ev(9, 'node.started', 'b'), ev(10, 'node.finished', 'b'), ev(11, 'run.finished', null, { usage: {} })], gr)
check('接着跑成功了:不再挂着"失败于 a"', ok.phase === 'succeeded' && ok.failedNodeId == null, `${ok.phase} / ${ok.failedNodeId}`)
})
await section('右栏收尾(decodeRun)', async () => {
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 9000 + seq, data })
const live = [
ev(1, 'run.started', null, { nodes: 4 }),
ev(2, 'node.started', 'in', { label: '输入' }), ev(3, 'node.finished', 'in'),
ev(4, 'node.started', 'q', { label: '查询' }),
ev(5, 'tool.start', 'q', { tool: 'db_query__shop', call_id: 'c1', args: { sql: 'select 1' } }),
ev(6, 'tool.start', 'q', { tool: 'web_search', call_id: 'c2', args: { q: 'x' } }),
ev(7, 'node.started', 'team', { label: '协作' }),
ev(8, 'agent.step.start', 'team', { agent: '甲', round: 0, instruction: '查一下' }),
]
const rows = (steps) => flat(steps).map((s) => `${s.kind}:${s.status}`).join(',')
const before = decode.decodeRun(live)
check('进行中:节点、工具、成员、开始运行都在转',
['node', 'query', 'tool', 'note', 'lifecycle'].every((k) => flat(before).some((s) => s.kind === k && s.status === 'running')),
rows(before))
const stop = decode.decodeRun([...live, ev(9, 'run.cancelled', null)])
check('取消:一行都不再转', !flat(stop).some((s) => s.status === 'running'), rows(stop))
check('取消:不画成失败', !flat(stop).some((s) => s.status === 'failed'), rows(stop))
check('取消:节点下面的查询、工具行也收成已取消',
['query', 'tool'].every((k) => flat(stop).find((s) => s.kind === k)?.status === 'cancelled')
&& flat(stop).find((s) => s.nodeId === 'q' && s.kind === 'node')?.status === 'cancelled')
check('取消:开始运行那行收成已取消', stop.find((s) => s.kind === 'lifecycle')?.status === 'cancelled')
const member = stop.find((s) => s.nodeId === 'team')?.team?.rounds[0]?.members[0]
check('取消:协作成员一起收', member?.status === 'cancelled', member?.status)
const down = decode.decodeRun([...live, ev(9, 'log', null, { level: 'warn', code: 'server_shutdown', message: '服务关停' })])
check('服务重启:转着的收成挂起', !flat(down).some((s) => s.status === 'running')
&& flat(down).filter((s) => ['node', 'query', 'tool'].includes(s.kind) && s.nodeId !== 'in').every((s) => s.status === 'suspended'),
rows(down))
const failedRun = decode.decodeRun([...live, ev(9, 'node.failed', 'q', { error: '查询超时' }),
ev(10, 'run.failed', null, { error: '查询超时' })])
check('失败:只有失败的那个是红的,连带停下的是已取消',
flat(failedRun).find((s) => s.nodeId === 'team' && s.kind === 'node')?.status === 'cancelled'
&& flat(failedRun).find((s) => s.nodeId === 'q' && s.kind === 'node')?.status === 'failed'
&& !flat(failedRun).some((s) => s.status === 'running'), rows(failedRun))
const waiting = decode.decodeRun(upto(fixtures.human, 6))
check('停在审批上:开始运行收成 done,不再转圈',
waiting.find((s) => s.kind === 'lifecycle')?.status === 'done', rows(waiting))
const killed = decode.decodeRun(live, { status: 'interrupted', pending: false })
check('强杀后对账(interrupted、无待审批):没有还在转的行',
!flat(killed).some((s) => s.status === 'running') && flat(killed).some((s) => s.status === 'suspended'), rows(killed))
const start = (agent, seq) => ev(seq, 'agent.step.start', 'team', { agent, round: 0, instruction: '' })
const end = (agent, seq, ms) => ev(seq, 'agent.step.end', 'team', { agent, round: 0, duration_ms: ms })
const fold = (evs) => evs.reduce((t, e) => decode.reduceTeam(t ?? undefined, e) ?? t, undefined)
check('一轮刚开始:省下 0,不报还没发生的收益', fold([start('甲', 1), start('乙', 2)]).savedMs === 0)
check('只回来一个:还是 0', fold([start('甲', 1), start('乙', 2), end('甲', 3, 100)]).savedMs === 0)
check('整轮都交回了:省下 = 各人之和 − 最慢的',
fold([start('甲', 1), start('乙', 2), end('甲', 3, 100), end('乙', 4, 300)]).savedMs === 100)
})
await section('金额估不出来的调用', async () => {
// 模型不在价格目录中:token 记到了,金额只能是 0。运行页写 $0 像是没花钱,改写「金额无法估算」
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 5500 + seq, data })
const head = [
ev(1, 'run.started', null, { nodes: 1 }),
ev(2, 'node.started', 'agent'),
ev(3, 'llm.end', 'agent', { model: 'vendor-unlisted', input_tokens: 900, output_tokens: 90, cost_usd: 0, unpriced: true }),
ev(4, 'llm.end', 'agent', { model: 'vendor-unlisted', input_tokens: 700, output_tokens: 70, cost_usd: 0, unpriced: true }),
]
const gr = g(['agent'], [])
const live = run(head, gr)
check('运行中:llm.end 标了 unpriced 的逐次数进来', live.unpricedCalls === 2 && live.costUsd === 0, `${live.unpricedCalls}`)
const done = run([...head, ev(5, 'node.finished', 'agent', { duration_ms: 10 }),
ev(6, 'run.finished', null, { usage: { input_tokens: 1600, output_tokens: 160, cost_usd: 0, unpriced_calls: 3 } })], gr)
check('终态以后端总数为准(老后端少发的也算上)', done.unpricedCalls === 3, `${done.unpricedCalls}`)
const plain = run([ev(1, 'run.started', null, { nodes: 1 }), ev(2, 'node.started', 'agent'),
ev(3, 'llm.end', 'agent', { input_tokens: 10, output_tokens: 1, cost_usd: 0.001 })], gr)
check('目录里有价的调用不算', plain.unpricedCalls === 0)
const { runCostText, COST_TEXT } = await import(await load('/src/lib/terms.ts'))
check('一分都没估出来:写「金额无法估算」,不写 $0', runCostText(0, 3) === COST_TEXT.unpriced, runCostText(0, 3))
check('……没拿到金额时也一样', runCostText(null, 1) === COST_TEXT.unpriced)
check('估出了一部分:写「≥」', runCostText(0.012, 2) === '≥ $0.012', runCostText(0.012, 2))
check('全都有价:照常写', runCostText(0.012, 0) === '$0.012' && runCostText(0, 0) === '$0', `${runCostText(0.012, 0)} / ${runCostText(0, 0)}`)
check('运行中只算已上报的:写「≥」', runCostText(0.012, 0, true) === '≥ $0.012')
check('没有金额也没有估不出的:交给调用方写占位', runCostText(undefined, 0) === null)
check('悬停说明写清几次调用、为什么', COST_TEXT.unpricedTitle(3).includes('3 次调用') && COST_TEXT.unpricedTitle(3).includes('价格目录'))
})
await section('回放时的用量和工具数(逐刻度)', async () => {
// 回放到半路,卡片和 HUD 要写「那一刻」的 tokens 和工具数,而不是拿终值冒充
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 5000 + seq, data })
const events = [
ev(1, 'run.started', null, { nodes: 2 }),
ev(2, 'node.started', 'agent'),
ev(3, 'llm.end', 'agent', { input_tokens: 100, output_tokens: 20, cost_usd: 0.001 }),
ev(4, 'tool.start', 'agent', { tool: 'db_query__shop' }),
ev(5, 'tool.end', 'agent', { tool: 'db_query__shop' }),
ev(6, 'tool.start', 'agent', { tool: 'db_query__shop' }),
ev(7, 'tool.end', 'agent', { tool: 'db_query__shop' }),
ev(8, 'llm.end', 'agent', { input_tokens: 300, output_tokens: 80, cost_usd: 0.004 }),
ev(9, 'node.finished', 'agent', { duration_ms: 7000 }),
ev(10, 'node.started', 'out'), ev(11, 'node.finished', 'out'),
ev(12, 'run.finished', null, { usage: { input_tokens: 400, output_tokens: 100, cost_usd: 0.005 } }),
]
const gr = g(['agent', 'out'], [['agent', 'out']])
const t = run(events, gr)
const at = (seq) => (5000 + seq) * 1000
const p4 = project(t, at(4) + 1)
check('第一次调工具那一刻:1 次工具、1 个在跑、tokens 只有第一轮',
p4.nodes.agent.tools === 1 && p4.nodes.agent.toolsRunning === 1 && p4.nodes.agent.tokensIn === 100
&& p4.nodes.agent.tokensOut === 20,
JSON.stringify(p4.nodes.agent))
const p6 = project(t, at(6) + 0.5)
check('第二次调工具时:2 次,第一次已经回来', p6.nodes.agent.tools === 2 && p6.nodes.agent.toolsRunning === 1,
JSON.stringify(p6.nodes.agent))
const p9 = project(t, at(9) + 1)
check('节点跑完:用量是这个节点的最终值、没有在跑的工具', p9.nodes.agent.tokensIn === 400
&& p9.nodes.agent.tokensOut === 100 && p9.nodes.agent.tools === 2 && p9.nodes.agent.toolsRunning === 0,
JSON.stringify(p9.nodes.agent))
check('回放到开始之前:0,不是终值', project(t, at(1)).nodes.agent.tools === 0
&& project(t, at(1)).nodes.agent.tokensIn === 0)
check('整次运行的用量也按刻度走', p4.tokensIn === 100 && p4.tokensOut === 20 && p9.tokensIn === 400,
`${p4.tokensIn}/${p4.tokensOut} → ${p9.tokensIn}`)
const live = project(t, trace.lastEventAt(t))
check('此刻(实时)读节点的当前值', live.nodes.agent.tokensIn === 400 && live.nodes.agent.tools === 2
&& live.tokensIn === 400)
// 老后端:agent 的调用不发 llm.end,run.finished 的总数比刻度加起来多——回放时那一刻的
// 整次用量不可信,给 undefined,不拿终值冒充
const old = run([...events.slice(0, 11),
ev(12, 'run.finished', null, { usage: { input_tokens: 9000, output_tokens: 900, cost_usd: 0.05 } })], gr)
const op = project(old, at(4) + 1)
check('刻度对不上后端总数:回放时整次用量写不出', op.tokensIn === undefined && op.tokensOut === undefined,
`${op.tokensIn}/${op.tokensOut}`)
check('……实时时照样是后端的总数', project(old, trace.lastEventAt(old)).tokensIn === 9000)
check('没有 tool / llm 事件的节点刻度为空、读数是 0',
t.nodes.out.marks === undefined && project(t, at(11)).nodes.out.tools === 0)
})
await section('最后一条事件的时刻(lastEventAt)', async () => {
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 6000 + seq, data })
const gr = g([{ id: 'in', type: 'input' }, 'boom', 'after'], [['in', 'boom'], ['boom', 'after']])
const t = run([
ev(1, 'run.started', null, { nodes: 3 }),
ev(2, 'node.started', 'in'), ev(3, 'node.finished', 'in'),
ev(4, 'node.started', 'boom'), ev(5, 'node.failed', 'boom', { error: 'x' }),
ev(6, 'run.failed', null, { error: 'x', timing: { wall_ms: 5000, active_ms: 4000, wait_ms: 0 } }),
], gr)
check('lastEventAt 是最后一条事件的 ts(毫秒)', trace.lastEventAt(t) === 6006 * 1000, `${trace.lastEventAt(t)}`)
const settled = project(t, trace.lastEventAt(t))
check('投影到这一刻 = 此刻:推导出的阻断还在', settled.nodes.after?.state === 'blocked', settled.nodes.after?.state)
check('……后端给的权威时长也在', settled.elapsedMs === 5000 && settled.activeMs === 4000,
`${settled.elapsedMs}/${settled.activeMs}`)
check('早一毫秒就是按段重建的回放(阻断不在)', project(t, trace.lastEventAt(t) - 1).nodes.after?.state !== 'blocked')
check('空航迹的 lastEventAt 是 0', trace.lastEventAt(emptyTrace()) === 0)
})
await section('性能', async () => {
// 256 段的大循环(真实运行里见过):一次折叠 + 投影都要快,拖游标才不卡
const ev = []
let seq = 0
const push = (type, node_id, data = {}) => ev.push({ seq: ++seq, type, node_id, ts: 3000 + seq / 100, data })
push('run.started', null, { nodes: 4 })
for (let i = 0; i < 128; i++) {
push('node.started', 'loop'); push('edge.taken', 'loop', { branch: 'body', iteration: i, total: 128 })
push('node.finished', 'loop', { duration_ms: 1 })
push('node.started', 'body'); push('llm.end', 'body', { input_tokens: 1, output_tokens: 2 })
push('node.finished', 'body', { duration_ms: 5 })
}
push('node.started', 'loop'); push('edge.taken', 'loop', { branch: 'done', iteration: 128, total: 128 })
push('node.finished', 'loop'); push('run.finished', null, { usage: {} })
const gr = g([{ id: 'loop', type: 'loop' }, 'body', 'after'], [['loop', 'body', 'body'], ['body', 'loop'], ['loop', 'after', 'done']])
const t0 = performance.now()
const t = run(ev, gr)
const foldMs = performance.now() - t0
const t1 = performance.now()
for (let i = 0; i < 60; i++) project(t, 3000_000 + i * 50)
const projMs = (performance.now() - t1) / 60
check('折叠 ~770 条事件(含 256 段)', foldMs < 100, `${foldMs.toFixed(1)}ms`)
check('一次投影 < 5ms', projMs < 5, `${projMs.toFixed(2)}ms`)
check('循环轮次对得上', t.nodes.loop.iteration === 128 && t.nodes.body.count === 128)
})
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 航迹全部通过')
process.exit(failed ? 1 : 0)