Commit 925d9db70d56fcb83a6a692bc5e839a85599fb8f

Authored by yibo
1 parent 2c65563a

feat(p4): R-19 产物索引——补上「同图内上游到下游没有数据通路」这个断点

第四次真跑的断点:org-planner-prd 失败,它自己的归因原话是
「不是上游没产出,是它的产出**结构上到不了我手上**」。核实属实 ——
上游 brd.md / contract.json 落在**节点私有目录**,prd 的提示词只有它自己的私有目录
+ 只读 artifacts/,而 brd--success-->prd 这条边**不携带任何文件引用**。
边一直只承载控制流(condition/edge_kind/fail_reason),R-4 要的「边的输入输出」
被落成了从 output_ref **推断**的 UI 展示、没有实体,而推断的那一端下游读不到。

## 定案(27-REQ R-19)

- 建 sqlite 表而不是 files.json:JSON 会重演 _registry.json 的并发重写与 git 冲突
  (本轮刚被它咬过,W13 只能把 usedCount 挪去另一个被 ignore 的文件)
- **两类数据流都要**:纯边绑定表达不了 R-9.3 的「planner 生成、validator 执行」——
  org graph 是 planner→coder→validator,acceptance 跳过 coder,这两点之间没有边
- workspace 根**代码推导不入库**:存绝对路径会烂,W13 清的那 10 条 legacy_path
  悬空行就是这个病。表里只存相对公司根的路径
- 路径走 tasks/{taskId}/,不走 workspace/:后者语义是「Agent 自治领地、框架不干预」
  (06-沙盒自治),与「框架管着、可检索的交付物区」是两种所有权
- 契约(kind)与实例(path)分规划时/运行时两阶段 → 引擎可在节点跑之前校验
  「你声明要吃 brd,但上游没人产 brd」,这类图结构错误此前只能等跑到那步才发现

## 实现

work-graph-db:graph_files(实例)+ graph_edge_contracts(契约)两张表。
graph_files 的当前版本走**部分唯一索引** WHERE superseded_by IS NULL ——
与 idx_org_graph_nodes_company_role_active 同一模式:要「同键只有一个活的」
又要留历史,条件得写进索引而不是靠删行。API:recordFile / findFiles /
declareEdgeContract / edgeContracts / inboundContracts / rebuildFilesFromNodes。

graph-engine 两侧都接:
- 输入侧 describeInputs:按**入边声明的 kind** + **本 task 共享产物** 查实例,
  把绝对路径拼进提示词。声明了却查不到的**明说缺哪个 kind**,不静默省略。
  刻意不是「注入一句『索引在某文件,自己去检索』」—— 那仍靠 agent 自觉拼路径,
  真跑已证明它拼不出来;引擎直接递到手上,不给它猜的机会
- 产出侧 indexNodeOutputs:节点 success 时登记落盘文件,kind 先看出边声明、
  再按文件名兜底;attempt-*.json 是引擎自读的归因文件,不当产物登记

文档:27-REQ 新增 R-19(四条,含两段原话);27-P4 §4.6 按定案重写
(原「公司根一个 files.json」作废)。

测试 788/788 全绿。后续(下一轮):规划侧声明契约、goal-contract 路径归并、
前端「输入输出」tab 展示解析结果、真 CLI 复验 prd 能拿到输入。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/todo/system-design/27-p4-parallel-and-artifacts.md
... ... @@ -199,50 +199,121 @@ DROP INDEX IF EXISTS idx_graph_runs_one_active_root;
199 199  
200 200 **为什么从「索引强制」改为「计数检查」**:唯一索引只能表达「≤1」,表达不了「≤3」。计数检查要放在**同一个事务**里(先 `SELECT COUNT(*)` 再 `INSERT`),否则并发建 run 会超额。
201 201  
202   -### 4.6 产物统一目录 + `files.json`
  202 +### 4.6 产物统一目录 + 产物索引表(R-19 定案后重写)
203 203  
204   -**目录约定**(用户要求的第 3 项,P3 未实现):
  204 +> **v0.2 重写**:本节原设计是「公司根放一个 `files.json`」。[27-REQ](./27-requirement.md) R-19
  205 +> 改为**建 sqlite 表**,并把「边即输入输出」这层显式化。原设计的目录约定保留。
  206 +
  207 +#### 为什么这一节从「优化项」变成「阻塞项」
  208 +
  209 +第四次真 CLI 冒烟(自动模式)跑出的断点:`org-planner-prd` 失败,它自己的归因说得很准 ——
  210 +**「不是上游没产出,是它的产出结构上到不了我手上」**。核实:上游 `brd` 的 `brd.md` /
  211 +`contract.json` 落在**它自己的节点私有目录**,共享区 `artifacts/` 只有
  212 +`project-root.json` 与 `task-goal.json`,而 `brd --success--> prd` 这条边
  213 +**不携带任何文件引用**。
  214 +
  215 +同一张 work graph 内部,上游到下游**没有任何数据通路**。这不是引擎 bug ——
  216 +边一直只承载控制流(`condition` / `edge_kind` / `fail_reason`),
  217 +而 [27-P3/P4](./27-p3-p4-work-graph-console.md) §四.B.3 把 R-4 要的「边的输入输出」
  218 +落成了**从 `output_ref` 推断**的 UI 展示,没有实体。推断的那一端是私有目录,下游读不到。
  219 +
  220 +**结论:不通这条路,任何多节点的图都只能跑完第一个节点。** 它不依赖并行,应从 P4 里
  221 +单独拆出来先做。
  222 +
  223 +#### 目录约定(沿用原设计)
205 224  
206 225 ```
207 226 data/companies/{companyId}/
208   - files.json ← 统一检索索引(本节重点)
209 227 tasks/{taskId}/
210   - brd.md org-planner 的 work graph 里 brd-planning 节点的产出
211   - prd.md
212   - sdd.md
213   - acceptance.json 验收契约(goal-contract.js 已有,路径归并到这里)
214   - {nodeName}/ 节点私有的中间产物(attempt-{n}.json 等)
  228 + brd.md · prd.md · sdd.md
  229 + acceptance.json 验收契约(goal-contract.js 的路径归并到这里)
  230 + {nodeName}/ 节点私有的中间产物(attempt-{n}.json 等)
215 231 ```
216 232  
217   -**为什么按 task 分目录而不是平铺在公司根目录**:同一家公司会跑很多个 task,平铺的话第二个 task 的 `prd.md` 会覆盖第一个。按 task 分目录既避免覆盖,也保留历史。
218   -
219   -**`files.json` 的形态**:
220   -
221   -```jsonc
222   -{
223   - "version": "1",
224   - "companyId": "auto-illust",
225   - "files": [
226   - {
227   - "path": "tasks/T-abc/prd.md", // 相对 data/companies/{cid}/
228   - "kind": "prd", // brd | prd | sdd | acceptance | artifact | log
229   - "taskId": "T-abc",
230   - "producedBy": { // 谁产出的
231   - "runId": "gr_xxx", "nodeName": "org-planner-prd", "systemAgentId": "prd-planning"
232   - },
233   - "consumedBy": ["org-coder", "org-validator"], // 声明式:谁应该读它
234   - "createdAt": "2026-08-16T...",
235   - "supersedes": "tasks/T-abc/prd.md@rev1" // 同名产物的版本链,可空
236   - }
237   - ]
238   -}
  233 +**按 task 分目录而不是平铺**:同一家公司会跑很多 task,平铺的话第二个 task 的 `prd.md`
  234 +会覆盖第一个。按 task 分既避免覆盖,也保留历史。
  235 +
  236 +**不走 `workspace/`**(R-19.3 定案):`workspace/` 的现有语义是
  237 +[06-沙盒自治](../../done/system-design/06-sandbox-autonomy.md) 定的
  238 +「**Agent 自治领地,框架不干预**」。产物索引要的是「**框架管着的、可检索的**交付物区」,
  239 +两种所有权混在一个目录里,索引就无法保证不漂移。
  240 +
  241 +**根路径用代码推导,不入库**(R-19.3 定案):库里只存**相对公司根**的路径。
  242 +理由是库里存绝对路径会烂 —— W13 清掉的那 10 条 `legacy_path` 悬空行就是这个病
  243 +(指向早已删除的目录)。推导方式与现有 `runRootDir` / `nodeArtifactDir` 一致,
  244 +需要覆盖时给环境变量(测试隔离已在用 `GRAPH_RUNS_DIR` 这招)。
  245 +另:**库里没有 `companies` 表**,公司是 `company.json` + 目录扫描,所以也没有
  246 +「company 表的定义」这个落点。
  247 +
  248 +#### 两类数据流,都要(R-19.3 定案①)
  249 +
  250 +纯「文件挂在边上」有硬伤:边是 `(from, to)` 二元关系,只能表达**相邻**节点间的传递。
  251 +而 R-9.3 定的「验收产物由 planner 生成、由 validator 执行」是**非相邻**依赖 ——
  252 +org graph 是 `org-planner → org-coder → org-validator`,`acceptance.json`
  253 +从 planner 流到 validator,**跳过 coder,这两个节点之间没有边**。
  254 +
  255 +| | 沿边传递 | 任务级共享 |
  256 +|---|---|---|
  257 +| 例 | `brd.md` → 紧邻的 prd 节点 | `acceptance.json` → 任意下游 validator |
  258 +| 绑定方式 | 边上声明契约(`kind`) | 挂在 task 上,按 `kind` 检索 |
  259 +| 受拓扑相邻性限制 | 是 | **否** |
  260 +
  261 +#### 契约与实例分两阶段(R-19.4)
  262 +
  263 +`workgraph-planning` 画图时**不可能知道文件名**(节点还没跑),但它能声明
  264 +「这条边传一份 `brd`」。所以:
  265 +
  266 +- **规划时**:边上声明**契约**(`kind`),落 `graph_edge_contracts`
  267 +- **运行时**:节点产出落盘后,引擎记**实例**(`rel_path`),落 `graph_files`
  268 +
  269 +由此得到一个原设计没有的收益:**引擎能在节点跑之前校验「你声明要吃 brd,但上游没有任何
  270 +节点产 brd」**。这类图结构错误现在完全查不出来,只能等跑到那一步 agent 说「我拿不到输入」
  271 +(第四次真跑正是这么发现的)。
  272 +
  273 +#### 表结构
  274 +
  275 +```sql
  276 +-- 产物实例:运行时由**引擎**追加。不靠 agent 自觉写,否则索引必然与实际文件漂移
  277 +-- (本轮已被验证过多次:凡「靠 agent 自觉」的环节几乎全部落空)。
  278 +CREATE TABLE IF NOT EXISTS graph_files (
  279 + id TEXT PRIMARY KEY, -- gf_<nanoid>
  280 + company_id TEXT NOT NULL,
  281 + task_id TEXT, -- 任务级共享产物按它检索
  282 + rel_path TEXT NOT NULL, -- **相对公司根**,根由代码推导
  283 + kind TEXT NOT NULL, -- brd|prd|sdd|acceptance|contract|artifact|log
  284 + produced_by_run_id TEXT,
  285 + produced_by_node_run_id TEXT, -- 与 graph_run_nodes.output_ref 同源
  286 + created_at TEXT NOT NULL,
  287 + superseded_by TEXT -- 同名产物的版本链,可空
  288 +);
  289 +
  290 +-- 边上声明的数据契约:规划时写,只有 kind 没有实例。
  291 +-- edge_layer 区分两层边表 —— org_graph_edges.id 是 INTEGER、work_graph_edges.id 是 TEXT,
  292 +-- 与 graph_run_nodes.def_node_id 同一处类型不整齐(见 27-P3/P4 §四.A.2 的注)。
  293 +CREATE TABLE IF NOT EXISTS graph_edge_contracts (
  294 + id TEXT PRIMARY KEY,
  295 + edge_layer TEXT NOT NULL CHECK (edge_layer IN ('org','work')),
  296 + edge_id TEXT NOT NULL,
  297 + kind TEXT NOT NULL,
  298 + required INTEGER NOT NULL DEFAULT 1, -- 0 = 可选输入,缺了不算错
  299 + created_at TEXT NOT NULL
  300 +);
239 301 ```
240 302  
241   -**谁写**:引擎在节点产出落盘后**自动追加**一条——不能靠 agent 自觉写,否则索引必然和实际文件漂移。这与 [27-P3/P4] 里 `output_ref` 的写入时机相同,是同一处扩展。
  303 +#### 引擎怎么用
  304 +
  305 +**产出侧**:节点结算时,除了写 `output_ref`(节点级指针,不变),
  306 +再按落盘的文件往 `graph_files` 追加实例。两者关系与原设计一致 ——
  307 +`output_ref` 回答「我这次跑出了什么」,`graph_files` 回答「这家公司有哪些产物、谁能用」,
  308 +后者由前者派生,所以要有 **rebuild** 能力(索引损坏时从节点行重建,类比 `_rebuildIndex()`)。
242 309  
243   -**谁读**:所有 node agent。在 prompt 里注入一句「本公司的产物索引在 `files.json`,按 `kind`/`taskId` 检索你需要的输入」,取代现在靠约定路径硬拼的方式。
  310 +**输入侧**(这是补上断点的关键):节点执行前,引擎按
  311 +「该节点**入边**声明的 `kind`」+「本 task 的共享产物」去 `graph_files` 查实例,
  312 +把**真实路径**拼进提示词。
244 313  
245   -**与 `output_ref` 的关系**:`output_ref` 是**节点级**的产物指针("我这次跑出了什么"),`files.json` 是**公司级**的检索索引("这家公司有哪些产物、谁能用")。两者不重复:前者一对一挂在 `graph_run_nodes` 行上,后者是聚合视图。**`files.json` 由 `output_ref` 派生**,所以需要一个 rebuild 能力(索引损坏时可从节点行重建),类比 `_index.json` 的 `_rebuildIndex()`。
  314 +> 这比原设计的「注入一句『索引在 files.json,按 kind/taskId 自己检索』」更可靠:
  315 +> 后者仍然依赖 agent 自觉去查、自觉拼对路径,而第四次真跑证明 agent 拼不出来 ——
  316 +> **引擎直接把输入递到它手上**,不给它猜的机会。
246 317  
247 318 ---
248 319  
... ...
docs/todo/system-design/27-requirement.md
... ... @@ -425,6 +425,71 @@ R-16 正文中的原话引用**保留 `validator-reflection` 的用词不改**
425 425  
426 426 ---
427 427  
  428 +## R-19 · 产物统一路径与「边即输入输出」
  429 +
  430 +起于第四次真跑:自动模式下 `org-planner-prd` 失败,它自己的归因写得很准 ——
  431 +「不是上游没产出,是它的产出**结构上到不了我手上**」。上游 `brd.md` / `contract.json`
  432 +落在**节点私有目录**,而 `prd` 的提示词只给了它自己的私有目录 + 只读 `artifacts/`,
  433 +那条 `brd --success--> prd` 的边**不携带任何文件引用**。同一张 work graph 内部
  434 +上游到下游没有数据通路 —— 这不是引擎 bug,是这条路从来没建。
  435 +
  436 +### R-19.1 · 统一产物路径 + 索引(原话)
  437 +
  438 +> 我记得之前说过这个,要所有的 agent 把自己的产出都放在 data/company/company-id/workspace 下,你可以约束一下所有的 agent subagent graph agent,统一一下输出和输入的共享路径。这个路径下有一个 json 文件描述节点,边(输入,输出文件),时间,作为所有文件的索引。
  439 +
  440 +**核实结果**:这条在 [27-REQ](./27-requirement.md) 里**没有记录**(本页此前 0 处提 workspace),
  441 +但设计确实存在 —— [27-P4](./27-p4-parallel-and-artifacts.md) §4.6「产物统一目录 + `files.json`」,
  442 +文中自己标着「用户要求的第 3 项,**P3 未实现**」。所以是**设计在、需求源头漏记、实现未做**。
  443 +
  444 +### R-19.2 · 建表而不是 JSON 文件(原话)
  445 +
  446 +> 所以我们应该在 sqlite 库里增加一个表 files ,再增加 edges-files 对应关系表,关联每一个边和每一个文件的对应关系,边代表着节点的输入输出,这样节点就能找到输入的文件都在哪里,输出的文件存哪里,真实存储目录则有全局的 workspace 指定,可以存在 company 表的定义里
  447 +
  448 +**改掉 §4.6 的 `files.json`,改为建表。** 理由不是抽象的:本轮刚被 `_registry.json` 咬过 ——
  449 +它既是人工维护清单又被 `_updateTemplateUsedCount()` 每次招聘原地改写,任何一次真跑都产生
  450 +git diff,最后 W13 只能把计数挪去另一个被 ignore 的文件。`files.json` 会是同一形状
  451 +(并发追加、整文件重写、和 git 打架)。表没有这些问题,还带外键与可查询性。
  452 +
  453 +### R-19.3 · 三处定案
  454 +
  455 +> 两类都要,workspace 根用代码推导,路径走 tasks/{taskId}/
  456 +
  457 +**① 两类数据流都要。** 纯 `edges_files` 有硬伤:边是 `(from, to)` 二元关系,只能表达
  458 +**相邻**节点间的传递,而 R-9.3 定的「验收产物由 planner 生成、由 validator 执行」是
  459 +**非相邻**依赖 —— org graph 是 `org-planner → org-coder → org-validator`,
  460 +`acceptance.json` 从 planner 流到 validator **跳过 coder,这两个节点之间没有边**。
  461 +所以分两类:
  462 +
  463 +| | 沿边传递 | 任务级共享 |
  464 +|---|---|---|
  465 +| 例 | `brd.md` → 紧邻的 prd 节点 | `acceptance.json` → 任意下游 validator |
  466 +| 绑定 | 边上声明契约(`kind`) | 挂在 task 上,按 `kind` 检索 |
  467 +| 不受限于 | —— | 拓扑相邻性 |
  468 +
  469 +**② workspace 根用代码推导,不入库。** 库里存路径会烂:W13 清掉的那 10 条
  470 +`legacy_path` 悬空行就是这个病(指向早已删除的目录)。`files` 表只存**相对公司根**的路径,
  471 +根由代码推导(与 `runRootDir` / `nodeArtifactDir` 现有做法一致,需要覆盖时给环境变量)。
  472 +另核实:**库里没有 `companies` 表** —— 公司是 `company.json` 文件 + 目录扫描,
  473 +所以原话里「存在 company 表的定义里」这个落点不存在。
  474 +
  475 +**③ 路径走 `tasks/{taskId}/`**,不是 `workspace/`。两个理由:§4.6 原文的「同一家公司会跑
  476 +很多 task,平铺的话第二个 task 的 `prd.md` 会覆盖第一个」;以及 `workspace/` 现有语义是
  477 +[06-沙盒自治](../../done/system-design/06-sandbox-autonomy.md) 定的「**Agent 自治领地,
  478 +框架不干预**」,把框架管着的索引塞进去会混淆两种所有权。
  479 +
  480 +### R-19.4 · 由此带来的一个设计收益(本条是我提出、用户定案的部分之外的推论)
  481 +
  482 +边上声明的是**契约(kind)**、`files` 表记的是**实例(path)**,两者是规划时 / 运行时两阶段:
  483 +`workgraph-planning` 画图时不可能知道文件名,但能声明「这条边传一份 brd」。
  484 +
  485 +于是引擎**能在节点跑之前就校验**「你声明要吃 brd,但上游没有任何节点产 brd」——
  486 +这类图结构错误现在完全查不出来,只能等跑到那一步 agent 说「我拿不到输入」
  487 +(第四次真跑就是这么发现的)。这比事后归因强得多。
  488 +
  489 +**落点**:[27-P4](./27-p4-parallel-and-artifacts.md) §4.6 重写。
  490 +
  491 +---
  492 +
428 493 ## 附 · 需求 → 设计落点 速查
429 494  
430 495 | 需求 | 一句话 | 主要落点 |
... ... @@ -455,3 +520,7 @@ R-16 正文中的原话引用**保留 `validator-reflection` 的用词不改**
455 520 | R-18.1 | 人工操作不该是跑脚本;点一下让 `orggraph-planning` 真跑并自行招聘 | P3B §四 W8 / P2 §4.1 |
456 521 | R-18.2 | **work 层本体由 `workgraph-planning` 自己雇**(方案 a);须同时解决 org graph 被污染 | P3B §四 W5-W7 |
457 522 | R-18.3 | **停用 mock,改真实 Claude CLI 验证**(mock 不读提示词,此前结论只验到引擎层) | P3B 全文 |
  523 +| R-19.1 | 统一产物路径 + 索引(原话此前**未记录**在源头文档,但 27-P4 §4.6 有设计、未实现) | P4 §4.6 |
  524 +| R-19.2 | **建 sqlite 表而不是 files.json**(JSON 会重演 _registry.json 的并发重写与 git 冲突) | P4 §4.6 |
  525 +| R-19.3 | **两类数据流都要**(沿边传递 + 任务级共享);workspace 根**代码推导**不入库;路径走 `tasks/{taskId}/` | P4 §4.6 |
  526 +| R-19.4 | 边存**契约(kind)**、files 表存**实例(path)**,规划/运行两阶段 → 引擎可在跑之前校验「声明要吃的输入没人产」 | P4 §4.6 |
... ...
server/services/graph-engine.js
... ... @@ -46,6 +46,12 @@ const {
46 46 bumpBackwardCount,
47 47 canTransitionRun,
48 48 canTransitionNode,
  49 + // R-19 产物索引(27-P4 §4.6):inboundContracts/findFiles 给输入侧解析,
  50 + // recordFile 给产出侧记实例 —— 都由**引擎**调,不给 agent 自己写
  51 + inboundContracts,
  52 + edgeContracts,
  53 + findFiles,
  54 + recordFile,
49 55 } = require('./work-graph-db');
50 56  
51 57 /**
... ... @@ -220,6 +226,11 @@ function parseNodeRef(json) {
220 226 }
221 227  
222 228 /** 判「这次失败了」的 verdict(agent.md 产出格式那三个取值里的两个);no_prior_failure 是放行 */
  229 +/** 任务级共享产物的 kind(不受拓扑相邻性限制,任何下游都可能要读,R-19.3 定案①) */
  230 +const TASK_SHARED_KINDS = ['acceptance', 'contract'];
  231 +/** 按文件名兜底认 kind 的白名单(出边没声明契约时用;认不出来一律 'artifact') */
  232 +const KNOWN_FILE_KINDS = ['brd', 'prd', 'sdd', 'acceptance', 'contract'];
  233 +
223 234 const FAILING_VERDICTS = new Set(['attributed', 'inconclusive']);
224 235  
225 236 /**
... ... @@ -1269,6 +1280,16 @@ async function advanceOne(runId, opts = {}) {
1269 1280 // 项目根同理,且比产物目录更早断:不写进提示词,agent 就会写到它能看见的最近的仓库。
1270 1281 if (ctx.nodeKind !== 'subgraph') ctx.task += describeArtifactContract(ctx);
1271 1282  
  1283 + // R-19:把**上游产物的真实路径**递到节点手上。
  1284 + //
  1285 + // 这是第四次真跑那个断点的正解。当时 prd 节点的归因原话是「不是上游没产出,
  1286 + // 是它的产出**结构上到不了我手上**」—— 边只承载控制流,上游的 brd.md 躺在它自己的
  1287 + // 私有目录里,下游的提示词里没有任何可读路径,只能靠硬拼,拼不出来。
  1288 + //
  1289 + // 刻意**不是**「注入一句『索引在某个文件里,自己去检索』」:那仍然依赖 agent 自觉去查、
  1290 + // 自觉拼对路径,而真跑已经证明它拼不出来。引擎直接把路径列出来,不给它猜的机会。
  1291 + ctx.task += describeInputs(ctx, def);
  1292 +
1272 1293 // 抢到节点即记账:一次调用一定发生了(哪怕它随后炸掉),预算「只增不减」(§四.A.3)。
1273 1294 // subgraph 节点这一次花的是 workgraph-planning 的调用,同样按一次计。
1274 1295 spendBudget(run, { agentCalls: 1 });
... ... @@ -1393,6 +1414,10 @@ function settleNode(runId, nodeRunId, outcome = {}) {
1393 1414 };
1394 1415  
1395 1416 if (to === 'success') {
  1417 + // R-19 产出侧:把这次落盘的文件登记进产物索引,下游节点靠它拿到真实路径。
  1418 + // 只在 success 时记 —— 失败节点的半成品不该被下游当输入读。
  1419 + indexNodeOutputs(run, def, nodeRun);
  1420 +
1396 1421 // 人工「重试」留下的契约快照在这里判:契约变了才级联下游(§四.C 末段)
1397 1422 out.contractDiff = applyRetryCascade(runId, def, nodeRun);
1398 1423 activateSuccessTargets(runId, def, nodeRun.def_node_id, out, outcome.remediationNote || null);
... ... @@ -3017,6 +3042,147 @@ function nodeView(runId, r) {
3017 3042 }
3018 3043  
3019 3044 /**
  3045 + * 公司根目录 —— R-19.3 定案:**代码推导,不入库**。
  3046 + * 库里存绝对路径会烂(W13 清掉的那 10 条 legacy_path 悬空行就是这个病),
  3047 + * 所以 graph_files.rel_path 只存**相对本目录**的路径,绝对路径每次由这里拼。
  3048 + * GRAPH_RUNS_DIR 覆盖时跟着挪,理由与 runRootDir 那处 override 一致:
  3049 + * 否则测试每跑一次就往仓库 data/companies/ 里堆目录。
  3050 + */
  3051 +function companyRootDir(companyId) {
  3052 + return RUNS_ROOT
  3053 + ? path.join(RUNS_ROOT, '_companies', companyId)
  3054 + : path.join(REPO_ROOT, 'data', 'companies', companyId);
  3055 +}
  3056 +
  3057 +/**
  3058 + * 本 task 的产物目录(R-19.3:走 tasks/{taskId}/,不走 workspace/)。
  3059 + *
  3060 + * 按 task 分而不是平铺:同一家公司会跑很多 task,平铺的话第二个 task 的 prd.md
  3061 + * 会覆盖第一个。不走 workspace/ 是因为它的现有语义是「Agent 自治领地、框架不干预」
  3062 + * (06-沙盒自治),而这里要的是框架管着、可检索的交付物区 —— 两种所有权混在
  3063 + * 一个目录里,索引就没法保证不漂移。
  3064 + */
  3065 +function taskDirRel(taskId) {
  3066 + return path.join('tasks', taskId || '_no-task');
  3067 +}
  3068 +
  3069 +/**
  3070 + * R-19 产出侧:把节点这次落盘的文件登记进 graph_files。
  3071 + *
  3072 + * **由引擎调,不给 agent 自己写** —— 原设计(§4.6 v0.1)也是这么定的,理由是
  3073 + * 「否则索引必然和实际文件漂移」。本轮反复验证过这条:凡「靠 agent 自觉」的环节
  3074 + * 几乎全部落空过(goal-contract 零调用、setPlanWorkGraph 零调用、三值透传只接了读那一半)。
  3075 + *
  3076 + * kind 怎么定:先看这个节点的**出边**上声明了什么(规划时就写下了「我会产一份 brd」),
  3077 + * 再按文件名兜底。声明优先,因为那是图作者的意图;文件名只是巧合。
  3078 + */
  3079 +function indexNodeOutputs(run, def, nodeRun) {
  3080 + if (!run || !nodeRun || !nodeRun.output_ref) return;
  3081 + let dir = nodeRun.output_ref;
  3082 + let files = [];
  3083 + try {
  3084 + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return;
  3085 + files = fs.readdirSync(dir).filter((f) => !f.startsWith('.'));
  3086 + } catch {
  3087 + return; // 目录读不了不该拖垮结算
  3088 + }
  3089 + if (!files.length) return;
  3090 +
  3091 + // 出边声明的 kind:优先用它给文件定 kind
  3092 + let declared = [];
  3093 + try {
  3094 + const layer = run.graph_kind === 'org' ? 'org' : 'work';
  3095 + const outbound = ((def && def.edges) || []).filter(
  3096 + (e) => String(e.from) === String(nodeRun.def_node_id) && (e.edgeKind || 'forward') !== 'backward'
  3097 + );
  3098 + for (const e of outbound) {
  3099 + for (const c of edgeContracts(layer, e.id)) declared.push(c.kind);
  3100 + }
  3101 + declared = [...new Set(declared)];
  3102 + } catch { /* 契约查不到就只靠文件名 */ }
  3103 +
  3104 + const companyRoot = companyRootDir(run.company_id);
  3105 + for (const f of files) {
  3106 + // attempt-*.json 是引擎自己读的归因文件(readNodeProduct),不是给下游的产物
  3107 + if (/^attempt-\d+\.json$/.test(f)) continue;
  3108 + const abs = path.join(dir, f);
  3109 + const base = f.replace(/\.[^.]+$/, '').toLowerCase();
  3110 + // 声明优先:出边说会产 brd,且文件名里就有 brd → 定 brd
  3111 + const kind = declared.find((k) => base === k || base.includes(k))
  3112 + || (KNOWN_FILE_KINDS.includes(base) ? base : 'artifact');
  3113 + try {
  3114 + recordFile({
  3115 + companyId: run.company_id,
  3116 + taskId: run.task_id,
  3117 + relPath: path.relative(companyRoot, abs),
  3118 + kind,
  3119 + runId: run.id,
  3120 + nodeRunId: nodeRun.id,
  3121 + });
  3122 + } catch (e) {
  3123 + timelineNote({ run, runId: run.id, nodeRunId: nodeRun.id, attempt: nodeRun.attempt },
  3124 + 'node.output', `产物索引写入失败 ${f}: ${e.message}`, { degraded: 'file_index_write_failed' });
  3125 + }
  3126 + }
  3127 +}
  3128 +
  3129 +/**
  3130 + * 「这个节点需要什么输入、在哪」—— 两类数据流都查(R-19.3 定案①):
  3131 + * ① 沿边传递:入边上声明的 kind(受拓扑相邻性限制)
  3132 + * ② 任务级共享:本 task 下的 acceptance / contract 这类,**不受相邻性限制**
  3133 + * —— R-9.3 的「planner 生成、validator 执行」是隔着一个 coder 的非相邻依赖,
  3134 + * 纯边绑定表达不了,所以必须有这一类。
  3135 + *
  3136 + * 声明了却查不到实例时**明说缺哪个 kind**,不要静默省略:那正是引擎能在跑之前
  3137 + * 发现「你要吃 brd,但上游没人产 brd」的地方(R-19.4)。
  3138 + */
  3139 +function describeInputs(ctx, def) {
  3140 + if (!ctx || !ctx.run || ctx.nodeKind === 'subgraph') return '';
  3141 + let contracts = [];
  3142 + let shared = [];
  3143 + try {
  3144 + const layer = ctx.run.graph_kind === 'org' ? 'org' : 'work';
  3145 + contracts = inboundContracts(layer, (def && def.edges) || [], ctx.defNodeId);
  3146 + shared = findFiles({
  3147 + companyId: ctx.companyId,
  3148 + taskId: ctx.run.task_id,
  3149 + kinds: TASK_SHARED_KINDS,
  3150 + });
  3151 + } catch {
  3152 + return ''; // 索引读不出来不该拖垮节点执行
  3153 + }
  3154 +
  3155 + const declared = [...new Set(contracts.map((c) => c.kind))];
  3156 + const lines = [];
  3157 +
  3158 + if (declared.length) {
  3159 + const found = findFiles({ companyId: ctx.companyId, taskId: ctx.run.task_id, kinds: declared });
  3160 + const byKind = new Map();
  3161 + for (const f of found) byKind.set(f.kind, f);
  3162 + const root = companyRootDir(ctx.companyId);
  3163 + for (const kind of declared) {
  3164 + const hit = byKind.get(kind);
  3165 + const required = contracts.some((c) => c.kind === kind && c.required);
  3166 + if (hit) lines.push(`- ${kind}:${path.join(root, hit.rel_path)}`);
  3167 + else if (required) lines.push(`- ${kind}:⚠️ 上游声明会产出,但索引里查不到实例`);
  3168 + }
  3169 + }
  3170 + for (const f of shared) {
  3171 + if (declared.includes(f.kind)) continue; // 已在上面列过
  3172 + lines.push(`- ${f.kind}(任务级共享):${path.join(companyRootDir(ctx.companyId), f.rel_path)}`);
  3173 + }
  3174 +
  3175 + if (!lines.length) return '';
  3176 + return [
  3177 + '', '',
  3178 + '[上游输入 —— 引擎已按图上声明的数据契约解析好,直接读这些路径]',
  3179 + ...lines,
  3180 + '标 ⚠️ 的表示图上声明了这份输入、但索引里没有实例:先确认是不是上游没产出,',
  3181 + '不要自己去别的节点私有目录里翻(跨节点私有目录不可读,见写入边界)。',
  3182 + ].join('\n');
  3183 +}
  3184 +
  3185 +/**
3020 3186 * 拼进提示词的产物目录约定(§四.A.5 + validator-triager/agent.md「产出格式」)。
3021 3187 *
3022 3188 * 路径与 readNodeProduct 读的**是同一个**(nodeArtifactDir),文件名给绝对路径,
... ...
server/services/work-graph-db.js
... ... @@ -163,6 +163,51 @@ function getDb() {
163 163 target_node_ref TEXT,
164 164 created_at TEXT NOT NULL
165 165 );
  166 +
  167 + -- ── R-19:产物索引(27-P4 §4.6)────────────────────────────────────
  168 + -- 补的是「同一张 work graph 内部上游到下游没有数据通路」这个断点:边一直只承载
  169 + -- 控制流(condition / edge_kind / fail_reason),而「边的输入输出」此前只是
  170 + -- 从 output_ref **推断**出来的 UI 展示、没有实体,推断的那一端是节点私有目录,
  171 + -- 下游读不到。第四次真跑就死在这:prd 节点的归因原话是
  172 + -- 「不是上游没产出,是它的产出结构上到不了我手上」。
  173 +
  174 + -- 产物**实例**:运行时由**引擎**追加。不靠 agent 自觉写 —— 否则索引与实际文件必然漂移
  175 + -- (本轮反复验证:凡「靠 agent 自觉」的环节几乎全部落空过)。
  176 + -- rel_path 是**相对公司根**的路径,根由代码推导不入库:存绝对路径会烂,
  177 + -- W13 清掉的那 10 条 legacy_path 悬空行就是这个病。
  178 + CREATE TABLE IF NOT EXISTS graph_files (
  179 + id TEXT PRIMARY KEY,
  180 + company_id TEXT NOT NULL,
  181 + task_id TEXT,
  182 + rel_path TEXT NOT NULL,
  183 + kind TEXT NOT NULL,
  184 + produced_by_run_id TEXT,
  185 + produced_by_node_run_id TEXT,
  186 + created_at TEXT NOT NULL,
  187 + superseded_by TEXT
  188 + );
  189 + -- **部分**唯一:只约束「当前版本」(superseded_by IS NULL),历史行可以同路径共存。
  190 + -- 与 idx_org_graph_nodes_company_role_active 同一个模式 —— 想要「同一键只有一个活的」
  191 + -- 又要留历史,就得把条件写进索引,而不是靠删行。
  192 + CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_files_current
  193 + ON graph_files(company_id, COALESCE(task_id, ''), rel_path) WHERE superseded_by IS NULL;
  194 + CREATE INDEX IF NOT EXISTS idx_graph_files_lookup
  195 + ON graph_files(company_id, COALESCE(task_id, ''), kind);
  196 +
  197 + -- 边上声明的数据**契约**:规划时写,只有 kind 没有实例。
  198 + -- edge_layer 区分两层边表 —— org_graph_edges.id 是 INTEGER、work_graph_edges.id 是 TEXT,
  199 + -- 与 graph_run_nodes.def_node_id 同一处类型不整齐(见 27-P3/P4 §四.A.2 的注),
  200 + -- 所以这里存字符串形式、不建外键,引用完整性靠服务层。
  201 + CREATE TABLE IF NOT EXISTS graph_edge_contracts (
  202 + id TEXT PRIMARY KEY,
  203 + edge_layer TEXT NOT NULL CHECK (edge_layer IN ('org','work')),
  204 + edge_id TEXT NOT NULL,
  205 + kind TEXT NOT NULL,
  206 + required INTEGER NOT NULL DEFAULT 1,
  207 + created_at TEXT NOT NULL
  208 + );
  209 + CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_edge_contracts_dedupe
  210 + ON graph_edge_contracts(edge_layer, edge_id, kind);
166 211 `);
167 212  
168 213 // 存量库迁移(写法照抄 graph-db.js migrate():hasColumn 判断 + ALTER TABLE ADD COLUMN,
... ... @@ -858,6 +903,133 @@ function listLoopRecords(runId, { edgeId = null } = {}) {
858 903 .all(runId, String(edgeId));
859 904 }
860 905  
  906 +
  907 +// ────────────────────────────────────────────────────────────────
  908 +// R-19 产物索引(27-P4 §4.6)
  909 +//
  910 +// 分工与 output_ref 不重复:
  911 +// output_ref 节点级指针 —— 「我这次跑出了什么」,一对一挂在 graph_run_nodes 行上
  912 +// graph_files 公司级索引 —— 「这家公司有哪些产物、谁能用」,聚合视图,由前者派生
  913 +// 所以 graph_files 必须可从节点行重建(rebuildFilesFromNodes),索引坏了不至于丢产物。
  914 +// ────────────────────────────────────────────────────────────────
  915 +
  916 +/**
  917 + * 记一条产物实例。**只由引擎调用**,不给 agent 自己写 ——
  918 + * 否则索引与实际文件必然漂移(本轮反复验证过这一点)。
  919 + *
  920 + * 同一 (company, task, relPath) 重复产出时:把旧行标 superseded_by 指向新行,
  921 + * 而不是堆一堆同路径的行。这样「最新的那份」是唯一 superseded_by IS NULL 的行,
  922 + * 历史仍可追溯。
  923 + */
  924 +function recordFile({ companyId, taskId = null, relPath, kind, runId = null, nodeRunId = null }) {
  925 + if (!companyId || !relPath || !kind) throw new Error('recordFile 需要 companyId / relPath / kind');
  926 + const db = getDb();
  927 + const now = new Date().toISOString();
  928 + const id = newId('gf');
  929 +
  930 + const tx = db.transaction(() => {
  931 + const prev = db
  932 + .prepare(`
  933 + SELECT id FROM graph_files
  934 + WHERE company_id = ? AND COALESCE(task_id, '') = ? AND rel_path = ?
  935 + AND superseded_by IS NULL
  936 + `)
  937 + .get(companyId, taskId || '', relPath);
  938 +
  939 + if (prev) {
  940 + // 同路径再产出:先把旧行指向新行(腾出部分唯一索引的位置),再插新行。
  941 + // 顺序不能反 —— 反了会撞 idx_graph_files_current。
  942 + db.prepare(`UPDATE graph_files SET superseded_by = ? WHERE id = ?`).run(id, prev.id);
  943 + }
  944 + db.prepare(`
  945 + INSERT INTO graph_files
  946 + (id, company_id, task_id, rel_path, kind,
  947 + produced_by_run_id, produced_by_node_run_id, created_at, superseded_by)
  948 + VALUES (@id, @company_id, @task_id, @rel_path, @kind, @run_id, @node_run_id, @now, NULL)
  949 + `).run({
  950 + id, company_id: companyId, task_id: taskId, rel_path: relPath, kind,
  951 + run_id: runId, node_run_id: nodeRunId, now,
  952 + });
  953 + });
  954 + tx();
  955 + return id;
  956 +}
  957 +
  958 +/** 按 kind 查这家公司/这个 task 下最新的产物实例(superseded_by IS NULL) */
  959 +function findFiles({ companyId, taskId = null, kinds = null }) {
  960 + const db = getDb();
  961 + const rows = db
  962 + .prepare(`
  963 + SELECT * FROM graph_files
  964 + WHERE company_id = ? AND COALESCE(task_id, '') = ? AND superseded_by IS NULL
  965 + ORDER BY created_at
  966 + `)
  967 + .all(companyId, taskId || '');
  968 + if (!kinds || !kinds.length) return rows;
  969 + const want = new Set(kinds);
  970 + return rows.filter((r) => want.has(r.kind));
  971 +}
  972 +
  973 +/** 在边上声明「这条边要传什么 kind」。规划时写,只有 kind 没有实例。 */
  974 +function declareEdgeContract({ edgeLayer, edgeId, kind, required = true }) {
  975 + if (!['org', 'work'].includes(edgeLayer)) throw new Error(`edgeLayer 非法: ${edgeLayer}`);
  976 + if (!edgeId || !kind) throw new Error('declareEdgeContract 需要 edgeId / kind');
  977 + getDb()
  978 + .prepare(`
  979 + INSERT INTO graph_edge_contracts (id, edge_layer, edge_id, kind, required, created_at)
  980 + VALUES (?, ?, ?, ?, ?, ?)
  981 + ON CONFLICT(edge_layer, edge_id, kind) DO NOTHING
  982 + `)
  983 + .run(newId('gec'), edgeLayer, String(edgeId), kind, required ? 1 : 0, new Date().toISOString());
  984 +}
  985 +
  986 +/** 某条边上声明的契约 */
  987 +function edgeContracts(edgeLayer, edgeId) {
  988 + return getDb()
  989 + .prepare(`SELECT * FROM graph_edge_contracts WHERE edge_layer = ? AND edge_id = ?`)
  990 + .all(edgeLayer, String(edgeId));
  991 +}
  992 +
  993 +/**
  994 + * 某个节点的**入边**上声明了哪些 kind —— 这是「这个节点需要什么输入」的答案。
  995 + * 只看 forward 入边:backward 入边是回退路径,不是数据来源。
  996 + */
  997 +function inboundContracts(edgeLayer, edges, defNodeId) {
  998 + const inbound = (edges || []).filter(
  999 + (e) => String(e.to) === String(defNodeId) && (e.edgeKind || 'forward') !== 'backward'
  1000 + );
  1001 + const out = [];
  1002 + for (const e of inbound) {
  1003 + for (const c of edgeContracts(edgeLayer, e.id)) out.push({ ...c, fromNodeId: e.from });
  1004 + }
  1005 + return out;
  1006 +}
  1007 +
  1008 +/**
  1009 + * 从 graph_run_nodes 重建索引(索引损坏时的兜底,类比 okr 的 _rebuildIndex)。
  1010 + * 只能恢复「节点产出过什么目录」这一层,恢复不了 kind —— 所以 kind 统一记 'artifact',
  1011 + * 这是有损重建,够用来定位文件,不够还原语义。
  1012 + */
  1013 +function rebuildFilesFromNodes(companyId) {
  1014 + const db = getDb();
  1015 + const rows = db
  1016 + .prepare(`
  1017 + SELECT n.id AS node_run_id, n.run_id, n.output_ref, r.task_id
  1018 + FROM graph_run_nodes n JOIN graph_runs r ON r.id = n.run_id
  1019 + WHERE r.company_id = ? AND n.output_ref IS NOT NULL
  1020 + `)
  1021 + .all(companyId);
  1022 + let n = 0;
  1023 + for (const r of rows) {
  1024 + recordFile({
  1025 + companyId, taskId: r.task_id, relPath: r.output_ref, kind: 'artifact',
  1026 + runId: r.run_id, nodeRunId: r.node_run_id,
  1027 + });
  1028 + n += 1;
  1029 + }
  1030 + return n;
  1031 +}
  1032 +
861 1033 module.exports = {
862 1034 getDb,
863 1035 newId,
... ... @@ -886,6 +1058,14 @@ module.exports = {
886 1058 listRunTree,
887 1059 listCompanyRuns,
888 1060  
  1061 + // R-19 产物索引(27-P4 §4.6)
  1062 + recordFile,
  1063 + findFiles,
  1064 + declareEdgeContract,
  1065 + edgeContracts,
  1066 + inboundContracts,
  1067 + rebuildFilesFromNodes,
  1068 +
889 1069 // 回退记录
890 1070 countLoopRecords,
891 1071 insertLoopRecord,
... ...