/** * Goal Contract — 任务级验收契约的格式、校验、执行与判决持久化(P3 阶段 0 / T0) * * 设计见 docs/todo/system-design/27-goal-contract-format.md, * 需求见 27-p3-p4-work-graph-console.md §五 阶段 0 / §四.A.5 / §八.9, * 以及 27-e2e-task-execution-flow.md §十一(三个验收产出节点)。 * * 形态直接借 DevLoop 已跑通的 `integration/spec.json`(_spec/sdd-model.md M-6) * 与 `runs/result.json`(M-7/M-8):契约是一份 JSON、判决是一份 JSON, * 都放在约定好的固定位置,谁都不用猜。 * * §八.9 划的优先级:**外层(存哪、怎么被引用、怎么被执行、判决存哪)要定死, * 内层断言字段允许后续演进**。所以本文件的稳定面是: * * data/companies/{cid}/runs/{rootRunId}/ * ├── artifacts/ * │ ├── acceptance.json ← 唯一契约入口(三 section,各自可为 null) * │ ├── goal-benchmark.json ← 大块内容外置,契约里用 {"$ref": "..."} 指过来 * │ ├── goal-test-data/ * │ ├── goal-test-script/ * │ └── verdicts/ * │ ├── {verdictId}.json ← 一次执行一份完整判决 * │ └── index.ndjson ← append-only 索引,可查、可追溯到 run/node/attempt * └── nodes/{nodeRunId}/ ← output_ref 指向这里,即「被测产物」 * * 与 output_ref 的关联方式:执行器的入参 targetPath 就是某个 graph_run_nodes.output_ref * 解析出的绝对路径;判决里回存 run_id / node_run_id / attempt / contract_hash 四元组, * 所以任何一份判决都能双向追溯到「哪次 run 的哪个节点的第几次尝试、用哪版契约判的」。 * * 不起 LLM:benchmark / test_data 是纯声明式比对,test_script 走真实子进程。 */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { spawn } = require('child_process'); // contract_hash 直接复用 DevLoop 的实现(只读借用,不改它一行;见 §七「明确不改」) const { hashContract } = require('./devloop/doc-store'); const DATA_DIR = path.resolve(__dirname, '../../data'); const CONTRACT_VERSION = '1'; const CONTRACT_FILE = 'acceptance.json'; const SECTIONS = ['benchmark', 'test_data', 'test_script']; // ponytail: v1 只支持 runner='node'(脚本退出码即判决),够表达一切断言。 // 上限:跑不了 vitest——实测仓库根 vitest.config 的 include 不覆盖 data/, // `vitest run <脚本>` / `--root` / `--dir` 都会被根 config 的 include 盖掉,直接 "No test files found"。 // 升级路径:真需要 vitest 时,为验收脚本生成一份临时 vitest.config 并用 `--config` 指过去, // 再把 'vitest' 加回这张白名单——契约的 runner 字段就是为此留的扩展位。 // 眼下 node 脚本里自己 spawn 任何测试框架也不受限制,所以不急。 const RUNNERS = ['node']; const MATCH_MODES = ['deep-equal', 'subset']; const DEFAULT_SCRIPT_TIMEOUT_MS = 120_000; const OUTPUT_CAP = 64 * 1024; // stdout/stderr 各留 64KB,超出截断 class ContractValidationError extends Error { constructor(errors) { super('goal contract validation failed'); this.name = 'ContractValidationError'; this.errors = errors; } } // ──────────────────────────────────────────────────────────────── // 路径 // ──────────────────────────────────────────────────────────────── /** root run 的产物根目录:data/companies/{cid}/runs/{rootRunId}/ */ function resolveRunDir(companyId, rootRunId) { return path.join(DATA_DIR, 'companies', String(companyId), 'runs', String(rootRunId)); } /** 任务级共享产物区(跨 org node 只读,写入是特权动作,见 §四.A.5) */ function resolveArtifactsDir(runDir) { return path.join(runDir, 'artifacts'); } function resolveContractPath(runDir) { return path.join(resolveArtifactsDir(runDir), CONTRACT_FILE); } function resolveVerdictsDir(runDir) { return path.join(resolveArtifactsDir(runDir), 'verdicts'); } /** 契约里的相对路径一律相对 artifacts/,且不许逃出 artifacts/(防契约指向任意可执行文件) */ function resolveInsideArtifacts(artifactsDir, rel) { const abs = path.resolve(artifactsDir, rel); const base = path.resolve(artifactsDir); if (abs !== base && !abs.startsWith(base + path.sep)) return null; return abs; } // ──────────────────────────────────────────────────────────────── // 小工具 // ──────────────────────────────────────────────────────────────── function now() { return new Date().toISOString(); } /** CONVENTIONS §2 的 ID 形态:vd-{YYYYMMDD-HHMM}-{4hex} */ function genVerdictId(d = new Date()) { const p2 = (n) => String(n).padStart(2, '0'); const stamp = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}` + `-${p2(d.getHours())}${p2(d.getMinutes())}`; return `vd-${stamp}-${crypto.randomBytes(2).toString('hex')}`; } /** key 排序的规范序列化,用于 deep-equal 比较 */ function stable(v) { if (v === null || typeof v !== 'object') return JSON.stringify(v) ?? 'null'; if (Array.isArray(v)) return '[' + v.map(stable).join(',') + ']'; return ( '{' + Object.keys(v) .sort() .map((k) => JSON.stringify(k) + ':' + stable(v[k])) .join(',') + '}' ); } function deepEqual(a, b) { return stable(a) === stable(b); } /** subset:expected 里出现的键/元素必须匹配,actual 允许有额外键 */ function matchSubset(expected, actual) { if (expected === null || typeof expected !== 'object') return expected === actual; if (Array.isArray(expected)) { if (!Array.isArray(actual) || actual.length !== expected.length) return false; return expected.every((e, i) => matchSubset(e, actual[i])); } if (actual === null || typeof actual !== 'object' || Array.isArray(actual)) return false; return Object.keys(expected).every((k) => matchSubset(expected[k], actual[k])); } function readJsonFile(abs) { try { return { ok: true, json: JSON.parse(fs.readFileSync(abs, 'utf8')) }; } catch (e) { return { ok: false, error: e.message }; } } /** CONVENTIONS §5:JSON 落盘走 tmp + rename */ function writeJsonAtomic(abs, json) { fs.mkdirSync(path.dirname(abs), { recursive: true }); const tmp = abs + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(json, null, 2)); fs.renameSync(tmp, abs); return abs; } // ──────────────────────────────────────────────────────────────── // 契约:读 / 写 / 校验 // ──────────────────────────────────────────────────────────────── /** * 校验契约本身是否合法(不碰被测产物)。 * * errors 形态与 org-graph-service.validateGraph 保持一致:{rule, detail}。 * rule 取值:schema | section | reference | runner * * @param {object} contract * @returns {{ok: boolean, errors: Array<{rule: string, detail: string}>}} */ function validateContract(contract) { const errors = []; const err = (rule, detail) => errors.push({ rule, detail }); if (!contract || typeof contract !== 'object' || Array.isArray(contract)) { return { ok: false, errors: [{ rule: 'schema', detail: 'contract must be a plain object' }] }; } if (contract.version !== CONTRACT_VERSION) { err('schema', `version must be "${CONTRACT_VERSION}", got ${JSON.stringify(contract.version)}`); } if (!contract.goal_id || typeof contract.goal_id !== 'string') { err('schema', 'missing field: goal_id'); } for (const k of Object.keys(contract)) { if (!['version', 'goal_id', 'created_by', 'created_at', ...SECTIONS].includes(k)) { err('schema', `unknown top-level field: ${k}`); } } // 至少一个 section 非 null。空契约会「平凡通过」——正是 §四.D.3 要求 planner-validator // 盯的那类漏洞,在格式层面直接堵掉。 const present = SECTIONS.filter((s) => contract[s] !== undefined && contract[s] !== null); if (present.length === 0) { err('section', 'at least one of benchmark / test_data / test_script must be non-null'); } for (const s of present) { const sec = contract[s]; if (typeof sec !== 'object' || Array.isArray(sec)) { err('section', `${s} must be an object or null`); continue; } // 未解引用的 $ref 只校验形态,内容等 readContract 解开后再校验 if (sec.$ref !== undefined) { if (typeof sec.$ref !== 'string' || !sec.$ref) err('reference', `${s}.$ref must be a non-empty string`); else if (path.isAbsolute(sec.$ref) || sec.$ref.split('/').includes('..')) { err('reference', `${s}.$ref must be a relative path inside artifacts/: ${sec.$ref}`); } continue; } if (s === 'benchmark') validateBenchmark(sec, err); if (s === 'test_data') validateTestData(sec, err); if (s === 'test_script') validateTestScript(sec, err); } return { ok: errors.length === 0, errors }; } function validateBenchmark(sec, err) { if (!Array.isArray(sec.dimensions) || sec.dimensions.length === 0) { err('section', 'benchmark.dimensions must be a non-empty array'); return; } let sum = 0; const seen = new Set(); for (const d of sec.dimensions) { if (!d || typeof d.name !== 'string' || !d.name) { err('section', 'benchmark.dimensions[].name required'); continue; } if (seen.has(d.name)) err('section', `benchmark duplicate dimension: ${d.name}`); seen.add(d.name); if (typeof d.weight !== 'number' || d.weight <= 0) err('section', `benchmark.${d.name}.weight must be > 0`); else sum += d.weight; if (d.pass_threshold !== undefined && typeof d.pass_threshold !== 'number') { err('section', `benchmark.${d.name}.pass_threshold must be a number`); } } if (seen.size > 0 && Math.abs(sum - 1) > 1e-6) { err('section', `benchmark dimension weights must sum to 1, got ${sum}`); } if (sec.overall_pass_threshold !== undefined && typeof sec.overall_pass_threshold !== 'number') { err('section', 'benchmark.overall_pass_threshold must be a number'); } } function validateTestData(sec, err) { if (!Array.isArray(sec.cases) || sec.cases.length === 0) { err('section', 'test_data.cases must be a non-empty array'); return; } if (sec.match !== undefined && !MATCH_MODES.includes(sec.match)) { err('section', `test_data.match must be one of ${MATCH_MODES.join('|')}`); } const seen = new Set(); for (const c of sec.cases) { if (!c || typeof c.id !== 'string' || !c.id) { err('section', 'test_data.cases[].id required'); continue; } if (seen.has(c.id)) err('section', `test_data duplicate case id: ${c.id}`); seen.add(c.id); if (!('expect' in c)) err('section', `test_data.cases[${c.id}].expect required`); } } function validateTestScript(sec, err) { if (!RUNNERS.includes(sec.runner)) { err('runner', `test_script.runner must be one of ${RUNNERS.join('|')}, got ${JSON.stringify(sec.runner)}`); } if (typeof sec.path !== 'string' || !sec.path) { err('section', 'test_script.path required'); } else if (path.isAbsolute(sec.path) || sec.path.split('/').includes('..')) { err('reference', `test_script.path must be a relative path inside artifacts/: ${sec.path}`); } if (sec.args !== undefined && !Array.isArray(sec.args)) err('section', 'test_script.args must be an array'); if (sec.timeout_ms !== undefined && (typeof sec.timeout_ms !== 'number' || sec.timeout_ms <= 0)) { err('section', 'test_script.timeout_ms must be a positive number'); } } /** * 解开 section 上的 $ref(相对 artifacts/,不许逃出去)。 * * contract_hash 一律按**解引用后**的契约计算——否则「内联写」和「$ref 外置写」 * 同一份验收标准会算出两个 hash,判决就追溯不回契约版本了。读和写都走这里。 */ function resolveRefs(contract, artifactsDir) { const out = { ...contract }; for (const s of SECTIONS) { const sec = out[s]; if (!sec || typeof sec !== 'object' || sec.$ref === undefined) continue; const abs = resolveInsideArtifacts(artifactsDir, sec.$ref); if (!abs) throw new ContractValidationError([{ rule: 'reference', detail: `${s}.$ref escapes artifacts/: ${sec.$ref}` }]); const sub = readJsonFile(abs); if (!sub.ok) throw new ContractValidationError([{ rule: 'reference', detail: `${s}.$ref unreadable: ${sec.$ref} (${sub.error})` }]); out[s] = sub.json; } return out; } /** * 读契约并解开 $ref。 * @returns {{contract: object, path: string}|null} 没有契约文件返回 null */ function readContract(runDir) { const p = resolveContractPath(runDir); if (!fs.existsSync(p)) return null; const r = readJsonFile(p); if (!r.ok) throw new ContractValidationError([{ rule: 'schema', detail: `unreadable ${CONTRACT_FILE}: ${r.error}` }]); return { contract: resolveRefs(r.json, resolveArtifactsDir(runDir)), path: p }; } /** * 写契约(写前必校验,不通过抛 ContractValidationError;CONVENTIONS §12 同款约定)。 * 文件按原样落盘(保留 $ref),返回的 contract_hash 按解引用后的形态算。 * 因此 $ref 指向的文件必须先写好,否则这里就报错——比等到执行时才炸要早。 */ function writeContract(runDir, contract) { const v = validateContract(contract); if (!v.ok) throw new ContractValidationError(v.errors); const resolved = resolveRefs(contract, resolveArtifactsDir(runDir)); const rv = validateContract(resolved); // 解引用后的内容同样要合法 if (!rv.ok) throw new ContractValidationError(rv.errors); const p = resolveContractPath(runDir); writeJsonAtomic(p, contract); return { path: p, contract_hash: hashContract(resolved) }; } // ──────────────────────────────────────────────────────────────── // 执行器 // ──────────────────────────────────────────────────────────────── /** * benchmark:被测产物提供各维度分数,执行器只做加权与阈值比较(纯声明式,不起 LLM)。 * 被测产物形态:{"scores": {"": 87, ...}} 或直接 {"": 87, ...} */ function runBenchmark(sec, target) { if (!target.ok) return { ok: false, reason: 'target_not_json', detail: target.error, dimensions: [] }; const scores = (target.json && typeof target.json.scores === 'object' && target.json.scores) || target.json || {}; const dims = []; let weighted = 0; let ok = true; for (const d of sec.dimensions) { const score = scores[d.name]; const has = typeof score === 'number'; const threshold = typeof d.pass_threshold === 'number' ? d.pass_threshold : null; const passed = has && (threshold === null || score >= threshold); if (!passed) ok = false; if (has) weighted += score * d.weight; dims.push({ name: d.name, weight: d.weight, score: has ? score : null, pass_threshold: threshold, ok: passed, reason: has ? undefined : 'missing score in target', }); } const overall = typeof sec.overall_pass_threshold === 'number' ? sec.overall_pass_threshold : null; const overallOk = overall === null || weighted >= overall; if (!overallOk) ok = false; return { ok, score: Number(weighted.toFixed(4)), overall_pass_threshold: overall, overall_ok: overallOk, dimensions: dims, failures: dims.filter((d) => !d.ok).map((d) => ({ case: d.name, expected: d.pass_threshold, got: d.score, hint: d.reason || 'dimension score below pass_threshold', })), }; } /** * test_data:被测产物提供各 case 的实际输出,执行器逐 case 比对(纯声明式,不起 LLM)。 * 被测产物形态:{"results": {"": }} 或 {"results": [{"id":..,"output":..}]} */ function runTestData(sec, target) { if (!target.ok) return { ok: false, reason: 'target_not_json', detail: target.error, failures: [] }; const raw = (target.json && target.json.results !== undefined ? target.json.results : target.json) || {}; const actual = Array.isArray(raw) ? Object.fromEntries(raw.map((r) => [r && r.id, r && (r.output !== undefined ? r.output : r.actual)])) : raw; const mode = sec.match || 'deep-equal'; const failures = []; for (const c of sec.cases) { const got = actual[c.id]; const missing = !(c.id in actual); const passed = !missing && (mode === 'subset' ? matchSubset(c.expect, got) : deepEqual(c.expect, got)); if (!passed) { failures.push({ case: c.id, expected: c.expect, got: missing ? null : got, hint: missing ? `no result for case ${c.id} in target` : `case ${c.id} mismatch (${mode})`, }); } } return { ok: failures.length === 0, match: mode, total: sec.cases.length, passed: sec.cases.length - failures.length, failed: failures.length, failures, }; } /** 起子进程跑脚本;超时先 SIGTERM 再 SIGKILL(沿用 integrator-tools.killAll 的做法) */ function spawnScript({ cmd, args, cwd, env, timeoutMs }) { return new Promise((resolve) => { const started = Date.now(); let stdout = ''; let stderr = ''; let timedOut = false; const child = spawn(cmd, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); const cap = (buf, chunk) => (buf.length >= OUTPUT_CAP ? buf : buf + String(chunk).slice(0, OUTPUT_CAP - buf.length)); child.stdout.on('data', (c) => { stdout = cap(stdout, c); }); child.stderr.on('data', (c) => { stderr = cap(stderr, c); }); const killer = setTimeout(() => { timedOut = true; try { child.kill('SIGTERM'); } catch { /* already gone */ } setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already gone */ } }, 3000).unref(); }, timeoutMs); killer.unref(); child.on('error', (e) => { clearTimeout(killer); resolve({ exit_code: null, spawn_error: e.message, stdout, stderr, timed_out: false, duration_ms: Date.now() - started }); }); child.on('close', (code, signal) => { clearTimeout(killer); resolve({ exit_code: code, signal: signal || null, stdout, stderr, timed_out: timedOut, duration_ms: Date.now() - started }); }); }); } /** * test_script:真实子进程执行(DevLoop Integrator 起真实进程跑 e2e 的同款做法)。 * 被测产物路径通过 env ACCEPTANCE_TARGET 交给脚本,脚本退出码即判决。 */ async function runTestScript(sec, { artifactsDir, runDir, targetPath }) { const scriptAbs = resolveInsideArtifacts(artifactsDir, sec.path); if (!scriptAbs) { return { ok: false, reason: 'script_escapes_artifacts', detail: sec.path, failures: [] }; } if (!fs.existsSync(scriptAbs)) { return { ok: false, reason: 'script_missing', detail: scriptAbs, failures: [] }; } const res = await spawnScript({ cmd: process.execPath, // RUNNERS 白名单目前只有 'node' args: [scriptAbs, ...(sec.args || [])], cwd: sec.cwd ? resolveInsideArtifacts(artifactsDir, sec.cwd) || runDir : runDir, env: { ...process.env, ACCEPTANCE_TARGET: targetPath, ACCEPTANCE_RUN_DIR: runDir || '', ACCEPTANCE_ARTIFACTS_DIR: artifactsDir || '', }, timeoutMs: sec.timeout_ms || DEFAULT_SCRIPT_TIMEOUT_MS, }); const expectCode = (sec.pass_on && typeof sec.pass_on.exit_code === 'number') ? sec.pass_on.exit_code : 0; const ok = !res.timed_out && !res.spawn_error && res.exit_code === expectCode; return { ok, runner: sec.runner, script: sec.path, exit_code: res.exit_code, signal: res.signal, timed_out: res.timed_out, duration_ms: res.duration_ms, stdout_excerpt: res.stdout.slice(-4000), stderr_excerpt: res.stderr.slice(-4000), reason: res.timed_out ? 'script_timeout' : (res.spawn_error ? 'spawn_error' : undefined), failures: ok ? [] : [{ case: sec.path, expected: `exit ${expectCode}`, got: res.timed_out ? 'timeout' : (res.spawn_error || `exit ${res.exit_code}`), hint: (res.stderr || res.stdout).slice(-800), }], }; } /** * 基础设施抖动 → infra_flake(§四.D.2.2 的取值域)。 * 这类失败在引擎侧原地重跑、不烧任何计数,所以必须与「真的没达标」区分开。 */ const INFRA_REASONS = new Set(['script_timeout', 'spawn_error']); /** * 执行一份契约,产出判决(纯执行,不落盘)。 * * @param {object} contract 已解引用的契约(readContract 的产物 / 手写对象) * @param {string} targetPath 被测产物绝对路径(通常来自 graph_run_nodes.output_ref) * @param {object} [opts] {runDir, runId, nodeRunId, attempt} * @returns {Promise} verdict */ async function executeContract(contract, targetPath, opts = {}) { const started = Date.now(); const runDir = opts.runDir || null; const artifactsDir = runDir ? resolveArtifactsDir(runDir) : null; const base = { verdict_id: genVerdictId(), goal_id: contract && contract.goal_id, contract_hash: null, run_id: opts.runId || null, node_run_id: opts.nodeRunId || null, attempt: opts.attempt || null, // 落在 run 目录里的产物存相对路径(整个 run 目录可整体搬走 / 归档后仍可读), // 外部路径原样存绝对路径 target: runDir && path.resolve(targetPath).startsWith(path.resolve(runDir) + path.sep) ? path.relative(runDir, targetPath) : targetPath, created_at: now(), }; const v = validateContract(contract); if (!v.ok) { // 契约本身不合法:不是产物的错,归因为「输入约束没写清」,退回契约产出者。 return { ...base, ok: false, fail_reason: 'input_underspecified', sections: { benchmark: null, test_data: null, test_script: null }, failures: v.errors.map((e) => ({ case: e.rule, expected: 'valid contract', got: e.detail, hint: 'fix acceptance.json' })), duration_ms: Date.now() - started, }; } base.contract_hash = hashContract(contract); const needsJson = contract.benchmark || contract.test_data; const target = needsJson ? (fs.existsSync(targetPath) ? readJsonFile(targetPath) : { ok: false, error: `target not found: ${targetPath}` }) : { ok: true, json: null }; const sections = { benchmark: null, test_data: null, test_script: null }; if (contract.benchmark) sections.benchmark = runBenchmark(contract.benchmark, target); if (contract.test_data) sections.test_data = runTestData(contract.test_data, target); if (contract.test_script) { sections.test_script = await runTestScript(contract.test_script, { artifactsDir, runDir, targetPath }); } const active = SECTIONS.map((s) => sections[s]).filter(Boolean); const ok = active.every((s) => s.ok); const infra = active.some((s) => INFRA_REASONS.has(s.reason)); return { ...base, ok, // 假设(R-16待定案): 判决只归到「这次验收没达成」这个 org node 粒度的结论, // 不跨层直接寻址子图里的具体节点——保持层级封装,remediation_note 随回退往下传, // 由该 org node 自己的 workgraph-planning 决定重规划还是重跑某节点。 fail_reason: ok ? null : (infra ? 'infra_flake' : 'acceptance_not_met'), sections, // 扁平汇总,供下游 validator-reflection 归因直接消费 // 假设(R-16待定案): 归因节点名保留 validator-reflection,不改 validator-triager。 failures: active.flatMap((s) => s.failures || []), duration_ms: Date.now() - started, }; } // ──────────────────────────────────────────────────────────────── // 判决持久化 // ──────────────────────────────────────────────────────────────── /** * 落盘一份判决:完整体写 verdicts/{verdictId}.json,摘要 append 到 verdicts/index.ndjson。 * index 永不改已写行(CONVENTIONS §12 的 ndjson 不变量)。 */ function persistVerdict(runDir, verdict) { const dir = resolveVerdictsDir(runDir); const p = path.join(dir, `${verdict.verdict_id}.json`); writeJsonAtomic(p, verdict); const indexPath = path.join(dir, 'index.ndjson'); const entry = { verdict_id: verdict.verdict_id, ok: verdict.ok, fail_reason: verdict.fail_reason || null, goal_id: verdict.goal_id || null, run_id: verdict.run_id || null, node_run_id: verdict.node_run_id || null, attempt: verdict.attempt || null, contract_hash: verdict.contract_hash || null, target: verdict.target || null, created_at: verdict.created_at, file: path.relative(runDir, p), }; fs.appendFileSync(indexPath, JSON.stringify(entry) + '\n'); return { path: p, indexPath, entry }; } /** 查判决索引(可按 run / node / 只看失败过滤),新的在后,limit 取末尾 N 条 */ function listVerdicts(runDir, filter = {}) { const indexPath = path.join(resolveVerdictsDir(runDir), 'index.ndjson'); if (!fs.existsSync(indexPath)) return []; let rows = fs .readFileSync(indexPath, 'utf8') .split('\n') .filter(Boolean) .map((l) => { try { return JSON.parse(l); } catch { return null; } }) .filter(Boolean); if (filter.runId) rows = rows.filter((r) => r.run_id === filter.runId); if (filter.nodeRunId) rows = rows.filter((r) => r.node_run_id === filter.nodeRunId); if (filter.onlyFailed) rows = rows.filter((r) => !r.ok); if (filter.limit) rows = rows.slice(-filter.limit); return rows; } /** 读单份完整判决 */ function readVerdict(runDir, verdictId) { const p = path.join(resolveVerdictsDir(runDir), `${verdictId}.json`); if (!fs.existsSync(p)) return null; const r = readJsonFile(p); return r.ok ? r.json : null; } /** * 一站式入口:读 runDir 的契约 → 执行 → 落盘判决。引擎侧的 validator 节点调这个。 * * @param {object} args {runDir, targetPath, runId, nodeRunId, attempt} * @returns {Promise<{verdict: object, path: string}>} */ async function runAcceptance({ runDir, targetPath, runId, nodeRunId, attempt }) { const loaded = readContract(runDir); if (!loaded) { throw new ContractValidationError([ { rule: 'reference', detail: `no ${CONTRACT_FILE} under ${resolveArtifactsDir(runDir)}` }, ]); } const verdict = await executeContract(loaded.contract, targetPath, { runDir, runId, nodeRunId, attempt }); const { path: p } = persistVerdict(runDir, verdict); return { verdict, path: p }; } module.exports = { // 常量 CONTRACT_VERSION, CONTRACT_FILE, SECTIONS, RUNNERS, ContractValidationError, // 路径 resolveRunDir, resolveArtifactsDir, resolveContractPath, resolveVerdictsDir, // 契约 validateContract, readContract, writeContract, resolveRefs, contractHash: hashContract, // 入参必须是**已解引用**的契约 // 执行 executeContract, runAcceptance, // 判决 persistVerdict, listVerdicts, readVerdict, };