From b72b03847cdae04d1fddabab60ca3526fd1aa679 Mon Sep 17 00:00:00 2001 From: bboonnee Date: Fri, 31 Jul 2026 17:41:09 +0800 Subject: [PATCH] U-026: v_graph_visualizer --- docs/development-plan.md | 3 +++ docs/sdd/unit_matrix.json | 2 +- frontend/app.js | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- frontend/style.css | 27 +++++++++++++++++++++++++++ src/task_query.py | 10 +++++++++- src/test_task_query.py | 28 ++++++++++++++++++++++++++++ 6 files changed, 173 insertions(+), 7 deletions(-) diff --git a/docs/development-plan.md b/docs/development-plan.md index e4ebd44..b3e6772 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -247,6 +247,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 - **U-026 [Value Path]** `v_graph_visualizer`(Org Graph + Work Graph 双层可视化,含 `waiting_for_quota` 信息态展示) - 依赖:U-024 + - Done:Org/Work Graph 双层可视化含 `waiting_for_quota` 信息态(原文档缺 Done/覆盖行,2026-07-31 补上) + - **实现补充(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:真实服务器 + 真实数据 + 直接跑生产代码里的分层函数,不是真人点击浏览器验证过的。 + - 覆盖:02-V `v_graph_visualizer`;03-C 3.6 节六态配色语义 ### PH3-S3 日志与 Artifact/Skill 管理 diff --git a/docs/sdd/unit_matrix.json b/docs/sdd/unit_matrix.json index 049c4d5..e5dedea 100644 --- a/docs/sdd/unit_matrix.json +++ b/docs/sdd/unit_matrix.json @@ -41,7 +41,7 @@ {"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 客户端验证过真实互操作性"}, {"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 为准"}, {"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"}, - {"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 信息态"}, + {"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 面板,不重复渲染"}, {"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 全部通过"}, {"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 列表+预览+血缘链可用"}, {"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 库与变更审计日志可查看"}, diff --git a/frontend/app.js b/frontend/app.js index 10d384c..4035b7e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -620,11 +620,107 @@ function mountTaskBoard() { registerRouteCleanup(() => socket.close()); } +// ---- v_graph_visualizer (02-V §2.7): embedded Work Graph DAG, layered by +// execution depth (computed from graph_manifest's depends_on edges) and +// colored by work_node_execution.status. Not an independent route -- +// embedded into v_task_detail below. The Org Graph layer from the same +// spec section is skipped here since v_task_detail already renders an +// equivalent org-strip panel just above (same classes/data); duplicating +// it a second time immediately below would just be redundant scroll. + +function computeGraphLayers(manifestNodes) { + const nodesById = {}; + for (const n of manifestNodes) nodesById[n.node_id] = n; + const layerById = {}; + + function layerOf(nodeId, seen) { + if (nodeId in layerById) return layerById[nodeId]; + if (seen.has(nodeId)) return 0; // dependency cycle -- shouldn't happen, don't loop forever + seen.add(nodeId); + const deps = (nodesById[nodeId] && nodesById[nodeId].depends_on) || []; + const layer = deps.length === 0 ? 0 : 1 + Math.max(...deps.map((d) => layerOf(d, seen))); + layerById[nodeId] = layer; + return layer; + } + + for (const n of manifestNodes) layerOf(n.node_id, new Set()); + const maxLayer = manifestNodes.length ? Math.max(...manifestNodes.map((n) => layerById[n.node_id])) : -1; + const rows = Array.from({ length: maxLayer + 1 }, () => []); + for (const n of manifestNodes) rows[layerById[n.node_id]].push(n); + return rows; +} + +const GRAPH_LEGEND_HTML = [ + ["pending", "status-pending"], ["running", "status-running"], + ["reflecting / retrying", "status-reflecting"], ["waiting_for_quota(信息态,非告警)", "status-waiting_for_quota"], + ["blocked / failed", "status-blocked"], ["done", "status-done"], +].map(([label, cls]) => + '
' + label + "
" +).join(""); + +function renderGraphVisualizerPanel(run) { + if (!run.graph_manifest || run.graph_manifest.nodes.length === 0) return ""; + const nodesById = {}; + for (const n of run.nodes) nodesById[n.node_id] = n; + const rowsHtml = computeGraphLayers(run.graph_manifest.nodes).map((row) => { + const cellsHtml = row.map((gn) => { + const wn = nodesById[gn.node_id]; + const status = wn ? wn.status : "pending"; + const quotaAttrs = wn && wn.status === "waiting_for_quota" + ? ' data-quota-limit-type="' + escapeHtml(wn.quota_limit_type || "") + + '" data-quota-wait-since="' + escapeHtml(wn.quota_wait_started_at || "") + '"' + : ""; + return ( + '
" + + '
' + escapeHtml(gn.node_id) + "
" + + '
' + escapeHtml(wn ? wn.subagent_type : "尚未开始") + "
" + + "
" + ); + }).join(""); + return '
' + cellsHtml + "
"; + }).join(""); + + return ( + '

' + run.org_node + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")

" + + '
' + rowsHtml + "
" + + '
' + GRAPH_LEGEND_HTML + "
" + + "
" + ); +} + +function mountGraphVisualizerTooltips(container) { + const tooltip = document.getElementById("graph-tooltip"); + if (!tooltip) return; + for (const node of container.querySelectorAll(".work-node")) { + node.addEventListener("mousemove", (evt) => { + const lines = ["skill: " + (node.querySelector(".node-skill").textContent || "")]; + if (node.dataset.quotaLimitType) { + lines.push("quota_limit_type: " + node.dataset.quotaLimitType); + lines.push("已等待:" + formatWaitDuration(node.dataset.quotaWaitSince)); + } + tooltip.innerHTML = "" + escapeHtml(node.dataset.nodeExecId || node.querySelector(".node-id").textContent) + "
" + + lines.map(escapeHtml).join("
"); + tooltip.style.left = evt.pageX + 12 + "px"; + tooltip.style.top = evt.pageY + 12 + "px"; + tooltip.classList.add("show"); + }); + node.addEventListener("mouseout", () => tooltip.classList.remove("show")); + } +} + +function formatWaitDuration(sqliteTimestamp) { + if (!sqliteTimestamp) return "未知时长"; + const startedAt = new Date(sqliteTimestamp.replace(" ", "T") + "Z").getTime(); + const minutes = Math.max(0, Math.round((Date.now() - startedAt) / 60000)); + return minutes < 60 ? minutes + " 分钟" : Math.round(minutes / 60) + " 小时"; +} + // ---- v_task_detail (02-V §2.6): task detail / process inspection ---- -// Embeds a to-do tree built from graph_manifest (01-M 1.3's "补充说明") -- -// the DAG-layout v_graph_visualizer and the raw-log v_log_viewer are each -// separate Units (U-026/U-027) and show as clearly-labeled placeholders -// here until they land, rather than faking their content. +// Embeds a to-do tree built from graph_manifest (01-M 1.3's "补充说明") +// and the v_graph_visualizer DAG view above. The raw-log v_log_viewer is a +// separate Unit (U-027) and shows as a clearly-labeled placeholder here +// until it lands, rather than faking its content. function renderTaskDetail(taskId) { if (!getCurrentProjectId()) return renderNoProjectSelected(); @@ -667,7 +763,7 @@ function mountTaskDetail(taskId) { return ( '

' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")

" + '" - + '
按执行层级排布的 DAG 视图(v_graph_visualizer)随 U-026 补上
' + + '
DAG 视图(同一份 graph_manifest 的另一种呈现)见下方 v_graph_visualizer
' + "
" ); } @@ -688,6 +784,7 @@ function mountTaskDetail(taskId) { ); const todoTreesHtml = task.work_graph_runs.map(renderTodoTree).join(""); + const graphVisualizerHtml = task.work_graph_runs.map(renderGraphVisualizerPanel).join(""); const artifactsHtml = task.artifacts.length ? ( '