goal-contract.js 27.6 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672
/**
 * 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": {"<dimension>": 87, ...}} 或直接 {"<dimension>": 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": {"<caseId>": <output>}} 或 {"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<object>} 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,
};