Commit ed477539aa5fd9f0097247ad39c1a056f7a9d897
1 parent
04aa7971
graphlemon: auto-commit uncommitted changes for 3e75741a-2cbb-4f80-90c6-8804b94f…
…76ae-main-checkout-drift
Showing
3 changed files
with
165 additions
and
5 deletions
frontend/app.js
| @@ -1126,6 +1126,18 @@ const GRAPH_LEGEND_HTML = [ | @@ -1126,6 +1126,18 @@ const GRAPH_LEGEND_HTML = [ | ||
| 1126 | '<div class="legend-item"><span class="legend-swatch ' + cls + '"></span>' + label + "</div>" | 1126 | '<div class="legend-item"><span class="legend-swatch ' + cls + '"></span>' + label + "</div>" |
| 1127 | ).join(""); | 1127 | ).join(""); |
| 1128 | 1128 | ||
| 1129 | +// 2026-08-08 (Work Graph self-planning + loop-back): how many times | ||
| 1130 | +// node_id was sent back to ITSELF (on_fail.action='retry_self' -- source | ||
| 1131 | +// and target are the same node, so there's no on_failure edge to draw, | ||
| 1132 | +// see work_graph_orchestrate.py's _LoopBackRequested/_on_failure_target | ||
| 1133 | +// design comment). Returns 0 if run.loop_back_events is absent (older | ||
| 1134 | +// runs/tasks predating this field) or empty. | ||
| 1135 | +function countSelfLoopAttempts(run, nodeId) { | ||
| 1136 | + return (run.loop_back_events || []).filter( | ||
| 1137 | + (e) => e.source_node_id === nodeId && e.target_node_id === nodeId | ||
| 1138 | + ).length; | ||
| 1139 | +} | ||
| 1140 | + | ||
| 1129 | function renderGraphVisualizerPanel(run, options) { | 1141 | function renderGraphVisualizerPanel(run, options) { |
| 1130 | if (!run.graph_manifest || run.graph_manifest.nodes.length === 0) return ""; | 1142 | if (!run.graph_manifest || run.graph_manifest.nodes.length === 0) return ""; |
| 1131 | // FR-4 (v_graph_console, 2026-08-03): `options` is new and optional -- | 1143 | // FR-4 (v_graph_console, 2026-08-03): `options` is new and optional -- |
| @@ -1149,10 +1161,19 @@ function renderGraphVisualizerPanel(run, options) { | @@ -1149,10 +1161,19 @@ function renderGraphVisualizerPanel(run, options) { | ||
| 1149 | const clickable = enableNodeClick && wn; | 1161 | const clickable = enableNodeClick && wn; |
| 1150 | const clickableAttrs = clickable ? ' data-clickable-node-exec-id="' + wn.node_exec_id + '"' : ""; | 1162 | const clickableAttrs = clickable ? ' data-clickable-node-exec-id="' + wn.node_exec_id + '"' : ""; |
| 1151 | const logSlotHtml = clickable ? '<div class="log-viewer-slot"></div>' : ""; | 1163 | const logSlotHtml = clickable ? '<div class="log-viewer-slot"></div>' : ""; |
| 1164 | + // 2026-08-08: retry_self loops never touch graph_manifest.edges (the | ||
| 1165 | + // target IS the source, so there's nothing to draw a line to) -- | ||
| 1166 | + // the only place this is visible is a badge on the node itself, | ||
| 1167 | + // read from loop_back_events (execution_event-backed, see | ||
| 1168 | + // countSelfLoopAttempts's own comment). | ||
| 1169 | + const selfLoopCount = countSelfLoopAttempts(run, gn.node_id); | ||
| 1170 | + const loopBadgeHtml = selfLoopCount > 0 | ||
| 1171 | + ? '<span class="loop-badge" title="已原地重试 ' + selfLoopCount + ' 次(on_fail.action=retry_self)">↺ ' + selfLoopCount + "</span>" | ||
| 1172 | + : ""; | ||
| 1152 | return ( | 1173 | return ( |
| 1153 | - '<div class="work-node status-' + status + (clickable ? " clickable" : "") + '" data-node-exec-id="' + (wn ? wn.node_exec_id : "") + '"' | 1174 | + '<div class="work-node status-' + status + (clickable ? " clickable" : "") + '" data-node-exec-id="' + (wn ? wn.node_exec_id : "") + '" data-node-id="' + escapeHtml(gn.node_id) + '"' |
| 1154 | + quotaAttrs + clickableAttrs + ">" | 1175 | + quotaAttrs + clickableAttrs + ">" |
| 1155 | - + '<div class="node-id">' + escapeHtml(gn.node_id) + "</div>" | 1176 | + + '<div class="node-id">' + escapeHtml(gn.node_id) + loopBadgeHtml + "</div>" |
| 1156 | + '<div class="node-skill">' + escapeHtml(wn ? wn.subagent_type : "尚未开始") + "</div>" | 1177 | + '<div class="node-skill">' + escapeHtml(wn ? wn.subagent_type : "尚未开始") + "</div>" |
| 1157 | + logSlotHtml | 1178 | + logSlotHtml |
| 1158 | + "</div>" | 1179 | + "</div>" |
| @@ -1161,17 +1182,92 @@ function renderGraphVisualizerPanel(run, options) { | @@ -1161,17 +1182,92 @@ function renderGraphVisualizerPanel(run, options) { | ||
| 1161 | return '<div class="work-row">' + cellsHtml + "</div>"; | 1182 | return '<div class="work-row">' + cellsHtml + "</div>"; |
| 1162 | }).join(""); | 1183 | }).join(""); |
| 1163 | 1184 | ||
| 1185 | + // 2026-08-08: on_failure edges (graph_manifest.edges, declared whenever a | ||
| 1186 | + // node has on_fail.action='loop_back') are the ones actually worth | ||
| 1187 | + // drawing as lines -- normal depends_on/needs_judgment/zero_token edges | ||
| 1188 | + // are already visually implied by row order (computeGraphLayers), so | ||
| 1189 | + // drawing them too would just be visual noise on top of what the rows | ||
| 1190 | + // already show. Each edge is annotated with how many times it was | ||
| 1191 | + // actually traversed (loop_back_events), not just declared -- a | ||
| 1192 | + // loop_back that was planned but never triggered still shows as a | ||
| 1193 | + // (0x) line, which is itself useful information. | ||
| 1194 | + const onFailureEdges = (run.graph_manifest.edges || []).filter((e) => e.edge_type === "on_failure"); | ||
| 1195 | + const edgesDataJson = escapeHtml(JSON.stringify(onFailureEdges.map((e) => { | ||
| 1196 | + const triggerCount = (run.loop_back_events || []).filter( | ||
| 1197 | + (ev) => ev.source_node_id === e.from && ev.target_node_id === e.to | ||
| 1198 | + ).length; | ||
| 1199 | + return { from: e.from, to: e.to, triggerCount: triggerCount }; | ||
| 1200 | + }))); | ||
| 1201 | + | ||
| 1164 | return ( | 1202 | return ( |
| 1165 | // id="graph-panel-<run_id>": Org Graph 点击可达详情's scroll/highlight | 1203 | // id="graph-panel-<run_id>": Org Graph 点击可达详情's scroll/highlight |
| 1166 | // target (focusOrgNodePanel above) -- additive, doesn't change any | 1204 | // target (focusOrgNodePanel above) -- additive, doesn't change any |
| 1167 | // existing class/data-* this function already emitted. | 1205 | // existing class/data-* this function already emitted. |
| 1168 | '<div class="panel" id="graph-panel-' + run.run_id + '"><h2>' + run.org_node + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")</h2>" | 1206 | '<div class="panel" id="graph-panel-' + run.run_id + '"><h2>' + run.org_node + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")</h2>" |
| 1169 | - + '<div class="work-graph">' + rowsHtml + "</div>" | ||
| 1170 | - + '<div class="legend" style="margin-top:14px;">' + GRAPH_LEGEND_HTML + "</div>" | ||
| 1171 | - + "</div>" | 1207 | + + '<div class="work-graph" data-on-failure-edges="' + edgesDataJson + '">' |
| 1208 | + + '<svg class="graph-edges-svg"></svg>' | ||
| 1209 | + + rowsHtml + "</div>" | ||
| 1210 | + + '<div class="legend" style="margin-top:14px;">' + GRAPH_LEGEND_HTML | ||
| 1211 | + + (onFailureEdges.length ? '<div class="legend-item"><span class="edge-sample edge-on-failure-sample"></span>loop_back(失败回路)</div>' : "") | ||
| 1212 | + + "</div></div>" | ||
| 1172 | ); | 1213 | ); |
| 1173 | } | 1214 | } |
| 1174 | 1215 | ||
| 1216 | +// 2026-08-08 (Work Graph self-planning + loop-back): draws the on_failure | ||
| 1217 | +// edges renderGraphVisualizerPanel embedded as data-on-failure-edges -- | ||
| 1218 | +// requires REAL layout (getBoundingClientRect), so it can only run after | ||
| 1219 | +// the panel's HTML is actually in the document, same reason | ||
| 1220 | +// mountGraphVisualizerTooltips is a separate mount step rather than | ||
| 1221 | +// inline in the render* function. A .work-node's real width depends on | ||
| 1222 | +// its content, so positions can't be precomputed at render time. | ||
| 1223 | +function mountGraphVisualizerEdges(container) { | ||
| 1224 | + for (const graph of container.querySelectorAll(".work-graph[data-on-failure-edges]")) { | ||
| 1225 | + let edges; | ||
| 1226 | + try { | ||
| 1227 | + edges = JSON.parse(graph.dataset.onFailureEdges || "[]"); | ||
| 1228 | + } catch (e) { | ||
| 1229 | + edges = []; | ||
| 1230 | + } | ||
| 1231 | + const svg = graph.querySelector(".graph-edges-svg"); | ||
| 1232 | + if (!svg || edges.length === 0) continue; | ||
| 1233 | + | ||
| 1234 | + const graphRect = graph.getBoundingClientRect(); | ||
| 1235 | + svg.setAttribute("width", graphRect.width); | ||
| 1236 | + svg.setAttribute("height", graphRect.height); | ||
| 1237 | + if (!svg.querySelector("defs")) { | ||
| 1238 | + svg.innerHTML = | ||
| 1239 | + '<defs><marker id="arrow-on-failure" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">' | ||
| 1240 | + + '<path d="M0,0 L6,3 L0,6 Z" fill="#dc2626"></path></marker></defs>'; | ||
| 1241 | + } | ||
| 1242 | + | ||
| 1243 | + const nodeCenter = (nodeId) => { | ||
| 1244 | + const el = graph.querySelector('.work-node[data-node-id="' + CSS.escape(nodeId) + '"]'); | ||
| 1245 | + if (!el) return null; | ||
| 1246 | + const r = el.getBoundingClientRect(); | ||
| 1247 | + return { x: r.left - graphRect.left + r.width / 2, y: r.top - graphRect.top + r.height / 2, w: r.width, h: r.height }; | ||
| 1248 | + }; | ||
| 1249 | + | ||
| 1250 | + const linesHtml = edges.map((edge) => { | ||
| 1251 | + const from = nodeCenter(edge.from); | ||
| 1252 | + const to = nodeCenter(edge.to); | ||
| 1253 | + if (!from || !to) return ""; | ||
| 1254 | + // pull the endpoint back to the target node's edge (roughly, using | ||
| 1255 | + // its own half-height) so the arrowhead doesn't render buried | ||
| 1256 | + // underneath the node box. | ||
| 1257 | + const dy = to.y - from.y; | ||
| 1258 | + const pullBack = dy === 0 ? 0 : Math.min(to.h / 2, Math.abs(dy) / 2) * Math.sign(dy); | ||
| 1259 | + const label = edge.triggerCount > 0 ? edge.triggerCount + "x" : "0x(已声明,未触发)"; | ||
| 1260 | + return ( | ||
| 1261 | + '<line x1="' + from.x + '" y1="' + from.y + '" x2="' + to.x + '" y2="' + (to.y - pullBack) | ||
| 1262 | + + '" class="edge-on-failure" marker-end="url(#arrow-on-failure)"></line>' | ||
| 1263 | + + '<text x="' + (from.x + to.x) / 2 + '" y="' + (from.y + to.y) / 2 | ||
| 1264 | + + '" class="edge-label">' + escapeHtml(label) + "</text>" | ||
| 1265 | + ); | ||
| 1266 | + }).join(""); | ||
| 1267 | + svg.innerHTML = svg.querySelector("defs").outerHTML + linesHtml; | ||
| 1268 | + } | ||
| 1269 | +} | ||
| 1270 | + | ||
| 1175 | function mountGraphVisualizerTooltips(container) { | 1271 | function mountGraphVisualizerTooltips(container) { |
| 1176 | const tooltip = document.getElementById("graph-tooltip"); | 1272 | const tooltip = document.getElementById("graph-tooltip"); |
| 1177 | if (!tooltip) return; | 1273 | if (!tooltip) return; |
| @@ -1481,6 +1577,7 @@ function mountTaskDetail(taskId, initialOrgNode) { | @@ -1481,6 +1577,7 @@ function mountTaskDetail(taskId, initialOrgNode) { | ||
| 1481 | }); | 1577 | }); |
| 1482 | } | 1578 | } |
| 1483 | mountGraphVisualizerTooltips(content); | 1579 | mountGraphVisualizerTooltips(content); |
| 1580 | + mountGraphVisualizerEdges(content); | ||
| 1484 | 1581 | ||
| 1485 | // Org Graph 三节点条点击可达详情: clicking a node here (already on | 1582 | // Org Graph 三节点条点击可达详情: clicking a node here (already on |
| 1486 | // v_task_detail) stays on this page -- update focusedOrgNode + the URL | 1583 | // v_task_detail) stays on this page -- update focusedOrgNode + the URL |
| @@ -1777,6 +1874,7 @@ function mountRunDetail(runId) { | @@ -1777,6 +1874,7 @@ function mountRunDetail(runId) { | ||
| 1777 | } | 1874 | } |
| 1778 | mountForkPanels(content); | 1875 | mountForkPanels(content); |
| 1779 | mountGraphVisualizerTooltips(content); | 1876 | mountGraphVisualizerTooltips(content); |
| 1877 | + mountGraphVisualizerEdges(content); | ||
| 1780 | 1878 | ||
| 1781 | if (!socket) { | 1879 | if (!socket) { |
| 1782 | socket = connectEventSocket(projectId, run.task_id, { | 1880 | socket = connectEventSocket(projectId, run.task_id, { |
src/task_query.py
| @@ -169,12 +169,36 @@ def get_task(conn, task_id): | @@ -169,12 +169,36 @@ def get_task(conn, task_id): | ||
| 169 | # run was forked from a historical node (src/work_graph_fork.py). | 169 | # run was forked from a historical node (src/work_graph_fork.py). |
| 170 | "parent_run_id": w[6], "fork_of_node_exec_id": w[7], | 170 | "parent_run_id": w[6], "fork_of_node_exec_id": w[7], |
| 171 | "nodes": _work_nodes_for_run(conn, w[0]), | 171 | "nodes": _work_nodes_for_run(conn, w[0]), |
| 172 | + # 2026-08-08 (Work Graph self-planning + loop-back): the console's | ||
| 173 | + # v_graph_visualizer needs this to actually draw a loop_back's | ||
| 174 | + # on_failure edge / a retry_self node's "sent back Nx" badge -- | ||
| 175 | + # neither is derivable from graph_manifest/work_node_execution | ||
| 176 | + # alone (loop_back_triggered is execution_event-only by design, | ||
| 177 | + # see docs/todo/work-graph-self-planning-and-loop-back-design.md §7). | ||
| 178 | + "loop_back_events": _loop_back_events_for_run(conn, w[0]), | ||
| 172 | } | 179 | } |
| 173 | for w in runs | 180 | for w in runs |
| 174 | ] | 181 | ] |
| 175 | return task | 182 | return task |
| 176 | 183 | ||
| 177 | 184 | ||
| 185 | +def _loop_back_events_for_run(conn, run_id): | ||
| 186 | + rows = conn.execute( | ||
| 187 | + "SELECT payload, created_at FROM execution_event " | ||
| 188 | + "WHERE run_id = ? AND event_type = 'loop_back_triggered' ORDER BY created_at", | ||
| 189 | + (run_id,), | ||
| 190 | + ).fetchall() | ||
| 191 | + events = [] | ||
| 192 | + for payload, created_at in rows: | ||
| 193 | + data = json.loads(payload) | ||
| 194 | + events.append({ | ||
| 195 | + "source_node_id": data["source_node_id"], "target_node_id": data["target_node_id"], | ||
| 196 | + "attempt_number": data["attempt_number"], "feedback": data.get("feedback") or [], | ||
| 197 | + "created_at": created_at, | ||
| 198 | + }) | ||
| 199 | + return events | ||
| 200 | + | ||
| 201 | + | ||
| 178 | def _work_nodes_for_run(conn, run_id): | 202 | def _work_nodes_for_run(conn, run_id): |
| 179 | # input_schema_ref/output_schema_ref/quota_limit_type/quota_wait_started_at | 203 | # input_schema_ref/output_schema_ref/quota_limit_type/quota_wait_started_at |
| 180 | # added for U-026's hover tooltip + waiting_for_quota display (02-V §2.7: | 204 | # added for U-026's hover tooltip + waiting_for_quota display (02-V §2.7: |
src/test_task_query.py
| @@ -146,6 +146,44 @@ class GetTaskTest(TaskQueryTestBase): | @@ -146,6 +146,44 @@ class GetTaskTest(TaskQueryTestBase): | ||
| 146 | self.assertIsNone(run["parent_run_id"]) | 146 | self.assertIsNone(run["parent_run_id"]) |
| 147 | self.assertIsNone(run["fork_of_node_exec_id"]) | 147 | self.assertIsNone(run["fork_of_node_exec_id"]) |
| 148 | 148 | ||
| 149 | + def test_includes_loop_back_events_for_the_run(self): | ||
| 150 | + # 2026-08-08 (Work Graph self-planning + loop-back): v_graph_visualizer | ||
| 151 | + # needs this to draw an on_failure edge/retry badge -- not derivable | ||
| 152 | + # from graph_manifest or work_node_execution alone. | ||
| 153 | + self._insert_task("task-1") | ||
| 154 | + self.conn.execute( | ||
| 155 | + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " | ||
| 156 | + "VALUES ('run-1', 'task-1', 'planner', '{}', 'running')" | ||
| 157 | + ) | ||
| 158 | + self.conn.execute( | ||
| 159 | + "INSERT INTO execution_event (event_id, project_id, task_id, run_id, event_type, payload) " | ||
| 160 | + "VALUES ('evt-1', 'proj-1', 'task-1', 'run-1', 'loop_back_triggered', ?)", | ||
| 161 | + (json.dumps({ | ||
| 162 | + "source_node_id": "benchmark", "target_node_id": "benchmark", | ||
| 163 | + "attempt_number": 1, "feedback": ["not enough detail"], | ||
| 164 | + }),), | ||
| 165 | + ) | ||
| 166 | + self.conn.commit() | ||
| 167 | + | ||
| 168 | + result = task_query.get_task(self.conn, "task-1") | ||
| 169 | + run = result["work_graph_runs"][0] | ||
| 170 | + self.assertEqual(len(run["loop_back_events"]), 1) | ||
| 171 | + event = run["loop_back_events"][0] | ||
| 172 | + self.assertEqual(event["source_node_id"], "benchmark") | ||
| 173 | + self.assertEqual(event["target_node_id"], "benchmark") | ||
| 174 | + self.assertEqual(event["attempt_number"], 1) | ||
| 175 | + self.assertEqual(event["feedback"], ["not enough detail"]) | ||
| 176 | + | ||
| 177 | + def test_no_loop_back_events_is_an_empty_list_not_missing_key(self): | ||
| 178 | + self._insert_task("task-1") | ||
| 179 | + self.conn.execute( | ||
| 180 | + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " | ||
| 181 | + "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')" | ||
| 182 | + ) | ||
| 183 | + self.conn.commit() | ||
| 184 | + result = task_query.get_task(self.conn, "task-1") | ||
| 185 | + self.assertEqual(result["work_graph_runs"][0]["loop_back_events"], []) | ||
| 186 | + | ||
| 149 | def test_forked_run_surfaces_parent_and_source_node(self): | 187 | def test_forked_run_surfaces_parent_and_source_node(self): |
| 150 | # D (2026-08-06): v_task_detail must be able to tell a fork branch | 188 | # D (2026-08-06): v_task_detail must be able to tell a fork branch |
| 151 | # apart from the original run it descends from. | 189 | # apart from the original run it descends from. |