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 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()
... ...