From ed477539aa5fd9f0097247ad39c1a056f7a9d897 Mon Sep 17 00:00:00 2001 From: graphlemon Date: Sat, 8 Aug 2026 11:51:43 +0800 Subject: [PATCH] graphlemon: auto-commit uncommitted changes for 3e75741a-2cbb-4f80-90c6-8804b94f76ae-main-checkout-drift --- frontend/app.js | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- src/task_query.py | 24 ++++++++++++++++++++++++ src/test_task_query.py | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 5 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index f026060..c2aa3ec 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1126,6 +1126,18 @@ const GRAPH_LEGEND_HTML = [ '
' + label + "
" ).join(""); +// 2026-08-08 (Work Graph self-planning + loop-back): how many times +// node_id was sent back to ITSELF (on_fail.action='retry_self' -- source +// and target are the same node, so there's no on_failure edge to draw, +// see work_graph_orchestrate.py's _LoopBackRequested/_on_failure_target +// design comment). Returns 0 if run.loop_back_events is absent (older +// runs/tasks predating this field) or empty. +function countSelfLoopAttempts(run, nodeId) { + return (run.loop_back_events || []).filter( + (e) => e.source_node_id === nodeId && e.target_node_id === nodeId + ).length; +} + function renderGraphVisualizerPanel(run, options) { if (!run.graph_manifest || run.graph_manifest.nodes.length === 0) return ""; // FR-4 (v_graph_console, 2026-08-03): `options` is new and optional -- @@ -1149,10 +1161,19 @@ function renderGraphVisualizerPanel(run, options) { const clickable = enableNodeClick && wn; const clickableAttrs = clickable ? ' data-clickable-node-exec-id="' + wn.node_exec_id + '"' : ""; const logSlotHtml = clickable ? '
' : ""; + // 2026-08-08: retry_self loops never touch graph_manifest.edges (the + // target IS the source, so there's nothing to draw a line to) -- + // the only place this is visible is a badge on the node itself, + // read from loop_back_events (execution_event-backed, see + // countSelfLoopAttempts's own comment). + const selfLoopCount = countSelfLoopAttempts(run, gn.node_id); + const loopBadgeHtml = selfLoopCount > 0 + ? '↺ ' + selfLoopCount + "" + : ""; return ( - '
" - + '
' + escapeHtml(gn.node_id) + "
" + + '
' + escapeHtml(gn.node_id) + loopBadgeHtml + "
" + '
' + escapeHtml(wn ? wn.subagent_type : "尚未开始") + "
" + logSlotHtml + "
" @@ -1161,17 +1182,92 @@ function renderGraphVisualizerPanel(run, options) { return '
' + cellsHtml + "
"; }).join(""); + // 2026-08-08: on_failure edges (graph_manifest.edges, declared whenever a + // node has on_fail.action='loop_back') are the ones actually worth + // drawing as lines -- normal depends_on/needs_judgment/zero_token edges + // are already visually implied by row order (computeGraphLayers), so + // drawing them too would just be visual noise on top of what the rows + // already show. Each edge is annotated with how many times it was + // actually traversed (loop_back_events), not just declared -- a + // loop_back that was planned but never triggered still shows as a + // (0x) line, which is itself useful information. + const onFailureEdges = (run.graph_manifest.edges || []).filter((e) => e.edge_type === "on_failure"); + const edgesDataJson = escapeHtml(JSON.stringify(onFailureEdges.map((e) => { + const triggerCount = (run.loop_back_events || []).filter( + (ev) => ev.source_node_id === e.from && ev.target_node_id === e.to + ).length; + return { from: e.from, to: e.to, triggerCount: triggerCount }; + }))); + return ( // id="graph-panel-": Org Graph 点击可达详情's scroll/highlight // target (focusOrgNodePanel above) -- additive, doesn't change any // existing class/data-* this function already emitted. '

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

" - + '
' + rowsHtml + "
" - + '
' + GRAPH_LEGEND_HTML + "
" - + "
" + + '
' + + '' + + rowsHtml + "
" + + '
' + GRAPH_LEGEND_HTML + + (onFailureEdges.length ? '
loop_back(失败回路)
' : "") + + "
" ); } +// 2026-08-08 (Work Graph self-planning + loop-back): draws the on_failure +// edges renderGraphVisualizerPanel embedded as data-on-failure-edges -- +// requires REAL layout (getBoundingClientRect), so it can only run after +// the panel's HTML is actually in the document, same reason +// mountGraphVisualizerTooltips is a separate mount step rather than +// inline in the render* function. A .work-node's real width depends on +// its content, so positions can't be precomputed at render time. +function mountGraphVisualizerEdges(container) { + for (const graph of container.querySelectorAll(".work-graph[data-on-failure-edges]")) { + let edges; + try { + edges = JSON.parse(graph.dataset.onFailureEdges || "[]"); + } catch (e) { + edges = []; + } + const svg = graph.querySelector(".graph-edges-svg"); + if (!svg || edges.length === 0) continue; + + const graphRect = graph.getBoundingClientRect(); + svg.setAttribute("width", graphRect.width); + svg.setAttribute("height", graphRect.height); + if (!svg.querySelector("defs")) { + svg.innerHTML = + '' + + ''; + } + + const nodeCenter = (nodeId) => { + const el = graph.querySelector('.work-node[data-node-id="' + CSS.escape(nodeId) + '"]'); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: r.left - graphRect.left + r.width / 2, y: r.top - graphRect.top + r.height / 2, w: r.width, h: r.height }; + }; + + const linesHtml = edges.map((edge) => { + const from = nodeCenter(edge.from); + const to = nodeCenter(edge.to); + if (!from || !to) return ""; + // pull the endpoint back to the target node's edge (roughly, using + // its own half-height) so the arrowhead doesn't render buried + // underneath the node box. + const dy = to.y - from.y; + const pullBack = dy === 0 ? 0 : Math.min(to.h / 2, Math.abs(dy) / 2) * Math.sign(dy); + const label = edge.triggerCount > 0 ? edge.triggerCount + "x" : "0x(已声明,未触发)"; + return ( + '' + + '' + escapeHtml(label) + "" + ); + }).join(""); + svg.innerHTML = svg.querySelector("defs").outerHTML + linesHtml; + } +} + function mountGraphVisualizerTooltips(container) { const tooltip = document.getElementById("graph-tooltip"); if (!tooltip) return; @@ -1481,6 +1577,7 @@ function mountTaskDetail(taskId, initialOrgNode) { }); } mountGraphVisualizerTooltips(content); + mountGraphVisualizerEdges(content); // Org Graph 三节点条点击可达详情: clicking a node here (already on // v_task_detail) stays on this page -- update focusedOrgNode + the URL @@ -1777,6 +1874,7 @@ function mountRunDetail(runId) { } mountForkPanels(content); mountGraphVisualizerTooltips(content); + mountGraphVisualizerEdges(content); if (!socket) { socket = connectEventSocket(projectId, run.task_id, { diff --git a/src/task_query.py b/src/task_query.py index 5fcc59d..9635f4d 100644 --- a/src/task_query.py +++ b/src/task_query.py @@ -169,12 +169,36 @@ def get_task(conn, task_id): # run was forked from a historical node (src/work_graph_fork.py). "parent_run_id": w[6], "fork_of_node_exec_id": w[7], "nodes": _work_nodes_for_run(conn, w[0]), + # 2026-08-08 (Work Graph self-planning + loop-back): the console's + # v_graph_visualizer needs this to actually draw a loop_back's + # on_failure edge / a retry_self node's "sent back Nx" badge -- + # neither is derivable from graph_manifest/work_node_execution + # alone (loop_back_triggered is execution_event-only by design, + # see docs/todo/work-graph-self-planning-and-loop-back-design.md §7). + "loop_back_events": _loop_back_events_for_run(conn, w[0]), } for w in runs ] return task +def _loop_back_events_for_run(conn, run_id): + rows = conn.execute( + "SELECT payload, created_at FROM execution_event " + "WHERE run_id = ? AND event_type = 'loop_back_triggered' ORDER BY created_at", + (run_id,), + ).fetchall() + events = [] + for payload, created_at in rows: + data = json.loads(payload) + events.append({ + "source_node_id": data["source_node_id"], "target_node_id": data["target_node_id"], + "attempt_number": data["attempt_number"], "feedback": data.get("feedback") or [], + "created_at": created_at, + }) + return events + + 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: diff --git a/src/test_task_query.py b/src/test_task_query.py index bb465fb..e50ba89 100644 --- a/src/test_task_query.py +++ b/src/test_task_query.py @@ -146,6 +146,44 @@ class GetTaskTest(TaskQueryTestBase): self.assertIsNone(run["parent_run_id"]) self.assertIsNone(run["fork_of_node_exec_id"]) + def test_includes_loop_back_events_for_the_run(self): + # 2026-08-08 (Work Graph self-planning + loop-back): v_graph_visualizer + # needs this to draw an on_failure edge/retry badge -- not derivable + # from graph_manifest or work_node_execution alone. + 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', 'planner', '{}', 'running')" + ) + self.conn.execute( + "INSERT INTO execution_event (event_id, project_id, task_id, run_id, event_type, payload) " + "VALUES ('evt-1', 'proj-1', 'task-1', 'run-1', 'loop_back_triggered', ?)", + (json.dumps({ + "source_node_id": "benchmark", "target_node_id": "benchmark", + "attempt_number": 1, "feedback": ["not enough detail"], + }),), + ) + self.conn.commit() + + result = task_query.get_task(self.conn, "task-1") + run = result["work_graph_runs"][0] + self.assertEqual(len(run["loop_back_events"]), 1) + event = run["loop_back_events"][0] + self.assertEqual(event["source_node_id"], "benchmark") + self.assertEqual(event["target_node_id"], "benchmark") + self.assertEqual(event["attempt_number"], 1) + self.assertEqual(event["feedback"], ["not enough detail"]) + + def test_no_loop_back_events_is_an_empty_list_not_missing_key(self): + 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', 'planner', '{}', 'done')" + ) + self.conn.commit() + result = task_query.get_task(self.conn, "task-1") + self.assertEqual(result["work_graph_runs"][0]["loop_back_events"], []) + def test_forked_run_surfaces_parent_and_source_node(self): # D (2026-08-06): v_task_detail must be able to tell a fork branch # apart from the original run it descends from. -- libgit2 0.21.4