Commit 70b4a11297a1904441d378d0a336e144c8439de3

Authored by yibo
1 parent 2f0c7088

feat(graph-console): 加任务级产物汇总面板 + 顺手修正 34 号文档的错误前提

用户反馈:控制台原来只能逐个节点点「输入输出」tab 看产物,缺一个全局入口。
新增 TaskFilesPanel.vue,挂在页头,跨节点/跨 run 一次性列出这个任务的全部产物。

做的时候发现分两类、且第二类之前完全没覆盖:
  · 节点产出索引(graph_files,按 company+task 检索)——规划笔记/日志/契约快照
  · 项目根(W2 ensureProjectRoot,data/companies/{cid}/projects/{pid}/)——
    真实交付物代码写的地方,跨节点共享,indexNodeOutputs 从不扫它

后端补 readProjectRootRecord 导出 + GET /api/graph-runs/:id 挂 projectRoot 字段;
项目根的文件列表复用已有的 GET /:companyId/files(此前零调用方),不新开列目录端点。
新增 graph-run-route-io.test.js 两例(分配前 null、分配后原样透传 + 幂等)。

顺带订正 34 号文档:当时断言"index.html 从未被写出来"是错的——真实网站文件
其实在项目根里,只是没查那个目录。34 号文档 §零 记录了订正过程,问题收窄成
"validator 有没有真的做过浏览器级检查"(仍未解决,留待下次)。

全量单测 1000/1000 通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/README.md
... ... @@ -49,7 +49,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d
49 49 | [29-dream-design.md](./todo/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) |
50 50 | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据 |
51 51 | [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 |
52   -| [34-validator-scored-without-real-deliverable.md](./todo/system-design/34-validator-scored-without-real-deliverable.md) | 📝 **调研记录**。真实回归任务里 `org-coder-impl` 从未产出任何 HTML/CSS 文件,`org-coder-validator` 却给六个视觉/交互维度全打了 100 分——rubric 的 `evidence_hint` 明确要求打开真实页面检查,validator 显然没真查就评了分。只记现象与证据,根因(提示词缺前置检查?工具不可用?)留给下次 |
  52 +| [34-validator-scored-without-real-deliverable.md](./todo/system-design/34-validator-scored-without-real-deliverable.md) | 📝 **调研记录,原始前提已订正**。最初以为 `org-coder-impl` 从未产出任何 HTML/CSS(`org-coder-validator` 却给六维度全打 100 分),后来发现真实网站其实存在于 `graph_files` 索引不覆盖的「项目根」目录(`data/companies/{cid}/projects/{pid}/`)——这半个发现已经顺手修了(控制台「产物」面板现在会展示项目根)。订正后仍未解决的窄问题:validator 有没有真的照 `evidence_hint` 做浏览器级检查,还是即使产物存在也没真查 |
53 53 | [33-needs-gate-role-filter-bug.md](./todo/system-design/33-needs-gate-role-filter-bug.md) | ✅ **已修复**。`executor.json`/`executor-fanout.json` 里 `produces:["log"]` 声明了一个 test/integration 类节点永远不会被索引到的 kind,导致 `org-coder-validator` 被 `missing_required_input` 反复误判 blocked;改成 `required:false`(对齐 `validator.json` 既有写法)。文档记录了一条**走过的弯路**(按 role 过滤 `inboundContracts` 会削弱 T-mt25ecbd 的产出闸门,已撤回),发现于 31 号文档的真实回归验证过程中,与兜底边问题无关 |
54 54  
55 55 ## todo/future/(暂缓,非当前重心)
... ...
docs/todo/system-design/34-validator-scored-without-real-deliverable.md
1 1 # 34-validator-scored-without-real-deliverable. coder-validator 在没有真实产物的情况下给了满分
2 2  
3 3 > **日期**: 2026-08-22
4   -> **状态**: 📝 **调研记录,非设计方案**——只记录现象与证据,不现场深挖根因(用户明确选择
5   -> 先记文档,验收链路本身的调查留给下次)
  4 +> **状态**: 📝 **调研记录,非设计方案,且 §一/§二的核心前提已被 §零 推翻大半**——
  5 +> 原始判断("index.html 从未被写出来")是错的,真实网站文件其实存在,只是不在
  6 +> `graph_files` 索引里。修订后的问题更窄:validator 有没有**真的**照 `evidence_hint`
  7 +> 做浏览器级检查,还是即使产物存在也照样走了个形式。这一层仍未核实,留待下次。
6 8 > **触发**: `e2e-demo` 公司真实回归任务 `T-mt2r6fz0-125b`(goal `G-mt2r62ia-73af`,
7 9 > root run `run_0e9074815130`)——[31 号文档](./31-work-graph-fail-fallback-coverage.md)
8 10 > 兜底边修复 + [33 号文档](./33-needs-gate-role-filter-bug.md) 契约修复之后,把这条任务
... ... @@ -10,6 +12,36 @@
10 12  
11 13 ---
12 14  
  15 +## 零、2026-08-22 补充:原始判断错了一半——真实网站文件其实存在
  16 +
  17 +写这份文档时只查了两处:`org-coder-impl`/`org-coder-test` 各自的节点私有目录
  18 +(`runs/{rootRunId}/nodes/{nodeRunId}/`)和 `graph_files` 索引表,两处都没有
  19 +HTML/CSS/JS,于是断言"首页从未被写出来"。
  20 +
  21 +后来在**同一次会话里**给控制台加"任务产物汇总"面板(用户反馈的另一个需求)时,
  22 +读 `graph-engine.js` 的 W2 段落才发现:这个引擎给每个任务的代码分配了一个**跨节点
  23 +共享的项目根**(`ensureProjectRoot`,复用 DevLoop 的 `path-resolver.allocateProjectRoot`,
  24 +落在 `data/companies/{cid}/projects/{pid}/`),这个目录**不受 `graph_files`/
  25 +`indexNodeOutputs` 覆盖**——后者只扫每个节点自己的私有产出目录。真去看
  26 +`data/companies/e2e-demo/projects/p-v82tpc/` 才发现:
  27 +
  28 +```
  29 +index.html 2.2KB 真实的 apple.com 风格单页站点(hero + 3 个卖点区块 + 平滑滚动导航)
  30 +script.js 1.0KB
  31 +styles.css 3.3KB
  32 +tests/unit-tester.check.js
  33 +workgraph-plan-T-mt2r6fz0-125b.md
  34 +```
  35 +
  36 +内容核对过:3 个卖点区块的标题与顺序(多智能体协作 → 全链路自动化 → Skill 竞技场)
  37 +与 `org-coder-impl` 自己 `_plan.md` 里写的验收对照表逐字对应,不是空壳/占位文件。
  38 +
  39 +**这次查漏的教训**:`graph_files` 索引 + 节点私有目录,只是这个系统里**两个**产出
  40 +落点中的一个;第三个(项目根)当时完全没查。下面 §一/§二 保留原文作历史记录,
  41 +但"index.html 从未被写出来"这句话是错的,请按本节订正读。
  42 +
  43 +## 一、(原始记录,前提有误,见 §零)
  44 +
13 45 ## 一、现象
14 46  
15 47 任务最终状态:`completed`,`org-planner` / `org-coder` / `org-validator` 三个 org 节点
... ... @@ -55,43 +87,46 @@ contract.json 跨节点契约快照
55 87 | `responsive_layout` | 「浏览器依次设置视口宽度 375/768/1440px 检查横向滚动条与重叠」 |
56 88 | `resource_integrity` | 「打开浏览器开发者工具 Console + Network 面板刷新页面检查」 |
57 89  
58   -这五项全都以"打开浏览器 / 打开 `index.html`"为前提——而 `index.html` 从来没有被
59   -`org-coder-impl` 写出来过。`org-coder-validator` 不可能真的执行了这些检查步骤,
60   -却仍然给出了 100/100。唯一有证据支撑的是第六维度 `pipeline_auto_recovery`
61   -(权重 0.25,本次回归验证真正要看的那一维)——它的 `evidence_hint` 是"读
62   -`attempt-*.json` 搜索 `fail_reason=input_underspecified`",这条本身不依赖前端产物,
63   -`org-coder` 的 `remediationNote` 里也确实写了"检索本 run 下 nodes/\*/attempt-\*.json
64   -的 verdict/fail_reason 字段,未发现任何一条真实的判定"——这条像是真查过的。
  90 +这五项全都以"打开浏览器 / 打开 `index.html`"为前提——**当时以为** `index.html`
  91 +从没被写出来过,所以断言 validator 不可能真的执行了这些检查步骤。**这个前提错了**
  92 +(见 §零):`index.html`/`styles.css`/`script.js` 其实都在 `projects/p-v82tpc/`
  93 +下真实存在,内容也对得上任务要求。
65 94  
66   -**结论**:`org-coder-validator` 至少在 5/6 维度上没有依据 `evidence_hint` 去核实
67   -产物是否存在就给了分,是一次**没有真实检查支撑的评分**(rubber-stamped),
68   -不是"检查过但判断错误"。
  95 +**订正后的结论**:不能再说这是"没有产物却给了满分"——产物是有的。真正没验证过的
  96 +是**更窄的一个问题**:`org-coder-validator` 是否真的照 `evidence_hint` 打开了这个
  97 +页面、量了字号比例、切了三档视口、开了 Console/Network 面板检查,还是即使产物
  98 +存在,它也只是读了读源码/自己的 `_plan.md` 就直接给了 100 分,没有真的做那些
  99 +`evidence_hint` 点名要求的浏览器级验证动作。这个问题现在的证据**不足以下结论**——
  100 +六个维度全 100 分本身既可能是"真查过、页面确实做得不错",也可能是"没查、乐观估计",
  101 +两种情况从现有的 `attempt-*.json`/`remediationNote` 文本里区分不出来。
69 102  
70   -## 三、影响面(未核实,留给下次)
  103 +## 三、影响面(订正后,仍未核实,留给下次)
71 104  
72 105 - 这不是 31/33 号文档改动引入的新问题——两次修复只是让流程**不再卡死**,
73   - 让这条原本会一直 blocked 的任务真的走到了 `completed`,才把这个一直存在的
74   - 验收缺口暴露出来。
75   -- 如果这是系统性行为(不是这一次运气差),意味着 `executor.json` 这类骨架的
76   - `validator` 节点在**缺少真实产物**时可能倾向于给出宽松评分,而不是判 fail
77   - 或至少低分——这会让"任务显示已完成"和"任务真的达成了原始需求"之间出现
78   - 静默的落差,且这个落差**在界面上完全看不出来**(`org-coder` 的
79   - `remediationNote` 里其实提到了"artifacts/ 下产物缺失"这类线索,但那是给
80   - 下一轮 agent 看的内部备注,不是给人看的告警)。
  106 + 让这条原本会一直 blocked 的任务真的走到了 `completed`,才有机会去看"最终产出
  107 + 在哪、验收是怎么做的"这件事。
  108 +- 真正独立于本文档、且已经确认修复的一个衍生问题:`graph_files`/`taskFiles`
  109 + 索引**不覆盖项目根**,这意味着控制台原本设想的"任务级产物汇总"面板如果只读
  110 + `graph_files`,会永远看不到真实交付物(这正是 §零 发现问题的路径)。这一半
  111 + 已经在同一次改动里修了:`graph-engine.readProjectRootRecord` 现在随
  112 + `GET /api/graph-runs/:id` 一起吐出来,控制台的「产物」按钮会分别列"最终交付物 ·
  113 + 项目根"和"节点产出索引"两节,见 `web/src/components/graph-console/TaskFilesPanel.vue`。
  114 +- 仍然悬而未决的是 validator **有没有真的做过视觉/交互检查**这件事本身——这需要
  115 + 证据链(比如强制要求 validator 在 `attempt-*.json` 里回填它量出的具体数字:
  116 + h1:tagline 字号比、三档视口下的滚动条状态等),而不是只回一个分数。
81 117  
82 118 ## 四、留待下次调查的问题(不在本次范围内动手)
83 119  
84 120 - [ ] `coder-validator` 本体(`system/agents/unit-tester/agent.md` 或对应角色)的
85   - 提示词里,评分前有没有一步"先确认产物文件真的存在"的强制检查?如果没有,
86   - 这可能是这次跑分虚高的直接原因。
87   -- [ ] `coder-validator` 是否有可用的浏览器/文件读取工具去真的执行
88   - `evidence_hint` 里描述的那些检查(截图、开发者工具)?如果工具不可用,
89   - "满分"可能是它在无法验证时的默认基调,而不是主动撒谎。
90   -- [ ] 这是不是这次真实回归测试里 `org-coder`/`unit-tester` 两个新招聘本体
91   - (见 [33 号文档提交时的记录](./33-needs-gate-role-filter-bug.md))本身
92   - 还没配好角色提示词导致的一次性现象,还是所有 `executor.json` 骨架的
93   - validator 节点普遍如此?
94   -- [ ] 是否需要给这类"需要打开真实产物检查"的维度加一道**引擎级的前置闸门**
95   - (类似 T-mt25ecbd 的 produces 闸门):`evidence_hint` 里点名要读的文件
96   - 如果在产物索引里查不到实例,直接判该维度 0 分或整体 blocked,不依赖
97   - validator 自己主动去查。
  121 + 提示词里,评分前有没有一步强制要求"回填 evidence_hint 点名要量的具体数字",
  122 + 而不是只回一个分数?没有这一步的话,"真查过"与"没查"从产出上完全无法区分。
  123 +- [ ] `coder-validator` 是否有可用的浏览器工具(截图、DOM 检查)去真的执行
  124 + `evidence_hint` 里描述的那些检查?如果连工具都没有,让它对着"评分要求打开浏览器"
  125 + 的 rubric 打分,本身就是设计上要求了它做不到的事。
  126 +- [ ] 抽查其他任务(比如 `p-bkhylu`/`p-v1mpcl` 这两个已存在的项目根)的 validator
  127 + 评分记录,看这次的六个满分是个例还是常态。
  128 +- [ ] 是否需要给"必须打开真实产物检查"的维度加一道**引擎级的前置闸门**:至少确保
  129 + 被检查的文件(如 `index.html`)在 `evidence_hint` 点名的路径下真实存在
  130 + (现在项目根已经可查,这道闸门补起来比 §零 之前容易),如果连文件都不存在
  131 + 就直接拦下不许打分;文件存在之后 validator 有没有真的看,还是只能靠 §四第
  132 + 一条的"强制回填具体数字"来间接约束。
... ...
server/routes/graph-run.js
... ... @@ -368,6 +368,11 @@ router.get(&#39;/:id&#39;, h((req, res) =&gt; {
368 368 edges,
369 369 companyRoot: companyRootDir(state.run.company_id),
370 370 taskFiles: findFiles({ companyId: state.run.company_id, taskId: state.run.task_id }),
  371 + // 控制台「产物」面板要区分两类产出:graph_files 只索引各节点私有目录下的东西
  372 + // (规划笔记、日志、契约快照……),真实交付物(代码)写在这个任务共享的项目根里
  373 + // (见 graph-engine.js「W2 · 每个 root run 一个项目根」那段),两者不是同一份数据。
  374 + // readProjectRootRecord 按 root_run_id 查,任一层 run 传进去结果相同(同一棵树共享一个项目根)。
  375 + projectRoot: engine.readProjectRootRecord(state.run),
371 376 loopRecords: listLoopRecords(state.run.id),
372 377 childRuns: listChildRuns(state.run.id),
373 378 budget,
... ...
server/services/graph-engine.js
... ... @@ -4363,6 +4363,10 @@ module.exports = {
4363 4363 // ensureProjectRoot 的真实调用方是 advanceOne,diffRepoStatus 的是 applyBoundaryGuard;
4364 4364 // 导出只是让单测能直接验这两条判据,不是新开一圈公开面。
4365 4365 ensureProjectRoot,
  4366 + // readProjectRootRecord:控制台「产物」面板要知道这个任务的真实交付物写在哪——
  4367 + // graph_files 只索引每个节点自己私有目录下的产出,project-root.json 指向的才是
  4368 + // 跨节点共享的项目根(真实代码落盘的地方,见本文件 W2 那段大注释)。
  4369 + readProjectRootRecord,
4366 4370 diffRepoStatus,
4367 4371 // 真实调用方是 advanceOne(每次节点执行前后各一次)。导出 + 可注入的 after 快照,
4368 4372 // 是为了让「越界 → success 降级成 fail + fail_reason=boundary_escape」这条分支
... ...
server/tests/unit/graph-run-route-io.test.js
... ... @@ -144,4 +144,28 @@ describe(&#39;GET /api/graph-runs/:id — R-19 输入输出三字段&#39;, () =&gt; {
144 144 const r = await fetch(`${base}/api/graph-runs/run_nope`);
145 145 expect(r.status).toBe(404);
146 146 });
  147 +
  148 + // 34 号文档:graph_files 只索引节点私有目录,真实交付物写在这个任务共享的
  149 + // 项目根(W2 ensureProjectRoot)——控制台「产物」面板要同时看到两处,缺一个字段
  150 + // 就是同一路「实现在、零调用方」再来一次。
  151 + it('projectRoot:没分配过就是 null(不假装有)', async () => {
  152 + const r = await fetch(`${base}/api/graph-runs/${runId}`);
  153 + const d = await r.json();
  154 + expect(d.projectRoot).toBeNull();
  155 + });
  156 +
  157 + it('projectRoot:分配过之后原样透传(root_path/pid 与落库的记录一致)', async () => {
  158 + const engine = require('../../services/graph-engine');
  159 + const run = wdb.getRun(runId);
  160 + const rec = engine.ensureProjectRoot({ run, companyId, nodeRunId: 'rn-fake' });
  161 + expect(rec).toBeTruthy();
  162 +
  163 + const r = await fetch(`${base}/api/graph-runs/${runId}`);
  164 + const d = await r.json();
  165 + expect(d.projectRoot).toBeTruthy();
  166 + expect(d.projectRoot.pid).toBe(rec.pid);
  167 + expect(d.projectRoot.root_path).toBe(rec.root_path);
  168 + // 幂等:再查一次不会重新分配一个不同的项目根
  169 + expect(engine.ensureProjectRoot({ run, companyId, nodeRunId: 'rn-fake-2' }).root_path).toBe(rec.root_path);
  170 + });
147 171 });
... ...
web/src/components/graph-console/GraphRunPane.vue
... ... @@ -101,6 +101,10 @@ const budget = ref(null);
101 101 // 「没有产物」和「后端没告诉我有没有产物」必须分得开。
102 102 const taskFiles = ref(null);
103 103 const companyRoot = ref(null);
  104 +// 这个任务真实交付物的落点(W2「每个 root run 一个项目根」)——与 taskFiles 是两类数据,
  105 +// 不要合并:graph_files 只索引节点私有目录,projectRoot 才是代码真正写的地方。
  106 +// 没有记录(节点还没跑到 ensureProjectRoot 那一步)时保持 null。
  107 +const projectRoot = ref(null);
104 108 const events = ref([]); // SSE 事件缓冲,供详情面板的「运行日志」tab 消费
105 109 const selectedDefId = ref(null);
106 110 const loading = ref(false);
... ... @@ -208,6 +212,7 @@ async function load() {
208 212 budget.value = j.budget || null;
209 213 taskFiles.value = Array.isArray(j.taskFiles) ? j.taskFiles : null;
210 214 companyRoot.value = j.companyRoot || null;
  215 + projectRoot.value = j.projectRoot || null;
211 216 mode.value = j.run?.execution_mode || 'manual';
212 217 push();
213 218 } catch (e) {
... ... @@ -226,6 +231,7 @@ function push() {
226 231 childRuns: childRuns.value,
227 232 // R-19:右栏「输入输出」tab 的实例侧数据,与 nodes/edges 走同一条 load
228 233 taskFiles: taskFiles.value, companyRoot: companyRoot.value,
  234 + projectRoot: projectRoot.value,
229 235 });
230 236 }
231 237  
... ...
web/src/components/graph-console/TaskFilesPanel.vue 0 → 100644
  1 +<!--
  2 + 任务级产物汇总(用户反馈:逐个节点点「输入输出」tab 才能看产物,控制台缺一个全局入口)。
  3 +
  4 + 两类数据源,**故意分成两节,不合并**(挖过一次才知道混了会看不见真东西):
  5 + ① 「输入输出」tab 的「输出」小节同一份数据(graph_files,按 company+task 检索,
  6 + 只回当前有效行)——这是各节点私有目录下的规划笔记/日志/契约快照,不是最终交付物。
  7 + ② 项目根(W2「每个 root run 一个项目根」,graph-engine.js ensureProjectRoot)——
  8 + 这个任务的代码真正写的地方,跨节点共享,图① 完全不覆盖它(indexNodeOutputs 只扫
  9 + 节点自己的 output_ref 目录)。第一版做完这个面板后才在真实回归任务里发现:
  10 + 没有它,用户问「最终结果在哪」时这个面板会一直说「没有」,而真实网站文件其实
  11 + 好好地躺在 `projects/{pid}/` 下,只是从没被 graph_files 索引过。
  12 + 预览统一走 FilePreviewModal,不新造渲染逻辑;项目根的文件树复用已有的
  13 + `GET /api/company/:cid/files`(公司级统一文件树,此前零调用方),不新开列目录端点。
  14 +-->
  15 +<template>
  16 + <button class="trigger" :disabled="!files && !projectRoot" @click="onOpen">
  17 + 📄 产物{{ files ? `(${files.length})` : '' }}
  18 + </button>
  19 +
  20 + <div v-if="open" class="mask" @click.self="close">
  21 + <div class="modal" role="dialog" aria-modal="true" aria-label="任务产物汇总">
  22 + <header class="head">
  23 + <strong>任务产物汇总</strong>
  24 + <button class="mini close" aria-label="关闭" @click="close">✕</button>
  25 + </header>
  26 +
  27 + <div class="scroll">
  28 + <!-- 项目根:真实交付物落点,放最上面——这通常才是用户问「结果在哪」真正要看的东西 -->
  29 + <template v-if="projectRoot">
  30 + <h4>最终交付物 · 项目根</h4>
  31 + <p class="muted small">
  32 + 这个任务的代码写在这里(跨节点共享,与下面「节点产出索引」不是同一批文件)。
  33 + </p>
  34 + <p class="mono path root">{{ projectRoot.root_path }}</p>
  35 +
  36 + <p v-if="projLoading" class="muted small">读取文件树中…</p>
  37 + <p v-else-if="projErr" class="muted small">读不出来:{{ projErr }}</p>
  38 + <ul v-else-if="projFiles.length" class="list">
  39 + <li v-for="f in projFiles" :key="f.path">
  40 + <div class="row">
  41 + <span class="from" :title="f.path">{{ f.path }}</span>
  42 + <span class="ts">{{ fmtSize(f.size) }}</span>
  43 + </div>
  44 + <button class="mono path as-link" :title="`预览 ${f.path}`" @click="previewProjectFile(f)">
  45 + {{ projectRoot.root_path }}/{{ f.path }}
  46 + </button>
  47 + </li>
  48 + </ul>
  49 + <p v-else class="muted small">项目根已分配,但目录里还没有文件。</p>
  50 + </template>
  51 +
  52 + <h4>节点产出索引</h4>
  53 + <p class="muted small">跨这个任务下的全部 run/节点,只列当前有效版本;规划笔记/日志/契约快照这类,不是最终交付物。</p>
  54 + <div v-if="!files" class="empty">
  55 + <p>产物索引未随响应下发(后端还没返回 <code>taskFiles</code>),这一块整体降级——不猜。</p>
  56 + </div>
  57 + <div v-else-if="!files.length" class="empty">
  58 + <p>这个任务下还没有任何节点产出并登记进索引的文件。</p>
  59 + </div>
  60 + <ul v-else class="list">
  61 + <li v-for="f in sorted" :key="f.id || f.relPath">
  62 + <div class="row">
  63 + <span class="kind">{{ f.kind }}</span>
  64 + <span class="from" :title="f.relPath">{{ shortLabel(f.relPath) }}</span>
  65 + <span class="ts">{{ f.createdAt }}</span>
  66 + </div>
  67 + <button class="mono path as-link" :title="`预览 ${f.relPath}`" @click="preview(f)">
  68 + {{ f.relPath }}
  69 + </button>
  70 + </li>
  71 + </ul>
  72 + </div>
  73 + </div>
  74 + </div>
  75 +
  76 + <FilePreviewModal
  77 + :open="pv.open"
  78 + :company-id="companyId"
  79 + :rel-path="pv.relPath"
  80 + :abs-path="pv.absPath"
  81 + :kind="pv.kind"
  82 + @close="pv.open = false"
  83 + />
  84 +</template>
  85 +
  86 +<script setup>
  87 +import { ref, reactive, computed, watch } from 'vue';
  88 +import FilePreviewModal from './FilePreviewModal.vue';
  89 +import { normFile } from './io-model';
  90 +
  91 +const props = defineProps({
  92 + // 与 NodeDetailPanel 的 `files` prop 同一份数据(GraphConsole 从 rootState/midState 转发)。
  93 + // null = 后端没吐这个字段(整块降级);[] = 吐了,但这个任务确实还没有产物。
  94 + files: { type: Array, default: null },
  95 + companyRoot: { type: String, default: null },
  96 + companyId: { type: String, default: '' },
  97 + // { pid, root_path, mode } | null —— graph-engine.readProjectRootRecord 的原样透传
  98 + projectRoot: { type: Object, default: null },
  99 +});
  100 +
  101 +const open = ref(false);
  102 +function close() { open.value = false; }
  103 +function onOpen() {
  104 + open.value = true;
  105 + if (props.projectRoot) loadProjectTree();
  106 +}
  107 +
  108 +// 与 NodeDetailPanel 的口径一致:snake_case/camelCase 都接得住
  109 +const norm = computed(() => (props.files || []).map(normFile));
  110 +// 最新产出的排前面——「最终结果」通常就是这个任务里最后落盘的那几份
  111 +const sorted = computed(() => [...norm.value].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))));
  112 +
  113 +// rel_path 形如 `runs/{runId}/nodes/{nodeRunId}/xxx` 或 `tasks/{taskId}/xxx`——
  114 +// 截掉后端目录结构里跟用户无关的那一段,只留「谁产的 / 文件名」这一句话
  115 +function shortLabel(relPath) {
  116 + const parts = String(relPath || '').split('/');
  117 + const file = parts.pop();
  118 + const nodeIdx = parts.indexOf('nodes');
  119 + if (nodeIdx >= 0 && parts[nodeIdx + 1]) return `${parts[nodeIdx + 1]} / ${file}`;
  120 + if (parts[0] === 'tasks') return `任务级共享 / ${file}`;
  121 + return file;
  122 +}
  123 +
  124 +const pv = reactive({ open: false, relPath: '', absPath: '', kind: '' });
  125 +function preview(f) {
  126 + if (!f.relPath) return;
  127 + pv.relPath = f.relPath;
  128 + pv.absPath = props.companyRoot ? `${props.companyRoot}/${f.relPath}` : f.relPath;
  129 + pv.kind = f.kind || '';
  130 + pv.open = true;
  131 +}
  132 +
  133 +// ── 项目根:文件树是懒加载的(只在面板打开且确实有 projectRoot 时才拉,
  134 +// 公司文件树可能很大,不该跟着 taskFiles 一起在后台轮询里拉)────────────
  135 +const projFiles = ref([]);
  136 +const projLoading = ref(false);
  137 +const projErr = ref('');
  138 +
  139 +/** 把 `GET /:companyId/files` 的树拍平成「path 以 projects/{pid}/ 开头」的文件列表 */
  140 +function flattenUnderProject(tree, pid) {
  141 + const prefix = `projects/${pid}/`;
  142 + const out = [];
  143 + function walk(nodes) {
  144 + for (const n of nodes || []) {
  145 + if (n.type === 'file' && n.path.startsWith(prefix)) {
  146 + out.push({ path: n.path.slice(prefix.length), size: n.size, modifiedAt: n.modifiedAt });
  147 + } else if (n.type === 'directory') {
  148 + walk(n.children);
  149 + }
  150 + }
  151 + }
  152 + walk(tree);
  153 + return out.sort((a, b) => a.path.localeCompare(b.path));
  154 +}
  155 +
  156 +async function loadProjectTree() {
  157 + if (!props.projectRoot || !props.companyId) return;
  158 + projLoading.value = true;
  159 + projErr.value = '';
  160 + try {
  161 + const r = await fetch(`/api/company/${props.companyId}/files`);
  162 + const j = await r.json().catch(() => ({}));
  163 + if (!r.ok) { projErr.value = j.error || `HTTP ${r.status}`; return; }
  164 + projFiles.value = flattenUnderProject(j.tree || [], props.projectRoot.pid);
  165 + } catch (e) {
  166 + projErr.value = e.message;
  167 + } finally {
  168 + projLoading.value = false;
  169 + }
  170 +}
  171 +
  172 +// 换了任务(projectRoot.pid 变了)且面板还开着时,跟着重新拉一次
  173 +watch(() => props.projectRoot && props.projectRoot.pid, () => { if (open.value) loadProjectTree(); });
  174 +
  175 +function previewProjectFile(f) {
  176 + pv.relPath = `projects/${props.projectRoot.pid}/${f.path}`;
  177 + pv.absPath = `${props.projectRoot.root_path}/${f.path}`;
  178 + pv.kind = 'artifact';
  179 + pv.open = true;
  180 +}
  181 +
  182 +function fmtSize(n) {
  183 + if (n == null) return '';
  184 + if (n < 1024) return `${n}B`;
  185 + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
  186 + return `${(n / 1024 / 1024).toFixed(1)}MB`;
  187 +}
  188 +</script>
  189 +
  190 +<style scoped>
  191 +.trigger {
  192 + border: 1px solid #ddd; background: #fff; border-radius: 5px; cursor: pointer;
  193 + font-size: 12px; padding: 4px 10px; color: #333;
  194 +}
  195 +.trigger:hover:not(:disabled) { border-color: #999; }
  196 +.trigger:disabled { opacity: .4; cursor: not-allowed; }
  197 +
  198 +.mask {
  199 + position: fixed; inset: 0; z-index: 55;
  200 + background: rgba(0, 0, 0, .42);
  201 + display: flex; align-items: center; justify-content: center; padding: 24px;
  202 +}
  203 +.modal {
  204 + background: #fff; color: #1a1a1a;
  205 + border: 1px solid #e2e2e6; border-radius: 10px;
  206 + width: min(640px, 100%); max-height: 80vh; display: flex; flex-direction: column;
  207 + box-shadow: 0 12px 40px rgba(0, 0, 0, .28);
  208 +}
  209 +.head { display: flex; align-items: center; gap: 10px; padding: 12px 14px 10px; border-bottom: 1px solid #eee; }
  210 +.mini.close {
  211 + margin-left: auto; font-size: 13px; padding: 3px 8px; cursor: pointer;
  212 + border: 1px solid #ddd; border-radius: 5px; background: #fafafa; color: #1a1a1a;
  213 +}
  214 +.mini.close:hover { background: #f0f0f2; }
  215 +.empty { padding: 6px 0; color: #888; font-size: 13px; }
  216 +.empty code { background: #f2f2f4; padding: 1px 5px; border-radius: 3px; }
  217 +
  218 +.scroll { overflow: auto; padding: 10px 14px 14px; flex: 1; min-height: 0; }
  219 +h4 { font-size: 12.5px; margin: 14px 0 4px; color: #1a1a1a; }
  220 +h4:first-child { margin-top: 0; }
  221 +.path.root { background: #f7f7f9; border-radius: 4px; padding: 5px 8px; margin: 4px 0 10px; color: #444; word-break: break-all; cursor: default; }
  222 +
  223 +.list { list-style: none; margin: 0; padding: 0; }
  224 +.list li { padding: 8px 0; border-bottom: 1px solid #f2f2f4; }
  225 +.list li:last-child { border-bottom: none; }
  226 +.row { display: flex; align-items: center; gap: 8px; font-size: 12px; margin-bottom: 3px; }
  227 +.kind {
  228 + font-family: ui-monospace, Menlo, monospace; font-size: 11px; font-weight: 700;
  229 + background: #eaf0ff; color: #1d4ed8; border-radius: 4px; padding: 1px 6px; flex-shrink: 0;
  230 +}
  231 +.from { color: #555; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  232 +.ts { margin-left: auto; color: #999; font-size: 10.5px; flex-shrink: 0; }
  233 +.path {
  234 + display: block; width: 100%; text-align: left; background: none; border: none;
  235 + font-size: 11px; color: #2b6cb0; cursor: pointer; padding: 0; word-break: break-all;
  236 +}
  237 +.path:hover { text-decoration: underline; }
  238 +.mono { font-family: ui-monospace, Menlo, monospace; }
  239 +.muted { color: #888; }
  240 +.small { font-size: 11px; }
  241 +</style>
... ...
web/src/views/GraphConsole.vue
... ... @@ -24,6 +24,13 @@
24 24 <div class="budget" v-else>
25 25 <span class="chip muted">无 root run 预算</span>
26 26 </div>
  27 + <TaskFilesPanel
  28 + v-if="selectedTaskId"
  29 + :files="taskFiles"
  30 + :company-root="taskFilesRoot"
  31 + :company-id="companyId"
  32 + :project-root="taskProjectRoot"
  33 + />
27 34 </header>
28 35  
29 36 <!-- 面包屑:公司 › org-coder › (子图)—— 与 §四.B.1 的图一致,点任一层回到那一层 -->
... ... @@ -173,6 +180,7 @@ import GraphRunPane from &#39;@/components/graph-console/GraphRunPane.vue&#39;;
173 180 import NodeDetailPanel from '@/components/graph-console/NodeDetailPanel.vue';
174 181 import ReplanOrgGraphButton from '@/components/graph-console/ReplanOrgGraphButton.vue';
175 182 import GoalTaskPane from '@/components/graph-console/GoalTaskPane.vue';
  183 +import TaskFilesPanel from '@/components/graph-console/TaskFilesPanel.vue';
176 184 import {
177 185 findTask, pickDefaultSelection, runStateToTaskPatch, hasStartedRun, pendingRunHint,
178 186 } from '@/components/graph-console/goal-task-model.js';
... ... @@ -241,6 +249,15 @@ const hasOrgGraph = computed(() =&gt; defNodes.value.length &gt; 0);
241 249 const currentRunId = computed(() => (stack.value.length ? stack.value[stack.value.length - 1].runId : null));
242 250 const budget = computed(() => rootState.value.budget || null);
243 251 const spent = computed(() => budget.value?.spent || {});
  252 +/**
  253 + * 任务级产物汇总按钮要的两份数据(TaskFilesPanel)。taskFiles 是**任务级**检索
  254 + * (`findFiles({companyId, taskId})`),不管从 root run 还是 mid run 拉都是同一份——
  255 + * 优先用 root(选中 task 就有),它还没加载时(比如只下钻过中栏还没点过左栏)退到 mid,
  256 + * 两者都没有就是 null(整块降级,不假装是空数组)。
  257 + */
  258 +const taskFiles = computed(() => rootState.value.taskFiles ?? midState.value.taskFiles ?? null);
  259 +const taskFilesRoot = computed(() => rootState.value.companyRoot ?? midState.value.companyRoot ?? null);
  260 +const taskProjectRoot = computed(() => rootState.value.projectRoot ?? midState.value.projectRoot ?? null);
244 261  
245 262 const selectedTask = computed(() => (findTask(goals.value, selectedTaskId.value) || {}).task || null);
246 263  
... ...