Commit b72b03847cdae04d1fddabab60ca3526fd1aa679
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>
Showing
6 changed files
with
173 additions
and
7 deletions
docs/development-plan.md
| @@ -247,6 +247,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | @@ -247,6 +247,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | ||
| 247 | 247 | ||
| 248 | - **U-026 [Value Path]** `v_graph_visualizer`(Org Graph + Work Graph 双层可视化,含 `waiting_for_quota` 信息态展示) | 248 | - **U-026 [Value Path]** `v_graph_visualizer`(Org Graph + Work Graph 双层可视化,含 `waiting_for_quota` 信息态展示) |
| 249 | - 依赖:U-024 | 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 | ### PH3-S3 日志与 Artifact/Skill 管理 | 254 | ### PH3-S3 日志与 Artifact/Skill 管理 |
| 252 | 255 |
docs/sdd/unit_matrix.json
| @@ -41,7 +41,7 @@ | @@ -41,7 +41,7 @@ | ||
| 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 客户端验证过真实互操作性"}, | 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 | {"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 为准"}, | 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 | {"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"}, | 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 | {"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 全部通过"}, | 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 | {"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 列表+预览+血缘链可用"}, | 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 | {"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 库与变更审计日志可查看"}, | 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,11 +620,107 @@ function mountTaskBoard() { | ||
| 620 | registerRouteCleanup(() => socket.close()); | 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 | // ---- v_task_detail (02-V §2.6): task detail / process inspection ---- | 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 | function renderTaskDetail(taskId) { | 725 | function renderTaskDetail(taskId) { |
| 630 | if (!getCurrentProjectId()) return renderNoProjectSelected(); | 726 | if (!getCurrentProjectId()) return renderNoProjectSelected(); |
| @@ -667,7 +763,7 @@ function mountTaskDetail(taskId) { | @@ -667,7 +763,7 @@ function mountTaskDetail(taskId) { | ||
| 667 | return ( | 763 | return ( |
| 668 | '<div class="panel"><h2>' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")</h2>" | 764 | '<div class="panel"><h2>' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")</h2>" |
| 669 | + '<ul class="todo-tree">' + items + "</ul>" | 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 | + "</div>" | 767 | + "</div>" |
| 672 | ); | 768 | ); |
| 673 | } | 769 | } |
| @@ -688,6 +784,7 @@ function mountTaskDetail(taskId) { | @@ -688,6 +784,7 @@ function mountTaskDetail(taskId) { | ||
| 688 | ); | 784 | ); |
| 689 | 785 | ||
| 690 | const todoTreesHtml = task.work_graph_runs.map(renderTodoTree).join(""); | 786 | const todoTreesHtml = task.work_graph_runs.map(renderTodoTree).join(""); |
| 787 | + const graphVisualizerHtml = task.work_graph_runs.map(renderGraphVisualizerPanel).join(""); | ||
| 691 | 788 | ||
| 692 | const artifactsHtml = task.artifacts.length ? ( | 789 | const artifactsHtml = task.artifacts.length ? ( |
| 693 | '<ul class="simple-list">' + task.artifacts.map((a) => | 790 | '<ul class="simple-list">' + task.artifacts.map((a) => |
| @@ -714,6 +811,8 @@ function mountTaskDetail(taskId) { | @@ -714,6 +811,8 @@ function mountTaskDetail(taskId) { | ||
| 714 | + "</div>" | 811 | + "</div>" |
| 715 | + '<div class="panel"><h2>Org Graph</h2><div class="org-strip">' + orgStripHtml + "</div></div>" | 812 | + '<div class="panel"><h2>Org Graph</h2><div class="org-strip">' + orgStripHtml + "</div></div>" |
| 716 | + todoTreesHtml | 813 | + todoTreesHtml |
| 814 | + + graphVisualizerHtml | ||
| 815 | + + '<div class="tooltip" id="graph-tooltip"></div>' | ||
| 717 | + '<div class="panel"><h2>关联 Artifact</h2>' + artifactsHtml + "</div>" | 816 | + '<div class="panel"><h2>关联 Artifact</h2>' + artifactsHtml + "</div>" |
| 718 | + '<div class="panel"><h2>关联 Bug 记录</h2>' + bugsHtml + "</div>" | 817 | + '<div class="panel"><h2>关联 Bug 记录</h2>' + bugsHtml + "</div>" |
| 719 | + '<div class="panel"><h2>原始执行日志</h2><div class="hint">v_log_viewer 随 U-027 补上</div></div>' | 818 | + '<div class="panel"><h2>原始执行日志</h2><div class="hint">v_log_viewer 随 U-027 补上</div></div>' |
| @@ -727,6 +826,7 @@ function mountTaskDetail(taskId) { | @@ -727,6 +826,7 @@ function mountTaskDetail(taskId) { | ||
| 727 | if (detail) detail.classList.toggle("open"); | 826 | if (detail) detail.classList.toggle("open"); |
| 728 | }); | 827 | }); |
| 729 | } | 828 | } |
| 829 | + mountGraphVisualizerTooltips(content); | ||
| 730 | 830 | ||
| 731 | const resumeBtn = document.getElementById("btn_manual_resume"); | 831 | const resumeBtn = document.getElementById("btn_manual_resume"); |
| 732 | if (resumeBtn) { | 832 | if (resumeBtn) { |
frontend/style.css
| @@ -89,6 +89,33 @@ table.data-table tr[data-task-id]:hover td { background: #f9fafb; } | @@ -89,6 +89,33 @@ table.data-table tr[data-task-id]:hover td { background: #f9fafb; } | ||
| 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; } | 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 | .todo-detail.open { display: block; } | 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 | ul.simple-list { list-style: none; margin: 0; padding: 0; font-size: 13px; } | 119 | ul.simple-list { list-style: none; margin: 0; padding: 0; font-size: 13px; } |
| 93 | ul.simple-list li { padding: 8px 0; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; } | 120 | ul.simple-list li { padding: 8px 0; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; } |
| 94 | ul.simple-list li:last-child { border-bottom: none; } | 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,9 +104,15 @@ def get_task(conn, task_id): | ||
| 104 | 104 | ||
| 105 | 105 | ||
| 106 | def _work_nodes_for_run(conn, run_id): | 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 | rows = conn.execute( | 112 | rows = conn.execute( |
| 108 | "SELECT node_exec_id, node_id, subagent_type, status, retry_count, input_payload, " | 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 | "FROM work_node_execution WHERE run_id = ? ORDER BY node_exec_id", | 116 | "FROM work_node_execution WHERE run_id = ? ORDER BY node_exec_id", |
| 111 | (run_id,), | 117 | (run_id,), |
| 112 | ).fetchall() | 118 | ).fetchall() |
| @@ -117,6 +123,8 @@ def _work_nodes_for_run(conn, run_id): | @@ -117,6 +123,8 @@ def _work_nodes_for_run(conn, run_id): | ||
| 117 | "input_payload": json.loads(n[5]) if n[5] else None, | 123 | "input_payload": json.loads(n[5]) if n[5] else None, |
| 118 | "output_payload": json.loads(n[6]) if n[6] else None, | 124 | "output_payload": json.loads(n[6]) if n[6] else None, |
| 119 | "reflection_notes": n[7], "git_worktree_ref": n[8], | 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 | for n in rows | 129 | for n in rows |
| 122 | ] | 130 | ] |
src/test_task_query.py
| @@ -145,6 +145,34 @@ class GetTaskTest(TaskQueryTestBase): | @@ -145,6 +145,34 @@ class GetTaskTest(TaskQueryTestBase): | ||
| 145 | self.assertEqual(run["nodes"][0]["node_id"], "n1") | 145 | self.assertEqual(run["nodes"][0]["node_id"], "n1") |
| 146 | self.assertEqual(run["nodes"][0]["status"], "done") | 146 | self.assertEqual(run["nodes"][0]["status"], "done") |
| 147 | self.assertEqual(run["nodes"][0]["output_payload"], {"x": 1}) | 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 | if __name__ == "__main__": | 178 | if __name__ == "__main__": |