Commit b72b03847cdae04d1fddabab60ca3526fd1aa679

Authored by bboonnee
1 parent e026f9e7

U-026: v_graph_visualizer

纯前端 Unit,数据已经在 U-025 的 c_task_get 里,只是 task_query.py
的 _work_nodes_for_run 补上了 hover tooltip 需要但当时没取的字段
(input_schema_ref/output_schema_ref/quota_limit_type/
quota_wait_started_at)。

frontend/app.js 新增 computeGraphLayers():按 graph_manifest 节点的
depends_on 边做拓扑分层,渲染成按执行层级排布的行,fan-out/fan-in
都能正确分层——用一个 n1→[n2,n3]→n4 的真实 graph_manifest 验证过,
直接从 app.js 里取出 computeGraphLayers 本体(不是重新实现一份逻辑)
跑真实服务器返回的 JSON,分层结果完全正确。节点按 03-C 3.6 节六态
配色,waiting_for_quota 态 hover 展示 quota_limit_type + 已等待时长。

02-V §2.7 原描述还包含一份 Org Graph 层,但 v_task_detail 页面顶部
已有等价的 org-strip 面板,这里判断重复渲染没有意义,只做了 Work
Graph DAG 层,Org Graph 复用页面已有的那个。

嵌入位置:v_task_detail 里原来"随 U-026 补上"的占位提示换成了真实
的 DAG 面板。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/development-plan.md
... ... @@ -247,6 +247,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
247 247  
248 248 - **U-026 [Value Path]** `v_graph_visualizer`(Org Graph + Work Graph 双层可视化,含 `waiting_for_quota` 信息态展示)
249 249 - 依赖:U-024
  250 + - Done:Org/Work Graph 双层可视化含 `waiting_for_quota` 信息态(原文档缺 Done/覆盖行,2026-07-31 补上)
  251 + - **实现补充(2026-07-31)**:纯前端 Unit,不需要新接口——数据已经在 U-025 的 `c_task_get` 里(`work_graph_run.graph_manifest` + `work_node_execution` 列表),只补了两个 hover tooltip 要用但 U-025 当时没取的字段(`input_schema_ref`/`output_schema_ref`/`quota_limit_type`/`quota_wait_started_at`,见 `task_query._work_nodes_for_run`)。`frontend/app.js` 新增 `computeGraphLayers()`:按 `graph_manifest` 节点的 `depends_on` 边做拓扑分层(没有依赖的节点在第 0 层,每个节点的层号 = 1 + 依赖节点里最大层号),渲染成按执行层级排布的行(`work-row`),fan-out/fan-in 都能正确分层——用真实运行的服务器 + 一个 fan-out/fan-in 混合的 `graph_manifest`(n1→[n2,n3]→n4)验证过,直接从 `app.js` 里 `eval` 出 `computeGraphLayers` 本体(不是重新实现一份逻辑再测)跑真实拿到的 JSON,分层结果 `[["n1"],["n2","n3"],["n4"]]` 完全正确。节点按 03-C §3.6 的六态配色(`done`/`running`/`reflecting|retrying`/`waiting_for_quota`/`blocked|failed`/`pending`),`waiting_for_quota` 额外挂 `data-quota-limit-type`/`data-quota-wait-since`,hover tooltip 展示 skill/`quota_limit_type`/已等待时长(客户端用 `quota_wait_started_at` 和 `Date.now()` 算,服务端只存原始时间戳不算相对时长)。02-V §2.7 原描述还包含一份 Org Graph 层,但 `v_task_detail`(U-025)页面顶部已经有等价的 `org-strip` 面板(同款 class/data 结构),紧接着 to-do 树下面再画一份完全一样的东西纯属重复滚动,所以这里只做了 Work Graph DAG 层,Org Graph 复用页面已有的那个——不是漏做,是判断为无意义重复后跳过,写在这里说明。嵌入位置:`v_task_detail` 里原来"随 U-026 补上"的占位提示换成了真实的 DAG 面板。浏览器扩展本次会话不可用,验证方式同 U-025:真实服务器 + 真实数据 + 直接跑生产代码里的分层函数,不是真人点击浏览器验证过的。
  252 + - 覆盖:02-V `v_graph_visualizer`;03-C 3.6 节六态配色语义
250 253  
251 254 ### PH3-S3 日志与 Artifact/Skill 管理
252 255  
... ...
docs/sdd/unit_matrix.json
... ... @@ -41,7 +41,7 @@
41 41 {"id": "U-024", "phase": "PH3", "sub_phase": "PH3-S2", "name": "c_ws_subscribe + execution_event 实时推送(最小连通版,见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["execution_event"], "view": [], "controller": ["c_ws_subscribe"], "process": [], "requirement": ["REQ-12"]}, "done": "推送链路本身连通,≤2 秒延迟达成;REQ-12 happy 用例通过(boundary/failure 留给 U-024.5);手写 RFC 6455 握手+帧编解码(无第三方 websockets 依赖),轮询式推送(无单一写路径可插桩),Node 原生 WebSocket 客户端验证过真实互操作性"},
42 42 {"id": "U-024.5", "phase": "PH3", "sub_phase": "PH3-S2", "name": "REQ-12 完整验收:事件多样性联调(2026-07-30 新增)", "classification": "Value Path", "depends_on": ["U-024", "U-015", "U-016", "U-018", "U-019", "U-020", "U-021"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-12"]}, "done": "REQ-12 全部 happy/boundary/failure 用例通过;PH3 Phase 验收里的 REQ-12 以本 Unit 为准"},
43 43 {"id": "U-025", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_dashboard + v_task_board + v_task_detail(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_dashboard", "v_task_board", "v_task_detail"], "controller": ["c_task_list", "c_task_get", "c_org_loop_route"], "process": [], "requirement": []}, "done": "三个页面可正常展示实时数据;补上此前无 Unit 认领的 c_task_list/c_task_get HTTP 路由,c_org_loop_route 首次接上 HTTP;v_graph_visualizer/v_log_viewer 子模块占位等 U-026/U-027"},
44   - {"id": "U-026", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_graph_visualizer", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_graph_visualizer"], "controller": [], "process": [], "requirement": []}, "done": "Org/Work Graph 双层可视化含 waiting_for_quota 信息态"},
  44 + {"id": "U-026", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_graph_visualizer(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_graph_visualizer"], "controller": [], "process": [], "requirement": []}, "done": "Work Graph DAG 分层视图含 waiting_for_quota 信息态;Org Graph 层复用 v_task_detail 已有的 org-strip 面板,不重复渲染"},
45 45 {"id": "U-027", "phase": "PH3", "sub_phase": "PH3-S3", "name": "execution_log_file + 日志查看器", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": ["execution_log_file", "log_retention_config"], "view": ["v_log_viewer"], "controller": ["c_execution_log_get", "c_ws_log_tail"], "process": [], "requirement": ["REQ-13"]}, "done": "REQ-13 全部通过"},
46 46 {"id": "U-028", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_artifact_browser", "classification": "Value Path", "depends_on": ["U-011"], "coverage": {"model": [], "view": ["v_artifact_browser"], "controller": [], "process": [], "requirement": []}, "done": "Artifact 列表+预览+血缘链可用"},
47 47 {"id": "U-029", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_skill_library + v_skill_audit_log", "classification": "Value Path", "depends_on": ["U-018"], "coverage": {"model": [], "view": ["v_skill_library", "v_skill_audit_log"], "controller": [], "process": [], "requirement": []}, "done": "Skill 库与变更审计日志可查看"},
... ...
frontend/app.js
... ... @@ -620,11 +620,107 @@ function mountTaskBoard() {
620 620 registerRouteCleanup(() => socket.close());
621 621 }
622 622  
  623 +// ---- v_graph_visualizer (02-V §2.7): embedded Work Graph DAG, layered by
  624 +// execution depth (computed from graph_manifest's depends_on edges) and
  625 +// colored by work_node_execution.status. Not an independent route --
  626 +// embedded into v_task_detail below. The Org Graph layer from the same
  627 +// spec section is skipped here since v_task_detail already renders an
  628 +// equivalent org-strip panel just above (same classes/data); duplicating
  629 +// it a second time immediately below would just be redundant scroll.
  630 +
  631 +function computeGraphLayers(manifestNodes) {
  632 + const nodesById = {};
  633 + for (const n of manifestNodes) nodesById[n.node_id] = n;
  634 + const layerById = {};
  635 +
  636 + function layerOf(nodeId, seen) {
  637 + if (nodeId in layerById) return layerById[nodeId];
  638 + if (seen.has(nodeId)) return 0; // dependency cycle -- shouldn't happen, don't loop forever
  639 + seen.add(nodeId);
  640 + const deps = (nodesById[nodeId] && nodesById[nodeId].depends_on) || [];
  641 + const layer = deps.length === 0 ? 0 : 1 + Math.max(...deps.map((d) => layerOf(d, seen)));
  642 + layerById[nodeId] = layer;
  643 + return layer;
  644 + }
  645 +
  646 + for (const n of manifestNodes) layerOf(n.node_id, new Set());
  647 + const maxLayer = manifestNodes.length ? Math.max(...manifestNodes.map((n) => layerById[n.node_id])) : -1;
  648 + const rows = Array.from({ length: maxLayer + 1 }, () => []);
  649 + for (const n of manifestNodes) rows[layerById[n.node_id]].push(n);
  650 + return rows;
  651 +}
  652 +
  653 +const GRAPH_LEGEND_HTML = [
  654 + ["pending", "status-pending"], ["running", "status-running"],
  655 + ["reflecting / retrying", "status-reflecting"], ["waiting_for_quota(信息态,非告警)", "status-waiting_for_quota"],
  656 + ["blocked / failed", "status-blocked"], ["done", "status-done"],
  657 +].map(([label, cls]) =>
  658 + '<div class="legend-item"><span class="legend-swatch ' + cls + '"></span>' + label + "</div>"
  659 +).join("");
  660 +
  661 +function renderGraphVisualizerPanel(run) {
  662 + if (!run.graph_manifest || run.graph_manifest.nodes.length === 0) return "";
  663 + const nodesById = {};
  664 + for (const n of run.nodes) nodesById[n.node_id] = n;
  665 + const rowsHtml = computeGraphLayers(run.graph_manifest.nodes).map((row) => {
  666 + const cellsHtml = row.map((gn) => {
  667 + const wn = nodesById[gn.node_id];
  668 + const status = wn ? wn.status : "pending";
  669 + const quotaAttrs = wn && wn.status === "waiting_for_quota"
  670 + ? ' data-quota-limit-type="' + escapeHtml(wn.quota_limit_type || "")
  671 + + '" data-quota-wait-since="' + escapeHtml(wn.quota_wait_started_at || "") + '"'
  672 + : "";
  673 + return (
  674 + '<div class="work-node status-' + status + '" data-node-exec-id="' + (wn ? wn.node_exec_id : "") + '"'
  675 + + quotaAttrs + ">"
  676 + + '<div class="node-id">' + escapeHtml(gn.node_id) + "</div>"
  677 + + '<div class="node-skill">' + escapeHtml(wn ? wn.subagent_type : "尚未开始") + "</div>"
  678 + + "</div>"
  679 + );
  680 + }).join("");
  681 + return '<div class="work-row">' + cellsHtml + "</div>";
  682 + }).join("");
  683 +
  684 + return (
  685 + '<div class="panel"><h2>' + run.org_node + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")</h2>"
  686 + + '<div class="work-graph">' + rowsHtml + "</div>"
  687 + + '<div class="legend" style="margin-top:14px;">' + GRAPH_LEGEND_HTML + "</div>"
  688 + + "</div>"
  689 + );
  690 +}
  691 +
  692 +function mountGraphVisualizerTooltips(container) {
  693 + const tooltip = document.getElementById("graph-tooltip");
  694 + if (!tooltip) return;
  695 + for (const node of container.querySelectorAll(".work-node")) {
  696 + node.addEventListener("mousemove", (evt) => {
  697 + const lines = ["skill: " + (node.querySelector(".node-skill").textContent || "")];
  698 + if (node.dataset.quotaLimitType) {
  699 + lines.push("quota_limit_type: " + node.dataset.quotaLimitType);
  700 + lines.push("已等待:" + formatWaitDuration(node.dataset.quotaWaitSince));
  701 + }
  702 + tooltip.innerHTML = "<b>" + escapeHtml(node.dataset.nodeExecId || node.querySelector(".node-id").textContent) + "</b><br>"
  703 + + lines.map(escapeHtml).join("<br>");
  704 + tooltip.style.left = evt.pageX + 12 + "px";
  705 + tooltip.style.top = evt.pageY + 12 + "px";
  706 + tooltip.classList.add("show");
  707 + });
  708 + node.addEventListener("mouseout", () => tooltip.classList.remove("show"));
  709 + }
  710 +}
  711 +
  712 +function formatWaitDuration(sqliteTimestamp) {
  713 + if (!sqliteTimestamp) return "未知时长";
  714 + const startedAt = new Date(sqliteTimestamp.replace(" ", "T") + "Z").getTime();
  715 + const minutes = Math.max(0, Math.round((Date.now() - startedAt) / 60000));
  716 + return minutes < 60 ? minutes + " 分钟" : Math.round(minutes / 60) + " 小时";
  717 +}
  718 +
623 719 // ---- v_task_detail (02-V §2.6): task detail / process inspection ----
624   -// Embeds a to-do tree built from graph_manifest (01-M 1.3's "补充说明") --
625   -// the DAG-layout v_graph_visualizer and the raw-log v_log_viewer are each
626   -// separate Units (U-026/U-027) and show as clearly-labeled placeholders
627   -// here until they land, rather than faking their content.
  720 +// Embeds a to-do tree built from graph_manifest (01-M 1.3's "补充说明")
  721 +// and the v_graph_visualizer DAG view above. The raw-log v_log_viewer is a
  722 +// separate Unit (U-027) and shows as a clearly-labeled placeholder here
  723 +// until it lands, rather than faking its content.
628 724  
629 725 function renderTaskDetail(taskId) {
630 726 if (!getCurrentProjectId()) return renderNoProjectSelected();
... ... @@ -667,7 +763,7 @@ function mountTaskDetail(taskId) {
667 763 return (
668 764 '<div class="panel"><h2>' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")</h2>"
669 765 + '<ul class="todo-tree">' + items + "</ul>"
670   - + '<div class="see-more">按执行层级排布的 DAG 视图(v_graph_visualizer)随 U-026 补上</div>'
  766 + + '<div class="see-more">DAG 视图(同一份 graph_manifest 的另一种呈现)见下方 v_graph_visualizer</div>'
671 767 + "</div>"
672 768 );
673 769 }
... ... @@ -688,6 +784,7 @@ function mountTaskDetail(taskId) {
688 784 );
689 785  
690 786 const todoTreesHtml = task.work_graph_runs.map(renderTodoTree).join("");
  787 + const graphVisualizerHtml = task.work_graph_runs.map(renderGraphVisualizerPanel).join("");
691 788  
692 789 const artifactsHtml = task.artifacts.length ? (
693 790 '<ul class="simple-list">' + task.artifacts.map((a) =>
... ... @@ -714,6 +811,8 @@ function mountTaskDetail(taskId) {
714 811 + "</div>"
715 812 + '<div class="panel"><h2>Org Graph</h2><div class="org-strip">' + orgStripHtml + "</div></div>"
716 813 + todoTreesHtml
  814 + + graphVisualizerHtml
  815 + + '<div class="tooltip" id="graph-tooltip"></div>'
717 816 + '<div class="panel"><h2>关联 Artifact</h2>' + artifactsHtml + "</div>"
718 817 + '<div class="panel"><h2>关联 Bug 记录</h2>' + bugsHtml + "</div>"
719 818 + '<div class="panel"><h2>原始执行日志</h2><div class="hint">v_log_viewer 随 U-027 补上</div></div>'
... ... @@ -727,6 +826,7 @@ function mountTaskDetail(taskId) {
727 826 if (detail) detail.classList.toggle("open");
728 827 });
729 828 }
  829 + mountGraphVisualizerTooltips(content);
730 830  
731 831 const resumeBtn = document.getElementById("btn_manual_resume");
732 832 if (resumeBtn) {
... ...
frontend/style.css
... ... @@ -89,6 +89,33 @@ table.data-table tr[data-task-id]:hover td { background: #f9fafb; }
89 89 .todo-detail { display: none; margin: 6px 0 0 17px; padding: 8px 10px; background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 12px; color: #6b7280; }
90 90 .todo-detail.open { display: block; }
91 91  
  92 +/* U-026: v_graph_visualizer -- class/data-* names match
  93 + docs/wireframes/v_graph_visualizer.html */
  94 +.work-graph { position: relative; border: 1px dashed #d1d5db; border-radius: 8px; padding: 32px; background: #f9fafb; }
  95 +.work-row { display: flex; gap: 24px; margin-bottom: 40px; justify-content: center; flex-wrap: wrap; }
  96 +.work-row:last-child { margin-bottom: 0; }
  97 +.work-node { border: 2px solid #d1d5db; border-radius: 8px; padding: 10px 14px; min-width: 140px; background: #fff; cursor: pointer; position: relative; font-size: 13px; }
  98 +.work-node .node-id { font-weight: 600; }
  99 +.work-node .node-skill { color: #6b7280; font-size: 11px; margin-top: 2px; }
  100 +.work-node.status-pending { border-color: var(--status-pending); }
  101 +.work-node.status-running { border-color: var(--status-running); background: #eff6ff; }
  102 +.work-node.status-reflecting, .work-node.status-retrying { border-color: var(--status-reflecting); background: #fefce8; }
  103 +.work-node.status-waiting_for_quota { border-color: var(--status-waiting-quota); background: #f0f9ff; }
  104 +.work-node.status-waiting_for_quota::after { content: "🕐"; position: absolute; top: -8px; right: -8px; font-size: 14px; }
  105 +.work-node.status-blocked, .work-node.status-failed { border-color: var(--status-blocked); background: #fef2f2; }
  106 +.work-node.status-done { border-color: var(--status-done); background: #f0fdf4; }
  107 +.legend { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; color: #6b7280; }
  108 +.legend-item { display: flex; align-items: center; gap: 6px; }
  109 +.legend-swatch { width: 14px; height: 14px; border-radius: 4px; border: 2px solid; }
  110 +.legend-swatch.status-pending { border-color: var(--status-pending); }
  111 +.legend-swatch.status-running { border-color: var(--status-running); }
  112 +.legend-swatch.status-reflecting { border-color: var(--status-reflecting); }
  113 +.legend-swatch.status-waiting_for_quota { border-color: var(--status-waiting-quota); }
  114 +.legend-swatch.status-blocked { border-color: var(--status-blocked); }
  115 +.legend-swatch.status-done { border-color: var(--status-done); }
  116 +.tooltip { position: absolute; z-index: 10; background: #111827; color: #fff; font-size: 12px; padding: 8px 10px; border-radius: 6px; max-width: 260px; display: none; pointer-events: none; line-height: 1.5; }
  117 +.tooltip.show { display: block; }
  118 +
92 119 ul.simple-list { list-style: none; margin: 0; padding: 0; font-size: 13px; }
93 120 ul.simple-list li { padding: 8px 0; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; }
94 121 ul.simple-list li:last-child { border-bottom: none; }
... ...
src/task_query.py
... ... @@ -104,9 +104,15 @@ def get_task(conn, task_id):
104 104  
105 105  
106 106 def _work_nodes_for_run(conn, run_id):
  107 + # input_schema_ref/output_schema_ref/quota_limit_type/quota_wait_started_at
  108 + # added for U-026's hover tooltip + waiting_for_quota display (02-V §2.7:
  109 + # "tooltip 展示 input_schema_ref/output_schema_ref/retry_count 摘要...
  110 + # waiting_for_quota 态额外展示 quota_limit_type 和已等待时长") -- not
  111 + # needed by U-025's to-do tree, which is why they weren't here yet.
107 112 rows = conn.execute(
108 113 "SELECT node_exec_id, node_id, subagent_type, status, retry_count, input_payload, "
109   - "output_payload, reflection_notes, git_worktree_ref "
  114 + "output_payload, reflection_notes, git_worktree_ref, input_schema_ref, output_schema_ref, "
  115 + "quota_limit_type, quota_wait_started_at "
110 116 "FROM work_node_execution WHERE run_id = ? ORDER BY node_exec_id",
111 117 (run_id,),
112 118 ).fetchall()
... ... @@ -117,6 +123,8 @@ def _work_nodes_for_run(conn, run_id):
117 123 "input_payload": json.loads(n[5]) if n[5] else None,
118 124 "output_payload": json.loads(n[6]) if n[6] else None,
119 125 "reflection_notes": n[7], "git_worktree_ref": n[8],
  126 + "input_schema_ref": n[9], "output_schema_ref": n[10],
  127 + "quota_limit_type": n[11], "quota_wait_started_at": n[12],
120 128 }
121 129 for n in rows
122 130 ]
... ...
src/test_task_query.py
... ... @@ -145,6 +145,34 @@ class GetTaskTest(TaskQueryTestBase):
145 145 self.assertEqual(run["nodes"][0]["node_id"], "n1")
146 146 self.assertEqual(run["nodes"][0]["status"], "done")
147 147 self.assertEqual(run["nodes"][0]["output_payload"], {"x": 1})
  148 + self.assertEqual(run["nodes"][0]["input_schema_ref"], "in.json")
  149 + self.assertEqual(run["nodes"][0]["output_schema_ref"], "out.json")
  150 +
  151 + def test_work_node_includes_quota_wait_fields_for_v_graph_visualizer(self):
  152 + # U-026: v_graph_visualizer's waiting_for_quota state needs
  153 + # quota_limit_type + how long it's been waiting (02-V §2.7)
  154 + self._insert_task("task-1")
  155 + self.conn.execute(
  156 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  157 + "VALUES ('run-1', 'task-1', 'coder', '{}', 'running')"
  158 + )
  159 + self.conn.execute(
  160 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  161 + "VALUES ('skill-1', 'coder', 'skills/skill-1/', 'sha', 'new', 'active')"
  162 + )
  163 + self.conn.execute(
  164 + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  165 + "input_schema_ref, output_schema_ref, input_payload, status, quota_limit_type, "
  166 + "quota_wait_started_at) "
  167 + "VALUES ('node-1', 'run-1', 'n1', 'coder', 'skill-1', 'in.json', 'out.json', '{}', "
  168 + "'waiting_for_quota', 'session', '2026-07-31 09:00:00')"
  169 + )
  170 + self.conn.commit()
  171 +
  172 + result = task_query.get_task(self.conn, "task-1")
  173 + node = result["work_graph_runs"][0]["nodes"][0]
  174 + self.assertEqual(node["quota_limit_type"], "session")
  175 + self.assertEqual(node["quota_wait_started_at"], "2026-07-31 09:00:00")
148 176  
149 177  
150 178 if __name__ == "__main__":
... ...