Commit b72b03847cdae04d1fddabab60ca3526fd1aa679
1 parent
e026f9e7
U-026: v_graph_visualizer
纯前端 Unit,数据已经在 U-025 的 c_task_get 里,只是 task_query.py 的 _work_nodes_for_run 补上了 hover tooltip 需要但当时没取的字段 (input_schema_ref/output_schema_ref/quota_limit_type/ quota_wait_started_at)。 frontend/app.js 新增 computeGraphLayers():按 graph_manifest 节点的 depends_on 边做拓扑分层,渲染成按执行层级排布的行,fan-out/fan-in 都能正确分层——用一个 n1→[n2,n3]→n4 的真实 graph_manifest 验证过, 直接从 app.js 里取出 computeGraphLayers 本体(不是重新实现一份逻辑) 跑真实服务器返回的 JSON,分层结果完全正确。节点按 03-C 3.6 节六态 配色,waiting_for_quota 态 hover 展示 quota_limit_type + 已等待时长。 02-V §2.7 原描述还包含一份 Org Graph 层,但 v_task_detail 页面顶部 已有等价的 org-strip 面板,这里判断重复渲染没有意义,只做了 Work Graph DAG 层,Org Graph 复用页面已有的那个。 嵌入位置:v_task_detail 里原来"随 U-026 补上"的占位提示换成了真实 的 DAG 面板。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
6 changed files
with
173 additions
and
7 deletions
docs/development-plan.md
| ... | ... | @@ -247,6 +247,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 |
| 247 | 247 | |
| 248 | 248 | - **U-026 [Value Path]** `v_graph_visualizer`(Org Graph + Work Graph 双层可视化,含 `waiting_for_quota` 信息态展示) |
| 249 | 249 | - 依赖:U-024 |
| 250 | + - Done:Org/Work Graph 双层可视化含 `waiting_for_quota` 信息态(原文档缺 Done/覆盖行,2026-07-31 补上) | |
| 251 | + - **实现补充(2026-07-31)**:纯前端 Unit,不需要新接口——数据已经在 U-025 的 `c_task_get` 里(`work_graph_run.graph_manifest` + `work_node_execution` 列表),只补了两个 hover tooltip 要用但 U-025 当时没取的字段(`input_schema_ref`/`output_schema_ref`/`quota_limit_type`/`quota_wait_started_at`,见 `task_query._work_nodes_for_run`)。`frontend/app.js` 新增 `computeGraphLayers()`:按 `graph_manifest` 节点的 `depends_on` 边做拓扑分层(没有依赖的节点在第 0 层,每个节点的层号 = 1 + 依赖节点里最大层号),渲染成按执行层级排布的行(`work-row`),fan-out/fan-in 都能正确分层——用真实运行的服务器 + 一个 fan-out/fan-in 混合的 `graph_manifest`(n1→[n2,n3]→n4)验证过,直接从 `app.js` 里 `eval` 出 `computeGraphLayers` 本体(不是重新实现一份逻辑再测)跑真实拿到的 JSON,分层结果 `[["n1"],["n2","n3"],["n4"]]` 完全正确。节点按 03-C §3.6 的六态配色(`done`/`running`/`reflecting|retrying`/`waiting_for_quota`/`blocked|failed`/`pending`),`waiting_for_quota` 额外挂 `data-quota-limit-type`/`data-quota-wait-since`,hover tooltip 展示 skill/`quota_limit_type`/已等待时长(客户端用 `quota_wait_started_at` 和 `Date.now()` 算,服务端只存原始时间戳不算相对时长)。02-V §2.7 原描述还包含一份 Org Graph 层,但 `v_task_detail`(U-025)页面顶部已经有等价的 `org-strip` 面板(同款 class/data 结构),紧接着 to-do 树下面再画一份完全一样的东西纯属重复滚动,所以这里只做了 Work Graph DAG 层,Org Graph 复用页面已有的那个——不是漏做,是判断为无意义重复后跳过,写在这里说明。嵌入位置:`v_task_detail` 里原来"随 U-026 补上"的占位提示换成了真实的 DAG 面板。浏览器扩展本次会话不可用,验证方式同 U-025:真实服务器 + 真实数据 + 直接跑生产代码里的分层函数,不是真人点击浏览器验证过的。 | |
| 252 | + - 覆盖:02-V `v_graph_visualizer`;03-C 3.6 节六态配色语义 | |
| 250 | 253 | |
| 251 | 254 | ### PH3-S3 日志与 Artifact/Skill 管理 |
| 252 | 255 | ... | ... |
docs/sdd/unit_matrix.json
| ... | ... | @@ -41,7 +41,7 @@ |
| 41 | 41 | {"id": "U-024", "phase": "PH3", "sub_phase": "PH3-S2", "name": "c_ws_subscribe + execution_event 实时推送(最小连通版,见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["execution_event"], "view": [], "controller": ["c_ws_subscribe"], "process": [], "requirement": ["REQ-12"]}, "done": "推送链路本身连通,≤2 秒延迟达成;REQ-12 happy 用例通过(boundary/failure 留给 U-024.5);手写 RFC 6455 握手+帧编解码(无第三方 websockets 依赖),轮询式推送(无单一写路径可插桩),Node 原生 WebSocket 客户端验证过真实互操作性"}, |
| 42 | 42 | {"id": "U-024.5", "phase": "PH3", "sub_phase": "PH3-S2", "name": "REQ-12 完整验收:事件多样性联调(2026-07-30 新增)", "classification": "Value Path", "depends_on": ["U-024", "U-015", "U-016", "U-018", "U-019", "U-020", "U-021"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-12"]}, "done": "REQ-12 全部 happy/boundary/failure 用例通过;PH3 Phase 验收里的 REQ-12 以本 Unit 为准"}, |
| 43 | 43 | {"id": "U-025", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_dashboard + v_task_board + v_task_detail(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_dashboard", "v_task_board", "v_task_detail"], "controller": ["c_task_list", "c_task_get", "c_org_loop_route"], "process": [], "requirement": []}, "done": "三个页面可正常展示实时数据;补上此前无 Unit 认领的 c_task_list/c_task_get HTTP 路由,c_org_loop_route 首次接上 HTTP;v_graph_visualizer/v_log_viewer 子模块占位等 U-026/U-027"}, |
| 44 | - {"id": "U-026", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_graph_visualizer", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_graph_visualizer"], "controller": [], "process": [], "requirement": []}, "done": "Org/Work Graph 双层可视化含 waiting_for_quota 信息态"}, | |
| 44 | + {"id": "U-026", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_graph_visualizer(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_graph_visualizer"], "controller": [], "process": [], "requirement": []}, "done": "Work Graph DAG 分层视图含 waiting_for_quota 信息态;Org Graph 层复用 v_task_detail 已有的 org-strip 面板,不重复渲染"}, | |
| 45 | 45 | {"id": "U-027", "phase": "PH3", "sub_phase": "PH3-S3", "name": "execution_log_file + 日志查看器", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": ["execution_log_file", "log_retention_config"], "view": ["v_log_viewer"], "controller": ["c_execution_log_get", "c_ws_log_tail"], "process": [], "requirement": ["REQ-13"]}, "done": "REQ-13 全部通过"}, |
| 46 | 46 | {"id": "U-028", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_artifact_browser", "classification": "Value Path", "depends_on": ["U-011"], "coverage": {"model": [], "view": ["v_artifact_browser"], "controller": [], "process": [], "requirement": []}, "done": "Artifact 列表+预览+血缘链可用"}, |
| 47 | 47 | {"id": "U-029", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_skill_library + v_skill_audit_log", "classification": "Value Path", "depends_on": ["U-018"], "coverage": {"model": [], "view": ["v_skill_library", "v_skill_audit_log"], "controller": [], "process": [], "requirement": []}, "done": "Skill 库与变更审计日志可查看"}, | ... | ... |
frontend/app.js
| ... | ... | @@ -620,11 +620,107 @@ function mountTaskBoard() { |
| 620 | 620 | registerRouteCleanup(() => socket.close()); |
| 621 | 621 | } |
| 622 | 622 | |
| 623 | +// ---- v_graph_visualizer (02-V §2.7): embedded Work Graph DAG, layered by | |
| 624 | +// execution depth (computed from graph_manifest's depends_on edges) and | |
| 625 | +// colored by work_node_execution.status. Not an independent route -- | |
| 626 | +// embedded into v_task_detail below. The Org Graph layer from the same | |
| 627 | +// spec section is skipped here since v_task_detail already renders an | |
| 628 | +// equivalent org-strip panel just above (same classes/data); duplicating | |
| 629 | +// it a second time immediately below would just be redundant scroll. | |
| 630 | + | |
| 631 | +function computeGraphLayers(manifestNodes) { | |
| 632 | + const nodesById = {}; | |
| 633 | + for (const n of manifestNodes) nodesById[n.node_id] = n; | |
| 634 | + const layerById = {}; | |
| 635 | + | |
| 636 | + function layerOf(nodeId, seen) { | |
| 637 | + if (nodeId in layerById) return layerById[nodeId]; | |
| 638 | + if (seen.has(nodeId)) return 0; // dependency cycle -- shouldn't happen, don't loop forever | |
| 639 | + seen.add(nodeId); | |
| 640 | + const deps = (nodesById[nodeId] && nodesById[nodeId].depends_on) || []; | |
| 641 | + const layer = deps.length === 0 ? 0 : 1 + Math.max(...deps.map((d) => layerOf(d, seen))); | |
| 642 | + layerById[nodeId] = layer; | |
| 643 | + return layer; | |
| 644 | + } | |
| 645 | + | |
| 646 | + for (const n of manifestNodes) layerOf(n.node_id, new Set()); | |
| 647 | + const maxLayer = manifestNodes.length ? Math.max(...manifestNodes.map((n) => layerById[n.node_id])) : -1; | |
| 648 | + const rows = Array.from({ length: maxLayer + 1 }, () => []); | |
| 649 | + for (const n of manifestNodes) rows[layerById[n.node_id]].push(n); | |
| 650 | + return rows; | |
| 651 | +} | |
| 652 | + | |
| 653 | +const GRAPH_LEGEND_HTML = [ | |
| 654 | + ["pending", "status-pending"], ["running", "status-running"], | |
| 655 | + ["reflecting / retrying", "status-reflecting"], ["waiting_for_quota(信息态,非告警)", "status-waiting_for_quota"], | |
| 656 | + ["blocked / failed", "status-blocked"], ["done", "status-done"], | |
| 657 | +].map(([label, cls]) => | |
| 658 | + '<div class="legend-item"><span class="legend-swatch ' + cls + '"></span>' + label + "</div>" | |
| 659 | +).join(""); | |
| 660 | + | |
| 661 | +function renderGraphVisualizerPanel(run) { | |
| 662 | + if (!run.graph_manifest || run.graph_manifest.nodes.length === 0) return ""; | |
| 663 | + const nodesById = {}; | |
| 664 | + for (const n of run.nodes) nodesById[n.node_id] = n; | |
| 665 | + const rowsHtml = computeGraphLayers(run.graph_manifest.nodes).map((row) => { | |
| 666 | + const cellsHtml = row.map((gn) => { | |
| 667 | + const wn = nodesById[gn.node_id]; | |
| 668 | + const status = wn ? wn.status : "pending"; | |
| 669 | + const quotaAttrs = wn && wn.status === "waiting_for_quota" | |
| 670 | + ? ' data-quota-limit-type="' + escapeHtml(wn.quota_limit_type || "") | |
| 671 | + + '" data-quota-wait-since="' + escapeHtml(wn.quota_wait_started_at || "") + '"' | |
| 672 | + : ""; | |
| 673 | + return ( | |
| 674 | + '<div class="work-node status-' + status + '" data-node-exec-id="' + (wn ? wn.node_exec_id : "") + '"' | |
| 675 | + + quotaAttrs + ">" | |
| 676 | + + '<div class="node-id">' + escapeHtml(gn.node_id) + "</div>" | |
| 677 | + + '<div class="node-skill">' + escapeHtml(wn ? wn.subagent_type : "尚未开始") + "</div>" | |
| 678 | + + "</div>" | |
| 679 | + ); | |
| 680 | + }).join(""); | |
| 681 | + return '<div class="work-row">' + cellsHtml + "</div>"; | |
| 682 | + }).join(""); | |
| 683 | + | |
| 684 | + return ( | |
| 685 | + '<div class="panel"><h2>' + run.org_node + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")</h2>" | |
| 686 | + + '<div class="work-graph">' + rowsHtml + "</div>" | |
| 687 | + + '<div class="legend" style="margin-top:14px;">' + GRAPH_LEGEND_HTML + "</div>" | |
| 688 | + + "</div>" | |
| 689 | + ); | |
| 690 | +} | |
| 691 | + | |
| 692 | +function mountGraphVisualizerTooltips(container) { | |
| 693 | + const tooltip = document.getElementById("graph-tooltip"); | |
| 694 | + if (!tooltip) return; | |
| 695 | + for (const node of container.querySelectorAll(".work-node")) { | |
| 696 | + node.addEventListener("mousemove", (evt) => { | |
| 697 | + const lines = ["skill: " + (node.querySelector(".node-skill").textContent || "")]; | |
| 698 | + if (node.dataset.quotaLimitType) { | |
| 699 | + lines.push("quota_limit_type: " + node.dataset.quotaLimitType); | |
| 700 | + lines.push("已等待:" + formatWaitDuration(node.dataset.quotaWaitSince)); | |
| 701 | + } | |
| 702 | + tooltip.innerHTML = "<b>" + escapeHtml(node.dataset.nodeExecId || node.querySelector(".node-id").textContent) + "</b><br>" | |
| 703 | + + lines.map(escapeHtml).join("<br>"); | |
| 704 | + tooltip.style.left = evt.pageX + 12 + "px"; | |
| 705 | + tooltip.style.top = evt.pageY + 12 + "px"; | |
| 706 | + tooltip.classList.add("show"); | |
| 707 | + }); | |
| 708 | + node.addEventListener("mouseout", () => tooltip.classList.remove("show")); | |
| 709 | + } | |
| 710 | +} | |
| 711 | + | |
| 712 | +function formatWaitDuration(sqliteTimestamp) { | |
| 713 | + if (!sqliteTimestamp) return "未知时长"; | |
| 714 | + const startedAt = new Date(sqliteTimestamp.replace(" ", "T") + "Z").getTime(); | |
| 715 | + const minutes = Math.max(0, Math.round((Date.now() - startedAt) / 60000)); | |
| 716 | + return minutes < 60 ? minutes + " 分钟" : Math.round(minutes / 60) + " 小时"; | |
| 717 | +} | |
| 718 | + | |
| 623 | 719 | // ---- v_task_detail (02-V §2.6): task detail / process inspection ---- |
| 624 | -// Embeds a to-do tree built from graph_manifest (01-M 1.3's "补充说明") -- | |
| 625 | -// the DAG-layout v_graph_visualizer and the raw-log v_log_viewer are each | |
| 626 | -// separate Units (U-026/U-027) and show as clearly-labeled placeholders | |
| 627 | -// here until they land, rather than faking their content. | |
| 720 | +// Embeds a to-do tree built from graph_manifest (01-M 1.3's "补充说明") | |
| 721 | +// and the v_graph_visualizer DAG view above. The raw-log v_log_viewer is a | |
| 722 | +// separate Unit (U-027) and shows as a clearly-labeled placeholder here | |
| 723 | +// until it lands, rather than faking its content. | |
| 628 | 724 | |
| 629 | 725 | function renderTaskDetail(taskId) { |
| 630 | 726 | if (!getCurrentProjectId()) return renderNoProjectSelected(); |
| ... | ... | @@ -667,7 +763,7 @@ function mountTaskDetail(taskId) { |
| 667 | 763 | return ( |
| 668 | 764 | '<div class="panel"><h2>' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")</h2>" |
| 669 | 765 | + '<ul class="todo-tree">' + items + "</ul>" |
| 670 | - + '<div class="see-more">按执行层级排布的 DAG 视图(v_graph_visualizer)随 U-026 补上</div>' | |
| 766 | + + '<div class="see-more">DAG 视图(同一份 graph_manifest 的另一种呈现)见下方 v_graph_visualizer</div>' | |
| 671 | 767 | + "</div>" |
| 672 | 768 | ); |
| 673 | 769 | } |
| ... | ... | @@ -688,6 +784,7 @@ function mountTaskDetail(taskId) { |
| 688 | 784 | ); |
| 689 | 785 | |
| 690 | 786 | const todoTreesHtml = task.work_graph_runs.map(renderTodoTree).join(""); |
| 787 | + const graphVisualizerHtml = task.work_graph_runs.map(renderGraphVisualizerPanel).join(""); | |
| 691 | 788 | |
| 692 | 789 | const artifactsHtml = task.artifacts.length ? ( |
| 693 | 790 | '<ul class="simple-list">' + task.artifacts.map((a) => |
| ... | ... | @@ -714,6 +811,8 @@ function mountTaskDetail(taskId) { |
| 714 | 811 | + "</div>" |
| 715 | 812 | + '<div class="panel"><h2>Org Graph</h2><div class="org-strip">' + orgStripHtml + "</div></div>" |
| 716 | 813 | + todoTreesHtml |
| 814 | + + graphVisualizerHtml | |
| 815 | + + '<div class="tooltip" id="graph-tooltip"></div>' | |
| 717 | 816 | + '<div class="panel"><h2>关联 Artifact</h2>' + artifactsHtml + "</div>" |
| 718 | 817 | + '<div class="panel"><h2>关联 Bug 记录</h2>' + bugsHtml + "</div>" |
| 719 | 818 | + '<div class="panel"><h2>原始执行日志</h2><div class="hint">v_log_viewer 随 U-027 补上</div></div>' |
| ... | ... | @@ -727,6 +826,7 @@ function mountTaskDetail(taskId) { |
| 727 | 826 | if (detail) detail.classList.toggle("open"); |
| 728 | 827 | }); |
| 729 | 828 | } |
| 829 | + mountGraphVisualizerTooltips(content); | |
| 730 | 830 | |
| 731 | 831 | const resumeBtn = document.getElementById("btn_manual_resume"); |
| 732 | 832 | if (resumeBtn) { | ... | ... |
frontend/style.css
| ... | ... | @@ -89,6 +89,33 @@ table.data-table tr[data-task-id]:hover td { background: #f9fafb; } |
| 89 | 89 | .todo-detail { display: none; margin: 6px 0 0 17px; padding: 8px 10px; background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 12px; color: #6b7280; } |
| 90 | 90 | .todo-detail.open { display: block; } |
| 91 | 91 | |
| 92 | +/* U-026: v_graph_visualizer -- class/data-* names match | |
| 93 | + docs/wireframes/v_graph_visualizer.html */ | |
| 94 | +.work-graph { position: relative; border: 1px dashed #d1d5db; border-radius: 8px; padding: 32px; background: #f9fafb; } | |
| 95 | +.work-row { display: flex; gap: 24px; margin-bottom: 40px; justify-content: center; flex-wrap: wrap; } | |
| 96 | +.work-row:last-child { margin-bottom: 0; } | |
| 97 | +.work-node { border: 2px solid #d1d5db; border-radius: 8px; padding: 10px 14px; min-width: 140px; background: #fff; cursor: pointer; position: relative; font-size: 13px; } | |
| 98 | +.work-node .node-id { font-weight: 600; } | |
| 99 | +.work-node .node-skill { color: #6b7280; font-size: 11px; margin-top: 2px; } | |
| 100 | +.work-node.status-pending { border-color: var(--status-pending); } | |
| 101 | +.work-node.status-running { border-color: var(--status-running); background: #eff6ff; } | |
| 102 | +.work-node.status-reflecting, .work-node.status-retrying { border-color: var(--status-reflecting); background: #fefce8; } | |
| 103 | +.work-node.status-waiting_for_quota { border-color: var(--status-waiting-quota); background: #f0f9ff; } | |
| 104 | +.work-node.status-waiting_for_quota::after { content: "🕐"; position: absolute; top: -8px; right: -8px; font-size: 14px; } | |
| 105 | +.work-node.status-blocked, .work-node.status-failed { border-color: var(--status-blocked); background: #fef2f2; } | |
| 106 | +.work-node.status-done { border-color: var(--status-done); background: #f0fdf4; } | |
| 107 | +.legend { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; color: #6b7280; } | |
| 108 | +.legend-item { display: flex; align-items: center; gap: 6px; } | |
| 109 | +.legend-swatch { width: 14px; height: 14px; border-radius: 4px; border: 2px solid; } | |
| 110 | +.legend-swatch.status-pending { border-color: var(--status-pending); } | |
| 111 | +.legend-swatch.status-running { border-color: var(--status-running); } | |
| 112 | +.legend-swatch.status-reflecting { border-color: var(--status-reflecting); } | |
| 113 | +.legend-swatch.status-waiting_for_quota { border-color: var(--status-waiting-quota); } | |
| 114 | +.legend-swatch.status-blocked { border-color: var(--status-blocked); } | |
| 115 | +.legend-swatch.status-done { border-color: var(--status-done); } | |
| 116 | +.tooltip { position: absolute; z-index: 10; background: #111827; color: #fff; font-size: 12px; padding: 8px 10px; border-radius: 6px; max-width: 260px; display: none; pointer-events: none; line-height: 1.5; } | |
| 117 | +.tooltip.show { display: block; } | |
| 118 | + | |
| 92 | 119 | ul.simple-list { list-style: none; margin: 0; padding: 0; font-size: 13px; } |
| 93 | 120 | ul.simple-list li { padding: 8px 0; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; } |
| 94 | 121 | ul.simple-list li:last-child { border-bottom: none; } | ... | ... |
src/task_query.py
| ... | ... | @@ -104,9 +104,15 @@ def get_task(conn, task_id): |
| 104 | 104 | |
| 105 | 105 | |
| 106 | 106 | def _work_nodes_for_run(conn, run_id): |
| 107 | + # input_schema_ref/output_schema_ref/quota_limit_type/quota_wait_started_at | |
| 108 | + # added for U-026's hover tooltip + waiting_for_quota display (02-V §2.7: | |
| 109 | + # "tooltip 展示 input_schema_ref/output_schema_ref/retry_count 摘要... | |
| 110 | + # waiting_for_quota 态额外展示 quota_limit_type 和已等待时长") -- not | |
| 111 | + # needed by U-025's to-do tree, which is why they weren't here yet. | |
| 107 | 112 | rows = conn.execute( |
| 108 | 113 | "SELECT node_exec_id, node_id, subagent_type, status, retry_count, input_payload, " |
| 109 | - "output_payload, reflection_notes, git_worktree_ref " | |
| 114 | + "output_payload, reflection_notes, git_worktree_ref, input_schema_ref, output_schema_ref, " | |
| 115 | + "quota_limit_type, quota_wait_started_at " | |
| 110 | 116 | "FROM work_node_execution WHERE run_id = ? ORDER BY node_exec_id", |
| 111 | 117 | (run_id,), |
| 112 | 118 | ).fetchall() |
| ... | ... | @@ -117,6 +123,8 @@ def _work_nodes_for_run(conn, run_id): |
| 117 | 123 | "input_payload": json.loads(n[5]) if n[5] else None, |
| 118 | 124 | "output_payload": json.loads(n[6]) if n[6] else None, |
| 119 | 125 | "reflection_notes": n[7], "git_worktree_ref": n[8], |
| 126 | + "input_schema_ref": n[9], "output_schema_ref": n[10], | |
| 127 | + "quota_limit_type": n[11], "quota_wait_started_at": n[12], | |
| 120 | 128 | } |
| 121 | 129 | for n in rows |
| 122 | 130 | ] | ... | ... |
src/test_task_query.py
| ... | ... | @@ -145,6 +145,34 @@ class GetTaskTest(TaskQueryTestBase): |
| 145 | 145 | self.assertEqual(run["nodes"][0]["node_id"], "n1") |
| 146 | 146 | self.assertEqual(run["nodes"][0]["status"], "done") |
| 147 | 147 | self.assertEqual(run["nodes"][0]["output_payload"], {"x": 1}) |
| 148 | + self.assertEqual(run["nodes"][0]["input_schema_ref"], "in.json") | |
| 149 | + self.assertEqual(run["nodes"][0]["output_schema_ref"], "out.json") | |
| 150 | + | |
| 151 | + def test_work_node_includes_quota_wait_fields_for_v_graph_visualizer(self): | |
| 152 | + # U-026: v_graph_visualizer's waiting_for_quota state needs | |
| 153 | + # quota_limit_type + how long it's been waiting (02-V §2.7) | |
| 154 | + self._insert_task("task-1") | |
| 155 | + self.conn.execute( | |
| 156 | + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " | |
| 157 | + "VALUES ('run-1', 'task-1', 'coder', '{}', 'running')" | |
| 158 | + ) | |
| 159 | + self.conn.execute( | |
| 160 | + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) " | |
| 161 | + "VALUES ('skill-1', 'coder', 'skills/skill-1/', 'sha', 'new', 'active')" | |
| 162 | + ) | |
| 163 | + self.conn.execute( | |
| 164 | + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, " | |
| 165 | + "input_schema_ref, output_schema_ref, input_payload, status, quota_limit_type, " | |
| 166 | + "quota_wait_started_at) " | |
| 167 | + "VALUES ('node-1', 'run-1', 'n1', 'coder', 'skill-1', 'in.json', 'out.json', '{}', " | |
| 168 | + "'waiting_for_quota', 'session', '2026-07-31 09:00:00')" | |
| 169 | + ) | |
| 170 | + self.conn.commit() | |
| 171 | + | |
| 172 | + result = task_query.get_task(self.conn, "task-1") | |
| 173 | + node = result["work_graph_runs"][0]["nodes"][0] | |
| 174 | + self.assertEqual(node["quota_limit_type"], "session") | |
| 175 | + self.assertEqual(node["quota_wait_started_at"], "2026-07-31 09:00:00") | |
| 148 | 176 | |
| 149 | 177 | |
| 150 | 178 | if __name__ == "__main__": | ... | ... |