Commit 01173527942db1f09fb34d088460b63e8704f558

Authored by graphlemon
2 parents 9334f8d6 13a4350b

graphlemon: merge 39efb231-d574-446d-813d-9eabd50b5981

frontend/app.js
... ... @@ -104,6 +104,11 @@ const ARTIFACT_DETAIL_RE = /^#\/artifacts\/([^/]+)$/;
104 104 // reaching renderTaskDetail/mountTaskDetail with a bogus org_node that would
105 105 // otherwise need its own defensive checks further down.
106 106 const ORG_NODE_DETAIL_RE = /^#\/tasks\/([^/]+)\/org\/(planner|coder|validator)$/;
  107 +// v_run_detail (U-046, 2026-08-06 SDD): "运行历史" (`#gc_runs_list`)
  108 +// previously had nowhere real to send a click -- it just called
  109 +// onSwitchToTask to switch v_graph_console's own task selector. This is the
  110 +// actual run-detail page/route that closes that gap.
  111 +const RUN_DETAIL_RE = /^#\/runs\/([^/]+)$/;
107 112  
108 113 let currentRouteCleanup = null;
109 114 function registerRouteCleanup(fn) {
... ... @@ -124,6 +129,7 @@ function render() {
124 129 const orgNodeMatch = hash.match(ORG_NODE_DETAIL_RE);
125 130 const skillMatch = hash.match(SKILL_AUDIT_RE);
126 131 const artifactMatch = hash.match(ARTIFACT_DETAIL_RE);
  132 + const runMatch = hash.match(RUN_DETAIL_RE);
127 133 if (orgNodeMatch) {
128 134 // Reuses v_task_detail itself (same render/mount) with the org_node
129 135 // carried through as a second arg -- no separate view/route handler.
... ... @@ -140,6 +146,9 @@ function render() {
140 146 // with the artifact_id pre-selected -- no separate detail view.
141 147 route = { render: renderArtifactBrowser, mount: mountArtifactBrowser };
142 148 routeArgs = [artifactMatch[1]];
  149 + } else if (runMatch) {
  150 + route = { render: renderRunDetail, mount: mountRunDetail };
  151 + routeArgs = [runMatch[1]];
143 152 }
144 153 }
145 154 if (!route) route = { render: () => "<h1>Not found</h1><p>" + hash + "</p>" };
... ... @@ -1468,6 +1477,122 @@ function mountTaskDetail(taskId, initialOrgNode) {
1468 1477 });
1469 1478 }
1470 1479  
  1480 +// ---- v_run_detail (`#/runs/:run_id`, U-046 2026-08-06 SDD): single Work
  1481 +// Graph Run detail -- every work_node_execution the run contains, each with
  1482 +// its own log (live c_ws_log_tail while running, static c_execution_log_get
  1483 +// once terminal). Reuses renderGraphVisualizerPanel/mountLogViewer
  1484 +// unmodified, same as v_task_detail's todo tree above; the one DOM-id
  1485 +// contract mountLogViewer relies on (`#todo-detail-<node_exec_id>
  1486 +// .log-autoscroll`) is reproduced here via renderRunNodeListHtml so the
  1487 +// existing auto-scroll checkbox keeps working without touching
  1488 +// mountLogViewer itself.
  1489 +
  1490 +function renderRunNodeListHtml(run) {
  1491 + if (!run.nodes.length) return '<div class="hint">该运行尚无节点</div>';
  1492 + const items = run.nodes.map((wn) => {
  1493 + const status = wn.status;
  1494 + return (
  1495 + '<li class="status-' + status + '">'
  1496 + + '<div class="todo-row" data-node-exec-id="' + wn.node_exec_id + '">'
  1497 + + '<span class="dot"></span>'
  1498 + + '<span class="todo-id">' + escapeHtml(wn.node_id) + "</span>"
  1499 + + '<span class="todo-status">' + escapeHtml(status) + " · " + escapeHtml(wn.subagent_type)
  1500 + + " · retry_count=" + wn.retry_count + " · node_exec_id=" + wn.node_exec_id + "</span>"
  1501 + + "</div>"
  1502 + + '<div class="todo-detail" id="todo-detail-' + wn.node_exec_id + '" data-status="' + status + '">'
  1503 + + (wn.reflection_notes ? "reflection_notes: " + escapeHtml(wn.reflection_notes) + "<br>" : "")
  1504 + + '<div class="log-viewer-slot" id="log-slot-' + wn.node_exec_id + '"></div>'
  1505 + + "</div>"
  1506 + + "</li>"
  1507 + );
  1508 + }).join("");
  1509 + return '<ul class="todo-tree">' + items + "</ul>";
  1510 +}
  1511 +
  1512 +function renderRunDetail(runId) {
  1513 + if (!getCurrentProjectId()) return renderNoProjectSelected();
  1514 + return `
  1515 + <div class="page page-wide">
  1516 + <div class="breadcrumb"><a href="#/graph">Graph 控制台</a> / run ${escapeHtml(runId)}</div>
  1517 + <div id="rd_content"><div class="hint">加载中…</div></div>
  1518 + </div>
  1519 + `;
  1520 +}
  1521 +
  1522 +function mountRunDetail(runId) {
  1523 + const projectId = getCurrentProjectId();
  1524 + if (!projectId) return;
  1525 + const content = document.getElementById("rd_content");
  1526 +
  1527 + // Same "track open log viewers, close them on re-render/route-leave"
  1528 + // pattern as mountTaskDetail/mountGraphConsole above.
  1529 + const activeLogViewers = new Map();
  1530 + function closeAllLogViewers() {
  1531 + for (const close of activeLogViewers.values()) close();
  1532 + activeLogViewers.clear();
  1533 + }
  1534 +
  1535 + // The event socket needs task_id, which we only learn from the run's own
  1536 + // response -- opened lazily on the first successful render, not before.
  1537 + let socket = null;
  1538 +
  1539 + function renderDetail(run) {
  1540 + closeAllLogViewers();
  1541 + content.innerHTML = (
  1542 + "<h1>" + escapeHtml(run.org_node) + " Work Graph Run</h1>"
  1543 + + '<div class="meta-row">'
  1544 + + "<div><b>RUN_ID</b>" + escapeHtml(run.run_id) + "</div>"
  1545 + + "<div><b>STATUS</b>" + escapeHtml(run.status) + "</div>"
  1546 + + "<div><b>STARTED_AT</b>" + escapeHtml(run.started_at || "") + "</div>"
  1547 + + "<div><b>COMPLETED_AT</b>" + escapeHtml(run.completed_at || "") + "</div>"
  1548 + + '<div><b>TASK</b><a href="#/tasks/' + encodeURIComponent(run.task_id) + '">' + escapeHtml(run.task_id) + "</a></div>"
  1549 + + "</div>"
  1550 + + renderGraphVisualizerPanel(run)
  1551 + + '<div class="tooltip" id="graph-tooltip"></div>'
  1552 + + '<div class="panel"><h2>节点列表</h2>' + renderRunNodeListHtml(run) + "</div>"
  1553 + );
  1554 +
  1555 + for (const row of content.querySelectorAll(".todo-row[data-node-exec-id]")) {
  1556 + row.addEventListener("click", () => {
  1557 + const nodeExecId = row.dataset.nodeExecId;
  1558 + if (!nodeExecId) return;
  1559 + const detail = document.getElementById("todo-detail-" + nodeExecId);
  1560 + if (!detail) return;
  1561 + detail.classList.toggle("open");
  1562 + if (detail.classList.contains("open") && !activeLogViewers.has(nodeExecId)) {
  1563 + const slot = document.getElementById("log-slot-" + nodeExecId);
  1564 + slot.innerHTML = renderLogViewerHtml(nodeExecId);
  1565 + activeLogViewers.set(nodeExecId, mountLogViewer(nodeExecId, detail.dataset.status));
  1566 + }
  1567 + });
  1568 + }
  1569 + mountGraphVisualizerTooltips(content);
  1570 +
  1571 + if (!socket) {
  1572 + socket = connectEventSocket(projectId, run.task_id, {
  1573 + onEvent: () => refresh(),
  1574 + onStatusChange: () => {},
  1575 + });
  1576 + }
  1577 + }
  1578 +
  1579 + async function refresh() {
  1580 + const resp = await fetch("/api/work-graph-runs/" + encodeURIComponent(runId));
  1581 + if (resp.status === 404) {
  1582 + content.innerHTML = '<div class="error-banner" style="display:block;">运行不存在</div>';
  1583 + return;
  1584 + }
  1585 + renderDetail(await resp.json());
  1586 + }
  1587 +
  1588 + refresh();
  1589 +
  1590 + registerRouteCleanup(() => {
  1591 + if (socket) socket.close();
  1592 + closeAllLogViewers();
  1593 + });
  1594 +}
  1595 +
1471 1596 // ---- v_graph_console (`#/graph`, 2026-08-03 SDD): Org/Work Graph
1472 1597 // visualization + Planner output overview. FR-1: independent Org Graph
1473 1598 // topology (not `org-strip`, see renderOrgGraphTopology below). FR-2/3:
... ... @@ -1525,9 +1650,11 @@ function renderArtifactSummaryList(artifacts, types, emptyText) {
1525 1650 // #/artifacts / #/tasks with no filter pre-applied -- v_artifact_browser
1526 1651 // and v_task_board don't parse URL query params today, and adding that is
1527 1652 // out of scope for this page (SDD §2.5's explicit, recorded trade-off).
1528   -// Graph run rows are the one exception: clicking a row switches this same
1529   -// page's own task switcher, since there's no separate "run detail" route.
1530   -async function mountArtifactsOverviewPanel(container, projectId, { onSwitchToTask }) {
  1653 +// Graph run rows: U-046 fixed the previous shortcut (switching this same
  1654 +// page's own task switcher via onSwitchToTask, because there was no
  1655 +// separate "run detail" route) to navigate to the real v_run_detail page
  1656 +// (#/runs/:run_id) instead.
  1657 +async function mountArtifactsOverviewPanel(container, projectId) {
1531 1658 container.innerHTML = '<div class="panel"><h2>Planner 产出总览</h2><div class="hint">加载中…</div></div>';
1532 1659 const [artifacts, runs, tasks] = await Promise.all([
1533 1660 fetch("/api/artifacts?project_id=" + encodeURIComponent(projectId)).then((r) => r.json()),
... ... @@ -1540,7 +1667,7 @@ async function mountArtifactsOverviewPanel(container, projectId, { onSwitchToTas
1540 1667  
1541 1668 const runsHtml = runs.length === 0 ? '<div class="hint">暂无 Graph 运行记录</div>' : (
1542 1669 '<ul class="simple-list" id="gc_runs_list">' + runs.slice(0, 10).map((r) =>
1543   - '<li data-run-task-id="' + r.task_id + '" style="cursor:pointer;"><span>' + escapeHtml(r.org_node) + " · run "
  1670 + '<li data-run-id="' + r.run_id + '" style="cursor:pointer;"><span>' + escapeHtml(r.org_node) + " · run "
1544 1671 + r.run_id.slice(0, 8) + "</span><span>" + escapeHtml(r.status) + " · " + r.node_count + " 节点</span></li>"
1545 1672 ).join("") + "</ul>"
1546 1673 );
... ... @@ -1564,8 +1691,10 @@ async function mountArtifactsOverviewPanel(container, projectId, { onSwitchToTas
1564 1691  
1565 1692 const runsListEl = document.getElementById("gc_runs_list");
1566 1693 if (runsListEl) {
1567   - for (const li of runsListEl.querySelectorAll("li[data-run-task-id]")) {
1568   - li.addEventListener("click", () => onSwitchToTask(li.dataset.runTaskId));
  1694 + for (const li of runsListEl.querySelectorAll("li[data-run-id]")) {
  1695 + li.addEventListener("click", () => {
  1696 + window.location.hash = "#/runs/" + encodeURIComponent(li.dataset.runId);
  1697 + });
1569 1698 }
1570 1699 }
1571 1700  
... ... @@ -1639,12 +1768,7 @@ function mountGraphConsole() {
1639 1768 }
1640 1769  
1641 1770 function refreshArtifactsOverview() {
1642   - mountArtifactsOverviewPanel(artifactsOverviewEl, projectId, {
1643   - onSwitchToTask: (taskId) => {
1644   - taskSelect.value = taskId;
1645   - loadTaskAndRender(taskId);
1646   - },
1647   - });
  1771 + mountArtifactsOverviewPanel(artifactsOverviewEl, projectId);
1648 1772 }
1649 1773  
1650 1774 async function loadTaskAndRender(taskId) {
... ...
frontend/test_run_detail_smoke.js 0 → 100644
  1 +// v_run_detail (`#/runs/:run_id`, U-046 2026-08-06 SDD) smoke test. Same
  2 +// no-framework/no-fixture convention as frontend/test_graph_console_smoke.js
  3 +// and frontend/test_org_click_through_smoke.js (see
  4 +// .claude/agent-memory/validator/feedback_no_js_test_framework.md) -- loads
  5 +// app.js in a bare vm.Context and asserts on generated HTML strings / regex
  6 +// matches for the new top-level pieces:
  7 +// - RUN_DETAIL_RE: the new #/runs/:run_id route.
  8 +// - renderRunNodeListHtml: the run-detail node list, including the
  9 +// todo-detail-<node_exec_id> DOM-id contract mountLogViewer relies on
  10 +// (`#todo-detail-<id> .log-autoscroll`) and the empty-run case.
  11 +// - mountArtifactsOverviewPanel's now-fixed click target: `#gc_runs_list`
  12 +// rows carry data-run-id (not the old data-run-task-id), so a real
  13 +// click can build "#/runs/" + run_id instead of only ever switching
  14 +// the task selector.
  15 +//
  16 +// Run: node frontend/test_run_detail_smoke.js
  17 +
  18 +"use strict";
  19 +const fs = require("fs");
  20 +const path = require("path");
  21 +const vm = require("vm");
  22 +const assert = require("assert");
  23 +
  24 +function makeFakeDiv() {
  25 + let text = "";
  26 + return {
  27 + set textContent(v) { text = v == null ? "" : String(v); },
  28 + get textContent() { return text; },
  29 + get innerHTML() {
  30 + return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
  31 + },
  32 + };
  33 +}
  34 +
  35 +const sandbox = {
  36 + document: {
  37 + createElement: () => makeFakeDiv(),
  38 + getElementById: () => null,
  39 + querySelectorAll: () => [],
  40 + addEventListener: () => {},
  41 + },
  42 + window: {
  43 + location: { protocol: "http:", host: "localhost", hash: "" },
  44 + addEventListener: () => {},
  45 + },
  46 + localStorage: { getItem: () => null, setItem: () => {} },
  47 + console,
  48 +};
  49 +vm.createContext(sandbox);
  50 +
  51 +const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8");
  52 +// Top-level `const` regexes stay lexically scoped to the script and never
  53 +// reach the sandbox object (same Annex B quirk the other smoke tests
  54 +// already document) -- re-export what this test needs.
  55 +const augmentedSrc = src + "\nglobalThis.RUN_DETAIL_RE = RUN_DETAIL_RE;\n";
  56 +new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox);
  57 +
  58 +let assertionCount = 0;
  59 +function check(condition, message) {
  60 + assertionCount++;
  61 + assert.ok(condition, message);
  62 +}
  63 +
  64 +// ---- 1. RUN_DETAIL_RE: happy / boundary / failure ----
  65 +{
  66 + const legal = "#/runs/run-abc-123".match(sandbox.RUN_DETAIL_RE);
  67 + check(legal, "legal run_id must match");
  68 + check(legal[1] === "run-abc-123", "captured group must be the run_id");
  69 +
  70 + check(!"#/runs/".match(sandbox.RUN_DETAIL_RE), "missing run_id segment must not match");
  71 + check(!"#/runs/abc/extra".match(sandbox.RUN_DETAIL_RE), "extra path segments must not match");
  72 + check(!"#/tasks/abc".match(sandbox.RUN_DETAIL_RE), "an unrelated route must not match");
  73 +}
  74 +
  75 +// ---- 2. renderRunNodeListHtml: node list + todo-detail-<id> DOM contract ----
  76 +{
  77 + const run = {
  78 + run_id: "run-1", org_node: "coder", status: "running",
  79 + nodes: [
  80 + {
  81 + node_exec_id: "exec-1", node_id: "n1", subagent_type: "coder", status: "running",
  82 + retry_count: 0, reflection_notes: null,
  83 + },
  84 + {
  85 + node_exec_id: "exec-2", node_id: "n2", subagent_type: "coder", status: "done",
  86 + retry_count: 1, reflection_notes: "retried once after a transient error",
  87 + },
  88 + ],
  89 + };
  90 + const html = sandbox.renderRunNodeListHtml(run);
  91 + check(html.includes('data-node-exec-id="exec-1"'), "each row must carry its node_exec_id for the click handler");
  92 + check(html.includes('id="todo-detail-exec-1"'), "must reproduce the todo-detail-<id> id mountLogViewer's querySelector needs");
  93 + check(html.includes('data-status="running"'), "todo-detail wrapper must carry the node's live status");
  94 + check(html.includes('id="log-slot-exec-2"'), "must reserve a log-viewer-slot per node, same as v_task_detail's todo tree");
  95 + check(html.includes("retried once after a transient error"), "reflection_notes must be shown when present");
  96 + check(!html.includes("reflection_notes: <br>"), "no stray reflection_notes line when it's null (exec-1)");
  97 +
  98 + const emptyHtml = sandbox.renderRunNodeListHtml({ nodes: [] });
  99 + check(emptyHtml.includes("该运行尚无节点"), "a run with zero nodes must render an explicit empty state, not blank/undefined");
  100 +}
  101 +
  102 +// ---- 3. gc_runs_list rows: run-detail click target (2026-08-06 fix) ----
  103 +{
  104 + // mountArtifactsOverviewPanel itself does an async fetch and can't run in
  105 + // this sandbox (no `fetch`), but the row-id contract it depends on is a
  106 + // pure string-building concern worth pinning directly: replicate the
  107 + // exact <li> markup mountArtifactsOverviewPanel emits for one run and
  108 + // confirm it carries data-run-id (not the removed data-run-task-id).
  109 + const escapeHtml = sandbox.escapeHtml;
  110 + const r = { org_node: "coder", run_id: "run-1234567890", status: "done", node_count: 3 };
  111 + const li = '<li data-run-id="' + r.run_id + '" style="cursor:pointer;"><span>' + escapeHtml(r.org_node) + " · run "
  112 + + r.run_id.slice(0, 8) + "</span><span>" + escapeHtml(r.status) + " · " + r.node_count + " 节点</span></li>";
  113 + check(li.includes('data-run-id="run-1234567890"'), "run row must carry the real run_id for #/runs/:run_id navigation");
  114 + check(!li.includes("data-run-task-id"), "the old task-switcher click target must be gone");
  115 +}
  116 +
  117 +console.log("frontend/test_run_detail_smoke.js: all " + assertionCount + " assertions passed");
... ...
src/backend_server.py
... ... @@ -130,6 +130,7 @@ _LOG_SUFFIX = &quot;/log&quot;
130 130 _ARTIFACTS_PREFIX = "/api/artifacts/"
131 131 _SKILLS_PREFIX = "/api/skills/"
132 132 _DEPLOYMENTS_PREFIX = "/api/deployments/"
  133 +_WORK_GRAPH_RUNS_PREFIX = "/api/work-graph-runs/"
133 134  
134 135  
135 136 class _Handler(BaseHTTPRequestHandler):
... ... @@ -180,6 +181,11 @@ class _Handler(BaseHTTPRequestHandler):
180 181 if parsed.path == "/api/work-graph-runs":
181 182 self._handle_work_graph_run_list(parsed.query)
182 183 return
  184 + if parsed.path.startswith(_WORK_GRAPH_RUNS_PREFIX):
  185 + run_id = parsed.path[len(_WORK_GRAPH_RUNS_PREFIX):]
  186 + if run_id and "/" not in run_id:
  187 + self._handle_work_graph_run_get(run_id)
  188 + return
183 189 if parsed.path.startswith(_ARTIFACTS_PREFIX):
184 190 artifact_id = parsed.path[len(_ARTIFACTS_PREFIX):]
185 191 if artifact_id and "/" not in artifact_id:
... ... @@ -666,6 +672,20 @@ class _Handler(BaseHTTPRequestHandler):
666 672 else:
667 673 self._send_json(200, result)
668 674  
  675 + def _handle_work_graph_run_get(self, run_id):
  676 + """c_work_graph_run_get (U-046, GET /api/work-graph-runs/:run_id):
  677 + v_run_detail's single-run lookup -- the list endpoint above has no
  678 + per-run detail shape, and v_run_detail needs the full node list
  679 + (same shape as task_query.get_task's embedded work_graph_runs[i])
  680 + to reuse renderGraphVisualizerPanel/mountLogViewer unmodified."""
  681 + try:
  682 + with self.server.conn_lock:
  683 + result = work_graph_run_query.get_work_graph_run(self.server.conn, run_id)
  684 + except work_graph_run_query.WorkGraphRunNotFoundError:
  685 + self._send_json(404, {"error": "NOT_FOUND"})
  686 + else:
  687 + self._send_json(200, result)
  688 +
669 689 def _handle_artifact_get(self, artifact_id):
670 690 try:
671 691 with self.server.conn_lock:
... ...
src/test_backend_server.py
... ... @@ -1354,6 +1354,19 @@ class WorkGraphRunListRouteTest(unittest.TestCase):
1354 1354 self.assertEqual(status, 200)
1355 1355 self.assertEqual(body, [])
1356 1356  
  1357 + def test_get_returns_run_with_nodes(self):
  1358 + status, body = self._get("/api/work-graph-runs/run-1")
  1359 + self.assertEqual(status, 200)
  1360 + self.assertEqual(body["run_id"], "run-1")
  1361 + self.assertEqual(body["task_id"], "task-1")
  1362 + self.assertEqual(body["org_node"], "coder")
  1363 + self.assertEqual(body["nodes"], [])
  1364 +
  1365 + def test_get_nonexistent_run_returns_404(self):
  1366 + status, body = self._get("/api/work-graph-runs/no-such-run")
  1367 + self.assertEqual(status, 404)
  1368 + self.assertEqual(body["error"], "NOT_FOUND")
  1369 +
1357 1370  
1358 1371 class DeploymentRouteTest(unittest.TestCase):
1359 1372 """U-043: c_deployment_list/c_deployment_current/c_deployment_get for
... ...
src/test_work_graph_run_query.py
... ... @@ -118,5 +118,33 @@ class ListWorkGraphRunsTest(WorkGraphRunQueryTestBase):
118 118 self.assertEqual(result, [])
119 119  
120 120  
  121 +class GetWorkGraphRunTest(WorkGraphRunQueryTestBase):
  122 + """U-046: c_work_graph_run_get -- v_run_detail's single-run lookup."""
  123 +
  124 + def test_returns_run_with_embedded_nodes(self):
  125 + self._insert_run("run-1", "coder", status="running", node_count=2)
  126 + result = work_graph_run_query.get_work_graph_run(self.conn, "run-1")
  127 + self.assertEqual(result["run_id"], "run-1")
  128 + self.assertEqual(result["task_id"], "task-1")
  129 + self.assertEqual(result["org_node"], "coder")
  130 + self.assertEqual(result["status"], "running")
  131 + self.assertEqual(result["graph_manifest"], {})
  132 + self.assertEqual(len(result["nodes"]), 2)
  133 + self.assertEqual(
  134 + {n["node_exec_id"] for n in result["nodes"]},
  135 + {"run-1-node-0", "run-1-node-1"},
  136 + )
  137 + self.assertEqual(result["nodes"][0]["status"], "done")
  138 +
  139 + def test_nonexistent_run_raises(self):
  140 + with self.assertRaises(work_graph_run_query.WorkGraphRunNotFoundError):
  141 + work_graph_run_query.get_work_graph_run(self.conn, "no-such-run")
  142 +
  143 + def test_run_with_no_nodes_returns_empty_list(self):
  144 + self._insert_run("run-1", "planner", node_count=0)
  145 + result = work_graph_run_query.get_work_graph_run(self.conn, "run-1")
  146 + self.assertEqual(result["nodes"], [])
  147 +
  148 +
121 149 if __name__ == "__main__":
122 150 unittest.main()
... ...
src/work_graph_run_query.py
... ... @@ -7,8 +7,21 @@ codebase that lists `work_graph_run` rows across all tasks in a project
7 7 rather than for one `task_id` at a time, which is why it lives in its own
8 8 module instead of `task_query.py` (that module's own docstring frames it
9 9 as "built around a single task_id").
  10 +
  11 +U-046 (2026-08-06 SDD, "运行历史点击跳转错误 + 缺运行详情页"): adds
  12 +`get_work_graph_run`, a single-`run_id` lookup for the new `v_run_detail`
  13 +page (`#/runs/:run_id`). This deliberately mirrors the shape of
  14 +`task_query.get_task`'s embedded `work_graph_runs[i]` entries (same keys,
  15 +same per-node field list via `_nodes_for_run`) so the frontend can reuse
  16 +`renderGraphVisualizerPanel`/`mountLogViewer` against this response without
  17 +any remapping -- it's a separate query (by `run_id` instead of `task_id`)
  18 +rather than a call into `task_query._work_nodes_for_run` because that
  19 +helper is private to its own module and this module already owns
  20 +`work_graph_run` reads.
10 21 """
11 22  
  23 +import json
  24 +
12 25 _VALID_ORG_NODES = ("planner", "coder", "validator")
13 26  
14 27  
... ... @@ -51,3 +64,48 @@ def list_work_graph_runs(conn, project_id, org_node=None, limit=20):
51 64 }
52 65 for r in rows
53 66 ]
  67 +
  68 +
  69 +class WorkGraphRunNotFoundError(Exception):
  70 + """NOT_FOUND -- c_work_graph_run_get on a nonexistent run_id."""
  71 +
  72 +
  73 +def get_work_graph_run(conn, run_id):
  74 + row = conn.execute(
  75 + "SELECT run_id, task_id, org_node, graph_manifest, status, started_at, completed_at "
  76 + "FROM work_graph_run WHERE run_id = ?",
  77 + (run_id,),
  78 + ).fetchone()
  79 + if row is None:
  80 + raise WorkGraphRunNotFoundError(run_id)
  81 + return {
  82 + "run_id": row[0], "task_id": row[1], "org_node": row[2],
  83 + "graph_manifest": json.loads(row[3]) if row[3] else None,
  84 + "status": row[4], "started_at": row[5], "completed_at": row[6],
  85 + "nodes": _nodes_for_run(conn, row[0]),
  86 + }
  87 +
  88 +
  89 +def _nodes_for_run(conn, run_id):
  90 + # Same column list as task_query._work_nodes_for_run -- kept in lockstep
  91 + # on purpose (see module docstring) so v_run_detail can reuse
  92 + # renderGraphVisualizerPanel/mountLogViewer unmodified.
  93 + rows = conn.execute(
  94 + "SELECT node_exec_id, node_id, subagent_type, status, retry_count, input_payload, "
  95 + "output_payload, reflection_notes, git_worktree_ref, input_schema_ref, output_schema_ref, "
  96 + "quota_limit_type, quota_wait_started_at "
  97 + "FROM work_node_execution WHERE run_id = ? ORDER BY node_exec_id",
  98 + (run_id,),
  99 + ).fetchall()
  100 + return [
  101 + {
  102 + "node_exec_id": n[0], "node_id": n[1], "subagent_type": n[2], "status": n[3],
  103 + "retry_count": n[4],
  104 + "input_payload": json.loads(n[5]) if n[5] else None,
  105 + "output_payload": json.loads(n[6]) if n[6] else None,
  106 + "reflection_notes": n[7], "git_worktree_ref": n[8],
  107 + "input_schema_ref": n[9], "output_schema_ref": n[10],
  108 + "quota_limit_type": n[11], "quota_wait_started_at": n[12],
  109 + }
  110 + for n in rows
  111 + ]
... ...