graph-files-index.test.js 28.3 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
/**
 * Unit Tests — R-19 产物索引(27-P4 §4.6)
 *
 * 锁的是第四次真 CLI 冒烟那个断点的正解。当时 org-planner-prd 的归因原话是
 * 「不是上游没产出,是它的产出**结构上到不了我手上**」—— 边只承载控制流
 * (condition / edge_kind / fail_reason),上游 brd.md 躺在节点私有目录,
 * 下游提示词里没有任何可读路径。
 *
 * 每条用例都按「产生 → 传递 → 落库 → 读回」四环走真入口:
 *   ①  版本链         同路径二次产出标 superseded_by,不删行(落库)
 *   ②  部分唯一索引    「同一键只有一个活的 + 留历史」这个选择本身
 *   ③a 沿边传递        出边声明 kind → 引擎登记实例 → 下游提示词里是绝对路径(全四环)
 *   ③b 任务级共享      acceptance 对**非相邻**节点也可见(R-9.3 planner→(coder)→validator)
 *   ④  缺输入告警      声明了却查不到实例时提示词里出现 ⚠️,不静默省略(R-19.4 读回端)
 *   ⑤  产出侧过滤      attempt-*.json 不登记(引擎自读的归因文件);节点 fail 不登记
 *   ⑥  索引重建        删掉 graph_files 后能从 graph_run_nodes 重建
 *
 * describeInputs / indexNodeOutputs / companyRootDir **故意不导出**(graph-engine.js
 * module.exports 那段注释写明了口径:不为测试新开公开面),所以这里全部通过 advanceOne
 * 这个真入口验 —— 输入侧读 executor 收到的 ctx.task,产出侧读 graph_files 落库行。
 *
 * 隔离手法同 graph-boundary.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,
 * 仓库 data/ 全程不被碰;executor 全用注入的假货,不起 Claude CLI。
 * (注入 executor 还顺带关掉了边界守卫 —— boundaryGuardApplicable 只在
 *   agentExecutor === defaultAgentExecutor 时成立,所以这些用例不扫宿主仓库。)
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

describe('R-19 产物索引:graph_files / graph_edge_contracts', () => {
  let wdb;
  let engine;
  let runsDir;
  const TEST_DB_PATH = '/tmp/test-graph-files-index.sqlite';

  beforeAll(() => {
    for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
      try { fs.unlinkSync(f); } catch {}
    }
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-files-runs-'));
    process.env.GRAPH_RUNS_DIR = runsDir;
    for (const m of [
      '../../services/graph-db',
      '../../services/graph-service',
      '../../services/org-graph-service',
      '../../services/work-graph-db',
      '../../services/graph-run-sse',
      '../../services/graph-engine',
    ]) {
      delete require.cache[require.resolve(m)];
    }
    wdb = require('../../services/work-graph-db');
    engine = require('../../services/graph-engine');
  });

  afterAll(() => {
    for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
      try { fs.unlinkSync(f); } catch {}
    }
    delete process.env.GRAPH_DB_PATH;
    try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
    delete process.env.GRAPH_RUNS_DIR;
    for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) {
      delete require.cache[require.resolve(m)];
    }
  });

  // ── 测试脚手架 ────────────────────────────────────────────────

  function ensureAgents(ids) {
    const db = wdb.getDb();
    const ts = new Date().toISOString();
    const ins = db.prepare(`
      INSERT OR IGNORE INTO system_agents
        (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
      VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
    `);
    for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, ts, ts);
  }

  /** 建图 + 建 run + 置 ready + start,并给出「按节点名拿 defNodeId / 边 id」的取值器 */
  function buildRun({ companyId, taskId, nodes, edges = [] }) {
    ensureAgents(nodes.map((n) => n.systemAgentId || n.nodeName));
    const g = wdb.createWorkGraph({ companyId, taskId, nodes, edges });
    expect(g.errors || []).toEqual([]);
    const def = wdb.readDefGraph('work', g.id);
    const byName = new Map(def.nodes.map((n) => [n.name, n.defNodeId]));
    const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, taskId });
    engine.markRunReady(run.id);
    engine.startRun(run.id, { mode: 'manual' });
    return {
      def,
      runId: run.id,
      defNodeId: (name) => byName.get(name),
      edgeId: (from, to) =>
        def.edges.find((e) => e.from === byName.get(from) && e.to === byName.get(to)).id,
    };
  }

  /** 节点私有产物目录 —— 与 graph-engine.nodeArtifactDir 同源(GRAPH_RUNS_DIR 覆盖分支) */
  const nodeDir = (rootRunId, nodeRunId) => path.join(runsDir, rootRunId, 'nodes', nodeRunId);
  /** 公司根 —— 与 graph-engine.companyRootDir 同源(同一个 override 分支) */
  const companyRoot = (companyId) => path.join(runsDir, '_companies', companyId);

  /**
   * 跑 n 步,收集每个节点看到的提示词。writes[nodeName] 给出这个节点要落盘的文件,
   * 落在它自己的私有目录里、outputRef 指向该目录 —— 与 defaultAgentExecutor 的形状一致。
   */
  async function advance(runId, steps, { writes = {}, status = () => 'success' } = {}) {
    const tasks = new Map();
    engine.setAgentExecutor(async (ctx) => {
      tasks.set(ctx.nodeName, ctx.task);
      const files = writes[ctx.nodeName];
      if (!files) return { status: status(ctx.nodeName) };
      const dir = nodeDir(ctx.run.root_run_id, ctx.nodeRunId);
      fs.mkdirSync(dir, { recursive: true });
      for (const [name, body] of Object.entries(files)) {
        fs.writeFileSync(path.join(dir, name), body);
      }
      return { status: status(ctx.nodeName), outputRef: dir };
    });
    try {
      for (let i = 0; i < steps; i++) await engine.advanceOne(runId);
    } finally {
      engine.setAgentExecutor(null);
    }
    return tasks;
  }

  const allFiles = (companyId) =>
    wdb.getDb()
      .prepare(`SELECT * FROM graph_files WHERE company_id = ? ORDER BY created_at, id`)
      .all(companyId);

  // ── ① 版本链 ─────────────────────────────────────────────────

  it('① 同一 (company, task, relPath) 二次产出:旧行标 superseded_by 而不是被删,findFiles 只给最新那份', () => {
    const companyId = 'co-files-ver';
    const taskId = 'task-ver';
    const rel = 'tasks/task-ver/brd.md';

    const v1 = wdb.recordFile({ companyId, taskId, relPath: rel, kind: 'brd' });
    const v2 = wdb.recordFile({ companyId, taskId, relPath: rel, kind: 'brd' });
    expect(v2).not.toBe(v1);

    const rows = allFiles(companyId);
    // 历史不被删:两行都在
    expect(rows.map((r) => r.id).sort()).toEqual([v1, v2].sort());
    // 旧行指向新行
    expect(rows.find((r) => r.id === v1).superseded_by).toBe(v2);
    // 当前版本恰好一行
    expect(rows.filter((r) => r.superseded_by === null).map((r) => r.id)).toEqual([v2]);

    // 读回:findFiles 只返回最新那份
    const found = wdb.findFiles({ companyId, taskId, kinds: ['brd'] });
    expect(found.map((f) => f.id)).toEqual([v2]);
  });

  // ── ② 部分唯一索引 ───────────────────────────────────────────

  it('② 部分唯一索引:两行同路径且都 superseded_by IS NULL 被拒;历史行同路径可共存', () => {
    const db = wdb.getDb();
    const companyId = 'co-files-idx';
    const rel = 'tasks/task-idx/prd.md';
    const ins = db.prepare(`
      INSERT INTO graph_files (id, company_id, task_id, rel_path, kind, created_at, superseded_by)
      VALUES (?, ?, 'task-idx', ?, 'prd', ?, ?)
    `);
    const now = new Date().toISOString();

    ins.run('gf-live-1', companyId, rel, now, null);
    // 第二个「当前版本」必须被索引挡住 —— 否则 findFiles 会同时返回两份,
    // 下游拿不准该读哪个(这条锁的是「用部分唯一索引」这个选择本身)
    expect(() => ins.run('gf-live-2', companyId, rel, now, null)).toThrow(/UNIQUE/i);
    // 而历史行(superseded_by 非空)同路径可以随便共存 —— 索引是**部分**的,不是全量的
    expect(() => ins.run('gf-old-1', companyId, rel, now, 'gf-live-1')).not.toThrow();
    expect(() => ins.run('gf-old-2', companyId, rel, now, 'gf-live-1')).not.toThrow();
    expect(allFiles(companyId).filter((r) => r.superseded_by === null)).toHaveLength(1);
  });

  // ── ③a 沿边传递(全四环)+ ⑤ attempt-*.json 不登记 ──────────

  it('③a 沿边传递:出边声明 brd → 引擎登记实例 → 下游提示词里是绝对路径;attempt-*.json 不登记', async () => {
    const companyId = 'co-files-edge';
    const taskId = 'task-edge';
    const b = buildRun({
      companyId, taskId,
      nodes: [{ nodeName: 'brd-writer' }, { nodeName: 'prd-writer' }],
      edges: [{ from: 'brd-writer', to: 'prd-writer', condition: 'success' }],
    });
    // 产生:规划时就在边上声明「这条边传一份 brd」
    wdb.declareEdgeContract({
      edgeLayer: 'work', edgeId: b.edgeId('brd-writer', 'prd-writer'), kind: 'brd',
    });

    const tasks = await advance(b.runId, 2, {
      writes: {
        'brd-writer': {
          'brd.md': '# BRD',
          // 引擎自读的归因文件,不是给下游的产物
          'attempt-1.json': JSON.stringify({ fail_reason: null }),
        },
      },
    });

    // 落库:只登记 brd.md,kind 取出边声明的 brd(不是兜底的 artifact)
    const rows = allFiles(companyId);
    expect(rows).toHaveLength(1);
    expect(rows[0].kind).toBe('brd');
    expect(path.basename(rows[0].rel_path)).toBe('brd.md');
    // attempt-*.json 一行都不该有
    expect(rows.filter((r) => /attempt-/.test(r.rel_path))).toEqual([]);

    // 传递:入边契约查得到
    const inbound = wdb.inboundContracts('work', b.def.edges, b.defNodeId('prd-writer'));
    expect(inbound.map((c) => c.kind)).toEqual(['brd']);

    // 读回:下游提示词里是能直接 open 的绝对路径
    const abs = path.join(companyRoot(companyId), rows[0].rel_path);
    expect(fs.existsSync(abs)).toBe(true);
    const prompt = tasks.get('prd-writer');
    expect(prompt).toContain('[上游输入');
    expect(prompt).toContain(`- brd:${abs}`);
    // 查得到实例就不该出现缺输入告警(段尾那句「标 ⚠️ 的表示…」是固定说明,不算告警)
    expect(prompt).not.toContain('⚠️ 上游声明会产出');
    // 上游自己那一步没有入边,不该凭空多一段
    expect(tasks.get('brd-writer')).not.toContain('[上游输入');
  });

  // ── ③b 任务级共享:非相邻可见 ────────────────────────────────

  it('③b 任务级共享:planner 产的 acceptance,隔着 coder 的 validator 也查得到(R-9.3 的形状)', async () => {
    const companyId = 'co-files-shared';
    const taskId = 'task-shared';
    const b = buildRun({
      companyId, taskId,
      nodes: [{ nodeName: 'planner' }, { nodeName: 'coder' }, { nodeName: 'validator' }],
      edges: [
        { from: 'planner', to: 'coder', condition: 'success' },
        { from: 'coder', to: 'validator', condition: 'success' },
      ],
    });
    // 刻意**不**声明任何边契约:planner→validator 根本没有边,纯边绑定表达不了这条依赖

    const tasks = await advance(b.runId, 3, {
      writes: { planner: { 'acceptance.json': JSON.stringify({ checks: ['a'] }) } },
    });

    const rows = allFiles(companyId);
    expect(rows).toHaveLength(1);
    expect(rows[0].kind).toBe('acceptance'); // 无声明时按文件名定 kind
    const abs = path.join(companyRoot(companyId), rows[0].rel_path);

    // 非相邻的 validator 拿到了路径 —— 这才是「两类数据流都要」的理由
    const prompt = tasks.get('validator');
    expect(prompt).toContain('[上游输入');
    expect(prompt).toContain(`- acceptance(任务级共享):${abs}`);
    // 相邻的 coder 同样看得到(任务级共享不受相邻性限制)
    expect(tasks.get('coder')).toContain(abs);
    // planner 跑的时候索引里还没有实例,不该有段落
    expect(tasks.get('planner')).not.toContain('[上游输入');
  });

  // ── ④ 缺输入告警(R-19.4 读回端)────────────────────────────

  it('④a 声明的 produces 一个候选文件都没落盘:节点本身按 fail 结算(produces_missing),不点亮下游', async () => {
    // T-mt25ecbd 复现原样:brd/prd/sdd 三个 planner 节点声明了 produces 却真的什么都没写,
    // executor 没抛异常就被引擎当 success——这道闸门堵在这里,不再等下游发现读不到东西。
    const companyId = 'co-files-missing';
    const taskId = 'task-missing';
    const b = buildRun({
      companyId, taskId,
      nodes: [{ nodeName: 'sdd-writer' }, { nodeName: 'impl' }],
      edges: [{ from: 'sdd-writer', to: 'impl', condition: 'success' }],
    });
    wdb.declareEdgeContract({
      edgeLayer: 'work', edgeId: b.edgeId('sdd-writer', 'impl'), kind: 'sdd', required: true,
    });

    // 上游什么都不写 → 声明在、连候选文件都没有
    const tasks = await advance(b.runId, 2);
    expect(allFiles(companyId)).toEqual([]);

    const state = wdb.getRunState(b.runId);
    const sddWriter = state.nodes.find((n) => n.name === 'sdd-writer');
    expect(sddWriter.status).toBe('blocked'); // fail → 没有 fail 边 → 终态 blocked(FSM)
    expect(sddWriter.failReason).toBe('produces_missing');
    // 上游都没能成功,下游压根不会被点亮,更谈不上跑
    expect(tasks.has('impl')).toBe(false);
  });

  it('④b 声明了 required 输入却查不到实例:提示词里出现 ⚠️,agent 判自己成功也不被引擎采信(missing_required_input)', async () => {
    const companyId = 'co-files-missing-needs';
    const taskId = 'task-missing-needs';
    const b = buildRun({
      companyId, taskId,
      nodes: [{ nodeName: 'sdd-writer' }, { nodeName: 'impl' }],
      edges: [{ from: 'sdd-writer', to: 'impl', condition: 'success' }],
    });
    wdb.declareEdgeContract({
      edgeLayer: 'work', edgeId: b.edgeId('sdd-writer', 'impl'), kind: 'sdd', required: true,
    });

    // 上游写了两份跟声明的 sdd 对不上、彼此也无歧义可猜的文件(同 ⑦ 的「有歧义时不猜」口径,
    // 两份都落成 kind=artifact)——产出闸门只看「有没有候选文件」,不判分类对不对,
    // 所以 sdd-writer 正常 success,impl 照样被点亮。但 graph_files 里查不到 kind=sdd 的
    // 实例——这正是 R-19.4 要报的那种「声明了却查不到」。
    const tasks = await advance(b.runId, 2, {
      writes: { 'sdd-writer': { '文档一.md': '跟 sdd 无关', '文档二.md': '跟 sdd 也无关' } },
    });

    const prompt = tasks.get('impl');
    expect(prompt).toContain('[上游输入');
    expect(prompt).toContain('- sdd:⚠️');
    expect(prompt).toContain('索引里查不到实例');

    // impl 自己的 executor 判了 success(advance 默认 status: () => 'success'),
    // 但引擎不采信——它自己也声明了要吃 required 的 sdd,查不到实例就不算数。
    const state = wdb.getRunState(b.runId);
    const impl = state.nodes.find((n) => n.name === 'impl');
    expect(impl.status).toBe('blocked');
    expect(impl.failReason).toBe('missing_required_input');
  });

  // ── ⑤ 节点 fail 不登记 ──────────────────────────────────────

  it('⑤ 节点 fail 时落盘的半成品不进索引(下游不该把它当输入读)', async () => {
    const companyId = 'co-files-fail';
    const taskId = 'task-fail';
    const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'flaky' }] });

    await advance(b.runId, 1, {
      writes: { flaky: { 'brd.md': '# 半成品' } },
      status: () => 'fail',
    });

    // 文件真的落盘了(不是没写),但索引里一行都没有
    const state = wdb.getRunState(b.runId);
    // 图上没有任何 fail 边,失败节点最终落 blocked(fail → blocked 是 FSM 的终态转换)
    expect(state.nodes[0].status).toBe('blocked');
    expect(fs.existsSync(path.join(state.nodes[0].outputRef, 'brd.md'))).toBe(true);
    expect(allFiles(companyId)).toEqual([]);
  });

  // ── ⑥ 索引重建 ─────────────────────────────────────────────

  it('⑥ graph_files 被清掉后能从 graph_run_nodes 重建(有损:kind 退成 artifact)', async () => {
    const companyId = 'co-files-rebuild';
    const taskId = 'task-rebuild';
    const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'brd-writer' }] });

    await advance(b.runId, 1, { writes: { 'brd-writer': { 'brd.md': '# BRD' } } });
    expect(allFiles(companyId)).toHaveLength(1);

    // 索引坏了 / 被清空
    wdb.getDb().prepare(`DELETE FROM graph_files WHERE company_id = ?`).run(companyId);
    expect(wdb.findFiles({ companyId, taskId })).toEqual([]);

    // 节点行还在 → 能重建出「这个节点产出过哪个目录」
    expect(wdb.rebuildFilesFromNodes(companyId)).toBe(1);
    const rebuilt = wdb.findFiles({ companyId, taskId });
    expect(rebuilt).toHaveLength(1);
    expect(rebuilt[0].kind).toBe('artifact'); // 重建恢复不了语义,只恢复位置
    expect(rebuilt[0].rel_path).toBe(wdb.getRunState(b.runId).nodes[0].outputRef);
  });

  // ── ⑦ 文件名认不出 kind 时靠声明认领 ────────────────────────
  //
  // 第五次真 CLI 冒烟里这一环成立**纯属侥幸**:brd 节点恰好把文件命名成 `brd.md`。
  // agent 给产物起中文名是完全正常的行为,不该由文件名决定索引对不对。

  it('⑦ 文件名认不出 kind 时按出边声明认领(一对一无歧义),下游照样拿到路径', async () => {
    const companyId = 'co-files-claim';
    const taskId = 'task-claim';
    const b = buildRun({
      companyId, taskId,
      nodes: [{ nodeName: 'brd-writer' }, { nodeName: 'prd-writer' }],
      edges: [{ from: 'brd-writer', to: 'prd-writer', condition: 'success' }],
    });
    wdb.declareEdgeContract({
      edgeLayer: 'work', edgeId: b.edgeId('brd-writer', 'prd-writer'), kind: 'brd',
    });

    const tasks = await advance(b.runId, 2, {
      writes: {
        'brd-writer': {
          // 文件名里一个 "brd" 字都没有 —— 老逻辑会落成 kind=artifact
          '业务需求文档.md': '# 业务需求',
          // 同目录还有一个能按文件名认出来的(真跑里就是这个形状),
          // 它必须先被 contract 认领掉,否则「还剩一个认不出的文件」不成立
          'contract.json': JSON.stringify({ outputs: [] }),
          'attempt-1.json': JSON.stringify({ fail_reason: null }),
        },
      },
    });

    const rows = allFiles(companyId);
    const byKind = new Map(rows.map((r) => [r.kind, r]));
    expect([...byKind.keys()].sort()).toEqual(['brd', 'contract']);
    expect(path.basename(byKind.get('brd').rel_path)).toBe('业务需求文档.md');

    // 真正要防的是下游拿不到输入 —— 落成 artifact 时这一行会变成 ⚠️
    const prompt = tasks.get('prd-writer');
    expect(prompt).toContain(`- brd:${path.join(companyRoot(companyId), byKind.get('brd').rel_path)}`);
    expect(prompt).not.toContain('⚠️ 上游声明会产出');
  });

  it('⑦ 有歧义时不猜:两个认不出的文件配一个声明 → 都落 artifact', async () => {
    const companyId = 'co-files-ambiguous';
    const taskId = 'task-ambiguous';
    const b = buildRun({
      companyId, taskId,
      nodes: [{ nodeName: 'brd-writer' }, { nodeName: 'prd-writer' }],
      edges: [{ from: 'brd-writer', to: 'prd-writer', condition: 'success' }],
    });
    wdb.declareEdgeContract({
      edgeLayer: 'work', edgeId: b.edgeId('brd-writer', 'prd-writer'), kind: 'brd',
    });

    const tasks = await advance(b.runId, 2, {
      writes: { 'brd-writer': { '文档一.md': 'a', '文档二.md': 'b' } },
    });

    // 猜错比留 artifact 更坏:错的 kind 会让下游读到一份不是它要的文件,
    // 而 artifact 只是查不到 —— 后者会如实报 ⚠️,前者是静默的错。
    const rows = allFiles(companyId);
    expect(rows.map((r) => r.kind)).toEqual(['artifact', 'artifact']);
    expect(tasks.get('prd-writer')).toContain('- brd:⚠️');
  });

  // ── ⑧ 任务级共享按 kind 去重 ────────────────────────────────

  it('⑧ 两个节点各写一份同 kind 的任务级产物:提示词里只列最新那一份,不逐个累加', async () => {
    const companyId = 'co-files-dedupe';
    const taskId = 'task-dedupe';
    const b = buildRun({
      companyId, taskId,
      nodes: [{ nodeName: 'first' }, { nodeName: 'second' }, { nodeName: 'third' }],
      edges: [
        { from: 'first', to: 'second', condition: 'success' },
        { from: 'second', to: 'third', condition: 'success' },
      ],
    });

    // 真跑里的形状:brd 与 prd 两个节点各写了一份 contract.json,都是 kind=contract
    const tasks = await advance(b.runId, 3, {
      writes: {
        first: { 'contract.json': JSON.stringify({ v: 1 }) },
        second: { 'contract.json': JSON.stringify({ v: 2 }) },
      },
    });

    // 两份都在索引里(各自路径不同,都是当前版本)——去重只发生在拼提示词那一步
    const rows = allFiles(companyId).filter((r) => r.kind === 'contract');
    expect(rows).toHaveLength(2);

    const prompt = tasks.get('third');
    const listed = prompt.split('\n').filter((l) => l.includes('(任务级共享)'));
    expect(listed).toHaveLength(1);
    // 最新的赢(findFiles 是 created_at 升序,与沿边那一半的 byKind.set 同口径)
    expect(listed[0]).toContain(path.join(companyRoot(companyId), rows[1].rel_path));
    expect(listed[0]).not.toContain(rows[0].rel_path);
  });

  // ── ⑨ 出边上的 needs 不算本节点的产出 ──────────────────────────
  //
  // Chrome 走查里实测到的:p-prd 的出边写 produces['prd'] + needs['brd'](中继声明),
  // 收集「本节点产出哪些 kind」时若不按 role 过滤,未认领 kind 数就是 2 而不是 1,
  // ⑦ 那条一对一认领当场失效 —— 文件退成 artifact、下游拿不到。

  it('⑨ 出边同时有 produces 与 needs:认领只看 produces,中继的 needs 不参与', async () => {
    const companyId = 'co-files-role';
    const taskId = 'task-role';
    const b = buildRun({
      companyId, taskId,
      nodes: [{ nodeName: 'w-brd' }, { nodeName: 'w-prd' }, { nodeName: 'w-val' }],
      edges: [
        { from: 'w-brd', to: 'w-prd', condition: 'success' },
        { from: 'w-prd', to: 'w-val', condition: 'success' },
      ],
    });
    // w-prd 的出边:它产 prd;同时声明 w-val 还要吃 brd(产出方是 w-brd,不是紧邻上游)
    const e2 = b.edgeId('w-prd', 'w-val');
    wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'prd', role: 'produces' });
    wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'brd', role: 'needs' });

    await advance(b.runId, 2, {
      writes: {
        // 文件名认不出 kind → 只能靠声明认领;needs 混进来的话这里会退成 artifact
        'w-prd': { '产品需求文档.md': '# 产品需求' },
      },
    });

    const rows = allFiles(companyId);
    expect(rows).toHaveLength(1);
    expect(rows[0].kind).toBe('prd');
  });

  it('⑨ 出边只有 needs 时不认领(本节点什么都没声明要产)', async () => {
    const companyId = 'co-files-only-needs';
    const taskId = 'task-only-needs';
    const b = buildRun({
      companyId, taskId,
      nodes: [{ nodeName: 'n-a' }, { nodeName: 'n-b' }],
      edges: [{ from: 'n-a', to: 'n-b', condition: 'success' }],
    });
    wdb.declareEdgeContract({
      edgeLayer: 'work', edgeId: b.edgeId('n-a', 'n-b'), kind: 'sdd', role: 'needs',
    });

    await advance(b.runId, 1, { writes: { 'n-a': { '随便写的.md': 'x' } } });

    // needs 不是产出声明,不该被拿来认领 —— 认领了就是把「下游要吃 sdd」
    // 误读成「我产 sdd」,下游读到一份不是它要的文件
    const rows = allFiles(companyId);
    expect(rows).toHaveLength(1);
    expect(rows[0].kind).toBe('artifact');
  });

  // ── ⑩ 引擎自己的簿记文件不进索引 ────────────────────────────────

  it('⑩ contract.prev.json 不进索引(retryNode 的契约快照,不是节点交付物)', async () => {
    const companyId = 'co-files-snapshot';
    const taskId = 'task-snapshot';
    const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 's-brd' }] });

    await advance(b.runId, 1, {
      writes: {
        's-brd': {
          'brd.md': '# BRD',
          // 引擎为算 contract_hash 留的上一轮快照。漏掉它的话,
          // 每次人工重试都往 graph_files 添一行 kind=artifact 的噪声
          'contract.prev.json': JSON.stringify({ outputs: [] }),
          'attempt-1.json': JSON.stringify({ fail_reason: null }),
        },
      },
    });

    const rows = allFiles(companyId);
    expect(rows.map((r) => path.basename(r.rel_path))).toEqual(['brd.md']);
  });

  // ── ⑪ 33 号文档:produces 声明 required:false 时不拦下游 ─────────
  //
  // 复现 executor.json 里 test→validator 那条边的真实形状:test 出边同时声明
  // produces:[{kind:'log', required:false}](自己产出的记录,写作文档但不强制)
  // 与 needs:[{kind:'artifact', required:true}](中继声明,产出方是更早的 impl)。
  // test 实际写的文件(_plan.md/_research.md 之类)从来认不出 kind=log ——
  // 这在真实回归任务 T-mt2r6fz0-125b 里复现过:validator 被 missing_required_input
  // 反复误判 blocked,而它自己判定 passed。required:false 让这条声明只留文档价值,
  // 不再是硬闸门;真正必需的 needs:artifact 依旧受硬闸门保护(不因这次修复被削弱)。

  it('⑪ produces 声明 required:false 时,即使这个 kind 从未被索引到,也不拦下游成功', async () => {
    const companyId = 'co-files-optional-produces';
    const taskId = 'task-optional-produces';
    const b = buildRun({
      companyId, taskId,
      nodes: [{ nodeName: 'w-impl' }, { nodeName: 'w-test' }, { nodeName: 'w-validator' }],
      edges: [
        { from: 'w-impl', to: 'w-test', condition: 'success' },
        { from: 'w-test', to: 'w-validator', condition: 'success' },
      ],
    });
    wdb.declareEdgeContract({
      edgeLayer: 'work', edgeId: b.edgeId('w-impl', 'w-test'), kind: 'artifact', role: 'produces',
    });
    const e2 = b.edgeId('w-test', 'w-validator');
    // 与 executor.json 修复后的形状逐字对应
    wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'log', role: 'produces', required: false });
    wdb.declareEdgeContract({ edgeLayer: 'work', edgeId: e2, kind: 'artifact', role: 'needs', required: true });

    await advance(b.runId, 3, {
      writes: {
        'w-impl': { 'impl.md': '# 实现产物' },
        // test 写的文件跟 "log" 毫无关联、彼此也无歧义可猜(同 ④b 的口径),
        // 这个 kind 永远不会被索引到——正是修复要覆盖的那种情形
        'w-test': { '_plan.md': '计划', '_research.md': '调研' },
      },
    });

    const state = wdb.getRunState(b.runId);
    const validator = state.nodes.find((n) => n.name === 'w-validator');
    // 关键断言:log 从未被索引,但它是 required:false,不该拦下游
    expect(allFiles(companyId).some((f) => f.kind === 'log')).toBe(false);
    expect(validator.status).toBe('success');
    expect(validator.failReason).toBeFalsy();
  });
});