@@ -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 `
+
+
仪表盘
+
+
实时连接已断开,正在重连…
+
+
+ `;
+}
+
+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_id | goal_text | status | assigned_org_node | loop_round | updated_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 + ")
"
+ + '
"
+ + '
按执行层级排布的 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 ? (
+ '
' + task.artifacts.map((a) =>
+ "- " + escapeHtml(a.file_path) + ""
+ + escapeHtml(a.artifact_type) + " · " + escapeHtml(a.validation_status) + "
"
+ ).join("") + "
"
+ ) : '
暂无 Artifact
';
+
+ const bugsHtml = task.bug_records.length ? (
+ '
' + task.bug_records.map((b) =>
+ "- " + escapeHtml(b.description) + ""
+ + escapeHtml(b.root_cause_category || "待归因") + " · " + escapeHtml(b.status) + "
"
+ ).join("") + "
"
+ ) : '
暂无 Bug 记录
';
+
+ content.innerHTML = (
+ "
" + escapeHtml(task.goal_text) + "
"
+ + alertHtml
+ + '
"
+ + '
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()