diff --git a/frontend/app.js b/frontend/app.js index 2f17236..da50ca9 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -104,6 +104,11 @@ const ARTIFACT_DETAIL_RE = /^#\/artifacts\/([^/]+)$/; // reaching renderTaskDetail/mountTaskDetail with a bogus org_node that would // otherwise need its own defensive checks further down. const ORG_NODE_DETAIL_RE = /^#\/tasks\/([^/]+)\/org\/(planner|coder|validator)$/; +// v_run_detail (U-046, 2026-08-06 SDD): "运行历史" (`#gc_runs_list`) +// previously had nowhere real to send a click -- it just called +// onSwitchToTask to switch v_graph_console's own task selector. This is the +// actual run-detail page/route that closes that gap. +const RUN_DETAIL_RE = /^#\/runs\/([^/]+)$/; let currentRouteCleanup = null; function registerRouteCleanup(fn) { @@ -124,6 +129,7 @@ function render() { const orgNodeMatch = hash.match(ORG_NODE_DETAIL_RE); const skillMatch = hash.match(SKILL_AUDIT_RE); const artifactMatch = hash.match(ARTIFACT_DETAIL_RE); + const runMatch = hash.match(RUN_DETAIL_RE); if (orgNodeMatch) { // Reuses v_task_detail itself (same render/mount) with the org_node // carried through as a second arg -- no separate view/route handler. @@ -140,6 +146,9 @@ function render() { // with the artifact_id pre-selected -- no separate detail view. route = { render: renderArtifactBrowser, mount: mountArtifactBrowser }; routeArgs = [artifactMatch[1]]; + } else if (runMatch) { + route = { render: renderRunDetail, mount: mountRunDetail }; + routeArgs = [runMatch[1]]; } } if (!route) route = { render: () => "

Not found

" + hash + "

" }; @@ -1468,6 +1477,122 @@ function mountTaskDetail(taskId, initialOrgNode) { }); } +// ---- v_run_detail (`#/runs/:run_id`, U-046 2026-08-06 SDD): single Work +// Graph Run detail -- every work_node_execution the run contains, each with +// its own log (live c_ws_log_tail while running, static c_execution_log_get +// once terminal). Reuses renderGraphVisualizerPanel/mountLogViewer +// unmodified, same as v_task_detail's todo tree above; the one DOM-id +// contract mountLogViewer relies on (`#todo-detail- +// .log-autoscroll`) is reproduced here via renderRunNodeListHtml so the +// existing auto-scroll checkbox keeps working without touching +// mountLogViewer itself. + +function renderRunNodeListHtml(run) { + if (!run.nodes.length) return '
该运行尚无节点
'; + const items = run.nodes.map((wn) => { + const status = wn.status; + return ( + '
  • ' + + '
    ' + + '' + + '' + escapeHtml(wn.node_id) + "" + + '' + escapeHtml(status) + " · " + escapeHtml(wn.subagent_type) + + " · retry_count=" + wn.retry_count + " · node_exec_id=" + wn.node_exec_id + "" + + "
    " + + '
    ' + + (wn.reflection_notes ? "reflection_notes: " + escapeHtml(wn.reflection_notes) + "
    " : "") + + '
    ' + + "
    " + + "
  • " + ); + }).join(""); + return '"; +} + +function renderRunDetail(runId) { + if (!getCurrentProjectId()) return renderNoProjectSelected(); + return ` +
    + +
    加载中…
    +
    + `; +} + +function mountRunDetail(runId) { + const projectId = getCurrentProjectId(); + if (!projectId) return; + const content = document.getElementById("rd_content"); + + // Same "track open log viewers, close them on re-render/route-leave" + // pattern as mountTaskDetail/mountGraphConsole above. + const activeLogViewers = new Map(); + function closeAllLogViewers() { + for (const close of activeLogViewers.values()) close(); + activeLogViewers.clear(); + } + + // The event socket needs task_id, which we only learn from the run's own + // response -- opened lazily on the first successful render, not before. + let socket = null; + + function renderDetail(run) { + closeAllLogViewers(); + content.innerHTML = ( + "

    " + escapeHtml(run.org_node) + " Work Graph Run

    " + + '
    ' + + "
    RUN_ID" + escapeHtml(run.run_id) + "
    " + + "
    STATUS" + escapeHtml(run.status) + "
    " + + "
    STARTED_AT" + escapeHtml(run.started_at || "") + "
    " + + "
    COMPLETED_AT" + escapeHtml(run.completed_at || "") + "
    " + + '
    TASK' + escapeHtml(run.task_id) + "
    " + + "
    " + + renderGraphVisualizerPanel(run) + + '
    ' + + '

    节点列表

    ' + renderRunNodeListHtml(run) + "
    " + ); + + for (const row of content.querySelectorAll(".todo-row[data-node-exec-id]")) { + row.addEventListener("click", () => { + const nodeExecId = row.dataset.nodeExecId; + if (!nodeExecId) return; + const detail = document.getElementById("todo-detail-" + nodeExecId); + if (!detail) return; + detail.classList.toggle("open"); + if (detail.classList.contains("open") && !activeLogViewers.has(nodeExecId)) { + const slot = document.getElementById("log-slot-" + nodeExecId); + slot.innerHTML = renderLogViewerHtml(nodeExecId); + activeLogViewers.set(nodeExecId, mountLogViewer(nodeExecId, detail.dataset.status)); + } + }); + } + mountGraphVisualizerTooltips(content); + + if (!socket) { + socket = connectEventSocket(projectId, run.task_id, { + onEvent: () => refresh(), + onStatusChange: () => {}, + }); + } + } + + async function refresh() { + const resp = await fetch("/api/work-graph-runs/" + encodeURIComponent(runId)); + if (resp.status === 404) { + content.innerHTML = '
    运行不存在
    '; + return; + } + renderDetail(await resp.json()); + } + + refresh(); + + registerRouteCleanup(() => { + if (socket) socket.close(); + closeAllLogViewers(); + }); +} + // ---- v_graph_console (`#/graph`, 2026-08-03 SDD): Org/Work Graph // visualization + Planner output overview. FR-1: independent Org Graph // topology (not `org-strip`, see renderOrgGraphTopology below). FR-2/3: @@ -1525,9 +1650,11 @@ function renderArtifactSummaryList(artifacts, types, emptyText) { // #/artifacts / #/tasks with no filter pre-applied -- v_artifact_browser // and v_task_board don't parse URL query params today, and adding that is // out of scope for this page (SDD §2.5's explicit, recorded trade-off). -// Graph run rows are the one exception: clicking a row switches this same -// page's own task switcher, since there's no separate "run detail" route. -async function mountArtifactsOverviewPanel(container, projectId, { onSwitchToTask }) { +// Graph run rows: U-046 fixed the previous shortcut (switching this same +// page's own task switcher via onSwitchToTask, because there was no +// separate "run detail" route) to navigate to the real v_run_detail page +// (#/runs/:run_id) instead. +async function mountArtifactsOverviewPanel(container, projectId) { container.innerHTML = '

    Planner 产出总览

    加载中…
    '; const [artifacts, runs, tasks] = await Promise.all([ fetch("/api/artifacts?project_id=" + encodeURIComponent(projectId)).then((r) => r.json()), @@ -1540,7 +1667,7 @@ async function mountArtifactsOverviewPanel(container, projectId, { onSwitchToTas const runsHtml = runs.length === 0 ? '
    暂无 Graph 运行记录
    ' : ( '" ); @@ -1564,8 +1691,10 @@ async function mountArtifactsOverviewPanel(container, projectId, { onSwitchToTas const runsListEl = document.getElementById("gc_runs_list"); if (runsListEl) { - for (const li of runsListEl.querySelectorAll("li[data-run-task-id]")) { - li.addEventListener("click", () => onSwitchToTask(li.dataset.runTaskId)); + for (const li of runsListEl.querySelectorAll("li[data-run-id]")) { + li.addEventListener("click", () => { + window.location.hash = "#/runs/" + encodeURIComponent(li.dataset.runId); + }); } } @@ -1639,12 +1768,7 @@ function mountGraphConsole() { } function refreshArtifactsOverview() { - mountArtifactsOverviewPanel(artifactsOverviewEl, projectId, { - onSwitchToTask: (taskId) => { - taskSelect.value = taskId; - loadTaskAndRender(taskId); - }, - }); + mountArtifactsOverviewPanel(artifactsOverviewEl, projectId); } async function loadTaskAndRender(taskId) { diff --git a/frontend/test_run_detail_smoke.js b/frontend/test_run_detail_smoke.js new file mode 100644 index 0000000..58befa9 --- /dev/null +++ b/frontend/test_run_detail_smoke.js @@ -0,0 +1,117 @@ +// v_run_detail (`#/runs/:run_id`, U-046 2026-08-06 SDD) smoke test. Same +// no-framework/no-fixture convention as frontend/test_graph_console_smoke.js +// and frontend/test_org_click_through_smoke.js (see +// .claude/agent-memory/validator/feedback_no_js_test_framework.md) -- loads +// app.js in a bare vm.Context and asserts on generated HTML strings / regex +// matches for the new top-level pieces: +// - RUN_DETAIL_RE: the new #/runs/:run_id route. +// - renderRunNodeListHtml: the run-detail node list, including the +// todo-detail- DOM-id contract mountLogViewer relies on +// (`#todo-detail- .log-autoscroll`) and the empty-run case. +// - mountArtifactsOverviewPanel's now-fixed click target: `#gc_runs_list` +// rows carry data-run-id (not the old data-run-task-id), so a real +// click can build "#/runs/" + run_id instead of only ever switching +// the task selector. +// +// Run: node frontend/test_run_detail_smoke.js + +"use strict"; +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const assert = require("assert"); + +function makeFakeDiv() { + let text = ""; + return { + set textContent(v) { text = v == null ? "" : String(v); }, + get textContent() { return text; }, + get innerHTML() { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + }, + }; +} + +const sandbox = { + document: { + createElement: () => makeFakeDiv(), + getElementById: () => null, + querySelectorAll: () => [], + addEventListener: () => {}, + }, + window: { + location: { protocol: "http:", host: "localhost", hash: "" }, + addEventListener: () => {}, + }, + localStorage: { getItem: () => null, setItem: () => {} }, + console, +}; +vm.createContext(sandbox); + +const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8"); +// Top-level `const` regexes stay lexically scoped to the script and never +// reach the sandbox object (same Annex B quirk the other smoke tests +// already document) -- re-export what this test needs. +const augmentedSrc = src + "\nglobalThis.RUN_DETAIL_RE = RUN_DETAIL_RE;\n"; +new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox); + +let assertionCount = 0; +function check(condition, message) { + assertionCount++; + assert.ok(condition, message); +} + +// ---- 1. RUN_DETAIL_RE: happy / boundary / failure ---- +{ + const legal = "#/runs/run-abc-123".match(sandbox.RUN_DETAIL_RE); + check(legal, "legal run_id must match"); + check(legal[1] === "run-abc-123", "captured group must be the run_id"); + + check(!"#/runs/".match(sandbox.RUN_DETAIL_RE), "missing run_id segment must not match"); + check(!"#/runs/abc/extra".match(sandbox.RUN_DETAIL_RE), "extra path segments must not match"); + check(!"#/tasks/abc".match(sandbox.RUN_DETAIL_RE), "an unrelated route must not match"); +} + +// ---- 2. renderRunNodeListHtml: node list + todo-detail- DOM contract ---- +{ + const run = { + run_id: "run-1", org_node: "coder", status: "running", + nodes: [ + { + node_exec_id: "exec-1", node_id: "n1", subagent_type: "coder", status: "running", + retry_count: 0, reflection_notes: null, + }, + { + node_exec_id: "exec-2", node_id: "n2", subagent_type: "coder", status: "done", + retry_count: 1, reflection_notes: "retried once after a transient error", + }, + ], + }; + const html = sandbox.renderRunNodeListHtml(run); + check(html.includes('data-node-exec-id="exec-1"'), "each row must carry its node_exec_id for the click handler"); + check(html.includes('id="todo-detail-exec-1"'), "must reproduce the todo-detail- id mountLogViewer's querySelector needs"); + check(html.includes('data-status="running"'), "todo-detail wrapper must carry the node's live status"); + check(html.includes('id="log-slot-exec-2"'), "must reserve a log-viewer-slot per node, same as v_task_detail's todo tree"); + check(html.includes("retried once after a transient error"), "reflection_notes must be shown when present"); + check(!html.includes("reflection_notes:
    "), "no stray reflection_notes line when it's null (exec-1)"); + + const emptyHtml = sandbox.renderRunNodeListHtml({ nodes: [] }); + check(emptyHtml.includes("该运行尚无节点"), "a run with zero nodes must render an explicit empty state, not blank/undefined"); +} + +// ---- 3. gc_runs_list rows: run-detail click target (2026-08-06 fix) ---- +{ + // mountArtifactsOverviewPanel itself does an async fetch and can't run in + // this sandbox (no `fetch`), but the row-id contract it depends on is a + // pure string-building concern worth pinning directly: replicate the + // exact
  • markup mountArtifactsOverviewPanel emits for one run and + // confirm it carries data-run-id (not the removed data-run-task-id). + const escapeHtml = sandbox.escapeHtml; + const r = { org_node: "coder", run_id: "run-1234567890", status: "done", node_count: 3 }; + const li = '
  • ' + escapeHtml(r.org_node) + " · run " + + r.run_id.slice(0, 8) + "" + escapeHtml(r.status) + " · " + r.node_count + " 节点
  • "; + check(li.includes('data-run-id="run-1234567890"'), "run row must carry the real run_id for #/runs/:run_id navigation"); + check(!li.includes("data-run-task-id"), "the old task-switcher click target must be gone"); +} + +console.log("frontend/test_run_detail_smoke.js: all " + assertionCount + " assertions passed"); diff --git a/src/backend_server.py b/src/backend_server.py index 470de0d..07b9551 100644 --- a/src/backend_server.py +++ b/src/backend_server.py @@ -130,6 +130,7 @@ _LOG_SUFFIX = "/log" _ARTIFACTS_PREFIX = "/api/artifacts/" _SKILLS_PREFIX = "/api/skills/" _DEPLOYMENTS_PREFIX = "/api/deployments/" +_WORK_GRAPH_RUNS_PREFIX = "/api/work-graph-runs/" class _Handler(BaseHTTPRequestHandler): @@ -180,6 +181,11 @@ class _Handler(BaseHTTPRequestHandler): if parsed.path == "/api/work-graph-runs": self._handle_work_graph_run_list(parsed.query) return + if parsed.path.startswith(_WORK_GRAPH_RUNS_PREFIX): + run_id = parsed.path[len(_WORK_GRAPH_RUNS_PREFIX):] + if run_id and "/" not in run_id: + self._handle_work_graph_run_get(run_id) + return if parsed.path.startswith(_ARTIFACTS_PREFIX): artifact_id = parsed.path[len(_ARTIFACTS_PREFIX):] if artifact_id and "/" not in artifact_id: @@ -666,6 +672,20 @@ class _Handler(BaseHTTPRequestHandler): else: self._send_json(200, result) + def _handle_work_graph_run_get(self, run_id): + """c_work_graph_run_get (U-046, GET /api/work-graph-runs/:run_id): + v_run_detail's single-run lookup -- the list endpoint above has no + per-run detail shape, and v_run_detail needs the full node list + (same shape as task_query.get_task's embedded work_graph_runs[i]) + to reuse renderGraphVisualizerPanel/mountLogViewer unmodified.""" + try: + with self.server.conn_lock: + result = work_graph_run_query.get_work_graph_run(self.server.conn, run_id) + except work_graph_run_query.WorkGraphRunNotFoundError: + self._send_json(404, {"error": "NOT_FOUND"}) + else: + self._send_json(200, result) + def _handle_artifact_get(self, artifact_id): try: with self.server.conn_lock: diff --git a/src/test_backend_server.py b/src/test_backend_server.py index 5dc6071..8f58afe 100644 --- a/src/test_backend_server.py +++ b/src/test_backend_server.py @@ -1354,6 +1354,19 @@ class WorkGraphRunListRouteTest(unittest.TestCase): self.assertEqual(status, 200) self.assertEqual(body, []) + def test_get_returns_run_with_nodes(self): + status, body = self._get("/api/work-graph-runs/run-1") + self.assertEqual(status, 200) + self.assertEqual(body["run_id"], "run-1") + self.assertEqual(body["task_id"], "task-1") + self.assertEqual(body["org_node"], "coder") + self.assertEqual(body["nodes"], []) + + def test_get_nonexistent_run_returns_404(self): + status, body = self._get("/api/work-graph-runs/no-such-run") + self.assertEqual(status, 404) + self.assertEqual(body["error"], "NOT_FOUND") + class DeploymentRouteTest(unittest.TestCase): """U-043: c_deployment_list/c_deployment_current/c_deployment_get for diff --git a/src/test_work_graph_run_query.py b/src/test_work_graph_run_query.py index 152e2c8..cc32498 100644 --- a/src/test_work_graph_run_query.py +++ b/src/test_work_graph_run_query.py @@ -118,5 +118,33 @@ class ListWorkGraphRunsTest(WorkGraphRunQueryTestBase): self.assertEqual(result, []) +class GetWorkGraphRunTest(WorkGraphRunQueryTestBase): + """U-046: c_work_graph_run_get -- v_run_detail's single-run lookup.""" + + def test_returns_run_with_embedded_nodes(self): + self._insert_run("run-1", "coder", status="running", node_count=2) + result = work_graph_run_query.get_work_graph_run(self.conn, "run-1") + self.assertEqual(result["run_id"], "run-1") + self.assertEqual(result["task_id"], "task-1") + self.assertEqual(result["org_node"], "coder") + self.assertEqual(result["status"], "running") + self.assertEqual(result["graph_manifest"], {}) + self.assertEqual(len(result["nodes"]), 2) + self.assertEqual( + {n["node_exec_id"] for n in result["nodes"]}, + {"run-1-node-0", "run-1-node-1"}, + ) + self.assertEqual(result["nodes"][0]["status"], "done") + + def test_nonexistent_run_raises(self): + with self.assertRaises(work_graph_run_query.WorkGraphRunNotFoundError): + work_graph_run_query.get_work_graph_run(self.conn, "no-such-run") + + def test_run_with_no_nodes_returns_empty_list(self): + self._insert_run("run-1", "planner", node_count=0) + result = work_graph_run_query.get_work_graph_run(self.conn, "run-1") + self.assertEqual(result["nodes"], []) + + if __name__ == "__main__": unittest.main() diff --git a/src/work_graph_run_query.py b/src/work_graph_run_query.py index de08790..0e84758 100644 --- a/src/work_graph_run_query.py +++ b/src/work_graph_run_query.py @@ -7,8 +7,21 @@ codebase that lists `work_graph_run` rows across all tasks in a project rather than for one `task_id` at a time, which is why it lives in its own module instead of `task_query.py` (that module's own docstring frames it as "built around a single task_id"). + +U-046 (2026-08-06 SDD, "运行历史点击跳转错误 + 缺运行详情页"): adds +`get_work_graph_run`, a single-`run_id` lookup for the new `v_run_detail` +page (`#/runs/:run_id`). This deliberately mirrors the shape of +`task_query.get_task`'s embedded `work_graph_runs[i]` entries (same keys, +same per-node field list via `_nodes_for_run`) so the frontend can reuse +`renderGraphVisualizerPanel`/`mountLogViewer` against this response without +any remapping -- it's a separate query (by `run_id` instead of `task_id`) +rather than a call into `task_query._work_nodes_for_run` because that +helper is private to its own module and this module already owns +`work_graph_run` reads. """ +import json + _VALID_ORG_NODES = ("planner", "coder", "validator") @@ -51,3 +64,48 @@ def list_work_graph_runs(conn, project_id, org_node=None, limit=20): } for r in rows ] + + +class WorkGraphRunNotFoundError(Exception): + """NOT_FOUND -- c_work_graph_run_get on a nonexistent run_id.""" + + +def get_work_graph_run(conn, run_id): + row = conn.execute( + "SELECT run_id, task_id, org_node, graph_manifest, status, started_at, completed_at " + "FROM work_graph_run WHERE run_id = ?", + (run_id,), + ).fetchone() + if row is None: + raise WorkGraphRunNotFoundError(run_id) + return { + "run_id": row[0], "task_id": row[1], "org_node": row[2], + "graph_manifest": json.loads(row[3]) if row[3] else None, + "status": row[4], "started_at": row[5], "completed_at": row[6], + "nodes": _nodes_for_run(conn, row[0]), + } + + +def _nodes_for_run(conn, run_id): + # Same column list as task_query._work_nodes_for_run -- kept in lockstep + # on purpose (see module docstring) so v_run_detail can reuse + # renderGraphVisualizerPanel/mountLogViewer unmodified. + rows = conn.execute( + "SELECT node_exec_id, node_id, subagent_type, status, retry_count, input_payload, " + "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() + return [ + { + "node_exec_id": n[0], "node_id": n[1], "subagent_type": n[2], "status": n[3], + "retry_count": n[4], + "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 + ]