Commit 70b4a11297a1904441d378d0a336e144c8439de3
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>
Showing
8 changed files
with
367 additions
and
35 deletions
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('/:id', h((req, res) => { |
| 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('GET /api/graph-runs/:id — R-19 输入输出三字段', () => { |
| 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 '@/components/graph-console/GraphRunPane.vue'; |
| 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(() => defNodes.value.length > 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 | ... | ... |