Commit a044b73df2312669a22174fcdb5716238068a80e
1 parent
ed477539
graphlemon: auto-commit uncommitted changes for judgment-550c6f093701-main-checkout-drift
Showing
3 changed files
with
90 additions
and
0 deletions
frontend/app.js
| @@ -2104,6 +2104,7 @@ function mountGraphConsole() { | @@ -2104,6 +2104,7 @@ function mountGraphConsole() { | ||
| 2104 | } | 2104 | } |
| 2105 | workGraphPanelsEl.innerHTML = panelsHtml; | 2105 | workGraphPanelsEl.innerHTML = panelsHtml; |
| 2106 | mountGraphVisualizerTooltips(workGraphPanelsEl); | 2106 | mountGraphVisualizerTooltips(workGraphPanelsEl); |
| 2107 | + mountGraphVisualizerEdges(workGraphPanelsEl); | ||
| 2107 | mountWorkNodeClickExpand(workGraphPanelsEl, { onNodeClick: onWorkNodeClick }); | 2108 | mountWorkNodeClickExpand(workGraphPanelsEl, { onNodeClick: onWorkNodeClick }); |
| 2108 | } | 2109 | } |
| 2109 | 2110 |
frontend/style.css
| @@ -136,6 +136,22 @@ table.data-table tr[data-task-id]:hover td { background: #f9fafb; } | @@ -136,6 +136,22 @@ table.data-table tr[data-task-id]:hover td { background: #f9fafb; } | ||
| 136 | .work-node.status-waiting_for_quota::after { content: "🕐"; position: absolute; top: -8px; right: -8px; font-size: 14px; } | 136 | .work-node.status-waiting_for_quota::after { content: "🕐"; position: absolute; top: -8px; right: -8px; font-size: 14px; } |
| 137 | .work-node.status-blocked, .work-node.status-failed { border-color: var(--status-blocked); background: #fef2f2; } | 137 | .work-node.status-blocked, .work-node.status-failed { border-color: var(--status-blocked); background: #fef2f2; } |
| 138 | .work-node.status-done { border-color: var(--status-done); background: #f0fdf4; } | 138 | .work-node.status-done { border-color: var(--status-done); background: #f0fdf4; } |
| 139 | + | ||
| 140 | +/* 2026-08-08 (Work Graph self-planning + loop-back): on_failure edges | ||
| 141 | + (loop_back) drawn as lines over the existing row layout, plus a badge | ||
| 142 | + for retry_self loops (which have no edge to draw -- see | ||
| 143 | + mountGraphVisualizerEdges/countSelfLoopAttempts in app.js). The svg | ||
| 144 | + sits absolutely over .work-graph (already position:relative) and | ||
| 145 | + paints behind the node boxes since it appears earlier in the DOM (see | ||
| 146 | + renderGraphVisualizerPanel) -- no z-index juggling needed. */ | ||
| 147 | +.graph-edges-svg { position: absolute; top: 0; left: 0; pointer-events: none; overflow: visible; } | ||
| 148 | +.edge-on-failure { stroke: #dc2626; stroke-width: 2; stroke-dasharray: 5 3; fill: none; } | ||
| 149 | +.edge-label { font-size: 10px; fill: #dc2626; text-anchor: middle; paint-order: stroke; stroke: #fff; stroke-width: 3px; } | ||
| 150 | +.loop-badge { | ||
| 151 | + display: inline-block; margin-left: 6px; padding: 0 5px; border-radius: 9px; | ||
| 152 | + background: #fee2e2; color: #dc2626; font-size: 10px; font-weight: 600; cursor: help; | ||
| 153 | +} | ||
| 154 | +.edge-sample { display: inline-block; width: 20px; height: 0; border-top: 2px dashed #dc2626; } | ||
| 139 | .legend { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; color: #6b7280; } | 155 | .legend { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; color: #6b7280; } |
| 140 | .legend-item { display: flex; align-items: center; gap: 6px; } | 156 | .legend-item { display: flex; align-items: center; gap: 6px; } |
| 141 | .legend-swatch { width: 14px; height: 14px; border-radius: 4px; border: 2px solid; } | 157 | .legend-swatch { width: 14px; height: 14px; border-radius: 4px; border: 2px solid; } |
frontend/test_graph_console_smoke.js
| @@ -121,6 +121,79 @@ new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox); | @@ -121,6 +121,79 @@ new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox); | ||
| 121 | assert.ok(graphConsoleHtml.includes("work-node status-running clickable"), "opted-in call adds the clickable class"); | 121 | assert.ok(graphConsoleHtml.includes("work-node status-running clickable"), "opted-in call adds the clickable class"); |
| 122 | assert.ok(graphConsoleHtml.includes('data-clickable-node-exec-id="exec-1"'), "opted-in call adds the click-target id"); | 122 | assert.ok(graphConsoleHtml.includes('data-clickable-node-exec-id="exec-1"'), "opted-in call adds the click-target id"); |
| 123 | assert.ok(graphConsoleHtml.includes('<div class="log-viewer-slot">'), "opted-in call reserves a log-viewer-slot"); | 123 | assert.ok(graphConsoleHtml.includes('<div class="log-viewer-slot">'), "opted-in call reserves a log-viewer-slot"); |
| 124 | + assert.ok(graphConsoleHtml.includes('data-node-id="n1"'), "every node carries data-node-id for edge-drawing lookup"); | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +// ---- renderGraphVisualizerPanel: Work Graph self-planning + loop-back | ||
| 128 | +// (2026-08-08) -- retry_self badge and on_failure edge data. Real line | ||
| 129 | +// positions require getBoundingClientRect (mountGraphVisualizerEdges), | ||
| 130 | +// which this bare vm sandbox can't provide -- that part is manual-browser- | ||
| 131 | +// verified only, same convention this file's own header note already | ||
| 132 | +// documents for DOM-rendering functions in general. What CAN be | ||
| 133 | +// mechanically checked here is the HTML-string generation itself: the | ||
| 134 | +// badge text/count, and the data-on-failure-edges payload the real mount | ||
| 135 | +// function would read. | ||
| 136 | +{ | ||
| 137 | + const runWithSelfLoop = { | ||
| 138 | + org_node: "planner", run_id: "run-2", | ||
| 139 | + graph_manifest: { nodes: [{ node_id: "benchmark", depends_on: [] }] }, | ||
| 140 | + nodes: [{ node_id: "benchmark", node_exec_id: "exec-2", subagent_type: "planner", status: "reflecting" }], | ||
| 141 | + loop_back_events: [ | ||
| 142 | + { source_node_id: "benchmark", target_node_id: "benchmark", attempt_number: 1, feedback: ["missing scenario"] }, | ||
| 143 | + { source_node_id: "benchmark", target_node_id: "benchmark", attempt_number: 2, feedback: ["still missing"] }, | ||
| 144 | + ], | ||
| 145 | + }; | ||
| 146 | + const html = sandbox.renderGraphVisualizerPanel(runWithSelfLoop); | ||
| 147 | + assert.ok(html.includes('class="loop-badge"'), "a node with self-loop events gets a loop-badge"); | ||
| 148 | + assert.ok(html.includes(">↺ 2<"), "the badge shows the real count of self-loop attempts, not just a presence flag"); | ||
| 149 | + // the attribute is always present (simpler than conditionally omitting | ||
| 150 | + // it), just empty when there's no on_failure edge to draw. | ||
| 151 | + assert.ok(html.includes('data-on-failure-edges="[]"'), "retry_self alone has no on_failure edges -- payload is an empty array"); | ||
| 152 | + | ||
| 153 | + const runWithoutLoop = { | ||
| 154 | + org_node: "planner", run_id: "run-3", | ||
| 155 | + graph_manifest: { nodes: [{ node_id: "brd", depends_on: [] }] }, | ||
| 156 | + nodes: [{ node_id: "brd", node_exec_id: "exec-3", subagent_type: "planner", status: "done" }], | ||
| 157 | + }; | ||
| 158 | + const plainHtml = sandbox.renderGraphVisualizerPanel(runWithoutLoop); | ||
| 159 | + assert.ok(!plainHtml.includes("loop-badge"), "a run with no loop_back_events at all (older tasks) renders no badge"); | ||
| 160 | + | ||
| 161 | + const runWithLoopBackEdge = { | ||
| 162 | + org_node: "planner", run_id: "run-4", | ||
| 163 | + graph_manifest: { | ||
| 164 | + nodes: [{ node_id: "sdd", depends_on: [] }, { node_id: "benchmark", depends_on: ["sdd"] }], | ||
| 165 | + edges: [{ from: "benchmark", to: "sdd", edge_type: "on_failure" }], | ||
| 166 | + }, | ||
| 167 | + nodes: [ | ||
| 168 | + { node_id: "sdd", node_exec_id: "exec-sdd", subagent_type: "planner", status: "done" }, | ||
| 169 | + { node_id: "benchmark", node_exec_id: "exec-bm", subagent_type: "planner", status: "running" }, | ||
| 170 | + ], | ||
| 171 | + loop_back_events: [ | ||
| 172 | + { source_node_id: "benchmark", target_node_id: "sdd", attempt_number: 1, feedback: ["x"] }, | ||
| 173 | + ], | ||
| 174 | + }; | ||
| 175 | + const loopBackHtml = sandbox.renderGraphVisualizerPanel(runWithLoopBackEdge); | ||
| 176 | + assert.ok(loopBackHtml.includes("data-on-failure-edges"), "a real on_failure edge emits the edges payload for mountGraphVisualizerEdges to read"); | ||
| 177 | + assert.ok(loopBackHtml.includes(""from":"benchmark""), "the payload's escaped JSON names the real source node"); | ||
| 178 | + assert.ok(loopBackHtml.includes(""triggerCount":1"), "the payload counts real triggers from loop_back_events, not just declares the edge"); | ||
| 179 | + assert.ok(loopBackHtml.includes("loop_back(失败回路)"), "the legend explains the on_failure line when one is present"); | ||
| 180 | + | ||
| 181 | + const noEdgeLegendHtml = sandbox.renderGraphVisualizerPanel(runWithSelfLoop); | ||
| 182 | + assert.ok(!noEdgeLegendHtml.includes("loop_back(失败回路)"), "no on_failure edge -- legend stays unchanged, no dangling explanation"); | ||
| 183 | +} | ||
| 184 | + | ||
| 185 | +// ---- countSelfLoopAttempts ---- | ||
| 186 | +{ | ||
| 187 | + const run = { | ||
| 188 | + loop_back_events: [ | ||
| 189 | + { source_node_id: "a", target_node_id: "a", attempt_number: 1 }, | ||
| 190 | + { source_node_id: "a", target_node_id: "b", attempt_number: 1 }, // loop_back, not a self-loop -- must not count | ||
| 191 | + { source_node_id: "a", target_node_id: "a", attempt_number: 2 }, | ||
| 192 | + ], | ||
| 193 | + }; | ||
| 194 | + assert.strictEqual(sandbox.countSelfLoopAttempts(run, "a"), 2, "counts only true self-loops (source===target===nodeId)"); | ||
| 195 | + assert.strictEqual(sandbox.countSelfLoopAttempts(run, "b"), 0, "a node that was only ever a loop_back TARGET (not source) has zero self-loops"); | ||
| 196 | + assert.strictEqual(sandbox.countSelfLoopAttempts({}, "a"), 0, "missing loop_back_events (older task) is treated as zero, not a crash"); | ||
| 124 | } | 197 | } |
| 125 | 198 | ||
| 126 | // ---- renderArtifactSummaryList (FR-5) ---- | 199 | // ---- renderArtifactSummaryList (FR-5) ---- |