diff --git a/docs/development-plan.md b/docs/development-plan.md index 87885ed..e4ebd44 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -241,6 +241,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 - **U-025 [Value Path]** `v_dashboard` + `v_task_board` + `v_task_detail` - 依赖:U-024 + - Done:三个页面可正常展示实时数据(原文档缺 Done/覆盖行,2026-07-31 补上) + - **实现补充(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` 过语法,不是真人点击浏览器验证过的。 + - 覆盖:02-V `v_dashboard`/`v_task_board`/`v_task_detail`;03-C `c_task_list`/`c_task_get`/`c_org_loop_route`(HTTP 接线) - **U-026 [Value Path]** `v_graph_visualizer`(Org Graph + Work Graph 双层可视化,含 `waiting_for_quota` 信息态展示) - 依赖:U-024 diff --git a/docs/sdd/03-C-控制与接口.md b/docs/sdd/03-C-控制与接口.md index 360192d..5ddb687 100644 --- a/docs/sdd/03-C-控制与接口.md +++ b/docs/sdd/03-C-控制与接口.md @@ -58,10 +58,10 @@ ## 3.2 `c_task_list` / `c_task_get` -- `GET /api/tasks?status=&org_node=&keyword=`、`GET /api/tasks/:task_id` -- `output_contract`:`c_task_list` 返回分页数组;`c_task_get` 返回单条 `task` + 关联 `artifact` 列表 + 关联 `bug_record` 列表 +- `GET /api/tasks?project_id=&status=&org_node=&keyword=`(`project_id` 必填,2026-07-31 订正补上——原文漏写,控制台单项目 MVP 下仍需要用它按项目过滤)、`GET /api/tasks/:task_id` +- `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 没写全) - `idempotent`:是 -- `errors`:`NOT_FOUND`(不可重试,仅 `c_task_get`) +- `errors`:`NOT_FOUND`(不可重试,仅 `c_task_get`);`INVALID_INPUT`(不可重试,仅 `c_task_list`):缺少 `project_id` ## 3.3 `c_org_node_trigger` diff --git a/docs/sdd/unit_matrix.json b/docs/sdd/unit_matrix.json index ab7192d..049c4d5 100644 --- a/docs/sdd/unit_matrix.json +++ b/docs/sdd/unit_matrix.json @@ -40,7 +40,7 @@ {"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 落地后换真下拉"}, {"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 客户端验证过真实互操作性"}, {"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 为准"}, - {"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": "三个页面可正常展示实时数据"}, + {"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"}, {"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 信息态"}, {"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 全部通过"}, {"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 列表+预览+血缘链可用"}, diff --git a/frontend/app.js b/frontend/app.js index d8dba9f..10d384c 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,13 +1,13 @@ // U-008: hash-based client-side router (no History API / server catch-all // needed). U-022 adds the first real page, #/setup (v_project_setup, 02-V -// §2.1); U-023 adds #/goals/new and #/bugs/new -- everything else is still -// a PH3 placeholder. +// §2.1); U-023 adds #/goals/new and #/bugs/new; U-025 adds the real #/ +// (v_dashboard), #/tasks (v_task_board), #/tasks/:task_id (v_task_detail). // 02-V's console has no multi-project switcher UI (decision 2), but // c_task_create/c_bug_report_create both require project_id (03-C §3.1; // §3.1.1 doesn't list it, but needs it too -- see bug_record.py's -// create_bug_report docstring). Until v_dashboard (U-025) exists to carry -// "current project" state, the id picked/created in v_project_setup is +// create_bug_report docstring). Until v_dashboard carries "current project" +// state through some richer UI, the id picked/created in v_project_setup is // stashed here and read back by the pages that need it. const CURRENT_PROJECT_KEY = "graphlemon_project_id"; function getCurrentProjectId() { @@ -17,20 +17,85 @@ function setCurrentProjectId(projectId) { localStorage.setItem(CURRENT_PROJECT_KEY, projectId); } +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text == null ? "" : String(text); + return div.innerHTML; +} + +// c_ws_subscribe (03-C §3.14, U-024): opens a live event feed for a project +// (optionally scoped to one task_id) and auto-reconnects on drop -- 02-V +// §2.2's "实时连接已断开,正在重连" exception state. REQ-12's deeper +// reconnect-catch-up semantics are U-024.5's job (03-C §3.14 itself routes +// that through REST snapshots, not WS replay); this only re-opens the +// socket and reports connection status back to the caller. +function connectEventSocket(projectId, taskId, { onEvent, onStatusChange }) { + const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + let url = wsProtocol + "//" + window.location.host + "/api/ws/projects/" + encodeURIComponent(projectId); + if (taskId) url += "?task_id=" + encodeURIComponent(taskId); + + let closedByCaller = false; + let socket = null; + + function open() { + socket = new WebSocket(url); + socket.onopen = () => onStatusChange("connected"); + socket.onmessage = (event) => onEvent(JSON.parse(event.data)); + socket.onclose = () => { + if (closedByCaller) return; + onStatusChange("disconnected"); + setTimeout(open, 2000); + }; + socket.onerror = () => socket.close(); + } + open(); + + return { + close: () => { + closedByCaller = true; + if (socket) socket.close(); + }, + }; +} + const routes = { - "#/": { render: () => "

Dashboard

placeholder -- real content is PH3 (v_dashboard, U-025)

" }, - "#/tasks": { render: () => "

Tasks

placeholder -- real content is PH3 (v_task_board, U-025)

" }, + "#/": { render: renderDashboard, mount: mountDashboard }, + "#/tasks": { render: renderTaskBoard, mount: mountTaskBoard }, "#/settings": { render: () => "

Settings

placeholder -- real content is PH3 (U-030)

" }, "#/setup": { render: renderProjectSetup, mount: mountProjectSetup }, "#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer }, "#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback }, }; +// #/tasks/:task_id (v_task_detail) is the one dynamic route -- not worth a +// full path-matching router for a single pattern. +const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/; + +let currentRouteCleanup = null; +function registerRouteCleanup(fn) { + currentRouteCleanup = fn; +} + function render() { + if (currentRouteCleanup) { + currentRouteCleanup(); + currentRouteCleanup = null; + } + const hash = window.location.hash || "#/"; - const route = routes[hash] || { render: () => "

Not found

" + hash + "

" }; - document.getElementById("app").innerHTML = route.render(); - if (route.mount) route.mount(); + let route = routes[hash]; + let routeArgs = []; + if (!route) { + const match = hash.match(TASK_DETAIL_RE); + if (match) { + route = { render: renderTaskDetail, mount: mountTaskDetail }; + routeArgs = [match[1]]; + } + } + if (!route) route = { render: () => "

Not found

" + hash + "

" }; + + document.getElementById("app").innerHTML = route.render(...routeArgs); + if (route.mount) route.mount(...routeArgs); for (const a of document.querySelectorAll("#nav a")) { a.classList.toggle("active", a.getAttribute("href") === hash); @@ -166,7 +231,6 @@ function renderGoalComposer() {
用自然语言描述你想让 graphlemon 完成的目标
-
@@ -190,7 +254,6 @@ function mountGoalComposer() { if (!getCurrentProjectId()) return; const textArea = document.getElementById("goal_text"); const errorBox = document.getElementById("goal_error"); - const successBox = document.getElementById("goal_success"); const form = document.getElementById("goal_form"); const confirmBanner = document.getElementById("goal_confirm_banner"); const confirmSummary = document.getElementById("goal_confirm_summary"); @@ -231,12 +294,8 @@ function mountGoalComposer() { errorBox.style.display = "block"; return; } - confirmBanner.style.display = "none"; - // v_task_detail doesn't exist yet (U-025) -- show the created task_id - // here instead of redirecting to a route that isn't built. - successBox.innerHTML = "已创建任务 " + body.task_id + "(" + body.status + ")。" - + '返回仪表盘'; - successBox.style.display = "block"; + // 02-V §2.3: 成功跳转 v_task_detail + window.location.hash = "#/tasks/" + body.task_id; }); } @@ -262,9 +321,10 @@ function renderBugFeedback() {
- - -
任务列表选择器随 U-025 补上,目前先直接填 task_id
+ +
@@ -279,11 +339,22 @@ function mountBugFeedback() { if (!getCurrentProjectId()) return; const descriptionArea = document.getElementById("bug_description"); const reproStepsArea = document.getElementById("bug_repro_steps"); - const relatedTaskIdInput = document.getElementById("bug_related_task_id"); + const relatedTaskIdSelect = document.getElementById("bug_related_task_id"); const errorBox = document.getElementById("bug_error"); const successBox = document.getElementById("bug_success"); const form = document.getElementById("bug_form"); + fetch("/api/tasks?project_id=" + encodeURIComponent(getCurrentProjectId())) + .then((resp) => resp.json()) + .then((tasks) => { + for (const task of tasks) { + const option = document.createElement("option"); + option.value = task.task_id; + option.textContent = task.goal_text.slice(0, 40) + " (" + task.task_id.slice(0, 8) + ")"; + relatedTaskIdSelect.appendChild(option); + } + }); + document.getElementById("btn_submit_bug").addEventListener("click", async () => { errorBox.style.display = "none"; const description = descriptionArea.value.trim(); @@ -292,13 +363,14 @@ function mountBugFeedback() { errorBox.style.display = "block"; return; } + const relatedTaskId = relatedTaskIdSelect.value || undefined; const resp = await fetch("/api/bug-reports", { method: "POST", body: JSON.stringify({ project_id: getCurrentProjectId(), description: description, repro_steps: reproStepsArea.value.trim() || undefined, - related_task_id: relatedTaskIdInput.value.trim() || undefined, + related_task_id: relatedTaskId, }), }); const body = await resp.json(); @@ -307,9 +379,382 @@ function mountBugFeedback() { errorBox.style.display = "block"; return; } + // 02-V §2.4: 跳转到该 bug 的详情视图,MVP 复用 v_task_detail 的 bug 展示区 + // -- only possible when the report was tied to a task; otherwise there's + // no task detail page to land on, so show the confirmation inline. + if (relatedTaskId) { + window.location.hash = "#/tasks/" + relatedTaskId; + return; + } form.style.display = "none"; successBox.innerHTML = "已提交,Validator 将进行归因确认(bug " + body.bug_id + ")。" + '返回仪表盘'; successBox.style.display = "block"; }); } + +// ---- v_dashboard (02-V §2.2): real-time landing page ---- + +const ORG_NODE_ORDER = ["planner", "coder", "validator"]; + +function renderDashboard() { + if (!getCurrentProjectId()) return renderNoProjectSelected(); + return ` +
+

仪表盘

+ + +
+
+
+

Org Graph 三节点状态

+
+
+
+

当前任务概要

+
+
+ +
+
+
+

预算用量

+
+
+
+

最近事件

+
+
+
+
+
+ `; +} + +function mountDashboard() { + const projectId = getCurrentProjectId(); + if (!projectId) return; + + const orgStrip = document.getElementById("org_strip"); + const taskSummary = document.getElementById("task_summary"); + const budgetSummary = document.getElementById("budget_summary"); + const eventTimeline = document.getElementById("event_timeline"); + const quotaBanner = document.getElementById("quota_info_banner"); + const wsBanner = document.getElementById("ws_disconnected_banner"); + const recentEvents = []; + + function renderOrgStrip(activeOrgNode, runStatusByOrgNode) { + orgStrip.innerHTML = ORG_NODE_ORDER.map((node, i) => { + const isActive = activeOrgNode === node; + const runStatus = runStatusByOrgNode[node]; + const hint = isActive ? "当前节点" : runStatus === "done" ? "已完成" : runStatus || "未开始"; + const arrow = i < ORG_NODE_ORDER.length - 1 ? '
→
' : ""; + return '
' + + node + '
' + escapeHtml(hint) + "
" + arrow; + }).join(""); + } + + async function refreshTasksAndOrgGraph() { + const resp = await fetch("/api/tasks?project_id=" + encodeURIComponent(projectId)); + const tasks = await resp.json(); + const active = tasks.find((t) => t.status !== "done") || tasks[0]; + if (!active) { + orgStrip.innerHTML = '
暂无运行中任务
'; + taskSummary.innerHTML = '
暂无运行中任务 · 提出第一个目标 →
'; + quotaBanner.style.display = "none"; + return; + } + taskSummary.innerHTML = ( + '
Goal' + escapeHtml(active.goal_text) + "
" + + '
Loop Round' + active.loop_round + "
" + + '" + ); + + const detailResp = await fetch("/api/tasks/" + active.task_id); + const detail = await detailResp.json(); + const runStatusByOrgNode = {}; + let waitingNode = null; + for (const run of detail.work_graph_runs) { + runStatusByOrgNode[run.org_node] = run.status; + if (!waitingNode) waitingNode = run.nodes.find((n) => n.status === "waiting_for_quota"); + } + renderOrgStrip(active.assigned_org_node, runStatusByOrgNode); + + if (waitingNode) { + quotaBanner.innerHTML = "🕐 有任务正在等待 Claude Code 额度恢复 · " + + '查看详情'; + quotaBanner.style.display = "flex"; + } else { + quotaBanner.style.display = "none"; + } + } + + async function refreshBudget() { + const resp = await fetch("/api/settings/budget"); + const configs = await resp.json(); + if (configs.length === 0) { + budgetSummary.innerHTML = '
预算护栏未配置
'; + return; + } + const c = configs[0]; + const pct = c.limit_value > 0 ? Math.min(100, Math.round((c.current_usage / c.limit_value) * 100)) : 0; + budgetSummary.innerHTML = ( + '
' + c.current_usage.toLocaleString() + " / " + + c.limit_value.toLocaleString() + " " + escapeHtml(c.limit_type) + "
" + + '
' + ); + } + + function prependEvent(event) { + recentEvents.unshift(event); + recentEvents.length = Math.min(recentEvents.length, 20); + eventTimeline.innerHTML = recentEvents.map((e) => + '
' + escapeHtml(e.event_type) + '
' + + escapeHtml(e.created_at || "") + "
" + ).join("") || '
暂无事件
'; + } + + refreshTasksAndOrgGraph(); + refreshBudget(); + eventTimeline.innerHTML = '
暂无事件
'; + + const socket = connectEventSocket(projectId, null, { + onEvent: (event) => { + prependEvent(event); + refreshTasksAndOrgGraph(); + if (event.event_type === "budget_updated") refreshBudget(); + }, + onStatusChange: (status) => { + wsBanner.style.display = status === "disconnected" ? "block" : "none"; + }, + }); + registerRouteCleanup(() => socket.close()); +} + +// ---- v_task_board (02-V §2.5): task list with filters ---- + +const TASK_STATUSES = [ + "queued", "planning_brd_prd_sdd", "planning_failed", "coding", "coding_failed", + "validating", "routed_fast_lane", "routed_full_replan", "blocked_pending_human", "done", +]; + +function renderTaskBoard() { + if (!getCurrentProjectId()) return renderNoProjectSelected(); + return ` +
+

任务看板

+
+ + + +
+ + + + + +
task_idgoal_textstatusassigned_org_nodeloop_roundupdated_at
+
+ `; +} + +function mountTaskBoard() { + const projectId = getCurrentProjectId(); + if (!projectId) return; + const rowsEl = document.getElementById("tb_rows"); + const keywordEl = document.getElementById("tb_keyword"); + const statusEl = document.getElementById("tb_status"); + const orgNodeEl = document.getElementById("tb_org_node"); + + async function refresh() { + const params = new URLSearchParams({ project_id: projectId }); + if (keywordEl.value.trim()) params.set("keyword", keywordEl.value.trim()); + if (statusEl.value) params.set("status", statusEl.value); + if (orgNodeEl.value) params.set("org_node", orgNodeEl.value); + const resp = await fetch("/api/tasks?" + params.toString()); + const tasks = await resp.json(); + if (tasks.length === 0) { + rowsEl.innerHTML = '暂无任务'; + return; + } + rowsEl.innerHTML = tasks.map((t) => ( + '' + + "" + t.task_id + "" + + "" + escapeHtml(t.goal_text) + "" + + '' + t.status + "" + + "" + t.assigned_org_node + "" + + "" + t.loop_round + "" + + "" + escapeHtml(t.updated_at) + "" + + "" + )).join(""); + for (const row of rowsEl.querySelectorAll("tr[data-task-id]")) { + row.addEventListener("click", () => { + window.location.hash = "#/tasks/" + row.dataset.taskId; + }); + } + } + + keywordEl.addEventListener("input", refresh); + statusEl.addEventListener("change", refresh); + orgNodeEl.addEventListener("change", refresh); + refresh(); + + const socket = connectEventSocket(projectId, null, { + onEvent: (event) => { + if (event.event_type === "task_status_changed") refresh(); + }, + onStatusChange: () => {}, + }); + registerRouteCleanup(() => socket.close()); +} + +// ---- v_task_detail (02-V §2.6): task detail / process inspection ---- +// Embeds a to-do tree built from graph_manifest (01-M 1.3's "补充说明") -- +// the DAG-layout v_graph_visualizer and the raw-log v_log_viewer are each +// separate Units (U-026/U-027) and show as clearly-labeled placeholders +// here until they land, rather than faking their content. + +function renderTaskDetail(taskId) { + if (!getCurrentProjectId()) return renderNoProjectSelected(); + return ` +
+ +
加载中…
+
+ `; +} + +function mountTaskDetail(taskId) { + const projectId = getCurrentProjectId(); + if (!projectId) return; + const content = document.getElementById("td_content"); + + function renderTodoTree(run) { + if (!run.graph_manifest) return ""; + const nodesById = {}; + for (const n of run.nodes) nodesById[n.node_id] = n; + const items = run.graph_manifest.nodes.map((gn) => { + const wn = nodesById[gn.node_id]; + const status = wn ? wn.status : "pending"; + const detail = wn ? ( + '
' + + "retry_count: " + wn.retry_count + + (wn.reflection_notes ? "
reflection_notes: " + escapeHtml(wn.reflection_notes) : "") + + '
原始日志查看器(v_log_viewer)随 U-027 补上
' + + "
" + ) : ""; + return ( + '
  • ' + + '
    ' + + '' + + 'to-do: ' + escapeHtml(gn.node_id) + "" + + '' + status + (wn ? " · node_exec_id=" + wn.node_exec_id : "") + "" + + "
    " + detail + "
  • " + ); + }).join(""); + return ( + '

    ' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")

    " + + '
      ' + items + "
    " + + '
    按执行层级排布的 DAG 视图(v_graph_visualizer)随 U-026 补上
    ' + + "
    " + ); + } + + function renderDetail(task) { + const activeSet = new Set([task.assigned_org_node]); + for (const run of task.work_graph_runs) { + if (run.status !== "done" && run.status !== "failed") activeSet.add(run.org_node); + } + const orgStripHtml = ORG_NODE_ORDER.map((node, i) => { + const arrow = i < ORG_NODE_ORDER.length - 1 ? '
    →
    ' : ""; + return '
    ' + node + "
    " + arrow; + }).join(""); + + const alertHtml = task.status !== "blocked_pending_human" ? "" : ( + '
    ⚠ 已阻塞(' + escapeHtml(task.blocked_reason || "") + '),需要人工干预' + + '
    ' + ); + + const todoTreesHtml = task.work_graph_runs.map(renderTodoTree).join(""); + + const artifactsHtml = task.artifacts.length ? ( + '" + ) : '
    暂无 Artifact
    '; + + const bugsHtml = task.bug_records.length ? ( + '" + ) : '
    暂无 Bug 记录
    '; + + content.innerHTML = ( + "

    " + escapeHtml(task.goal_text) + "

    " + + alertHtml + + '
    ' + + "
    STATUS" + escapeHtml(task.status) + "
    " + + "
    LOOP_ROUND" + task.loop_round + "
    " + + "
    ASSIGNED_ORG_NODE" + task.assigned_org_node + "
    " + + "
    CREATED_VIA" + escapeHtml(task.created_via) + "
    " + + "
    " + + '

    Org Graph

    ' + orgStripHtml + "
    " + + todoTreesHtml + + '

    关联 Artifact

    ' + artifactsHtml + "
    " + + '

    关联 Bug 记录

    ' + bugsHtml + "
    " + + '

    原始执行日志

    v_log_viewer 随 U-027 补上
    ' + ); + + for (const row of content.querySelectorAll(".todo-row[data-node-exec-id]")) { + row.addEventListener("click", () => { + const nodeExecId = row.dataset.nodeExecId; + if (!nodeExecId) return; + const detail = document.getElementById("todo-detail-" + nodeExecId); + if (detail) detail.classList.toggle("open"); + }); + } + + const resumeBtn = document.getElementById("btn_manual_resume"); + if (resumeBtn) { + resumeBtn.addEventListener("click", async () => { + resumeBtn.disabled = true; + await fetch("/api/tasks/" + taskId + "/route", { + method: "POST", + body: JSON.stringify({ decided_route: "manual_resume" }), + }); + refresh(); + }); + } + } + + async function refresh() { + const resp = await fetch("/api/tasks/" + taskId); + if (resp.status === 404) { + content.innerHTML = '
    任务不存在
    '; + return; + } + renderDetail(await resp.json()); + } + + refresh(); + + const socket = connectEventSocket(projectId, taskId, { + onEvent: () => refresh(), + onStatusChange: () => {}, + }); + registerRouteCleanup(() => socket.close()); +} diff --git a/frontend/style.css b/frontend/style.css index 212d665..1b614fd 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -25,3 +25,70 @@ body { font-family: system-ui, sans-serif; margin: 0; } .error-banner { background: #fef2f2; border: 1px solid #ef4444; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-bottom: 16px; } .success-banner { background: #f0fdf4; border: 1px solid #22c55e; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-bottom: 16px; } .success-banner a { margin-left: 8px; color: #4f46e5; } + +/* U-025: v_dashboard/v_task_board/v_task_detail -- class names match + docs/wireframes/v_dashboard.html, v_task_board.html, v_task_detail.html */ +:root { + --status-pending: #9ca3af; --status-running: #3b82f6; --status-reflecting: #eab308; + --status-waiting-quota: #7dd3fc; --status-blocked: #ef4444; --status-done: #22c55e; +} +.page.page-wide { max-width: 1200px; } +.hint { font-size: 12px; color: #6b7280; } +.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; } +.info-banner a { color: #4f46e5; } +.grid { display: grid; grid-template-columns: 2fr 1fr; gap: 20px; } +.grid .panel { margin-bottom: 20px; } +.panel h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: #6b7280; margin: 0 0 14px; } +.org-strip { display: flex; align-items: center; gap: 0; margin-bottom: 6px; } +.org-node { border: 2px solid #e5e7eb; border-radius: 8px; padding: 10px 18px; text-align: center; background: #fff; flex: 1; font-size: 12px; } +.org-node.active { border-color: var(--status-running); background: #eff6ff; } +.org-node .role { font-weight: 600; font-size: 13px; } +.org-node .hint { margin-top: 2px; } +.org-arrow { flex: 0 0 30px; text-align: center; color: #6b7280; } +.task-summary .row { display: flex; justify-content: space-between; font-size: 13px; padding: 6px 0; border-bottom: 1px solid #e5e7eb; } +.task-summary .row:last-child { border-bottom: none; } +.progress { height: 8px; border-radius: 4px; background: #e5e7eb; overflow: hidden; margin-top: 6px; } +.progress .fill { height: 100%; background: #4f46e5; } +.event-item { font-size: 12px; padding: 8px 0; border-bottom: 1px solid #e5e7eb; } +.event-item .t { color: #6b7280; } +.actions { display: flex; gap: 10px; margin-bottom: 20px; } + +.filters { display: flex; gap: 10px; margin-bottom: 16px; } +.filters input, .filters select { padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 13px; } +table.data-table { width: 100%; border-collapse: collapse; font-size: 13px; } +table.data-table th { text-align: left; padding: 8px 10px; border-bottom: 2px solid #e5e7eb; color: #6b7280; font-weight: 600; } +table.data-table td { padding: 10px; border-bottom: 1px solid #e5e7eb; } +table.data-table tr[data-task-id] { cursor: pointer; } +table.data-table tr[data-task-id]:hover td { background: #f9fafb; } +.badge { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; color: #fff; background: #6b7280; } +.badge.done { background: var(--status-done); } +.badge.coding, .badge.validating { background: var(--status-running); } +.badge.blocked_pending_human, .badge.coding_failed, .badge.planning_failed { background: var(--status-blocked); } +.badge.queued { background: var(--status-pending); } + +.breadcrumb { font-size: 13px; color: #6b7280; margin-bottom: 6px; } +.breadcrumb a { color: #6b7280; } +.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; } +.btn-danger { background: #dc2626; border-color: #dc2626; color: #fff; } +.meta-row { display: flex; gap: 28px; font-size: 13px; margin-bottom: 6px; } +.meta-row b { display: block; font-size: 11px; color: #6b7280; font-weight: 600; } +.see-more { font-size: 12px; color: #6b7280; margin-top: 8px; } + +.todo-tree { list-style: none; margin: 0 0 4px; padding: 0; font-size: 13px; } +.todo-tree li { position: relative; padding: 6px 0 6px 22px; border-left: 1px dashed #e5e7eb; margin-left: 8px; } +.todo-tree li:last-child { border-left-color: transparent; } +.todo-tree .todo-row { display: flex; align-items: center; gap: 8px; cursor: pointer; } +.todo-tree .dot { width: 9px; height: 9px; border-radius: 50%; border: 2px solid var(--status-pending); flex: 0 0 auto; } +.todo-tree li.status-done .dot { border-color: var(--status-done); background: var(--status-done); } +.todo-tree li.status-running .dot { border-color: var(--status-running); background: var(--status-running); } +.todo-tree li.status-reflecting .dot, .todo-tree li.status-retrying .dot { border-color: var(--status-reflecting); background: var(--status-reflecting); } +.todo-tree li.status-waiting_for_quota .dot { border-color: var(--status-waiting-quota); background: var(--status-waiting-quota); } +.todo-tree li.status-blocked .dot, .todo-tree li.status-failed .dot { border-color: var(--status-blocked); background: var(--status-blocked); } +.todo-tree .todo-id { font-weight: 600; } +.todo-tree .todo-status { margin-left: auto; font-size: 11px; color: #6b7280; } +.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; } +.todo-detail.open { display: block; } + +ul.simple-list { list-style: none; margin: 0; padding: 0; font-size: 13px; } +ul.simple-list li { padding: 8px 0; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; } +ul.simple-list li:last-child { border-bottom: none; } diff --git a/src/backend_server.py b/src/backend_server.py index e1d0856..4abc07d 100644 --- a/src/backend_server.py +++ b/src/backend_server.py @@ -22,12 +22,16 @@ import json import os import platform import socket +import sqlite3 import subprocess import threading import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from src import bug_record, paths, project_scaffold, schema, task_create, ws_subscribe +from src import ( + budget_guardrail, bug_record, org_loop_route, paths, project_scaffold, + schema, task_create, task_query, ws_subscribe, +) _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend") @@ -110,12 +114,26 @@ def _pick_folder_linux(): _WS_PROJECTS_PREFIX = "/api/ws/projects/" +_TASKS_PREFIX = "/api/tasks/" + + class _Handler(BaseHTTPRequestHandler): def do_GET(self): parsed = urllib.parse.urlsplit(self.path) if parsed.path.startswith(_WS_PROJECTS_PREFIX): self._handle_ws_subscribe(parsed.path, parsed.query) return + if parsed.path == "/api/tasks": + self._handle_task_list(parsed.query) + return + if parsed.path.startswith(_TASKS_PREFIX): + task_id = parsed.path[len(_TASKS_PREFIX):] + if task_id and "/" not in task_id: + self._handle_task_get(task_id) + return + if parsed.path == "/api/settings/budget": + self._handle_budget_list() + return entry = _STATIC_FILES.get(self.path) if entry is None: @@ -139,6 +157,9 @@ class _Handler(BaseHTTPRequestHandler): self._handle_task_create() elif self.path == "/api/bug-reports": self._handle_bug_report_create() + elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/route"): + task_id = self.path[len(_TASKS_PREFIX):-len("/route")] + self._handle_task_route(task_id) else: self._send_json(404, {"error": "NOT_FOUND"}) @@ -221,6 +242,53 @@ class _Handler(BaseHTTPRequestHandler): else: self._send_json(200, result) + def _handle_task_list(self, query_string): + params = urllib.parse.parse_qs(query_string) + project_id = params.get("project_id", [None])[0] + if not project_id: + self._send_json(400, {"error": "INVALID_INPUT", "detail": "project_id is required"}) + return + with self.server.conn_lock: + result = task_query.list_tasks( + self.server.conn, project_id, + status=params.get("status", [None])[0], + org_node=params.get("org_node", [None])[0], + keyword=params.get("keyword", [None])[0], + ) + self._send_json(200, result) + + def _handle_task_get(self, task_id): + try: + with self.server.conn_lock: + result = task_query.get_task(self.server.conn, task_id) + except task_query.TaskNotFoundError: + self._send_json(404, {"error": "NOT_FOUND"}) + else: + self._send_json(200, result) + + def _handle_task_route(self, task_id): + """c_org_loop_route (03-C §3.11) -- the underlying route_org_loop() + was built in U-016 with no HTTP route until now; v_task_detail's + click_retry (manual_resume) is what actually needs it wired up.""" + payload, error = self._read_json_body() + if error: + self._send_json(400, error) + return + try: + with self.server.conn_lock: + result = org_loop_route.route_org_loop(self.server.conn, task_id, payload.get("decided_route")) + except org_loop_route.LoopLimitReachedError as e: + self._send_json(409, {"error": "LOOP_LIMIT_REACHED", "detail": str(e)}) + except (org_loop_route.TaskNotBlockedError, org_loop_route.TaskNotValidatingError, ValueError) as e: + self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)}) + else: + self._send_json(200, result) + + def _handle_budget_list(self): + with self.server.conn_lock: + result = budget_guardrail.list_budget_configs(self.server.conn) + self._send_json(200, result) + def _handle_ws_subscribe(self, path, query_string): """c_ws_subscribe (03-C §3.14): WS /api/ws/projects/:project_id. Rejects with a normal HTTP response (no 101) before ever touching @@ -279,6 +347,8 @@ class _Handler(BaseHTTPRequestHandler): self.connection.sendall(ws_subscribe.encode_text_frame(json.dumps(event))) except (ConnectionError, OSError): pass # client disconnected -- nothing more to send + except sqlite3.ProgrammingError: + pass # server shutting down closed the shared conn out from under this poll loop def _send_json(self, status, obj): body = json.dumps(obj).encode("utf-8") diff --git a/src/budget_guardrail.py b/src/budget_guardrail.py index eb37839..2d9acf3 100644 --- a/src/budget_guardrail.py +++ b/src/budget_guardrail.py @@ -26,6 +26,25 @@ def _write_status_event(conn, task_id, project_id, from_status, to_status): ) +def list_budget_configs(conn): + """Read-only snapshot for v_dashboard's budget bar (02-V §2.2: "预算用量 + 条:引用 budget_guardrail_config.current_usage/limit_value") -- 03-C only + documents a PUT for this table (§3.13, U-030's settings-page scope); a + read path is needed for the dashboard and didn't exist anywhere, so it's + added here rather than invented ad hoc in the HTTP layer.""" + rows = conn.execute( + "SELECT config_id, scope, limit_type, limit_value, current_usage, updated_at " + "FROM budget_guardrail_config ORDER BY config_id" + ).fetchall() + return [ + { + "config_id": r[0], "scope": r[1], "limit_type": r[2], + "limit_value": r[3], "current_usage": r[4], "updated_at": r[5], + } + for r in rows + ] + + def check_budget(conn, task_id, config_id, estimated_usage): """Checked before/during c_work_graph_execute (03-C §3.12). On success, accumulates current_usage (04-P monitoring->monitoring: "累加 diff --git a/src/task_query.py b/src/task_query.py new file mode 100644 index 0000000..132cb5d --- /dev/null +++ b/src/task_query.py @@ -0,0 +1,122 @@ +"""c_task_list / c_task_get (03-C §3.2): read-only task queries for the +console (v_task_board, v_task_detail). Unowned by any Unit until now -- +built here because U-025 is the first Unit that actually needs them. + +c_task_get's output goes beyond 03-C's literal "task + artifact 列表 + bug_record +列表" wording: v_task_detail (02-V §2.6) also needs the task's work_graph_run(s) +(for the to-do tree built from graph_manifest, 01-M 1.3's "补充说明") and each +run's work_node_execution rows (status per to-do node, expandable +input/output/reflection_notes) -- 03-C's own wording undersells what the view +it feeds actually requires, so this returns the fuller shape rather than +forcing the frontend to make N more round trips. +""" + +import json + + +def list_tasks(conn, project_id, status=None, org_node=None, keyword=None): + query = ( + "SELECT task_id, goal_text, task_type, status, assigned_org_node, loop_round, " + "created_at, updated_at FROM task WHERE project_id = ?" + ) + params = [project_id] + if status: + query += " AND status = ?" + params.append(status) + if org_node: + query += " AND assigned_org_node = ?" + params.append(org_node) + if keyword: + query += " AND goal_text LIKE ?" + params.append("%" + keyword + "%") + query += " ORDER BY updated_at DESC" + rows = conn.execute(query, params).fetchall() + return [ + { + "task_id": r[0], "goal_text": r[1], "task_type": r[2], "status": r[3], + "assigned_org_node": r[4], "loop_round": r[5], "created_at": r[6], "updated_at": r[7], + } + for r in rows + ] + + +class TaskNotFoundError(Exception): + """NOT_FOUND -- 03-C §3.2: c_task_get on a nonexistent task_id.""" + + +def get_task(conn, task_id): + row = conn.execute( + "SELECT task_id, project_id, goal_text, task_type, origin_ref, created_via, status, route, " + "loop_round, blocked_reason, assigned_org_node, created_at, updated_at FROM task WHERE task_id = ?", + (task_id,), + ).fetchone() + if row is None: + raise TaskNotFoundError(task_id) + + task = { + "task_id": row[0], "project_id": row[1], "goal_text": row[2], "task_type": row[3], + "origin_ref": row[4], "created_via": row[5], "status": row[6], "route": row[7], + "loop_round": row[8], "blocked_reason": row[9], "assigned_org_node": row[10], + "created_at": row[11], "updated_at": row[12], + } + + artifacts = conn.execute( + "SELECT artifact_id, artifact_type, produced_by_run_id, file_path, git_commit_sha, " + "validation_status, created_at FROM artifact WHERE task_id = ? ORDER BY created_at", + (task_id,), + ).fetchall() + task["artifacts"] = [ + { + "artifact_id": a[0], "artifact_type": a[1], "produced_by_run_id": a[2], "file_path": a[3], + "git_commit_sha": a[4], "validation_status": a[5], "created_at": a[6], + } + for a in artifacts + ] + + bugs = conn.execute( + "SELECT bug_id, source, description, root_cause_category, suggested_route, status, repro_steps " + "FROM bug_record WHERE task_id = ?", + (task_id,), + ).fetchall() + task["bug_records"] = [ + { + "bug_id": b[0], "source": b[1], "description": b[2], "root_cause_category": b[3], + "suggested_route": b[4], "status": b[5], "repro_steps": b[6], + } + for b in bugs + ] + + runs = conn.execute( + "SELECT run_id, org_node, graph_manifest, status, started_at, completed_at " + "FROM work_graph_run WHERE task_id = ? ORDER BY started_at", + (task_id,), + ).fetchall() + task["work_graph_runs"] = [ + { + "run_id": w[0], "org_node": w[1], + "graph_manifest": json.loads(w[2]) if w[2] else None, + "status": w[3], "started_at": w[4], "completed_at": w[5], + "nodes": _work_nodes_for_run(conn, w[0]), + } + for w in runs + ] + return task + + +def _work_nodes_for_run(conn, run_id): + rows = conn.execute( + "SELECT node_exec_id, node_id, subagent_type, status, retry_count, input_payload, " + "output_payload, reflection_notes, git_worktree_ref " + "FROM work_node_execution WHERE run_id = ? ORDER BY node_exec_id", + (run_id,), + ).fetchall() + return [ + { + "node_exec_id": n[0], "node_id": n[1], "subagent_type": n[2], "status": n[3], + "retry_count": n[4], + "input_payload": json.loads(n[5]) if n[5] else None, + "output_payload": json.loads(n[6]) if n[6] else None, + "reflection_notes": n[7], "git_worktree_ref": n[8], + } + for n in rows + ] diff --git a/src/test_backend_server.py b/src/test_backend_server.py index de653a8..1442057 100644 --- a/src/test_backend_server.py +++ b/src/test_backend_server.py @@ -392,6 +392,125 @@ class TaskAndBugReportRouteTest(unittest.TestCase): self.assertEqual(body["error"], "INVALID_PARAM") +class TaskQueryAndRouteRouteTest(unittest.TestCase): + """U-025: c_task_list/c_task_get (03-C §3.2), c_org_loop_route (03-C + §3.11, first HTTP wiring since U-016 built the underlying function), + and the budget-config read route for v_dashboard's budget bar.""" + + def setUp(self): + self.server = backend_server.create_server(db_path=":memory:") + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.server.conn.execute( + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" + ) + self.server.conn.commit() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + + def _get(self, path): + host, port = self.server.server_address + conn = http.client.HTTPConnection(host, port, timeout=5) + conn.request("GET", path) + resp = conn.getresponse() + body = json.loads(resp.read()) + conn.close() + return resp.status, body + + def _post(self, path, payload): + host, port = self.server.server_address + conn = http.client.HTTPConnection(host, port, timeout=5) + conn.request("POST", path, body=json.dumps(payload).encode("utf-8")) + resp = conn.getresponse() + body = json.loads(resp.read()) + conn.close() + return resp.status, body + + def test_task_list_requires_project_id(self): + status, body = self._get("/api/tasks") + self.assertEqual(status, 400) + self.assertEqual(body["error"], "INVALID_INPUT") + + def test_task_list_returns_created_tasks(self): + self._post("/api/tasks", {"project_id": "proj-1", "goal_text": "x"}) + status, body = self._get("/api/tasks?project_id=proj-1") + self.assertEqual(status, 200) + self.assertEqual(len(body), 1) + + def test_task_list_status_filter(self): + self._post("/api/tasks", {"project_id": "proj-1", "goal_text": "x"}) + status, body = self._get("/api/tasks?project_id=proj-1&status=done") + self.assertEqual(status, 200) + self.assertEqual(body, []) + + def test_task_get_returns_full_detail(self): + _, created = self._post("/api/tasks", {"project_id": "proj-1", "goal_text": "x"}) + status, body = self._get("/api/tasks/" + created["task_id"]) + self.assertEqual(status, 200) + self.assertEqual(body["task_id"], created["task_id"]) + self.assertIn("artifacts", body) + self.assertIn("bug_records", body) + self.assertIn("work_graph_runs", body) + + def test_task_get_nonexistent_returns_404(self): + status, body = self._get("/api/tasks/no-such-task") + self.assertEqual(status, 404) + self.assertEqual(body["error"], "NOT_FOUND") + + def test_route_manual_resume_on_blocked_task(self): + _, created = self._post("/api/tasks", {"project_id": "proj-1", "goal_text": "x"}) + task_id = created["task_id"] + with self.server.conn_lock: + self.server.conn.execute( + "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'loop_limit_reached' " + "WHERE task_id = ?", + (task_id,), + ) + # _manual_resume derives the pre-blocked status to restore from + # execution_event history (org_loop_route.py) -- a real block + # always writes this event on the way in, so the test does too. + self.server.conn.execute( + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " + "VALUES ('evt-block', 'proj-1', ?, 'task_status_changed', " + "'{\"from_status\": \"queued\", \"to_status\": \"blocked_pending_human\"}')", + (task_id,), + ) + self.server.conn.commit() + status, body = self._post("/api/tasks/" + task_id + "/route", {"decided_route": "manual_resume"}) + self.assertEqual(status, 200) + self.assertEqual(body["task_id"], task_id) + + def test_route_invalid_decided_route_returns_400(self): + _, created = self._post("/api/tasks", {"project_id": "proj-1", "goal_text": "x"}) + status, body = self._post( + "/api/tasks/" + created["task_id"] + "/route", {"decided_route": "not_a_real_route"} + ) + self.assertEqual(status, 400) + self.assertEqual(body["error"], "INVALID_PARAM") + + def test_budget_settings_empty_when_unconfigured(self): + status, body = self._get("/api/settings/budget") + self.assertEqual(status, 200) + self.assertEqual(body, []) + + def test_budget_settings_returns_configured_row(self): + with self.server.conn_lock: + self.server.conn.execute( + "INSERT INTO budget_guardrail_config (config_id, scope, limit_type, limit_value, " + "current_usage, updated_by) VALUES ('default', 'global', 'token_count', 1000, 10, 'test')" + ) + self.server.conn.commit() + status, body = self._get("/api/settings/budget") + self.assertEqual(status, 200) + self.assertEqual(len(body), 1) + self.assertEqual(body[0]["limit_value"], 1000) + self.assertEqual(body[0]["current_usage"], 10) + + def _ws_handshake_request(path, key): return ( "GET {} HTTP/1.1\r\n" diff --git a/src/test_budget_guardrail.py b/src/test_budget_guardrail.py index 7014f22..78ac0f1 100644 --- a/src/test_budget_guardrail.py +++ b/src/test_budget_guardrail.py @@ -31,6 +31,24 @@ class BudgetGuardrailTestBase(unittest.TestCase): self.conn.close() +class ListBudgetConfigsTest(BudgetGuardrailTestBase): + def test_returns_the_configured_row(self): + result = budget_guardrail.list_budget_configs(self.conn) + self.assertEqual(len(result), 1) + self.assertEqual( + result[0], + { + "config_id": "default", "scope": "global", "limit_type": "token_count", + "limit_value": 100, "current_usage": 0, "updated_at": result[0]["updated_at"], + }, + ) + + def test_no_config_returns_empty_list(self): + self.conn.execute("DELETE FROM budget_guardrail_config") + self.conn.commit() + self.assertEqual(budget_guardrail.list_budget_configs(self.conn), []) + + class CheckBudgetTest(BudgetGuardrailTestBase): def test_under_limit_allowed_and_accumulates(self): result = budget_guardrail.check_budget(self.conn, "task-1", "default", 30) diff --git a/src/test_task_query.py b/src/test_task_query.py new file mode 100644 index 0000000..bf232d8 --- /dev/null +++ b/src/test_task_query.py @@ -0,0 +1,151 @@ +"""U-025 tests: c_task_list / c_task_get (03-C §3.2), real sqlite (no +mocking needed). + +Run: python3 -m unittest src.test_task_query -v +""" + +import json +import unittest + +from src import schema, task_query + + +class TaskQueryTestBase(unittest.TestCase): + def setUp(self): + self.conn = schema.connect(":memory:") + schema.create_schema(self.conn) + self.conn.execute( + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" + ) + self.conn.commit() + + def tearDown(self): + self.conn.close() + + def _insert_task(self, task_id, status="queued", assigned_org_node="planner", goal_text="goal"): + self.conn.execute( + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " + "VALUES (?, 'proj-1', ?, 'goal', ?, ?)", + (task_id, goal_text, status, assigned_org_node), + ) + self.conn.commit() + + +class ListTasksTest(TaskQueryTestBase): + def test_lists_tasks_for_project(self): + self._insert_task("task-1") + self._insert_task("task-2") + result = task_query.list_tasks(self.conn, "proj-1") + self.assertEqual({t["task_id"] for t in result}, {"task-1", "task-2"}) + + def test_other_projects_excluded(self): + self.conn.execute( + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " + "VALUES ('proj-2', 'P2', '/tmp/proj-2', 1, 'active')" + ) + self.conn.commit() + self._insert_task("task-1") + self.conn.execute( + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " + "VALUES ('task-2', 'proj-2', 'g', 'goal', 'queued', 'planner')" + ) + self.conn.commit() + result = task_query.list_tasks(self.conn, "proj-1") + self.assertEqual([t["task_id"] for t in result], ["task-1"]) + + def test_status_filter(self): + self._insert_task("task-1", status="queued") + self._insert_task("task-2", status="done") + result = task_query.list_tasks(self.conn, "proj-1", status="done") + self.assertEqual([t["task_id"] for t in result], ["task-2"]) + + def test_org_node_filter(self): + self._insert_task("task-1", assigned_org_node="coder") + self._insert_task("task-2", assigned_org_node="validator") + result = task_query.list_tasks(self.conn, "proj-1", org_node="coder") + self.assertEqual([t["task_id"] for t in result], ["task-1"]) + + def test_keyword_filter_matches_goal_text_substring(self): + self._insert_task("task-1", goal_text="implement snake game") + self._insert_task("task-2", goal_text="fix login bug") + result = task_query.list_tasks(self.conn, "proj-1", keyword="snake") + self.assertEqual([t["task_id"] for t in result], ["task-1"]) + + def test_empty_project_returns_empty_list(self): + self.assertEqual(task_query.list_tasks(self.conn, "proj-1"), []) + + +class GetTaskTest(TaskQueryTestBase): + def test_nonexistent_task_raises(self): + with self.assertRaises(task_query.TaskNotFoundError): + task_query.get_task(self.conn, "no-such-task") + + def test_returns_task_fields(self): + self._insert_task("task-1", status="coding", assigned_org_node="coder") + result = task_query.get_task(self.conn, "task-1") + self.assertEqual(result["task_id"], "task-1") + self.assertEqual(result["status"], "coding") + self.assertEqual(result["assigned_org_node"], "coder") + self.assertEqual(result["artifacts"], []) + self.assertEqual(result["bug_records"], []) + self.assertEqual(result["work_graph_runs"], []) + + def test_includes_artifacts(self): + self._insert_task("task-1") + self.conn.execute( + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " + "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')" + ) + self.conn.execute( + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, file_path, " + "git_commit_sha) VALUES ('art-1', 'brd', 'task-1', 'run-1', 'docs/brd.md', 'sha1')" + ) + self.conn.commit() + result = task_query.get_task(self.conn, "task-1") + self.assertEqual(len(result["artifacts"]), 1) + self.assertEqual(result["artifacts"][0]["artifact_id"], "art-1") + + def test_includes_bug_records(self): + self._insert_task("task-1") + self.conn.execute( + "INSERT INTO bug_record (bug_id, task_id, source, description, status) " + "VALUES ('bug-1', 'task-1', 'human', 'x', 'open')" + ) + self.conn.commit() + result = task_query.get_task(self.conn, "task-1") + self.assertEqual(len(result["bug_records"]), 1) + self.assertEqual(result["bug_records"][0]["bug_id"], "bug-1") + + def test_includes_work_graph_runs_with_parsed_graph_manifest_and_nodes(self): + self._insert_task("task-1") + manifest = {"nodes": [{"node_id": "n1", "artifact_type": "brd", "depends_on": []}], "edges": []} + self.conn.execute( + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) " + "VALUES ('run-1', 'task-1', 'planner', ?, 'running')", + (json.dumps(manifest),), + ) + self.conn.execute( + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) " + "VALUES ('skill-1', 'planner', 'skills/skill-1/', 'sha', 'new', 'active')" + ) + self.conn.execute( + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, " + "input_schema_ref, output_schema_ref, input_payload, output_payload, status, retry_count) " + "VALUES ('node-1', 'run-1', 'n1', 'planner', 'skill-1', 'in.json', 'out.json', '{}', " + "'{\"x\": 1}', 'done', 0)" + ) + self.conn.commit() + + result = task_query.get_task(self.conn, "task-1") + self.assertEqual(len(result["work_graph_runs"]), 1) + run = result["work_graph_runs"][0] + self.assertEqual(run["graph_manifest"], manifest) + self.assertEqual(len(run["nodes"]), 1) + self.assertEqual(run["nodes"][0]["node_id"], "n1") + self.assertEqual(run["nodes"][0]["status"], "done") + self.assertEqual(run["nodes"][0]["output_payload"], {"x": 1}) + + +if __name__ == "__main__": + unittest.main()