Commit e026f9e7448a0910e101357c57e5020f39822888
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>
Showing
11 changed files
with
1041 additions
and
27 deletions
docs/development-plan.md
| ... | ... | @@ -241,6 +241,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 |
| 241 | 241 | |
| 242 | 242 | - **U-025 [Value Path]** `v_dashboard` + `v_task_board` + `v_task_detail` |
| 243 | 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 | 248 | - **U-026 [Value Path]** `v_graph_visualizer`(Org Graph + Work Graph 双层可视化,含 `waiting_for_quota` 信息态展示) |
| 246 | 249 | - 依赖:U-024 | ... | ... |
docs/sdd/03-C-控制与接口.md
| ... | ... | @@ -58,10 +58,10 @@ |
| 58 | 58 | |
| 59 | 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 | 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 | 66 | ## 3.3 `c_org_node_trigger` |
| 67 | 67 | ... | ... |
docs/sdd/unit_matrix.json
| ... | ... | @@ -40,7 +40,7 @@ |
| 40 | 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 | 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 | - {"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 | 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 | 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 列表+预览+血缘链可用"}, | ... | ... |
frontend/app.js
| 1 | 1 | // U-008: hash-based client-side router (no History API / server catch-all |
| 2 | 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 | 6 | // 02-V's console has no multi-project switcher UI (decision 2), but |
| 7 | 7 | // c_task_create/c_bug_report_create both require project_id (03-C §3.1; |
| 8 | 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 | 11 | // stashed here and read back by the pages that need it. |
| 12 | 12 | const CURRENT_PROJECT_KEY = "graphlemon_project_id"; |
| 13 | 13 | function getCurrentProjectId() { |
| ... | ... | @@ -17,20 +17,85 @@ function setCurrentProjectId(projectId) { |
| 17 | 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 | 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 | 64 | "#/settings": { render: () => "<h1>Settings</h1><p>placeholder -- real content is PH3 (U-030)</p>" }, |
| 24 | 65 | "#/setup": { render: renderProjectSetup, mount: mountProjectSetup }, |
| 25 | 66 | "#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer }, |
| 26 | 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 | 79 | function render() { |
| 80 | + if (currentRouteCleanup) { | |
| 81 | + currentRouteCleanup(); | |
| 82 | + currentRouteCleanup = null; | |
| 83 | + } | |
| 84 | + | |
| 30 | 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 | 100 | for (const a of document.querySelectorAll("#nav a")) { |
| 36 | 101 | a.classList.toggle("active", a.getAttribute("href") === hash); |
| ... | ... | @@ -166,7 +231,6 @@ function renderGoalComposer() { |
| 166 | 231 | <div class="page-sub">用自然语言描述你想让 graphlemon 完成的目标</div> |
| 167 | 232 | <div class="panel"> |
| 168 | 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 | 234 | <div class="field" id="goal_form"> |
| 171 | 235 | <label>目标描述(goal_text,必填,最长 ${GOAL_TEXT_MAX_LEN} 字)</label> |
| 172 | 236 | <textarea id="goal_text" rows="6" maxlength="${GOAL_TEXT_MAX_LEN}"></textarea> |
| ... | ... | @@ -190,7 +254,6 @@ function mountGoalComposer() { |
| 190 | 254 | if (!getCurrentProjectId()) return; |
| 191 | 255 | const textArea = document.getElementById("goal_text"); |
| 192 | 256 | const errorBox = document.getElementById("goal_error"); |
| 193 | - const successBox = document.getElementById("goal_success"); | |
| 194 | 257 | const form = document.getElementById("goal_form"); |
| 195 | 258 | const confirmBanner = document.getElementById("goal_confirm_banner"); |
| 196 | 259 | const confirmSummary = document.getElementById("goal_confirm_summary"); |
| ... | ... | @@ -231,12 +294,8 @@ function mountGoalComposer() { |
| 231 | 294 | errorBox.style.display = "block"; |
| 232 | 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 | 321 | <textarea id="bug_repro_steps" rows="3"></textarea> |
| 263 | 322 | </div> |
| 264 | 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 | 328 | </div> |
| 269 | 329 | <div class="btn-row"> |
| 270 | 330 | <button class="btn btn-primary" id="btn_submit_bug" type="button">提交反馈</button> |
| ... | ... | @@ -279,11 +339,22 @@ function mountBugFeedback() { |
| 279 | 339 | if (!getCurrentProjectId()) return; |
| 280 | 340 | const descriptionArea = document.getElementById("bug_description"); |
| 281 | 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 | 343 | const errorBox = document.getElementById("bug_error"); |
| 284 | 344 | const successBox = document.getElementById("bug_success"); |
| 285 | 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 | 358 | document.getElementById("btn_submit_bug").addEventListener("click", async () => { |
| 288 | 359 | errorBox.style.display = "none"; |
| 289 | 360 | const description = descriptionArea.value.trim(); |
| ... | ... | @@ -292,13 +363,14 @@ function mountBugFeedback() { |
| 292 | 363 | errorBox.style.display = "block"; |
| 293 | 364 | return; |
| 294 | 365 | } |
| 366 | + const relatedTaskId = relatedTaskIdSelect.value || undefined; | |
| 295 | 367 | const resp = await fetch("/api/bug-reports", { |
| 296 | 368 | method: "POST", |
| 297 | 369 | body: JSON.stringify({ |
| 298 | 370 | project_id: getCurrentProjectId(), |
| 299 | 371 | description: description, |
| 300 | 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 | 376 | const body = await resp.json(); |
| ... | ... | @@ -307,9 +379,382 @@ function mountBugFeedback() { |
| 307 | 379 | errorBox.style.display = "block"; |
| 308 | 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 | 389 | form.style.display = "none"; |
| 311 | 390 | successBox.innerHTML = "已提交,Validator 将进行归因确认(bug <code>" + body.bug_id + "</code>)。" |
| 312 | 391 | + '<a href="#/">返回仪表盘</a>'; |
| 313 | 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 | 25 | .error-banner { background: #fef2f2; border: 1px solid #ef4444; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-bottom: 16px; } |
| 26 | 26 | .success-banner { background: #f0fdf4; border: 1px solid #22c55e; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-bottom: 16px; } |
| 27 | 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 | 22 | import os |
| 23 | 23 | import platform |
| 24 | 24 | import socket |
| 25 | +import sqlite3 | |
| 25 | 26 | import subprocess |
| 26 | 27 | import threading |
| 27 | 28 | import urllib.parse |
| 28 | 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 | 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 | 114 | _WS_PROJECTS_PREFIX = "/api/ws/projects/" |
| 111 | 115 | |
| 112 | 116 | |
| 117 | +_TASKS_PREFIX = "/api/tasks/" | |
| 118 | + | |
| 119 | + | |
| 113 | 120 | class _Handler(BaseHTTPRequestHandler): |
| 114 | 121 | def do_GET(self): |
| 115 | 122 | parsed = urllib.parse.urlsplit(self.path) |
| 116 | 123 | if parsed.path.startswith(_WS_PROJECTS_PREFIX): |
| 117 | 124 | self._handle_ws_subscribe(parsed.path, parsed.query) |
| 118 | 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 | 138 | entry = _STATIC_FILES.get(self.path) |
| 121 | 139 | if entry is None: |
| ... | ... | @@ -139,6 +157,9 @@ class _Handler(BaseHTTPRequestHandler): |
| 139 | 157 | self._handle_task_create() |
| 140 | 158 | elif self.path == "/api/bug-reports": |
| 141 | 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 | 163 | else: |
| 143 | 164 | self._send_json(404, {"error": "NOT_FOUND"}) |
| 144 | 165 | |
| ... | ... | @@ -221,6 +242,53 @@ class _Handler(BaseHTTPRequestHandler): |
| 221 | 242 | else: |
| 222 | 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 | 292 | def _handle_ws_subscribe(self, path, query_string): |
| 225 | 293 | """c_ws_subscribe (03-C §3.14): WS /api/ws/projects/:project_id. |
| 226 | 294 | Rejects with a normal HTTP response (no 101) before ever touching |
| ... | ... | @@ -279,6 +347,8 @@ class _Handler(BaseHTTPRequestHandler): |
| 279 | 347 | self.connection.sendall(ws_subscribe.encode_text_frame(json.dumps(event))) |
| 280 | 348 | except (ConnectionError, OSError): |
| 281 | 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 | 353 | def _send_json(self, status, obj): |
| 284 | 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 | 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 | 48 | def check_budget(conn, task_id, config_id, estimated_usage): |
| 30 | 49 | """Checked before/during c_work_graph_execute (03-C §3.12). On success, |
| 31 | 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 | 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 | 514 | def _ws_handshake_request(path, key): |
| 396 | 515 | return ( |
| 397 | 516 | "GET {} HTTP/1.1\r\n" | ... | ... |
src/test_budget_guardrail.py
| ... | ... | @@ -31,6 +31,24 @@ class BudgetGuardrailTestBase(unittest.TestCase): |
| 31 | 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 | 52 | class CheckBudgetTest(BudgetGuardrailTestBase): |
| 35 | 53 | def test_under_limit_allowed_and_accumulates(self): |
| 36 | 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() | ... | ... |