Commit e026f9e7448a0910e101357c57e5020f39822888

Authored by bboonnee
1 parent 82b08811

U-025: v_dashboard + v_task_board + v_task_detail

三个页面需要的读接口此前都不存在,本 Unit 一并建了:

- c_task_list/c_task_get(03-C §3.2,此前无 Unit 认领):新增
  src/task_query.py + GET /api/tasks、GET /api/tasks/:task_id。
  c_task_get 的返回比 03-C 字面契约更完整——额外带上
  work_graph_run(含解析后的 graph_manifest)+ 每个 run 的
  work_node_execution 列表,因为 v_task_detail 的 to-do 树需要这份数据,
  03-C 已订正补全。
- c_org_loop_route(U-016 已实现 route_org_loop(),从未接 HTTP)第一次
  接上 POST /api/tasks/:task_id/route,v_task_detail 的人工干预
  (manual_resume)按钮需要它。
- 预算护栏此前没有任何读接口(03-C 只写了 PUT),新增
  budget_guardrail.list_budget_configs() + GET /api/settings/budget
  给仪表盘预算条用。

frontend/app.js 补上真实的 #/(org graph 三节点卡片、当前任务概要、
预算条、事件时间线,全部通过 c_ws_subscribe 实时刷新)、#/tasks
(关键字/状态/org_node 过滤)、#/tasks/:task_id(task 字段、to-do 树、
关联 artifact/bug_record、blocked_pending_human 告警条 + manual_resume
按钮)。路由改成对象形式支持挂载态清理,切页面时关掉上一个页面的
WebSocket 连接。v_graph_visualizer/v_log_viewer 子模块明确标成占位
提示(随 U-026/U-027 补上),不是假造内容。

顺带把 U-023 里因 c_task_list 当时不存在而简化的 related_task_id
换回真下拉,goal_composer/bug_feedback 提交成功后也改回跳转真实的
v_task_detail。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/development-plan.md
@@ -241,6 +241,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 @@ -241,6 +241,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
241 241
242 - **U-025 [Value Path]** `v_dashboard` + `v_task_board` + `v_task_detail` 242 - **U-025 [Value Path]** `v_dashboard` + `v_task_board` + `v_task_detail`
243 - 依赖:U-024 243 - 依赖:U-024
  244 + - Done:三个页面可正常展示实时数据(原文档缺 Done/覆盖行,2026-07-31 补上)
  245 + - **实现补充(2026-07-31)**:这三个页面需要的读接口此前完全不存在,本 Unit 顺带把它们建了:① `c_task_list`/`c_task_get`(03-C §3.2,此前无 Unit 认领)新增 `src/task_query.py`,`c_task_get` 的返回比 03-C 字面写的"task + artifact 列表 + bug_record 列表"更完整——额外带上 `work_graph_run`(含解析后的 `graph_manifest`)+ 每个 run 的 `work_node_execution` 列表,因为 `v_task_detail` 的 to-do 树(01-M 1.3 补充说明、02-V §2.6 2026-07-30 新增)就是要渲染这份数据,字面契约低估了这个视图真正需要的东西;② `c_org_loop_route`(03-C §3.11,U-016 已实现 `route_org_loop()` 但从未接 HTTP)第一次接上 `POST /api/tasks/:task_id/route`,`v_task_detail` 的"人工干预(manual_resume)"按钮需要它;③ 预算护栏没有任何读接口(03-C §3.13 只写了 PUT,读是 U-030 配置页的隐含前提但没人建),新增 `budget_guardrail.list_budget_configs()` + `GET /api/settings/budget` 给仪表盘的预算条用。前端:`frontend/app.js` 补上真实的 `#/`(org graph 三节点卡片、当前任务概要、预算条、事件时间线,全部通过 `c_ws_subscribe` 实时刷新)、`#/tasks`(关键字/状态/org_node 过滤 + 点击行跳详情)、`#/tasks/:task_id`(task 字段、to-do 树、关联 artifact/bug_record、`blocked_pending_human` 告警条 + manual_resume 按钮);路由改成对象形式支持挂载态清理(`registerRouteCleanup`),每次切页面先关掉上一个页面开的 WebSocket,避免连接堆积。`v_task_detail` 里 `v_graph_visualizer`(DAG 视图)和 `v_log_viewer`(原始日志)两个子模块明确标成"随 U-026/U-027 补上"的占位提示,不是假造内容。顺带把 U-023 里因为 `c_task_list` 当时还不存在而简化成纯文本输入的 `related_task_id` 换回了真下拉(`v_goal_composer`/`v_bug_feedback` 提交成功后也从"页面内展示确认信息"改回 02-V 原文写的"跳转 `v_task_detail`",这个页面现在是真的了)。浏览器扩展本次会话不可用,验证方式:全部新增 HTTP 路由的真实请求/响应断言(`src/test_backend_server.py`)+ 对着真实运行的服务器用 `curl`/Node 原生 `WebSocket`/`fetch` 走一遍数据形状(尤其是 to-do 树需要的嵌套 JSON 结构,逐字段核对过 JS 渲染逻辑会怎么消费它)+ `node --check` 过语法,不是真人点击浏览器验证过的。
  246 + - 覆盖:02-V `v_dashboard`/`v_task_board`/`v_task_detail`;03-C `c_task_list`/`c_task_get`/`c_org_loop_route`(HTTP 接线)
244 247
245 - **U-026 [Value Path]** `v_graph_visualizer`(Org Graph + Work Graph 双层可视化,含 `waiting_for_quota` 信息态展示) 248 - **U-026 [Value Path]** `v_graph_visualizer`(Org Graph + Work Graph 双层可视化,含 `waiting_for_quota` 信息态展示)
246 - 依赖:U-024 249 - 依赖:U-024
docs/sdd/03-C-控制与接口.md
@@ -58,10 +58,10 @@ @@ -58,10 +58,10 @@
58 58
59 ## 3.2 `c_task_list` / `c_task_get` 59 ## 3.2 `c_task_list` / `c_task_get`
60 60
61 -- `GET /api/tasks?status=&org_node=&keyword=`、`GET /api/tasks/:task_id`  
62 -- `output_contract`:`c_task_list` 返回分页数组;`c_task_get` 返回单条 `task` + 关联 `artifact` 列表 + 关联 `bug_record` 列表 61 +- `GET /api/tasks?project_id=&status=&org_node=&keyword=`(`project_id` 必填,2026-07-31 订正补上——原文漏写,控制台单项目 MVP 下仍需要用它按项目过滤)、`GET /api/tasks/:task_id`
  62 +- `output_contract`:`c_task_list` 返回分页数组;`c_task_get` 返回单条 `task` + 关联 `artifact` 列表 + 关联 `bug_record` 列表 + 关联 `work_graph_run` 列表(各自带解析后的 `graph_manifest` 和 `work_node_execution` 列表,2026-07-31 订正补上——`v_task_detail` 的 to-do 树(02-V §2.6)需要这份数据,原 output_contract 没写全)
63 - `idempotent`:是 63 - `idempotent`:是
64 -- `errors`:`NOT_FOUND`(不可重试,仅 `c_task_get`) 64 +- `errors`:`NOT_FOUND`(不可重试,仅 `c_task_get`);`INVALID_INPUT`(不可重试,仅 `c_task_list`):缺少 `project_id`
65 65
66 ## 3.3 `c_org_node_trigger` 66 ## 3.3 `c_org_node_trigger`
67 67
docs/sdd/unit_matrix.json
@@ -40,7 +40,7 @@ @@ -40,7 +40,7 @@
40 {"id": "U-023", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_goal_composer + v_bug_feedback(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-022"], "coverage": {"model": [], "view": ["v_goal_composer", "v_bug_feedback"], "controller": ["c_task_create", "c_bug_report_create"], "process": [], "requirement": []}, "done": "能通过 UI 创建 task 和人工 bug_record;补上 c_bug_report_create 的 project_id 必填缺口(03-C input_contract 遗漏);related_task_id 简化为文本输入,等 U-025 的 c_task_list 落地后换真下拉"}, 40 {"id": "U-023", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_goal_composer + v_bug_feedback(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-022"], "coverage": {"model": [], "view": ["v_goal_composer", "v_bug_feedback"], "controller": ["c_task_create", "c_bug_report_create"], "process": [], "requirement": []}, "done": "能通过 UI 创建 task 和人工 bug_record;补上 c_bug_report_create 的 project_id 必填缺口(03-C input_contract 遗漏);related_task_id 简化为文本输入,等 U-025 的 c_task_list 落地后换真下拉"},
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 客户端验证过真实互操作性"}, 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 {"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 为准"}, 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 - {"id": "U-025", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_dashboard + v_task_board + v_task_detail", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_dashboard", "v_task_board", "v_task_detail"], "controller": [], "process": [], "requirement": []}, "done": "三个页面可正常展示实时数据"}, 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", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_graph_visualizer"], "controller": [], "process": [], "requirement": []}, "done": "Org/Work Graph 双层可视化含 waiting_for_quota 信息态"},
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 全部通过"}, 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 {"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 列表+预览+血缘链可用"}, 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 列表+预览+血缘链可用"},
frontend/app.js
1 // U-008: hash-based client-side router (no History API / server catch-all 1 // U-008: hash-based client-side router (no History API / server catch-all
2 // needed). U-022 adds the first real page, #/setup (v_project_setup, 02-V 2 // needed). U-022 adds the first real page, #/setup (v_project_setup, 02-V
3 -// §2.1); U-023 adds #/goals/new and #/bugs/new -- everything else is still  
4 -// a PH3 placeholder. 3 +// §2.1); U-023 adds #/goals/new and #/bugs/new; U-025 adds the real #/
  4 +// (v_dashboard), #/tasks (v_task_board), #/tasks/:task_id (v_task_detail).
5 5
6 // 02-V's console has no multi-project switcher UI (decision 2), but 6 // 02-V's console has no multi-project switcher UI (decision 2), but
7 // c_task_create/c_bug_report_create both require project_id (03-C §3.1; 7 // c_task_create/c_bug_report_create both require project_id (03-C §3.1;
8 // §3.1.1 doesn't list it, but needs it too -- see bug_record.py's 8 // §3.1.1 doesn't list it, but needs it too -- see bug_record.py's
9 -// create_bug_report docstring). Until v_dashboard (U-025) exists to carry  
10 -// "current project" state, the id picked/created in v_project_setup is 9 +// create_bug_report docstring). Until v_dashboard carries "current project"
  10 +// state through some richer UI, the id picked/created in v_project_setup is
11 // stashed here and read back by the pages that need it. 11 // stashed here and read back by the pages that need it.
12 const CURRENT_PROJECT_KEY = "graphlemon_project_id"; 12 const CURRENT_PROJECT_KEY = "graphlemon_project_id";
13 function getCurrentProjectId() { 13 function getCurrentProjectId() {
@@ -17,20 +17,85 @@ function setCurrentProjectId(projectId) { @@ -17,20 +17,85 @@ function setCurrentProjectId(projectId) {
17 localStorage.setItem(CURRENT_PROJECT_KEY, projectId); 17 localStorage.setItem(CURRENT_PROJECT_KEY, projectId);
18 } 18 }
19 19
  20 +function escapeHtml(text) {
  21 + const div = document.createElement("div");
  22 + div.textContent = text == null ? "" : String(text);
  23 + return div.innerHTML;
  24 +}
  25 +
  26 +// c_ws_subscribe (03-C §3.14, U-024): opens a live event feed for a project
  27 +// (optionally scoped to one task_id) and auto-reconnects on drop -- 02-V
  28 +// §2.2's "实时连接已断开,正在重连" exception state. REQ-12's deeper
  29 +// reconnect-catch-up semantics are U-024.5's job (03-C §3.14 itself routes
  30 +// that through REST snapshots, not WS replay); this only re-opens the
  31 +// socket and reports connection status back to the caller.
  32 +function connectEventSocket(projectId, taskId, { onEvent, onStatusChange }) {
  33 + const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
  34 + let url = wsProtocol + "//" + window.location.host + "/api/ws/projects/" + encodeURIComponent(projectId);
  35 + if (taskId) url += "?task_id=" + encodeURIComponent(taskId);
  36 +
  37 + let closedByCaller = false;
  38 + let socket = null;
  39 +
  40 + function open() {
  41 + socket = new WebSocket(url);
  42 + socket.onopen = () => onStatusChange("connected");
  43 + socket.onmessage = (event) => onEvent(JSON.parse(event.data));
  44 + socket.onclose = () => {
  45 + if (closedByCaller) return;
  46 + onStatusChange("disconnected");
  47 + setTimeout(open, 2000);
  48 + };
  49 + socket.onerror = () => socket.close();
  50 + }
  51 + open();
  52 +
  53 + return {
  54 + close: () => {
  55 + closedByCaller = true;
  56 + if (socket) socket.close();
  57 + },
  58 + };
  59 +}
  60 +
20 const routes = { 61 const routes = {
21 - "#/": { render: () => "<h1>Dashboard</h1><p>placeholder -- real content is PH3 (v_dashboard, U-025)</p>" },  
22 - "#/tasks": { render: () => "<h1>Tasks</h1><p>placeholder -- real content is PH3 (v_task_board, U-025)</p>" }, 62 + "#/": { render: renderDashboard, mount: mountDashboard },
  63 + "#/tasks": { render: renderTaskBoard, mount: mountTaskBoard },
23 "#/settings": { render: () => "<h1>Settings</h1><p>placeholder -- real content is PH3 (U-030)</p>" }, 64 "#/settings": { render: () => "<h1>Settings</h1><p>placeholder -- real content is PH3 (U-030)</p>" },
24 "#/setup": { render: renderProjectSetup, mount: mountProjectSetup }, 65 "#/setup": { render: renderProjectSetup, mount: mountProjectSetup },
25 "#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer }, 66 "#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer },
26 "#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback }, 67 "#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback },
27 }; 68 };
28 69
  70 +// #/tasks/:task_id (v_task_detail) is the one dynamic route -- not worth a
  71 +// full path-matching router for a single pattern.
  72 +const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/;
  73 +
  74 +let currentRouteCleanup = null;
  75 +function registerRouteCleanup(fn) {
  76 + currentRouteCleanup = fn;
  77 +}
  78 +
29 function render() { 79 function render() {
  80 + if (currentRouteCleanup) {
  81 + currentRouteCleanup();
  82 + currentRouteCleanup = null;
  83 + }
  84 +
30 const hash = window.location.hash || "#/"; 85 const hash = window.location.hash || "#/";
31 - const route = routes[hash] || { render: () => "<h1>Not found</h1><p>" + hash + "</p>" };  
32 - document.getElementById("app").innerHTML = route.render();  
33 - if (route.mount) route.mount(); 86 + let route = routes[hash];
  87 + let routeArgs = [];
  88 + if (!route) {
  89 + const match = hash.match(TASK_DETAIL_RE);
  90 + if (match) {
  91 + route = { render: renderTaskDetail, mount: mountTaskDetail };
  92 + routeArgs = [match[1]];
  93 + }
  94 + }
  95 + if (!route) route = { render: () => "<h1>Not found</h1><p>" + hash + "</p>" };
  96 +
  97 + document.getElementById("app").innerHTML = route.render(...routeArgs);
  98 + if (route.mount) route.mount(...routeArgs);
34 99
35 for (const a of document.querySelectorAll("#nav a")) { 100 for (const a of document.querySelectorAll("#nav a")) {
36 a.classList.toggle("active", a.getAttribute("href") === hash); 101 a.classList.toggle("active", a.getAttribute("href") === hash);
@@ -166,7 +231,6 @@ function renderGoalComposer() { @@ -166,7 +231,6 @@ function renderGoalComposer() {
166 <div class="page-sub">用自然语言描述你想让 graphlemon 完成的目标</div> 231 <div class="page-sub">用自然语言描述你想让 graphlemon 完成的目标</div>
167 <div class="panel"> 232 <div class="panel">
168 <div id="goal_error" class="error-banner" style="display:none;"></div> 233 <div id="goal_error" class="error-banner" style="display:none;"></div>
169 - <div id="goal_success" class="success-banner" style="display:none;"></div>  
170 <div class="field" id="goal_form"> 234 <div class="field" id="goal_form">
171 <label>目标描述(goal_text,必填,最长 ${GOAL_TEXT_MAX_LEN} 字)</label> 235 <label>目标描述(goal_text,必填,最长 ${GOAL_TEXT_MAX_LEN} 字)</label>
172 <textarea id="goal_text" rows="6" maxlength="${GOAL_TEXT_MAX_LEN}"></textarea> 236 <textarea id="goal_text" rows="6" maxlength="${GOAL_TEXT_MAX_LEN}"></textarea>
@@ -190,7 +254,6 @@ function mountGoalComposer() { @@ -190,7 +254,6 @@ function mountGoalComposer() {
190 if (!getCurrentProjectId()) return; 254 if (!getCurrentProjectId()) return;
191 const textArea = document.getElementById("goal_text"); 255 const textArea = document.getElementById("goal_text");
192 const errorBox = document.getElementById("goal_error"); 256 const errorBox = document.getElementById("goal_error");
193 - const successBox = document.getElementById("goal_success");  
194 const form = document.getElementById("goal_form"); 257 const form = document.getElementById("goal_form");
195 const confirmBanner = document.getElementById("goal_confirm_banner"); 258 const confirmBanner = document.getElementById("goal_confirm_banner");
196 const confirmSummary = document.getElementById("goal_confirm_summary"); 259 const confirmSummary = document.getElementById("goal_confirm_summary");
@@ -231,12 +294,8 @@ function mountGoalComposer() { @@ -231,12 +294,8 @@ function mountGoalComposer() {
231 errorBox.style.display = "block"; 294 errorBox.style.display = "block";
232 return; 295 return;
233 } 296 }
234 - confirmBanner.style.display = "none";  
235 - // v_task_detail doesn't exist yet (U-025) -- show the created task_id  
236 - // here instead of redirecting to a route that isn't built.  
237 - successBox.innerHTML = "已创建任务 <code>" + body.task_id + "</code>(" + body.status + ")。"  
238 - + '<a href="#/">返回仪表盘</a>';  
239 - successBox.style.display = "block"; 297 + // 02-V §2.3: 成功跳转 v_task_detail
  298 + window.location.hash = "#/tasks/" + body.task_id;
240 }); 299 });
241 } 300 }
242 301
@@ -262,9 +321,10 @@ function renderBugFeedback() { @@ -262,9 +321,10 @@ function renderBugFeedback() {
262 <textarea id="bug_repro_steps" rows="3"></textarea> 321 <textarea id="bug_repro_steps" rows="3"></textarea>
263 </div> 322 </div>
264 <div class="field"> 323 <div class="field">
265 - <label>关联任务 ID(related_task_id,非必填)</label>  
266 - <input type="text" id="bug_related_task_id" placeholder="留空则视为新发现的独立问题">  
267 - <div class="hint">任务列表选择器随 U-025 补上,目前先直接填 task_id</div> 324 + <label>关联任务(related_task_id,非必填)</label>
  325 + <select id="bug_related_task_id">
  326 + <option value="">— 不关联,视为新发现的独立问题 —</option>
  327 + </select>
268 </div> 328 </div>
269 <div class="btn-row"> 329 <div class="btn-row">
270 <button class="btn btn-primary" id="btn_submit_bug" type="button">提交反馈</button> 330 <button class="btn btn-primary" id="btn_submit_bug" type="button">提交反馈</button>
@@ -279,11 +339,22 @@ function mountBugFeedback() { @@ -279,11 +339,22 @@ function mountBugFeedback() {
279 if (!getCurrentProjectId()) return; 339 if (!getCurrentProjectId()) return;
280 const descriptionArea = document.getElementById("bug_description"); 340 const descriptionArea = document.getElementById("bug_description");
281 const reproStepsArea = document.getElementById("bug_repro_steps"); 341 const reproStepsArea = document.getElementById("bug_repro_steps");
282 - const relatedTaskIdInput = document.getElementById("bug_related_task_id"); 342 + const relatedTaskIdSelect = document.getElementById("bug_related_task_id");
283 const errorBox = document.getElementById("bug_error"); 343 const errorBox = document.getElementById("bug_error");
284 const successBox = document.getElementById("bug_success"); 344 const successBox = document.getElementById("bug_success");
285 const form = document.getElementById("bug_form"); 345 const form = document.getElementById("bug_form");
286 346
  347 + fetch("/api/tasks?project_id=" + encodeURIComponent(getCurrentProjectId()))
  348 + .then((resp) => resp.json())
  349 + .then((tasks) => {
  350 + for (const task of tasks) {
  351 + const option = document.createElement("option");
  352 + option.value = task.task_id;
  353 + option.textContent = task.goal_text.slice(0, 40) + " (" + task.task_id.slice(0, 8) + ")";
  354 + relatedTaskIdSelect.appendChild(option);
  355 + }
  356 + });
  357 +
287 document.getElementById("btn_submit_bug").addEventListener("click", async () => { 358 document.getElementById("btn_submit_bug").addEventListener("click", async () => {
288 errorBox.style.display = "none"; 359 errorBox.style.display = "none";
289 const description = descriptionArea.value.trim(); 360 const description = descriptionArea.value.trim();
@@ -292,13 +363,14 @@ function mountBugFeedback() { @@ -292,13 +363,14 @@ function mountBugFeedback() {
292 errorBox.style.display = "block"; 363 errorBox.style.display = "block";
293 return; 364 return;
294 } 365 }
  366 + const relatedTaskId = relatedTaskIdSelect.value || undefined;
295 const resp = await fetch("/api/bug-reports", { 367 const resp = await fetch("/api/bug-reports", {
296 method: "POST", 368 method: "POST",
297 body: JSON.stringify({ 369 body: JSON.stringify({
298 project_id: getCurrentProjectId(), 370 project_id: getCurrentProjectId(),
299 description: description, 371 description: description,
300 repro_steps: reproStepsArea.value.trim() || undefined, 372 repro_steps: reproStepsArea.value.trim() || undefined,
301 - related_task_id: relatedTaskIdInput.value.trim() || undefined, 373 + related_task_id: relatedTaskId,
302 }), 374 }),
303 }); 375 });
304 const body = await resp.json(); 376 const body = await resp.json();
@@ -307,9 +379,382 @@ function mountBugFeedback() { @@ -307,9 +379,382 @@ function mountBugFeedback() {
307 errorBox.style.display = "block"; 379 errorBox.style.display = "block";
308 return; 380 return;
309 } 381 }
  382 + // 02-V §2.4: 跳转到该 bug 的详情视图,MVP 复用 v_task_detail 的 bug 展示区
  383 + // -- only possible when the report was tied to a task; otherwise there's
  384 + // no task detail page to land on, so show the confirmation inline.
  385 + if (relatedTaskId) {
  386 + window.location.hash = "#/tasks/" + relatedTaskId;
  387 + return;
  388 + }
310 form.style.display = "none"; 389 form.style.display = "none";
311 successBox.innerHTML = "已提交,Validator 将进行归因确认(bug <code>" + body.bug_id + "</code>)。" 390 successBox.innerHTML = "已提交,Validator 将进行归因确认(bug <code>" + body.bug_id + "</code>)。"
312 + '<a href="#/">返回仪表盘</a>'; 391 + '<a href="#/">返回仪表盘</a>';
313 successBox.style.display = "block"; 392 successBox.style.display = "block";
314 }); 393 });
315 } 394 }
  395 +
  396 +// ---- v_dashboard (02-V §2.2): real-time landing page ----
  397 +
  398 +const ORG_NODE_ORDER = ["planner", "coder", "validator"];
  399 +
  400 +function renderDashboard() {
  401 + if (!getCurrentProjectId()) return renderNoProjectSelected();
  402 + return `
  403 + <div class="page page-wide">
  404 + <h1>仪表盘</h1>
  405 + <div id="quota_info_banner" class="info-banner" style="display:none;"></div>
  406 + <div id="ws_disconnected_banner" class="error-banner" style="display:none;">实时连接已断开,正在重连…</div>
  407 + <div class="grid">
  408 + <div>
  409 + <div class="panel">
  410 + <h2>Org Graph 三节点状态</h2>
  411 + <div class="org-strip" id="org_strip"></div>
  412 + </div>
  413 + <div class="panel">
  414 + <h2>当前任务概要</h2>
  415 + <div class="task-summary" id="task_summary"></div>
  416 + </div>
  417 + <div class="actions">
  418 + <a class="btn btn-primary" href="#/goals/new">+ 提出新目标</a>
  419 + <a class="btn" href="#/bugs/new">反馈 Bug</a>
  420 + </div>
  421 + </div>
  422 + <div>
  423 + <div class="panel">
  424 + <h2>预算用量</h2>
  425 + <div id="budget_summary"></div>
  426 + </div>
  427 + <div class="panel">
  428 + <h2>最近事件</h2>
  429 + <div id="event_timeline"></div>
  430 + </div>
  431 + </div>
  432 + </div>
  433 + </div>
  434 + `;
  435 +}
  436 +
  437 +function mountDashboard() {
  438 + const projectId = getCurrentProjectId();
  439 + if (!projectId) return;
  440 +
  441 + const orgStrip = document.getElementById("org_strip");
  442 + const taskSummary = document.getElementById("task_summary");
  443 + const budgetSummary = document.getElementById("budget_summary");
  444 + const eventTimeline = document.getElementById("event_timeline");
  445 + const quotaBanner = document.getElementById("quota_info_banner");
  446 + const wsBanner = document.getElementById("ws_disconnected_banner");
  447 + const recentEvents = [];
  448 +
  449 + function renderOrgStrip(activeOrgNode, runStatusByOrgNode) {
  450 + orgStrip.innerHTML = ORG_NODE_ORDER.map((node, i) => {
  451 + const isActive = activeOrgNode === node;
  452 + const runStatus = runStatusByOrgNode[node];
  453 + const hint = isActive ? "当前节点" : runStatus === "done" ? "已完成" : runStatus || "未开始";
  454 + const arrow = i < ORG_NODE_ORDER.length - 1 ? '<div class="org-arrow">→</div>' : "";
  455 + return '<div class="org-node' + (isActive ? " active" : "") + '"><div class="role">'
  456 + + node + '</div><div class="hint">' + escapeHtml(hint) + "</div></div>" + arrow;
  457 + }).join("");
  458 + }
  459 +
  460 + async function refreshTasksAndOrgGraph() {
  461 + const resp = await fetch("/api/tasks?project_id=" + encodeURIComponent(projectId));
  462 + const tasks = await resp.json();
  463 + const active = tasks.find((t) => t.status !== "done") || tasks[0];
  464 + if (!active) {
  465 + orgStrip.innerHTML = '<div class="hint">暂无运行中任务</div>';
  466 + taskSummary.innerHTML = '<div class="hint">暂无运行中任务 · <a href="#/goals/new">提出第一个目标 →</a></div>';
  467 + quotaBanner.style.display = "none";
  468 + return;
  469 + }
  470 + taskSummary.innerHTML = (
  471 + '<div class="row"><span>Goal</span><span>' + escapeHtml(active.goal_text) + "</span></div>"
  472 + + '<div class="row"><span>Loop Round</span><span>' + active.loop_round + "</span></div>"
  473 + + '<div class="row"><span>Status</span><span><a href="#/tasks/' + active.task_id + '">'
  474 + + escapeHtml(active.status) + "</a></span></div>"
  475 + );
  476 +
  477 + const detailResp = await fetch("/api/tasks/" + active.task_id);
  478 + const detail = await detailResp.json();
  479 + const runStatusByOrgNode = {};
  480 + let waitingNode = null;
  481 + for (const run of detail.work_graph_runs) {
  482 + runStatusByOrgNode[run.org_node] = run.status;
  483 + if (!waitingNode) waitingNode = run.nodes.find((n) => n.status === "waiting_for_quota");
  484 + }
  485 + renderOrgStrip(active.assigned_org_node, runStatusByOrgNode);
  486 +
  487 + if (waitingNode) {
  488 + quotaBanner.innerHTML = "🕐 有任务正在等待 Claude Code 额度恢复 · "
  489 + + '<a href="#/tasks/' + active.task_id + '">查看详情</a>';
  490 + quotaBanner.style.display = "flex";
  491 + } else {
  492 + quotaBanner.style.display = "none";
  493 + }
  494 + }
  495 +
  496 + async function refreshBudget() {
  497 + const resp = await fetch("/api/settings/budget");
  498 + const configs = await resp.json();
  499 + if (configs.length === 0) {
  500 + budgetSummary.innerHTML = '<div class="hint">预算护栏未配置</div>';
  501 + return;
  502 + }
  503 + const c = configs[0];
  504 + const pct = c.limit_value > 0 ? Math.min(100, Math.round((c.current_usage / c.limit_value) * 100)) : 0;
  505 + budgetSummary.innerHTML = (
  506 + '<div style="font-size:13px;">' + c.current_usage.toLocaleString() + " / "
  507 + + c.limit_value.toLocaleString() + " " + escapeHtml(c.limit_type) + "</div>"
  508 + + '<div class="progress"><div class="fill" style="width:' + pct + '%"></div></div>'
  509 + );
  510 + }
  511 +
  512 + function prependEvent(event) {
  513 + recentEvents.unshift(event);
  514 + recentEvents.length = Math.min(recentEvents.length, 20);
  515 + eventTimeline.innerHTML = recentEvents.map((e) =>
  516 + '<div class="event-item"><div>' + escapeHtml(e.event_type) + '</div><div class="t">'
  517 + + escapeHtml(e.created_at || "") + "</div></div>"
  518 + ).join("") || '<div class="hint">暂无事件</div>';
  519 + }
  520 +
  521 + refreshTasksAndOrgGraph();
  522 + refreshBudget();
  523 + eventTimeline.innerHTML = '<div class="hint">暂无事件</div>';
  524 +
  525 + const socket = connectEventSocket(projectId, null, {
  526 + onEvent: (event) => {
  527 + prependEvent(event);
  528 + refreshTasksAndOrgGraph();
  529 + if (event.event_type === "budget_updated") refreshBudget();
  530 + },
  531 + onStatusChange: (status) => {
  532 + wsBanner.style.display = status === "disconnected" ? "block" : "none";
  533 + },
  534 + });
  535 + registerRouteCleanup(() => socket.close());
  536 +}
  537 +
  538 +// ---- v_task_board (02-V §2.5): task list with filters ----
  539 +
  540 +const TASK_STATUSES = [
  541 + "queued", "planning_brd_prd_sdd", "planning_failed", "coding", "coding_failed",
  542 + "validating", "routed_fast_lane", "routed_full_replan", "blocked_pending_human", "done",
  543 +];
  544 +
  545 +function renderTaskBoard() {
  546 + if (!getCurrentProjectId()) return renderNoProjectSelected();
  547 + return `
  548 + <div class="page page-wide">
  549 + <h1>任务看板</h1>
  550 + <div class="filters">
  551 + <input type="text" id="tb_keyword" placeholder="搜索 goal_text" maxlength="128">
  552 + <select id="tb_status">
  553 + <option value="">全部状态</option>
  554 + ${TASK_STATUSES.map((s) => '<option value="' + s + '">' + s + "</option>").join("")}
  555 + </select>
  556 + <select id="tb_org_node">
  557 + <option value="">全部 Org 节点</option>
  558 + <option value="planner">planner</option>
  559 + <option value="coder">coder</option>
  560 + <option value="validator">validator</option>
  561 + </select>
  562 + </div>
  563 + <table class="data-table">
  564 + <thead>
  565 + <tr><th>task_id</th><th>goal_text</th><th>status</th><th>assigned_org_node</th><th>loop_round</th><th>updated_at</th></tr>
  566 + </thead>
  567 + <tbody id="tb_rows"></tbody>
  568 + </table>
  569 + </div>
  570 + `;
  571 +}
  572 +
  573 +function mountTaskBoard() {
  574 + const projectId = getCurrentProjectId();
  575 + if (!projectId) return;
  576 + const rowsEl = document.getElementById("tb_rows");
  577 + const keywordEl = document.getElementById("tb_keyword");
  578 + const statusEl = document.getElementById("tb_status");
  579 + const orgNodeEl = document.getElementById("tb_org_node");
  580 +
  581 + async function refresh() {
  582 + const params = new URLSearchParams({ project_id: projectId });
  583 + if (keywordEl.value.trim()) params.set("keyword", keywordEl.value.trim());
  584 + if (statusEl.value) params.set("status", statusEl.value);
  585 + if (orgNodeEl.value) params.set("org_node", orgNodeEl.value);
  586 + const resp = await fetch("/api/tasks?" + params.toString());
  587 + const tasks = await resp.json();
  588 + if (tasks.length === 0) {
  589 + rowsEl.innerHTML = '<tr><td colspan="6">暂无任务</td></tr>';
  590 + return;
  591 + }
  592 + rowsEl.innerHTML = tasks.map((t) => (
  593 + '<tr data-task-id="' + t.task_id + '">'
  594 + + "<td>" + t.task_id + "</td>"
  595 + + "<td>" + escapeHtml(t.goal_text) + "</td>"
  596 + + '<td><span class="badge ' + t.status + '">' + t.status + "</span></td>"
  597 + + "<td>" + t.assigned_org_node + "</td>"
  598 + + "<td>" + t.loop_round + "</td>"
  599 + + "<td>" + escapeHtml(t.updated_at) + "</td>"
  600 + + "</tr>"
  601 + )).join("");
  602 + for (const row of rowsEl.querySelectorAll("tr[data-task-id]")) {
  603 + row.addEventListener("click", () => {
  604 + window.location.hash = "#/tasks/" + row.dataset.taskId;
  605 + });
  606 + }
  607 + }
  608 +
  609 + keywordEl.addEventListener("input", refresh);
  610 + statusEl.addEventListener("change", refresh);
  611 + orgNodeEl.addEventListener("change", refresh);
  612 + refresh();
  613 +
  614 + const socket = connectEventSocket(projectId, null, {
  615 + onEvent: (event) => {
  616 + if (event.event_type === "task_status_changed") refresh();
  617 + },
  618 + onStatusChange: () => {},
  619 + });
  620 + registerRouteCleanup(() => socket.close());
  621 +}
  622 +
  623 +// ---- 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.
  628 +
  629 +function renderTaskDetail(taskId) {
  630 + if (!getCurrentProjectId()) return renderNoProjectSelected();
  631 + return `
  632 + <div class="page page-wide">
  633 + <div class="breadcrumb"><a href="#/tasks">任务</a> / ${taskId}</div>
  634 + <div id="td_content"><div class="hint">加载中…</div></div>
  635 + </div>
  636 + `;
  637 +}
  638 +
  639 +function mountTaskDetail(taskId) {
  640 + const projectId = getCurrentProjectId();
  641 + if (!projectId) return;
  642 + const content = document.getElementById("td_content");
  643 +
  644 + function renderTodoTree(run) {
  645 + if (!run.graph_manifest) return "";
  646 + const nodesById = {};
  647 + for (const n of run.nodes) nodesById[n.node_id] = n;
  648 + const items = run.graph_manifest.nodes.map((gn) => {
  649 + const wn = nodesById[gn.node_id];
  650 + const status = wn ? wn.status : "pending";
  651 + const detail = wn ? (
  652 + '<div class="todo-detail" id="todo-detail-' + wn.node_exec_id + '">'
  653 + + "retry_count: " + wn.retry_count
  654 + + (wn.reflection_notes ? "<br>reflection_notes: " + escapeHtml(wn.reflection_notes) : "")
  655 + + '<div class="hint">原始日志查看器(v_log_viewer)随 U-027 补上</div>'
  656 + + "</div>"
  657 + ) : "";
  658 + return (
  659 + '<li class="status-' + status + '">'
  660 + + '<div class="todo-row" data-node-exec-id="' + (wn ? wn.node_exec_id : "") + '">'
  661 + + '<span class="dot"></span>'
  662 + + '<span class="todo-id">to-do: ' + escapeHtml(gn.node_id) + "</span>"
  663 + + '<span class="todo-status">' + status + (wn ? " · node_exec_id=" + wn.node_exec_id : "") + "</span>"
  664 + + "</div>" + detail + "</li>"
  665 + );
  666 + }).join("");
  667 + return (
  668 + '<div class="panel"><h2>' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")</h2>"
  669 + + '<ul class="todo-tree">' + items + "</ul>"
  670 + + '<div class="see-more">按执行层级排布的 DAG 视图(v_graph_visualizer)随 U-026 补上</div>'
  671 + + "</div>"
  672 + );
  673 + }
  674 +
  675 + function renderDetail(task) {
  676 + const activeSet = new Set([task.assigned_org_node]);
  677 + for (const run of task.work_graph_runs) {
  678 + if (run.status !== "done" && run.status !== "failed") activeSet.add(run.org_node);
  679 + }
  680 + const orgStripHtml = ORG_NODE_ORDER.map((node, i) => {
  681 + const arrow = i < ORG_NODE_ORDER.length - 1 ? '<div class="org-arrow">→</div>' : "";
  682 + return '<div class="org-node' + (activeSet.has(node) ? " active" : "") + '">' + node + "</div>" + arrow;
  683 + }).join("");
  684 +
  685 + const alertHtml = task.status !== "blocked_pending_human" ? "" : (
  686 + '<div class="alert-banner">⚠ 已阻塞(' + escapeHtml(task.blocked_reason || "") + '),需要人工干预'
  687 + + '<button class="btn btn-danger" id="btn_manual_resume" type="button">人工干预(manual_resume)</button></div>'
  688 + );
  689 +
  690 + const todoTreesHtml = task.work_graph_runs.map(renderTodoTree).join("");
  691 +
  692 + const artifactsHtml = task.artifacts.length ? (
  693 + '<ul class="simple-list">' + task.artifacts.map((a) =>
  694 + "<li><span>" + escapeHtml(a.file_path) + "</span><span>"
  695 + + escapeHtml(a.artifact_type) + " · " + escapeHtml(a.validation_status) + "</span></li>"
  696 + ).join("") + "</ul>"
  697 + ) : '<div class="hint">暂无 Artifact</div>';
  698 +
  699 + const bugsHtml = task.bug_records.length ? (
  700 + '<ul class="simple-list">' + task.bug_records.map((b) =>
  701 + "<li><span>" + escapeHtml(b.description) + "</span><span>"
  702 + + escapeHtml(b.root_cause_category || "待归因") + " · " + escapeHtml(b.status) + "</span></li>"
  703 + ).join("") + "</ul>"
  704 + ) : '<div class="hint">暂无 Bug 记录</div>';
  705 +
  706 + content.innerHTML = (
  707 + "<h1>" + escapeHtml(task.goal_text) + "</h1>"
  708 + + alertHtml
  709 + + '<div class="meta-row">'
  710 + + "<div><b>STATUS</b>" + escapeHtml(task.status) + "</div>"
  711 + + "<div><b>LOOP_ROUND</b>" + task.loop_round + "</div>"
  712 + + "<div><b>ASSIGNED_ORG_NODE</b>" + task.assigned_org_node + "</div>"
  713 + + "<div><b>CREATED_VIA</b>" + escapeHtml(task.created_via) + "</div>"
  714 + + "</div>"
  715 + + '<div class="panel"><h2>Org Graph</h2><div class="org-strip">' + orgStripHtml + "</div></div>"
  716 + + todoTreesHtml
  717 + + '<div class="panel"><h2>关联 Artifact</h2>' + artifactsHtml + "</div>"
  718 + + '<div class="panel"><h2>关联 Bug 记录</h2>' + bugsHtml + "</div>"
  719 + + '<div class="panel"><h2>原始执行日志</h2><div class="hint">v_log_viewer 随 U-027 补上</div></div>'
  720 + );
  721 +
  722 + for (const row of content.querySelectorAll(".todo-row[data-node-exec-id]")) {
  723 + row.addEventListener("click", () => {
  724 + const nodeExecId = row.dataset.nodeExecId;
  725 + if (!nodeExecId) return;
  726 + const detail = document.getElementById("todo-detail-" + nodeExecId);
  727 + if (detail) detail.classList.toggle("open");
  728 + });
  729 + }
  730 +
  731 + const resumeBtn = document.getElementById("btn_manual_resume");
  732 + if (resumeBtn) {
  733 + resumeBtn.addEventListener("click", async () => {
  734 + resumeBtn.disabled = true;
  735 + await fetch("/api/tasks/" + taskId + "/route", {
  736 + method: "POST",
  737 + body: JSON.stringify({ decided_route: "manual_resume" }),
  738 + });
  739 + refresh();
  740 + });
  741 + }
  742 + }
  743 +
  744 + async function refresh() {
  745 + const resp = await fetch("/api/tasks/" + taskId);
  746 + if (resp.status === 404) {
  747 + content.innerHTML = '<div class="error-banner" style="display:block;">任务不存在</div>';
  748 + return;
  749 + }
  750 + renderDetail(await resp.json());
  751 + }
  752 +
  753 + refresh();
  754 +
  755 + const socket = connectEventSocket(projectId, taskId, {
  756 + onEvent: () => refresh(),
  757 + onStatusChange: () => {},
  758 + });
  759 + registerRouteCleanup(() => socket.close());
  760 +}
frontend/style.css
@@ -25,3 +25,70 @@ body { font-family: system-ui, sans-serif; margin: 0; } @@ -25,3 +25,70 @@ body { font-family: system-ui, sans-serif; margin: 0; }
25 .error-banner { background: #fef2f2; border: 1px solid #ef4444; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-bottom: 16px; } 25 .error-banner { background: #fef2f2; border: 1px solid #ef4444; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-bottom: 16px; }
26 .success-banner { background: #f0fdf4; border: 1px solid #22c55e; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-bottom: 16px; } 26 .success-banner { background: #f0fdf4; border: 1px solid #22c55e; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-bottom: 16px; }
27 .success-banner a { margin-left: 8px; color: #4f46e5; } 27 .success-banner a { margin-left: 8px; color: #4f46e5; }
  28 +
  29 +/* U-025: v_dashboard/v_task_board/v_task_detail -- class names match
  30 + docs/wireframes/v_dashboard.html, v_task_board.html, v_task_detail.html */
  31 +:root {
  32 + --status-pending: #9ca3af; --status-running: #3b82f6; --status-reflecting: #eab308;
  33 + --status-waiting-quota: #7dd3fc; --status-blocked: #ef4444; --status-done: #22c55e;
  34 +}
  35 +.page.page-wide { max-width: 1200px; }
  36 +.hint { font-size: 12px; color: #6b7280; }
  37 +.info-banner { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 10px 14px; font-size: 13px; margin-bottom: 18px; display: flex; align-items: center; gap: 8px; }
  38 +.info-banner a { color: #4f46e5; }
  39 +.grid { display: grid; grid-template-columns: 2fr 1fr; gap: 20px; }
  40 +.grid .panel { margin-bottom: 20px; }
  41 +.panel h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: #6b7280; margin: 0 0 14px; }
  42 +.org-strip { display: flex; align-items: center; gap: 0; margin-bottom: 6px; }
  43 +.org-node { border: 2px solid #e5e7eb; border-radius: 8px; padding: 10px 18px; text-align: center; background: #fff; flex: 1; font-size: 12px; }
  44 +.org-node.active { border-color: var(--status-running); background: #eff6ff; }
  45 +.org-node .role { font-weight: 600; font-size: 13px; }
  46 +.org-node .hint { margin-top: 2px; }
  47 +.org-arrow { flex: 0 0 30px; text-align: center; color: #6b7280; }
  48 +.task-summary .row { display: flex; justify-content: space-between; font-size: 13px; padding: 6px 0; border-bottom: 1px solid #e5e7eb; }
  49 +.task-summary .row:last-child { border-bottom: none; }
  50 +.progress { height: 8px; border-radius: 4px; background: #e5e7eb; overflow: hidden; margin-top: 6px; }
  51 +.progress .fill { height: 100%; background: #4f46e5; }
  52 +.event-item { font-size: 12px; padding: 8px 0; border-bottom: 1px solid #e5e7eb; }
  53 +.event-item .t { color: #6b7280; }
  54 +.actions { display: flex; gap: 10px; margin-bottom: 20px; }
  55 +
  56 +.filters { display: flex; gap: 10px; margin-bottom: 16px; }
  57 +.filters input, .filters select { padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 13px; }
  58 +table.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
  59 +table.data-table th { text-align: left; padding: 8px 10px; border-bottom: 2px solid #e5e7eb; color: #6b7280; font-weight: 600; }
  60 +table.data-table td { padding: 10px; border-bottom: 1px solid #e5e7eb; }
  61 +table.data-table tr[data-task-id] { cursor: pointer; }
  62 +table.data-table tr[data-task-id]:hover td { background: #f9fafb; }
  63 +.badge { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; color: #fff; background: #6b7280; }
  64 +.badge.done { background: var(--status-done); }
  65 +.badge.coding, .badge.validating { background: var(--status-running); }
  66 +.badge.blocked_pending_human, .badge.coding_failed, .badge.planning_failed { background: var(--status-blocked); }
  67 +.badge.queued { background: var(--status-pending); }
  68 +
  69 +.breadcrumb { font-size: 13px; color: #6b7280; margin-bottom: 6px; }
  70 +.breadcrumb a { color: #6b7280; }
  71 +.alert-banner { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin: 14px 0; display: flex; justify-content: space-between; align-items: center; }
  72 +.btn-danger { background: #dc2626; border-color: #dc2626; color: #fff; }
  73 +.meta-row { display: flex; gap: 28px; font-size: 13px; margin-bottom: 6px; }
  74 +.meta-row b { display: block; font-size: 11px; color: #6b7280; font-weight: 600; }
  75 +.see-more { font-size: 12px; color: #6b7280; margin-top: 8px; }
  76 +
  77 +.todo-tree { list-style: none; margin: 0 0 4px; padding: 0; font-size: 13px; }
  78 +.todo-tree li { position: relative; padding: 6px 0 6px 22px; border-left: 1px dashed #e5e7eb; margin-left: 8px; }
  79 +.todo-tree li:last-child { border-left-color: transparent; }
  80 +.todo-tree .todo-row { display: flex; align-items: center; gap: 8px; cursor: pointer; }
  81 +.todo-tree .dot { width: 9px; height: 9px; border-radius: 50%; border: 2px solid var(--status-pending); flex: 0 0 auto; }
  82 +.todo-tree li.status-done .dot { border-color: var(--status-done); background: var(--status-done); }
  83 +.todo-tree li.status-running .dot { border-color: var(--status-running); background: var(--status-running); }
  84 +.todo-tree li.status-reflecting .dot, .todo-tree li.status-retrying .dot { border-color: var(--status-reflecting); background: var(--status-reflecting); }
  85 +.todo-tree li.status-waiting_for_quota .dot { border-color: var(--status-waiting-quota); background: var(--status-waiting-quota); }
  86 +.todo-tree li.status-blocked .dot, .todo-tree li.status-failed .dot { border-color: var(--status-blocked); background: var(--status-blocked); }
  87 +.todo-tree .todo-id { font-weight: 600; }
  88 +.todo-tree .todo-status { margin-left: auto; font-size: 11px; color: #6b7280; }
  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 +.todo-detail.open { display: block; }
  91 +
  92 +ul.simple-list { list-style: none; margin: 0; padding: 0; font-size: 13px; }
  93 +ul.simple-list li { padding: 8px 0; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; }
  94 +ul.simple-list li:last-child { border-bottom: none; }
src/backend_server.py
@@ -22,12 +22,16 @@ import json @@ -22,12 +22,16 @@ import json
22 import os 22 import os
23 import platform 23 import platform
24 import socket 24 import socket
  25 +import sqlite3
25 import subprocess 26 import subprocess
26 import threading 27 import threading
27 import urllib.parse 28 import urllib.parse
28 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 29 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
29 30
30 -from src import bug_record, paths, project_scaffold, schema, task_create, ws_subscribe 31 +from src import (
  32 + budget_guardrail, bug_record, org_loop_route, paths, project_scaffold,
  33 + schema, task_create, task_query, ws_subscribe,
  34 +)
31 35
32 _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend") 36 _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend")
33 37
@@ -110,12 +114,26 @@ def _pick_folder_linux(): @@ -110,12 +114,26 @@ def _pick_folder_linux():
110 _WS_PROJECTS_PREFIX = "/api/ws/projects/" 114 _WS_PROJECTS_PREFIX = "/api/ws/projects/"
111 115
112 116
  117 +_TASKS_PREFIX = "/api/tasks/"
  118 +
  119 +
113 class _Handler(BaseHTTPRequestHandler): 120 class _Handler(BaseHTTPRequestHandler):
114 def do_GET(self): 121 def do_GET(self):
115 parsed = urllib.parse.urlsplit(self.path) 122 parsed = urllib.parse.urlsplit(self.path)
116 if parsed.path.startswith(_WS_PROJECTS_PREFIX): 123 if parsed.path.startswith(_WS_PROJECTS_PREFIX):
117 self._handle_ws_subscribe(parsed.path, parsed.query) 124 self._handle_ws_subscribe(parsed.path, parsed.query)
118 return 125 return
  126 + if parsed.path == "/api/tasks":
  127 + self._handle_task_list(parsed.query)
  128 + return
  129 + if parsed.path.startswith(_TASKS_PREFIX):
  130 + task_id = parsed.path[len(_TASKS_PREFIX):]
  131 + if task_id and "/" not in task_id:
  132 + self._handle_task_get(task_id)
  133 + return
  134 + if parsed.path == "/api/settings/budget":
  135 + self._handle_budget_list()
  136 + return
119 137
120 entry = _STATIC_FILES.get(self.path) 138 entry = _STATIC_FILES.get(self.path)
121 if entry is None: 139 if entry is None:
@@ -139,6 +157,9 @@ class _Handler(BaseHTTPRequestHandler): @@ -139,6 +157,9 @@ class _Handler(BaseHTTPRequestHandler):
139 self._handle_task_create() 157 self._handle_task_create()
140 elif self.path == "/api/bug-reports": 158 elif self.path == "/api/bug-reports":
141 self._handle_bug_report_create() 159 self._handle_bug_report_create()
  160 + elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/route"):
  161 + task_id = self.path[len(_TASKS_PREFIX):-len("/route")]
  162 + self._handle_task_route(task_id)
142 else: 163 else:
143 self._send_json(404, {"error": "NOT_FOUND"}) 164 self._send_json(404, {"error": "NOT_FOUND"})
144 165
@@ -221,6 +242,53 @@ class _Handler(BaseHTTPRequestHandler): @@ -221,6 +242,53 @@ class _Handler(BaseHTTPRequestHandler):
221 else: 242 else:
222 self._send_json(200, result) 243 self._send_json(200, result)
223 244
  245 + def _handle_task_list(self, query_string):
  246 + params = urllib.parse.parse_qs(query_string)
  247 + project_id = params.get("project_id", [None])[0]
  248 + if not project_id:
  249 + self._send_json(400, {"error": "INVALID_INPUT", "detail": "project_id is required"})
  250 + return
  251 + with self.server.conn_lock:
  252 + result = task_query.list_tasks(
  253 + self.server.conn, project_id,
  254 + status=params.get("status", [None])[0],
  255 + org_node=params.get("org_node", [None])[0],
  256 + keyword=params.get("keyword", [None])[0],
  257 + )
  258 + self._send_json(200, result)
  259 +
  260 + def _handle_task_get(self, task_id):
  261 + try:
  262 + with self.server.conn_lock:
  263 + result = task_query.get_task(self.server.conn, task_id)
  264 + except task_query.TaskNotFoundError:
  265 + self._send_json(404, {"error": "NOT_FOUND"})
  266 + else:
  267 + self._send_json(200, result)
  268 +
  269 + def _handle_task_route(self, task_id):
  270 + """c_org_loop_route (03-C §3.11) -- the underlying route_org_loop()
  271 + was built in U-016 with no HTTP route until now; v_task_detail's
  272 + click_retry (manual_resume) is what actually needs it wired up."""
  273 + payload, error = self._read_json_body()
  274 + if error:
  275 + self._send_json(400, error)
  276 + return
  277 + try:
  278 + with self.server.conn_lock:
  279 + result = org_loop_route.route_org_loop(self.server.conn, task_id, payload.get("decided_route"))
  280 + except org_loop_route.LoopLimitReachedError as e:
  281 + self._send_json(409, {"error": "LOOP_LIMIT_REACHED", "detail": str(e)})
  282 + except (org_loop_route.TaskNotBlockedError, org_loop_route.TaskNotValidatingError, ValueError) as e:
  283 + self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)})
  284 + else:
  285 + self._send_json(200, result)
  286 +
  287 + def _handle_budget_list(self):
  288 + with self.server.conn_lock:
  289 + result = budget_guardrail.list_budget_configs(self.server.conn)
  290 + self._send_json(200, result)
  291 +
224 def _handle_ws_subscribe(self, path, query_string): 292 def _handle_ws_subscribe(self, path, query_string):
225 """c_ws_subscribe (03-C §3.14): WS /api/ws/projects/:project_id. 293 """c_ws_subscribe (03-C §3.14): WS /api/ws/projects/:project_id.
226 Rejects with a normal HTTP response (no 101) before ever touching 294 Rejects with a normal HTTP response (no 101) before ever touching
@@ -279,6 +347,8 @@ class _Handler(BaseHTTPRequestHandler): @@ -279,6 +347,8 @@ class _Handler(BaseHTTPRequestHandler):
279 self.connection.sendall(ws_subscribe.encode_text_frame(json.dumps(event))) 347 self.connection.sendall(ws_subscribe.encode_text_frame(json.dumps(event)))
280 except (ConnectionError, OSError): 348 except (ConnectionError, OSError):
281 pass # client disconnected -- nothing more to send 349 pass # client disconnected -- nothing more to send
  350 + except sqlite3.ProgrammingError:
  351 + pass # server shutting down closed the shared conn out from under this poll loop
282 352
283 def _send_json(self, status, obj): 353 def _send_json(self, status, obj):
284 body = json.dumps(obj).encode("utf-8") 354 body = json.dumps(obj).encode("utf-8")
src/budget_guardrail.py
@@ -26,6 +26,25 @@ def _write_status_event(conn, task_id, project_id, from_status, to_status): @@ -26,6 +26,25 @@ def _write_status_event(conn, task_id, project_id, from_status, to_status):
26 ) 26 )
27 27
28 28
  29 +def list_budget_configs(conn):
  30 + """Read-only snapshot for v_dashboard's budget bar (02-V §2.2: "预算用量
  31 + 条:引用 budget_guardrail_config.current_usage/limit_value") -- 03-C only
  32 + documents a PUT for this table (§3.13, U-030's settings-page scope); a
  33 + read path is needed for the dashboard and didn't exist anywhere, so it's
  34 + added here rather than invented ad hoc in the HTTP layer."""
  35 + rows = conn.execute(
  36 + "SELECT config_id, scope, limit_type, limit_value, current_usage, updated_at "
  37 + "FROM budget_guardrail_config ORDER BY config_id"
  38 + ).fetchall()
  39 + return [
  40 + {
  41 + "config_id": r[0], "scope": r[1], "limit_type": r[2],
  42 + "limit_value": r[3], "current_usage": r[4], "updated_at": r[5],
  43 + }
  44 + for r in rows
  45 + ]
  46 +
  47 +
29 def check_budget(conn, task_id, config_id, estimated_usage): 48 def check_budget(conn, task_id, config_id, estimated_usage):
30 """Checked before/during c_work_graph_execute (03-C §3.12). On success, 49 """Checked before/during c_work_graph_execute (03-C §3.12). On success,
31 accumulates current_usage (04-P monitoring->monitoring: "累加 50 accumulates current_usage (04-P monitoring->monitoring: "累加
src/task_query.py 0 → 100644
  1 +"""c_task_list / c_task_get (03-C §3.2): read-only task queries for the
  2 +console (v_task_board, v_task_detail). Unowned by any Unit until now --
  3 +built here because U-025 is the first Unit that actually needs them.
  4 +
  5 +c_task_get's output goes beyond 03-C's literal "task + artifact 列表 + bug_record
  6 +列表" wording: v_task_detail (02-V §2.6) also needs the task's work_graph_run(s)
  7 +(for the to-do tree built from graph_manifest, 01-M 1.3's "补充说明") and each
  8 +run's work_node_execution rows (status per to-do node, expandable
  9 +input/output/reflection_notes) -- 03-C's own wording undersells what the view
  10 +it feeds actually requires, so this returns the fuller shape rather than
  11 +forcing the frontend to make N more round trips.
  12 +"""
  13 +
  14 +import json
  15 +
  16 +
  17 +def list_tasks(conn, project_id, status=None, org_node=None, keyword=None):
  18 + query = (
  19 + "SELECT task_id, goal_text, task_type, status, assigned_org_node, loop_round, "
  20 + "created_at, updated_at FROM task WHERE project_id = ?"
  21 + )
  22 + params = [project_id]
  23 + if status:
  24 + query += " AND status = ?"
  25 + params.append(status)
  26 + if org_node:
  27 + query += " AND assigned_org_node = ?"
  28 + params.append(org_node)
  29 + if keyword:
  30 + query += " AND goal_text LIKE ?"
  31 + params.append("%" + keyword + "%")
  32 + query += " ORDER BY updated_at DESC"
  33 + rows = conn.execute(query, params).fetchall()
  34 + return [
  35 + {
  36 + "task_id": r[0], "goal_text": r[1], "task_type": r[2], "status": r[3],
  37 + "assigned_org_node": r[4], "loop_round": r[5], "created_at": r[6], "updated_at": r[7],
  38 + }
  39 + for r in rows
  40 + ]
  41 +
  42 +
  43 +class TaskNotFoundError(Exception):
  44 + """NOT_FOUND -- 03-C §3.2: c_task_get on a nonexistent task_id."""
  45 +
  46 +
  47 +def get_task(conn, task_id):
  48 + row = conn.execute(
  49 + "SELECT task_id, project_id, goal_text, task_type, origin_ref, created_via, status, route, "
  50 + "loop_round, blocked_reason, assigned_org_node, created_at, updated_at FROM task WHERE task_id = ?",
  51 + (task_id,),
  52 + ).fetchone()
  53 + if row is None:
  54 + raise TaskNotFoundError(task_id)
  55 +
  56 + task = {
  57 + "task_id": row[0], "project_id": row[1], "goal_text": row[2], "task_type": row[3],
  58 + "origin_ref": row[4], "created_via": row[5], "status": row[6], "route": row[7],
  59 + "loop_round": row[8], "blocked_reason": row[9], "assigned_org_node": row[10],
  60 + "created_at": row[11], "updated_at": row[12],
  61 + }
  62 +
  63 + artifacts = conn.execute(
  64 + "SELECT artifact_id, artifact_type, produced_by_run_id, file_path, git_commit_sha, "
  65 + "validation_status, created_at FROM artifact WHERE task_id = ? ORDER BY created_at",
  66 + (task_id,),
  67 + ).fetchall()
  68 + task["artifacts"] = [
  69 + {
  70 + "artifact_id": a[0], "artifact_type": a[1], "produced_by_run_id": a[2], "file_path": a[3],
  71 + "git_commit_sha": a[4], "validation_status": a[5], "created_at": a[6],
  72 + }
  73 + for a in artifacts
  74 + ]
  75 +
  76 + bugs = conn.execute(
  77 + "SELECT bug_id, source, description, root_cause_category, suggested_route, status, repro_steps "
  78 + "FROM bug_record WHERE task_id = ?",
  79 + (task_id,),
  80 + ).fetchall()
  81 + task["bug_records"] = [
  82 + {
  83 + "bug_id": b[0], "source": b[1], "description": b[2], "root_cause_category": b[3],
  84 + "suggested_route": b[4], "status": b[5], "repro_steps": b[6],
  85 + }
  86 + for b in bugs
  87 + ]
  88 +
  89 + runs = conn.execute(
  90 + "SELECT run_id, org_node, graph_manifest, status, started_at, completed_at "
  91 + "FROM work_graph_run WHERE task_id = ? ORDER BY started_at",
  92 + (task_id,),
  93 + ).fetchall()
  94 + task["work_graph_runs"] = [
  95 + {
  96 + "run_id": w[0], "org_node": w[1],
  97 + "graph_manifest": json.loads(w[2]) if w[2] else None,
  98 + "status": w[3], "started_at": w[4], "completed_at": w[5],
  99 + "nodes": _work_nodes_for_run(conn, w[0]),
  100 + }
  101 + for w in runs
  102 + ]
  103 + return task
  104 +
  105 +
  106 +def _work_nodes_for_run(conn, run_id):
  107 + rows = conn.execute(
  108 + "SELECT node_exec_id, node_id, subagent_type, status, retry_count, input_payload, "
  109 + "output_payload, reflection_notes, git_worktree_ref "
  110 + "FROM work_node_execution WHERE run_id = ? ORDER BY node_exec_id",
  111 + (run_id,),
  112 + ).fetchall()
  113 + return [
  114 + {
  115 + "node_exec_id": n[0], "node_id": n[1], "subagent_type": n[2], "status": n[3],
  116 + "retry_count": n[4],
  117 + "input_payload": json.loads(n[5]) if n[5] else None,
  118 + "output_payload": json.loads(n[6]) if n[6] else None,
  119 + "reflection_notes": n[7], "git_worktree_ref": n[8],
  120 + }
  121 + for n in rows
  122 + ]
src/test_backend_server.py
@@ -392,6 +392,125 @@ class TaskAndBugReportRouteTest(unittest.TestCase): @@ -392,6 +392,125 @@ class TaskAndBugReportRouteTest(unittest.TestCase):
392 self.assertEqual(body["error"], "INVALID_PARAM") 392 self.assertEqual(body["error"], "INVALID_PARAM")
393 393
394 394
  395 +class TaskQueryAndRouteRouteTest(unittest.TestCase):
  396 + """U-025: c_task_list/c_task_get (03-C §3.2), c_org_loop_route (03-C
  397 + §3.11, first HTTP wiring since U-016 built the underlying function),
  398 + and the budget-config read route for v_dashboard's budget bar."""
  399 +
  400 + def setUp(self):
  401 + self.server = backend_server.create_server(db_path=":memory:")
  402 + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  403 + self.thread.start()
  404 + self.server.conn.execute(
  405 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  406 + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
  407 + )
  408 + self.server.conn.commit()
  409 +
  410 + def tearDown(self):
  411 + self.server.shutdown()
  412 + self.server.server_close()
  413 + self.thread.join(timeout=5)
  414 +
  415 + def _get(self, path):
  416 + host, port = self.server.server_address
  417 + conn = http.client.HTTPConnection(host, port, timeout=5)
  418 + conn.request("GET", path)
  419 + resp = conn.getresponse()
  420 + body = json.loads(resp.read())
  421 + conn.close()
  422 + return resp.status, body
  423 +
  424 + def _post(self, path, payload):
  425 + host, port = self.server.server_address
  426 + conn = http.client.HTTPConnection(host, port, timeout=5)
  427 + conn.request("POST", path, body=json.dumps(payload).encode("utf-8"))
  428 + resp = conn.getresponse()
  429 + body = json.loads(resp.read())
  430 + conn.close()
  431 + return resp.status, body
  432 +
  433 + def test_task_list_requires_project_id(self):
  434 + status, body = self._get("/api/tasks")
  435 + self.assertEqual(status, 400)
  436 + self.assertEqual(body["error"], "INVALID_INPUT")
  437 +
  438 + def test_task_list_returns_created_tasks(self):
  439 + self._post("/api/tasks", {"project_id": "proj-1", "goal_text": "x"})
  440 + status, body = self._get("/api/tasks?project_id=proj-1")
  441 + self.assertEqual(status, 200)
  442 + self.assertEqual(len(body), 1)
  443 +
  444 + def test_task_list_status_filter(self):
  445 + self._post("/api/tasks", {"project_id": "proj-1", "goal_text": "x"})
  446 + status, body = self._get("/api/tasks?project_id=proj-1&status=done")
  447 + self.assertEqual(status, 200)
  448 + self.assertEqual(body, [])
  449 +
  450 + def test_task_get_returns_full_detail(self):
  451 + _, created = self._post("/api/tasks", {"project_id": "proj-1", "goal_text": "x"})
  452 + status, body = self._get("/api/tasks/" + created["task_id"])
  453 + self.assertEqual(status, 200)
  454 + self.assertEqual(body["task_id"], created["task_id"])
  455 + self.assertIn("artifacts", body)
  456 + self.assertIn("bug_records", body)
  457 + self.assertIn("work_graph_runs", body)
  458 +
  459 + def test_task_get_nonexistent_returns_404(self):
  460 + status, body = self._get("/api/tasks/no-such-task")
  461 + self.assertEqual(status, 404)
  462 + self.assertEqual(body["error"], "NOT_FOUND")
  463 +
  464 + def test_route_manual_resume_on_blocked_task(self):
  465 + _, created = self._post("/api/tasks", {"project_id": "proj-1", "goal_text": "x"})
  466 + task_id = created["task_id"]
  467 + with self.server.conn_lock:
  468 + self.server.conn.execute(
  469 + "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'loop_limit_reached' "
  470 + "WHERE task_id = ?",
  471 + (task_id,),
  472 + )
  473 + # _manual_resume derives the pre-blocked status to restore from
  474 + # execution_event history (org_loop_route.py) -- a real block
  475 + # always writes this event on the way in, so the test does too.
  476 + self.server.conn.execute(
  477 + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
  478 + "VALUES ('evt-block', 'proj-1', ?, 'task_status_changed', "
  479 + "'{\"from_status\": \"queued\", \"to_status\": \"blocked_pending_human\"}')",
  480 + (task_id,),
  481 + )
  482 + self.server.conn.commit()
  483 + status, body = self._post("/api/tasks/" + task_id + "/route", {"decided_route": "manual_resume"})
  484 + self.assertEqual(status, 200)
  485 + self.assertEqual(body["task_id"], task_id)
  486 +
  487 + def test_route_invalid_decided_route_returns_400(self):
  488 + _, created = self._post("/api/tasks", {"project_id": "proj-1", "goal_text": "x"})
  489 + status, body = self._post(
  490 + "/api/tasks/" + created["task_id"] + "/route", {"decided_route": "not_a_real_route"}
  491 + )
  492 + self.assertEqual(status, 400)
  493 + self.assertEqual(body["error"], "INVALID_PARAM")
  494 +
  495 + def test_budget_settings_empty_when_unconfigured(self):
  496 + status, body = self._get("/api/settings/budget")
  497 + self.assertEqual(status, 200)
  498 + self.assertEqual(body, [])
  499 +
  500 + def test_budget_settings_returns_configured_row(self):
  501 + with self.server.conn_lock:
  502 + self.server.conn.execute(
  503 + "INSERT INTO budget_guardrail_config (config_id, scope, limit_type, limit_value, "
  504 + "current_usage, updated_by) VALUES ('default', 'global', 'token_count', 1000, 10, 'test')"
  505 + )
  506 + self.server.conn.commit()
  507 + status, body = self._get("/api/settings/budget")
  508 + self.assertEqual(status, 200)
  509 + self.assertEqual(len(body), 1)
  510 + self.assertEqual(body[0]["limit_value"], 1000)
  511 + self.assertEqual(body[0]["current_usage"], 10)
  512 +
  513 +
395 def _ws_handshake_request(path, key): 514 def _ws_handshake_request(path, key):
396 return ( 515 return (
397 "GET {} HTTP/1.1\r\n" 516 "GET {} HTTP/1.1\r\n"
src/test_budget_guardrail.py
@@ -31,6 +31,24 @@ class BudgetGuardrailTestBase(unittest.TestCase): @@ -31,6 +31,24 @@ class BudgetGuardrailTestBase(unittest.TestCase):
31 self.conn.close() 31 self.conn.close()
32 32
33 33
  34 +class ListBudgetConfigsTest(BudgetGuardrailTestBase):
  35 + def test_returns_the_configured_row(self):
  36 + result = budget_guardrail.list_budget_configs(self.conn)
  37 + self.assertEqual(len(result), 1)
  38 + self.assertEqual(
  39 + result[0],
  40 + {
  41 + "config_id": "default", "scope": "global", "limit_type": "token_count",
  42 + "limit_value": 100, "current_usage": 0, "updated_at": result[0]["updated_at"],
  43 + },
  44 + )
  45 +
  46 + def test_no_config_returns_empty_list(self):
  47 + self.conn.execute("DELETE FROM budget_guardrail_config")
  48 + self.conn.commit()
  49 + self.assertEqual(budget_guardrail.list_budget_configs(self.conn), [])
  50 +
  51 +
34 class CheckBudgetTest(BudgetGuardrailTestBase): 52 class CheckBudgetTest(BudgetGuardrailTestBase):
35 def test_under_limit_allowed_and_accumulates(self): 53 def test_under_limit_allowed_and_accumulates(self):
36 result = budget_guardrail.check_budget(self.conn, "task-1", "default", 30) 54 result = budget_guardrail.check_budget(self.conn, "task-1", "default", 30)
src/test_task_query.py 0 → 100644
  1 +"""U-025 tests: c_task_list / c_task_get (03-C §3.2), real sqlite (no
  2 +mocking needed).
  3 +
  4 +Run: python3 -m unittest src.test_task_query -v
  5 +"""
  6 +
  7 +import json
  8 +import unittest
  9 +
  10 +from src import schema, task_query
  11 +
  12 +
  13 +class TaskQueryTestBase(unittest.TestCase):
  14 + def setUp(self):
  15 + self.conn = schema.connect(":memory:")
  16 + schema.create_schema(self.conn)
  17 + self.conn.execute(
  18 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  19 + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
  20 + )
  21 + self.conn.commit()
  22 +
  23 + def tearDown(self):
  24 + self.conn.close()
  25 +
  26 + def _insert_task(self, task_id, status="queued", assigned_org_node="planner", goal_text="goal"):
  27 + self.conn.execute(
  28 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  29 + "VALUES (?, 'proj-1', ?, 'goal', ?, ?)",
  30 + (task_id, goal_text, status, assigned_org_node),
  31 + )
  32 + self.conn.commit()
  33 +
  34 +
  35 +class ListTasksTest(TaskQueryTestBase):
  36 + def test_lists_tasks_for_project(self):
  37 + self._insert_task("task-1")
  38 + self._insert_task("task-2")
  39 + result = task_query.list_tasks(self.conn, "proj-1")
  40 + self.assertEqual({t["task_id"] for t in result}, {"task-1", "task-2"})
  41 +
  42 + def test_other_projects_excluded(self):
  43 + self.conn.execute(
  44 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  45 + "VALUES ('proj-2', 'P2', '/tmp/proj-2', 1, 'active')"
  46 + )
  47 + self.conn.commit()
  48 + self._insert_task("task-1")
  49 + self.conn.execute(
  50 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  51 + "VALUES ('task-2', 'proj-2', 'g', 'goal', 'queued', 'planner')"
  52 + )
  53 + self.conn.commit()
  54 + result = task_query.list_tasks(self.conn, "proj-1")
  55 + self.assertEqual([t["task_id"] for t in result], ["task-1"])
  56 +
  57 + def test_status_filter(self):
  58 + self._insert_task("task-1", status="queued")
  59 + self._insert_task("task-2", status="done")
  60 + result = task_query.list_tasks(self.conn, "proj-1", status="done")
  61 + self.assertEqual([t["task_id"] for t in result], ["task-2"])
  62 +
  63 + def test_org_node_filter(self):
  64 + self._insert_task("task-1", assigned_org_node="coder")
  65 + self._insert_task("task-2", assigned_org_node="validator")
  66 + result = task_query.list_tasks(self.conn, "proj-1", org_node="coder")
  67 + self.assertEqual([t["task_id"] for t in result], ["task-1"])
  68 +
  69 + def test_keyword_filter_matches_goal_text_substring(self):
  70 + self._insert_task("task-1", goal_text="implement snake game")
  71 + self._insert_task("task-2", goal_text="fix login bug")
  72 + result = task_query.list_tasks(self.conn, "proj-1", keyword="snake")
  73 + self.assertEqual([t["task_id"] for t in result], ["task-1"])
  74 +
  75 + def test_empty_project_returns_empty_list(self):
  76 + self.assertEqual(task_query.list_tasks(self.conn, "proj-1"), [])
  77 +
  78 +
  79 +class GetTaskTest(TaskQueryTestBase):
  80 + def test_nonexistent_task_raises(self):
  81 + with self.assertRaises(task_query.TaskNotFoundError):
  82 + task_query.get_task(self.conn, "no-such-task")
  83 +
  84 + def test_returns_task_fields(self):
  85 + self._insert_task("task-1", status="coding", assigned_org_node="coder")
  86 + result = task_query.get_task(self.conn, "task-1")
  87 + self.assertEqual(result["task_id"], "task-1")
  88 + self.assertEqual(result["status"], "coding")
  89 + self.assertEqual(result["assigned_org_node"], "coder")
  90 + self.assertEqual(result["artifacts"], [])
  91 + self.assertEqual(result["bug_records"], [])
  92 + self.assertEqual(result["work_graph_runs"], [])
  93 +
  94 + def test_includes_artifacts(self):
  95 + self._insert_task("task-1")
  96 + self.conn.execute(
  97 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  98 + "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')"
  99 + )
  100 + self.conn.execute(
  101 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, file_path, "
  102 + "git_commit_sha) VALUES ('art-1', 'brd', 'task-1', 'run-1', 'docs/brd.md', 'sha1')"
  103 + )
  104 + self.conn.commit()
  105 + result = task_query.get_task(self.conn, "task-1")
  106 + self.assertEqual(len(result["artifacts"]), 1)
  107 + self.assertEqual(result["artifacts"][0]["artifact_id"], "art-1")
  108 +
  109 + def test_includes_bug_records(self):
  110 + self._insert_task("task-1")
  111 + self.conn.execute(
  112 + "INSERT INTO bug_record (bug_id, task_id, source, description, status) "
  113 + "VALUES ('bug-1', 'task-1', 'human', 'x', 'open')"
  114 + )
  115 + self.conn.commit()
  116 + result = task_query.get_task(self.conn, "task-1")
  117 + self.assertEqual(len(result["bug_records"]), 1)
  118 + self.assertEqual(result["bug_records"][0]["bug_id"], "bug-1")
  119 +
  120 + def test_includes_work_graph_runs_with_parsed_graph_manifest_and_nodes(self):
  121 + self._insert_task("task-1")
  122 + manifest = {"nodes": [{"node_id": "n1", "artifact_type": "brd", "depends_on": []}], "edges": []}
  123 + self.conn.execute(
  124 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  125 + "VALUES ('run-1', 'task-1', 'planner', ?, 'running')",
  126 + (json.dumps(manifest),),
  127 + )
  128 + self.conn.execute(
  129 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  130 + "VALUES ('skill-1', 'planner', 'skills/skill-1/', 'sha', 'new', 'active')"
  131 + )
  132 + self.conn.execute(
  133 + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  134 + "input_schema_ref, output_schema_ref, input_payload, output_payload, status, retry_count) "
  135 + "VALUES ('node-1', 'run-1', 'n1', 'planner', 'skill-1', 'in.json', 'out.json', '{}', "
  136 + "'{\"x\": 1}', 'done', 0)"
  137 + )
  138 + self.conn.commit()
  139 +
  140 + result = task_query.get_task(self.conn, "task-1")
  141 + self.assertEqual(len(result["work_graph_runs"]), 1)
  142 + run = result["work_graph_runs"][0]
  143 + self.assertEqual(run["graph_manifest"], manifest)
  144 + self.assertEqual(len(run["nodes"]), 1)
  145 + self.assertEqual(run["nodes"][0]["node_id"], "n1")
  146 + self.assertEqual(run["nodes"][0]["status"], "done")
  147 + self.assertEqual(run["nodes"][0]["output_payload"], {"x": 1})
  148 +
  149 +
  150 +if __name__ == "__main__":
  151 + unittest.main()