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 ? (
'' + task.artifacts.map((a) =>
@@ -714,6 +811,8 @@ function mountTaskDetail(taskId) {
+ ""
+ 'Org Graph
' + orgStripHtml + "
"
+ todoTreesHtml
+ + graphVisualizerHtml
+ + ''
+ '关联 Artifact
' + artifactsHtml + ""
+ '关联 Bug 记录
' + bugsHtml + ""
+ '原始执行日志
v_log_viewer 随 U-027 补上
'
@@ -727,6 +826,7 @@ function mountTaskDetail(taskId) {
if (detail) detail.classList.toggle("open");
});
}
+ mountGraphVisualizerTooltips(content);
const resumeBtn = document.getElementById("btn_manual_resume");
if (resumeBtn) {
diff --git a/frontend/style.css b/frontend/style.css
index 1b614fd..af870cc 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -89,6 +89,33 @@ table.data-table tr[data-task-id]:hover td { background: #f9fafb; }
.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; }
.todo-detail.open { display: block; }
+/* U-026: v_graph_visualizer -- class/data-* names match
+ docs/wireframes/v_graph_visualizer.html */
+.work-graph { position: relative; border: 1px dashed #d1d5db; border-radius: 8px; padding: 32px; background: #f9fafb; }
+.work-row { display: flex; gap: 24px; margin-bottom: 40px; justify-content: center; flex-wrap: wrap; }
+.work-row:last-child { margin-bottom: 0; }
+.work-node { border: 2px solid #d1d5db; border-radius: 8px; padding: 10px 14px; min-width: 140px; background: #fff; cursor: pointer; position: relative; font-size: 13px; }
+.work-node .node-id { font-weight: 600; }
+.work-node .node-skill { color: #6b7280; font-size: 11px; margin-top: 2px; }
+.work-node.status-pending { border-color: var(--status-pending); }
+.work-node.status-running { border-color: var(--status-running); background: #eff6ff; }
+.work-node.status-reflecting, .work-node.status-retrying { border-color: var(--status-reflecting); background: #fefce8; }
+.work-node.status-waiting_for_quota { border-color: var(--status-waiting-quota); background: #f0f9ff; }
+.work-node.status-waiting_for_quota::after { content: "🕐"; position: absolute; top: -8px; right: -8px; font-size: 14px; }
+.work-node.status-blocked, .work-node.status-failed { border-color: var(--status-blocked); background: #fef2f2; }
+.work-node.status-done { border-color: var(--status-done); background: #f0fdf4; }
+.legend { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; color: #6b7280; }
+.legend-item { display: flex; align-items: center; gap: 6px; }
+.legend-swatch { width: 14px; height: 14px; border-radius: 4px; border: 2px solid; }
+.legend-swatch.status-pending { border-color: var(--status-pending); }
+.legend-swatch.status-running { border-color: var(--status-running); }
+.legend-swatch.status-reflecting { border-color: var(--status-reflecting); }
+.legend-swatch.status-waiting_for_quota { border-color: var(--status-waiting-quota); }
+.legend-swatch.status-blocked { border-color: var(--status-blocked); }
+.legend-swatch.status-done { border-color: var(--status-done); }
+.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; }
+.tooltip.show { display: block; }
+
ul.simple-list { list-style: none; margin: 0; padding: 0; font-size: 13px; }
ul.simple-list li { padding: 8px 0; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; }
ul.simple-list li:last-child { border-bottom: none; }
diff --git a/src/task_query.py b/src/task_query.py
index 132cb5d..f3607a0 100644
--- a/src/task_query.py
+++ b/src/task_query.py
@@ -104,9 +104,15 @@ def get_task(conn, task_id):
def _work_nodes_for_run(conn, run_id):
+ # input_schema_ref/output_schema_ref/quota_limit_type/quota_wait_started_at
+ # added for U-026's hover tooltip + waiting_for_quota display (02-V §2.7:
+ # "tooltip 展示 input_schema_ref/output_schema_ref/retry_count 摘要...
+ # waiting_for_quota 态额外展示 quota_limit_type 和已等待时长") -- not
+ # needed by U-025's to-do tree, which is why they weren't here yet.
rows = conn.execute(
"SELECT node_exec_id, node_id, subagent_type, status, retry_count, input_payload, "
- "output_payload, reflection_notes, git_worktree_ref "
+ "output_payload, reflection_notes, git_worktree_ref, input_schema_ref, output_schema_ref, "
+ "quota_limit_type, quota_wait_started_at "
"FROM work_node_execution WHERE run_id = ? ORDER BY node_exec_id",
(run_id,),
).fetchall()
@@ -117,6 +123,8 @@ def _work_nodes_for_run(conn, run_id):
"input_payload": json.loads(n[5]) if n[5] else None,
"output_payload": json.loads(n[6]) if n[6] else None,
"reflection_notes": n[7], "git_worktree_ref": n[8],
+ "input_schema_ref": n[9], "output_schema_ref": n[10],
+ "quota_limit_type": n[11], "quota_wait_started_at": n[12],
}
for n in rows
]
diff --git a/src/test_task_query.py b/src/test_task_query.py
index bf232d8..9b27e1d 100644
--- a/src/test_task_query.py
+++ b/src/test_task_query.py
@@ -145,6 +145,34 @@ class GetTaskTest(TaskQueryTestBase):
self.assertEqual(run["nodes"][0]["node_id"], "n1")
self.assertEqual(run["nodes"][0]["status"], "done")
self.assertEqual(run["nodes"][0]["output_payload"], {"x": 1})
+ self.assertEqual(run["nodes"][0]["input_schema_ref"], "in.json")
+ self.assertEqual(run["nodes"][0]["output_schema_ref"], "out.json")
+
+ def test_work_node_includes_quota_wait_fields_for_v_graph_visualizer(self):
+ # U-026: v_graph_visualizer's waiting_for_quota state needs
+ # quota_limit_type + how long it's been waiting (02-V §2.7)
+ self._insert_task("task-1")
+ self.conn.execute(
+ "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
+ "VALUES ('run-1', 'task-1', 'coder', '{}', 'running')"
+ )
+ self.conn.execute(
+ "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
+ "VALUES ('skill-1', 'coder', 'skills/skill-1/', 'sha', 'new', 'active')"
+ )
+ self.conn.execute(
+ "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
+ "input_schema_ref, output_schema_ref, input_payload, status, quota_limit_type, "
+ "quota_wait_started_at) "
+ "VALUES ('node-1', 'run-1', 'n1', 'coder', 'skill-1', 'in.json', 'out.json', '{}', "
+ "'waiting_for_quota', 'session', '2026-07-31 09:00:00')"
+ )
+ self.conn.commit()
+
+ result = task_query.get_task(self.conn, "task-1")
+ node = result["work_graph_runs"][0]["nodes"][0]
+ self.assertEqual(node["quota_limit_type"], "session")
+ self.assertEqual(node["quota_wait_started_at"], "2026-07-31 09:00:00")
if __name__ == "__main__":