Commit 925d9db70d56fcb83a6a692bc5e839a85599fb8f
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>
Showing
4 changed files
with
519 additions
and
33 deletions
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, | ... | ... |