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,
};