Files
AgentLab/scripts/check-all.mjs
T
ThearchyHelios fcdcc30d4e 可点击证据第二期:取数链路打通、问数据的答案逐段可点;发布前自动修复
一、可点击证据第二期

取数链路
- 智能体、工具节点、代码节点、知识检索取数时都记进证据台账;报告目录按台账编出 Q1… / K1…
- 报告可以引用快照里的一格([[v:Q1.r0.gmv]]),也可以由系统从快照生成整张表([[table:Q1 cols=… rows=0-4]]),
  表里每一格都是可点的片段;取值一律以快照为准,取回快照时复验哈希
- 口径卡表达式新增 cell(x, row, column),lineage 精确到单元格
- 智能体开 cite_fields(配合 output_schema)后,循环结束做一次结构化抽取,每个字段标出取自哪次查询的哪一格,
  再到快照里核对:一致记 verified;不一致以快照为准并告警;查不到的记为空值,永远不兜底成 0
- 代码节点新增 evidence_role(source / compute)和 code_sha;沙箱算出来的数不能被报告直接引用,要进口径卡
- 查询快照记下列类型,文本列按文本渲染(年份 "2026" 不再显示成「2,026」)
- 升级前发起的运行(停在审批上的)一律走旧逻辑:新的 task 只追加在循环之后,恢复时不会错号

证据接口与面板
- 片段链接上查询步骤:SQL、被引用的行加前后各 2 行、高亮到格;数据源可设 mask_columns 遮罩列,
  遮罩在查询时记进快照,数据源事后改名或删除也照样遮罩(在有身份体系之前,遮罩只减少暴露,不是安全边界)
- 输入来源写明核对结果(与快照一致 / 模型报的值和快照不一致 / 没查到记为空);口径卡缺输入时也给出完整的链
- 受管出具要在契约里声明 cells 才能引用单元格;受管与否记在所钉版本上,改画布不再让受管版本的正式运行降级
- 口径卡可以用 caliber_from 钉住另一个已发布工作流里的口径卡,上游有新版本时按 upgrade_policy 处置,
  和子工作流同一套规则;子工作流节点也补上了升版处置的配置项

Copilot、校验与报错
- 问数据的答案里有数字时,搭 input → 智能体 → 报告 → 出口,报告直接引用查过的单元格
- 写 SQL 前核对数据源的表结构,SQL 里出现表结构里没有的列会被打回
- 不再搭「智能体文字 → 转换节点解析 JSON」这种图;转换节点的 JSON 报错分清是上游模型写坏了、
  模板里裸插了模型文字(给出 | json 的改法),还是模板本身写错了
- 工具节点遇到「查询失败」「SQL 被拒绝」判为失败;发起运行前检查绑定的工具是否存在,不存在时直接拒绝并给出入口

二、发布前自动修复

- 问题带上机器编号(code)和修复 id;新增只读接口 POST /api/workflows/{id}/publish-check 和 /autofix
- 修复分三类:答案唯一、不降低要求的一键修复;要人拿主意的给选项(不替人选);结构性问题交给 Copilot
- 一律先预览,不自动保存、不自动发布;删节点、删契约、放宽审批、关掉 strict、改级别等降低要求的改动一律拒绝,
  Copilot 的方案也要过这一关;应用后重跑检查,错误必须变少且不能冒出新的
- 发布弹窗打开就先检查;问题面板新增「发布前检查」,平时就能看到、就地修掉
- 门禁修正:契约有 report_from 时不再要求 narrative(模板⑨可以发成受管);全图默认审批为「全部放行」时同样拦下
- 修掉画布保存会抹掉全图默认(defaults)的老问题:以前任何一次保存都会把它清空,审批 always 因此被悄悄放宽

验证(沙箱):tsc 干净,生产构建通过,pytest 2268 passed(两遍),check-all 17 项检查、3915 项全过
(新增 check-publish);关键行为做了变异验证、全部抓到。
2026-09-28 21:20:04 +08:00

287 lines
14 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.
// 分道并行跑全部检查,汇总成一张表,给一个总的退出码。
//
// node scripts/check-all.mjs 全部跑一遍
// node scripts/check-all.mjs ui runs 只跑 check-ui、check-runs(名字去掉 check- 前缀)
// node scripts/check-all.mjs --repeat 2 整套连跑两遍,列出只在某一遍失败的项(偶发)
// node scripts/check-all.mjs --lanes 1 一个接一个地跑(以前的跑法);也可以写 CHECK_LANES=1
//
// 默认连 5273 / 8000。对别的实例(比如一份沙箱拷贝)跑时带上地址,会原样传给每个检查:
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-all.mjs
// 每个检查的完整输出写进 CHECK_LOGS(默认 /tmp/agentlab-checks/<时间>/),终端只给汇总。
//
// 默认 4 道并行。各检查对后端只读(几处 POST 都是纯计算的排版接口),截图各有各的目录,
// 时间大多花在 waitForTimeout 的固定等待上,所以并行几乎是线性提速。代价是共用同一个
// vite 开发服务器,有几项还量时间(审批卡多少毫秒出现、计时器走没走)。两道防线:
// - 量时间最多的几项(EXCLUSIVE)互相不重叠,同一时刻最多跑其中一项;
// - 并行时没过的,全部跑完后再单独、一个接一个地重跑一遍。重跑通过的算通过,但单列成
// 「偶发」:它在挤的时候会挂,迟早也会在别处挂。连跑几遍(--repeat)时不重跑,要的就是
// 原始的偶发率。
//
// 各检查自己的段过滤(CHECK_ONLY、ONLY、THEMES……)不往下传:整套检查就是整套,只跑某几段
// 请直接跑那个脚本。
//
// 退出码:0 全部通过;1 有检查没通过、中途崩了、超时或一项都没跑;2 前后端连不上,一项都没跑。
import { spawn } from 'node:child_process'
import { createWriteStream, mkdirSync, readFileSync, readdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
const HERE = dirname(fileURLToPath(import.meta.url))
// 先跑快的、不开浏览器的,再按页面从外壳往里跑;最后是连真数据的两项
const ORDER = [
'tokens', // 令牌、别名、原生对话框:纯静态扫源码
'trace', // 运行态内核的纯函数
'decode', // 事件翻译层
'canvas-layout', // 走线
'ui-kit', // 公共组件和 lib
'guards', // 输入法、未知节点、错误边界
'shell', // 外壳:导航、徽标、命令面板、离开前确认、启动页
'stream', // 回答流的渲染
'evidence', // 可点击证据:报告逐段渲染、证据面板、旧契约按位置标记
'run-states', // 节点卡的运行态
'canvas-fx', // 画布表层的运行态
'studio', // 编排页的编辑
'publish', // 发布前检查与自动修复:发布弹窗、问题面板
'chat', // 问数据
'runs', // 记录
'manage', // 数据、工具、知识、设置
'ui', // 各页面端到端,连真数据
]
// 最近一次全量里各项的耗时(秒)。只决定排队顺序:长的先开跑,短的填空,结果不受影响。
// 新加的检查不在表里就按 60 秒算
const ESTIMATE = {
'run-states': 220, 'canvas-fx': 181, stream: 162, studio: 125, chat: 124, manage: 115, runs: 91,
shell: 84, 'ui-kit': 69, ui: 61, evidence: 56, publish: 31, guards: 10, 'canvas-layout': 4, tokens: 1, trace: 1, decode: 1,
}
// 量时间最多、以前并行最容易挂的:同一时刻最多跑其中一项
const EXCLUSIVE = new Set(['run-states', 'canvas-fx'])
const TIMEOUT_MS = Number(process.env.CHECK_TIMEOUT_MS ?? 15 * 60_000)
// 各检查只跑其中几段、只跑一套主题的开关。壳里留着一个 ONLY=… 就会让整套检查悄悄只跑一小截,
// 却照样报「全部通过」,所以一律不传给子进程
const FILTERS = ['CHECK_ONLY', 'ONLY', 'THEMES', 'FX_ONLY', 'STUDIO_ONLY', 'RUN_STATES_ONLY', 'UI_KIT_ONLY', 'CHECK_THEME',
'EVIDENCE_ONLY', 'PUBLISH_ONLY']
// 新加的 check-*.mjs 忘了写进 ORDER 就永远不会跑:列出来提醒
const unlisted = readdirSync(HERE)
.map((f) => f.match(/^check-(.+)\.mjs$/)?.[1])
.filter((n) => n && n !== 'all' && !ORDER.includes(n))
const args = process.argv.slice(2)
let repeat = 1
let lanes = Math.max(1, Number(process.env.CHECK_LANES) || 4)
const picked = []
for (let i = 0; i < args.length; i++) {
if (args[i] === '--repeat') repeat = Math.max(1, Number(args[++i]) || 1)
else if (args[i].startsWith('--repeat=')) repeat = Math.max(1, Number(args[i].slice(9)) || 1)
else if (args[i] === '--lanes') lanes = Math.max(1, Number(args[++i]) || 1)
else if (args[i].startsWith('--lanes=')) lanes = Math.max(1, Number(args[i].slice(8)) || 1)
else picked.push(args[i].replace(/^check-/, '').replace(/\.mjs$/, ''))
}
const unknown = picked.filter((n) => !ORDER.includes(n))
if (unknown.length) {
console.error(`认不出的检查:${unknown.join('、')}。可选的有:${ORDER.join(' ')}`)
process.exit(2)
}
const names = picked.length ? ORDER.filter((n) => picked.includes(n)) : ORDER
// 连不上就别跑:十几个检查挨个报「打不开页面」只会淹没真正的原因
const reach = async (url) => {
try {
const r = await fetch(url, { signal: AbortSignal.timeout(5000) })
return r.ok ? '' : `HTTP ${r.status}`
} catch (e) {
return e.cause?.code ?? e.name ?? String(e)
}
}
const needsServers = names.some((n) => n !== 'tokens')
if (needsServers) {
const [web, api] = await Promise.all([reach(WEB), reach(`${API}/health`)])
if (web || api) {
if (web) console.error(`✗ 前端连不上:${WEB}(${web})`)
if (api) console.error(`✗ 后端连不上:${API}/health(${api})`)
console.error(' 先把前后端起来(./scripts/dev.sh),或者用 AGENTLAB_WEB / AGENTLAB_API 指到要检查的那一份')
process.exit(2)
}
}
const now = new Date()
const pad = (n) => String(n).padStart(2, '0')
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
const LOGS = process.env.CHECK_LOGS ?? join('/tmp/agentlab-checks', stamp)
mkdirSync(LOGS, { recursive: true })
console.log(`前端 ${WEB} · 后端 ${API}`)
const dropped = FILTERS.filter((k) => process.env[k])
if (dropped.length) console.log(`没传给各检查的段过滤:${dropped.map((k) => `${k}=${process.env[k]}`).join(' ')}(只跑某几段请直接跑那个脚本)`)
lanes = Math.min(lanes, names.length)
console.log(`${names.length} 项检查${repeat > 1 ? ` × ${repeat} 遍` : ''},${lanes > 1 ? `${lanes} 道并行` : '一个接一个'},完整输出在 ${LOGS}/\n`)
const childEnv = { ...process.env, AGENTLAB_WEB: WEB, AGENTLAB_API: API }
for (const k of FILTERS) delete childEnv[k]
/**
* 跑一个检查,输出落盘,数 ✓ / ✗。退出码非 0 却一个 ✗ 都没有的,是中途崩了;
* 退出码 0 却一个 ✓ 都没有的,是什么都没查——都不算通过
*/
function runOne(name, pass, suffix = '') {
const file = join(LOGS, `${name}${repeat > 1 ? `.${pass}` : ''}${suffix}.log`)
const out = createWriteStream(file)
const started = Date.now()
return new Promise((resolve) => {
const child = spawn(process.execPath, [join(HERE, `check-${name}.mjs`)], {
env: childEnv,
stdio: ['ignore', 'pipe', 'pipe'],
})
child.stdout.pipe(out, { end: false })
child.stderr.pipe(out, { end: false })
let timedOut = false
// 先 SIGTERM:Playwright 收到它会把自己拉起来的 Chrome 一起关掉;直接 SIGKILL 会留下孤儿浏览器
let hard
const timer = setTimeout(() => {
timedOut = true
child.kill('SIGTERM')
hard = setTimeout(() => child.kill('SIGKILL'), 10_000)
}, TIMEOUT_MS)
child.on('close', (code) => {
clearTimeout(timer)
clearTimeout(hard)
out.end(() => {
const text = readFileSync(file, 'utf8')
const lines = text.split('\n')
// 检查项是缩进的一行;顶格的「✓ 全部通过」「✗ N 项未通过」是脚本自己的总结
const ok = lines.filter((l) => /^\s+✓ /.test(l)).length
const bad = lines.filter((l) => /^\s+✗ /.test(l)).map((l) => l.trim())
const secs = Math.round((Date.now() - started) / 1000)
const crashed = !timedOut && code !== 0 && !bad.length
const empty = !timedOut && code === 0 && ok === 0
const passed = code === 0 && ok > 0
const tail = crashed || empty ? lines.filter(Boolean).slice(-4).join(' ⏎ ').slice(0, 300) : ''
resolve({ name, pass, code, ok, bad, secs, timedOut, crashed, empty, passed, tail, file })
})
})
})
}
const limit = TIMEOUT_MS >= 60_000 ? `${Math.round(TIMEOUT_MS / 60_000)} 分钟` : `${Math.round(TIMEOUT_MS / 1000)} 秒`
const verdict = (r) => r.timedOut ? `超时(${limit})`
: r.crashed ? '中途崩了'
: r.empty ? '一项都没跑'
: r.passed ? '通过'
: `${r.bad.length} 项没过`
let spent = 0 // 各项累计的秒数,重跑的那次也算
const report = (r) => {
spent += r.secs
const mark = r.passed ? '✓' : '✗'
console.log(`${mark} ${`check-${r.name}`.padEnd(20)} ${verdict(r).padEnd(10)} ${String(r.ok).padStart(4)} 项通过 · ${r.secs}s`)
for (const l of r.bad.slice(0, 5)) console.log(` ${l}`)
if (r.bad.length > 5) console.log(` ……另有 ${r.bad.length - 5} 项,见 ${r.file}`)
if (r.tail) console.log(` ${r.tail}`)
}
/**
* 一遍:lanes 道同时从队里取。长的排前面;EXCLUSIVE 里的一项在跑时,另一项先让给队里
* 后面的。结果按跑完的先后打印,返回时按 ORDER 排好
*/
async function runPass(pass) {
const queue = [...names].sort((a, b) => (ESTIMATE[b] ?? 60) - (ESTIMATE[a] ?? 60))
const done = []
let exclusiveBusy = false
let waiting = [] // 等 EXCLUSIVE 那一项跑完的几道
const take = () => {
const i = queue.findIndex((n) => !(EXCLUSIVE.has(n) && exclusiveBusy))
return i < 0 ? null : queue.splice(i, 1)[0]
}
const lane = async () => {
while (queue.length) {
const name = take()
if (!name) {
// 队里只剩 EXCLUSIVE 的,而另一项正在跑:等它跑完再取
await new Promise((resolve) => waiting.push(resolve))
continue
}
const exclusive = EXCLUSIVE.has(name)
if (exclusive) exclusiveBusy = true
const r = await runOne(name, pass)
if (exclusive) {
exclusiveBusy = false
const woken = waiting
waiting = []
for (const resolve of woken) resolve()
}
done.push(r)
report(r)
}
}
await Promise.all(Array.from({ length: lanes }, lane))
return names.map((n) => done.find((r) => r.name === n))
}
const results = []
const flakyRerun = [] // 并行时没过、单独重跑通过的
const started = Date.now()
for (let pass = 1; pass <= repeat; pass++) {
if (repeat > 1) console.log(`—— 第 ${pass} 遍 ——`)
const got = await runPass(pass)
if (lanes > 1 && repeat === 1 && got.some((r) => !r.passed)) {
const failed = got.filter((r) => !r.passed)
console.log(`\n并行时没过的 ${failed.length} 项,单独重跑一遍:`)
for (const first of failed) {
const again = await runOne(first.name, pass, '.solo')
report(again)
if (again.passed) flakyRerun.push({ first, again })
got[got.indexOf(first)] = again
}
}
results.push(...got)
if (repeat > 1) console.log('')
}
if (flakyRerun.length) {
console.log('\n偶发(并行时没过、单独重跑通过):')
for (const { first } of flakyRerun) {
const why = first.bad.length ? first.bad.slice(0, 3).join(';') : verdict(first)
console.log(` check-${first.name}:${why}(见 ${first.file})`)
}
}
console.log('')
// 连跑几遍时:同一项只在部分遍数里失败,就是偶发——比稳定失败更该查。按项名认,不带
// 「 — 」后面的读数:每遍都没过、只是毫秒数或像素差不一样的,是稳定失败,不是偶发
if (repeat > 1) {
const seen = new Map() // `${name}\t${项名}` → [{ pass, line }]
for (const r of results) {
const lines = r.crashed || r.timedOut || r.empty ? [verdict(r)] : r.bad
for (const line of lines) {
const k = `${r.name}\t${line.replace(/ — .*$/, '')}`
seen.set(k, [...(seen.get(k) ?? []), { pass: r.pass, line }])
}
}
const flaky = [...seen].filter(([, hits]) => new Set(hits.map((h) => h.pass)).size < repeat)
if (flaky.length) {
console.log('偶发(只在部分遍数里失败):')
for (const [k, hits] of flaky) {
const name = k.split('\t')[0]
for (const h of hits) console.log(` check-${name} 第 ${h.pass} 遍:${h.line}`)
}
console.log('')
}
}
if (unlisted.length && !picked.length) {
console.log(`没跑:${unlisted.map((n) => `check-${n}`).join('、')} 不在 check-all 的清单里(要算进来就加进 ORDER)\n`)
}
const failedRuns = results.filter((r) => !r.passed)
const total = results.reduce((n, r) => n + r.ok, 0)
const minutes = (secs) => `${Math.floor(secs / 60)} 分 ${String(secs % 60).padStart(2, '0')} 秒`
const wall = Math.round((Date.now() - started) / 1000)
const clock = lanes > 1 ? `用时 ${minutes(wall)}(各项累计 ${minutes(spent)})` : `共 ${minutes(wall)}`
const flakyNote = flakyRerun.length ? `,其中 ${flakyRerun.length} 项是重跑才过的,见上面「偶发」` : ''
if (failedRuns.length) {
console.log(`✗ ${failedRuns.length}/${results.length} 次检查没通过(${[...new Set(failedRuns.map((r) => `check-${r.name}`))].join('、')})· ${clock}`)
} else {
console.log(`✓ 全部通过:${results.length} 次检查、${total} 项${flakyNote} · ${clock}`)
}
process.exit(failedRuns.length ? 1 : 0)