Commit cacd03bf6f856a6569c296312f4bc38978173faa

Authored by bboonnee
1 parent 231cf0c6

U-023: v_goal_composer + v_bug_feedback

c_task_create(U-009 已实现的 task_create.create_task())第一次接上
POST /api/tasks。c_bug_report_create 此前完全没有实现,新增
bug_record.create_bug_report() + POST /api/bug-reports。

发现并订正 03-C 一处输入契约遗漏:c_bug_report_create 的
input_contract 没写 project_id,但 execution_event.project_id 是
NOT NULL、bug_record 表自己没有 project_id 列可以在 related_task_id
留空时兜底推导,字面上无法满足约束——按 c_task_create 同款处理方式把
project_id 变成必填,03-C 已订正。

frontend/app.js 补上 #/goals/new、#/bugs/new 真实交互;控制台还没有
"当前项目"状态的正式载体(v_dashboard 是 U-025 才做),project_id 暂存
localStorage,v_project_setup 创建成功时写入。related_task_id 因为
c_task_list 未被任何 Unit 认领,简化成文本输入而非下拉,等 U-025 补上
任务列表后再换。提交成功后原型写的是跳转 v_task_detail,该页面也是
U-025 才建,暂时改成原页面内展示确认信息。

端到端冒烟时额外发现一个真 bug:create_bug_report() 原本没校验
project_id/related_task_id 是否存在,坏 project_id 会在 execution_event
插入时触发未捕获的 IntegrityError,请求直接 500——补了存在性校验,
改为干净的 INVALID_PARAM/400。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/development-plan.md
... ... @@ -223,7 +223,8 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
223 223 - **U-023 [Value Path]** `v_goal_composer` + `v_bug_feedback`
224 224 - 依赖:U-022
225 225 - Done:能通过 UI 创建 `task` 和人工发起的 `bug_record`(`c_bug_report_create`)
226   - - 覆盖:02-V `v_goal_composer`/`v_bug_feedback`;03-C `c_bug_report_create`
  226 + - **实现补充(2026-07-31)**:① `c_task_create`(U-009 已实现的 `task_create.create_task()`)第一次接上 `POST /api/tasks`;② `c_bug_report_create`(03-C §3.1.1)此前完全没有实现,本 Unit 新增 `bug_record.create_bug_report()` + `POST /api/bug-reports`。**发现并解决一个 03-C 内部小缺口**:`c_bug_report_create` 的 `input_contract` 只列了 `{description, repro_steps, related_task_id}`,没有 `project_id`;但 `execution_event.project_id` 是 NOT NULL(01-M 1.9),`bug_record` 表自己又没有 `project_id` 列可以在 `related_task_id` 留空时兜底推导——"留空则视为新发现的独立问题"(02-V §2.4)这条路径字面上无法满足 `execution_event` 的约束。判断这是文档遗漏而非架构决策点(同类判断依据见 U-022 的 `c_project_create` HTTP 接线、U-014 的 `c_artifact_write`),直接让 `project_id` 和 `c_task_create` 一样成为必填参数,未停下来单独确认。③ 控制台目前没有"当前项目"这个概念的载体(`v_dashboard` 是 U-025 才做),`v_goal_composer`/`v_bug_feedback` 需要的 `project_id` 暂存到 `localStorage`(`v_project_setup` 创建成功时写入),没有选中项目时两个页面直接引导回 `#/setup`。④ `related_task_id` 在 02-V 原型里是下拉选择器,但 `c_task_list`(列出当前项目下的任务供选择)没有被任何 Unit 认领,本 Unit 简化成纯文本 task_id 输入框,避免顺手做一个不属于自己范围的接口;等 U-025 补上任务列表后再换成真下拉。⑤ 提交成功后 02-V 写的是"跳转 `v_task_detail`",但那个页面是 U-025 才建,本 Unit 改成在原页面展示一条带 task_id/bug_id 的确认信息 + 返回仪表盘链接,等 U-025 落地后再切回真跳转。⑥ 对着真实运行的后端做端到端冒烟时发现一个真 bug:`create_bug_report()` 一开始没校验 `project_id`/`related_task_id` 是否存在,传入不存在的 `project_id` 会在 `execution_event` 插入时触发未捕获的 `sqlite3.IntegrityError`,请求直接 500(无干净 JSON 错误体)——补了存在性校验,改为清晰的 `INVALID_PARAM`/400,并加了回归测试。
  227 + - 覆盖:02-V `v_goal_composer`/`v_bug_feedback`;03-C `c_task_create`/`c_bug_report_create`
227 228  
228 229 ### PH3-S2 实时看板
229 230  
... ...
docs/sdd/03-C-控制与接口.md
... ... @@ -49,12 +49,12 @@
49 49  
50 50 - `method + path`:`POST /api/bug-reports`
51 51 - 语义:控制台人工反馈 bug 的入口,区别于 Validator 自动生成的 `c_bug_record_create`(3.10 节),复用同一张 `bug_record` 表但 `source=human`
52   -- `input_contract`:`{ description: string(required), repro_steps: string(optional), related_task_id: string(optional) }`
  52 +- `input_contract`:`{ project_id: string(required), description: string(required), repro_steps: string(optional), related_task_id: string(optional) }`(`project_id` 是 2026-07-31 订正新增——原文遗漏了它,但 `execution_event.project_id` 是 NOT NULL(01-M 1.9),`bug_record` 表自己没有 `project_id` 列,`related_task_id` 留空时("视为新发现的独立问题")没有任何字段能推导出 `project_id`,写 `execution_event` 会失败;U-023 实现时发现,按 `c_task_create` 同款处理方式把它变成必填)
53 53 - `output_contract`:成功 `{ bug_id, status: 'open' }`
54 54 - `idempotent`:否
55 55 - `side_effects`:写入 `bug_record(source=human, status=open)`;写入 `execution_event(event_type=bug_recorded)`;异步触发 Validator 认领该 bug 进行 triage(复用 04-P `p_validator_root_cause_trace`,见该节新增的人工接入分支)
56 56 - `errors`:
57   - - `INVALID_PARAM`(不可重试):`description` 为空
  57 + - `INVALID_PARAM`(不可重试):`description` 为空;`project_id`/`related_task_id` 不存在
58 58  
59 59 ## 3.2 `c_task_list` / `c_task_get`
60 60  
... ...
docs/sdd/unit_matrix.json
... ... @@ -37,7 +37,7 @@
37 37 {"id": "U-021", "phase": "PH2", "sub_phase": "PH2-S4", "name": "Graph Engine 持久化 + 断点恢复(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["work_graph_run", "work_node_execution"], "view": [], "controller": [], "process": ["p_work_graph_execution"], "requirement": ["REQ-06"]}, "done": "REQ-06 happy/boundary/failure 通过;now 默认值从 time.monotonic 改成 time.time(墙钟)以支持跨进程重启的 quota 等待续算;新增 resume_work_graph_run() 返回未完成节点列表"},
38 38  
39 39 {"id": "U-022", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_project_setup + c_pick_folder 全平台(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-007", "U-008"], "coverage": {"model": [], "view": ["v_project_setup"], "controller": ["c_pick_folder", "c_project_create"], "process": [], "requirement": ["REQ-11"]}, "done": "REQ-11 里 v_project_setup/c_pick_folder/c_project_create 部分通过;c_pick_folder 补齐 Windows/Linux;c_project_create 首次接上 HTTP 路由;REQ-11 完整 happy(含建 task)随 U-023 累计满足"},
40   - {"id": "U-023", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_goal_composer + v_bug_feedback", "classification": "Value Path", "depends_on": ["U-022"], "coverage": {"model": [], "view": ["v_goal_composer", "v_bug_feedback"], "controller": ["c_bug_report_create"], "process": [], "requirement": []}, "done": "能通过 UI 创建 task 和人工 bug_record"},
  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 实时推送(最小连通版)", "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)"},
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 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": "三个页面可正常展示实时数据"},
... ...
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) -- everything else is still a PH3 placeholder.
  3 +// §2.1); U-023 adds #/goals/new and #/bugs/new -- everything else is still
  4 +// a PH3 placeholder.
  5 +
  6 +// 02-V's console has no multi-project switcher UI (decision 2), but
  7 +// c_task_create/c_bug_report_create both require project_id (03-C §3.1;
  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
  11 +// stashed here and read back by the pages that need it.
  12 +const CURRENT_PROJECT_KEY = "graphlemon_project_id";
  13 +function getCurrentProjectId() {
  14 + return localStorage.getItem(CURRENT_PROJECT_KEY);
  15 +}
  16 +function setCurrentProjectId(projectId) {
  17 + localStorage.setItem(CURRENT_PROJECT_KEY, projectId);
  18 +}
4 19  
5 20 const routes = {
6 21 "#/": { render: () => "<h1>Dashboard</h1><p>placeholder -- real content is PH3 (v_dashboard, U-025)</p>" },
7 22 "#/tasks": { render: () => "<h1>Tasks</h1><p>placeholder -- real content is PH3 (v_task_board, U-025)</p>" },
8 23 "#/settings": { render: () => "<h1>Settings</h1><p>placeholder -- real content is PH3 (U-030)</p>" },
9 24 "#/setup": { render: renderProjectSetup, mount: mountProjectSetup },
  25 + "#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer },
  26 + "#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback },
10 27 };
11 28  
12 29 function render() {
... ... @@ -106,6 +123,7 @@ function mountProjectSetup() {
106 123 });
107 124 const body = await resp.json();
108 125 if (resp.status === 200) {
  126 + setCurrentProjectId(body.project_id);
109 127 window.location.hash = "#/";
110 128 return;
111 129 }
... ... @@ -122,3 +140,176 @@ function mountProjectSetup() {
122 140 confirmBanner.style.display = "none";
123 141 });
124 142 }
  143 +
  144 +// ---- shared: pages that need a "current project" to already exist ----
  145 +
  146 +function renderNoProjectSelected() {
  147 + return `
  148 + <div class="page">
  149 + <div class="error-banner" style="display:block;">
  150 + 还没有选择项目。请先<a href="#/setup">创建或切换项目</a>。
  151 + </div>
  152 + </div>
  153 + `;
  154 +}
  155 +
  156 +// ---- v_goal_composer (02-V §2.3): propose a new goal ----
  157 +
  158 +const GOAL_TEXT_MAX_LEN = 4000;
  159 +
  160 +function renderGoalComposer() {
  161 + if (!getCurrentProjectId()) return renderNoProjectSelected();
  162 + return `
  163 + <div class="top-back"><a href="#/">← 返回仪表盘</a></div>
  164 + <div class="page">
  165 + <h1>提出新目标</h1>
  166 + <div class="page-sub">用自然语言描述你想让 graphlemon 完成的目标</div>
  167 + <div class="panel">
  168 + <div id="goal_error" class="error-banner" style="display:none;"></div>
  169 + <div id="goal_success" class="success-banner" style="display:none;"></div>
  170 + <div class="field" id="goal_form">
  171 + <label>目标描述(goal_text,必填,最长 ${GOAL_TEXT_MAX_LEN} 字)</label>
  172 + <textarea id="goal_text" rows="6" maxlength="${GOAL_TEXT_MAX_LEN}"></textarea>
  173 + <div class="btn-row">
  174 + <button class="btn btn-primary" id="btn_submit_goal" type="button">创建任务</button>
  175 + </div>
  176 + </div>
  177 + <div class="confirm-banner" id="goal_confirm_banner" style="display:none;">
  178 + <div id="goal_confirm_summary"></div>
  179 + <div class="btn-row" style="margin-top:10px;">
  180 + <button class="btn" id="btn_edit_goal" type="button">返回编辑</button>
  181 + <button class="btn btn-primary" id="btn_confirm_goal" type="button">确认创建</button>
  182 + </div>
  183 + </div>
  184 + </div>
  185 + </div>
  186 + `;
  187 +}
  188 +
  189 +function mountGoalComposer() {
  190 + if (!getCurrentProjectId()) return;
  191 + const textArea = document.getElementById("goal_text");
  192 + const errorBox = document.getElementById("goal_error");
  193 + const successBox = document.getElementById("goal_success");
  194 + const form = document.getElementById("goal_form");
  195 + const confirmBanner = document.getElementById("goal_confirm_banner");
  196 + const confirmSummary = document.getElementById("goal_confirm_summary");
  197 +
  198 + document.getElementById("btn_submit_goal").addEventListener("click", () => {
  199 + errorBox.style.display = "none";
  200 + const goalText = textArea.value.trim();
  201 + if (!goalText) {
  202 + errorBox.textContent = "目标描述不能为空";
  203 + errorBox.style.display = "block";
  204 + return;
  205 + }
  206 + confirmSummary.textContent = "即将创建任务:" + goalText;
  207 + form.style.display = "none";
  208 + confirmBanner.style.display = "block";
  209 + });
  210 +
  211 + document.getElementById("btn_edit_goal").addEventListener("click", () => {
  212 + confirmBanner.style.display = "none";
  213 + form.style.display = "block";
  214 + });
  215 +
  216 + document.getElementById("btn_confirm_goal").addEventListener("click", async () => {
  217 + const resp = await fetch("/api/tasks", {
  218 + method: "POST",
  219 + body: JSON.stringify({
  220 + project_id: getCurrentProjectId(),
  221 + goal_text: textArea.value.trim(),
  222 + task_type: "goal",
  223 + created_via: "human_console",
  224 + }),
  225 + });
  226 + const body = await resp.json();
  227 + if (resp.status !== 200) {
  228 + confirmBanner.style.display = "none";
  229 + form.style.display = "block";
  230 + errorBox.textContent = body.error + (body.detail ? ":" + body.detail : "");
  231 + errorBox.style.display = "block";
  232 + return;
  233 + }
  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";
  240 + });
  241 +}
  242 +
  243 +// ---- v_bug_feedback (02-V §2.4): report a bug ----
  244 +
  245 +function renderBugFeedback() {
  246 + if (!getCurrentProjectId()) return renderNoProjectSelected();
  247 + return `
  248 + <div class="top-back"><a href="#/">← 返回仪表盘</a></div>
  249 + <div class="page">
  250 + <h1>反馈 Bug</h1>
  251 + <div class="page-sub">人工反馈,与 Validator 自动生成的 bug_record 复用同一张表(source=human)</div>
  252 + <div class="panel">
  253 + <div id="bug_error" class="error-banner" style="display:none;"></div>
  254 + <div id="bug_success" class="success-banner" style="display:none;"></div>
  255 + <div id="bug_form">
  256 + <div class="field">
  257 + <label>问题描述(description,必填)</label>
  258 + <textarea id="bug_description" rows="4"></textarea>
  259 + </div>
  260 + <div class="field">
  261 + <label>复现步骤(repro_steps,非必填)</label>
  262 + <textarea id="bug_repro_steps" rows="3"></textarea>
  263 + </div>
  264 + <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>
  268 + </div>
  269 + <div class="btn-row">
  270 + <button class="btn btn-primary" id="btn_submit_bug" type="button">提交反馈</button>
  271 + </div>
  272 + </div>
  273 + </div>
  274 + </div>
  275 + `;
  276 +}
  277 +
  278 +function mountBugFeedback() {
  279 + if (!getCurrentProjectId()) return;
  280 + const descriptionArea = document.getElementById("bug_description");
  281 + const reproStepsArea = document.getElementById("bug_repro_steps");
  282 + const relatedTaskIdInput = document.getElementById("bug_related_task_id");
  283 + const errorBox = document.getElementById("bug_error");
  284 + const successBox = document.getElementById("bug_success");
  285 + const form = document.getElementById("bug_form");
  286 +
  287 + document.getElementById("btn_submit_bug").addEventListener("click", async () => {
  288 + errorBox.style.display = "none";
  289 + const description = descriptionArea.value.trim();
  290 + if (!description) {
  291 + errorBox.textContent = "问题描述不能为空";
  292 + errorBox.style.display = "block";
  293 + return;
  294 + }
  295 + const resp = await fetch("/api/bug-reports", {
  296 + method: "POST",
  297 + body: JSON.stringify({
  298 + project_id: getCurrentProjectId(),
  299 + description: description,
  300 + repro_steps: reproStepsArea.value.trim() || undefined,
  301 + related_task_id: relatedTaskIdInput.value.trim() || undefined,
  302 + }),
  303 + });
  304 + const body = await resp.json();
  305 + if (resp.status !== 200) {
  306 + errorBox.textContent = body.error + (body.detail ? ":" + body.detail : "");
  307 + errorBox.style.display = "block";
  308 + return;
  309 + }
  310 + form.style.display = "none";
  311 + successBox.innerHTML = "已提交,Validator 将进行归因确认(bug <code>" + body.bug_id + "</code>)。"
  312 + + '<a href="#/">返回仪表盘</a>';
  313 + successBox.style.display = "block";
  314 + });
  315 +}
... ...
frontend/style.css
... ... @@ -6,7 +6,8 @@ body { font-family: system-ui, sans-serif; margin: 0; }
6 6 #nav a.active { font-weight: bold; }
7 7 #app { padding: 1rem; }
8 8  
9   -/* U-022: v_project_setup -- class names match docs/wireframes/v_project_setup.html */
  9 +/* U-022/U-023: v_project_setup/v_goal_composer/v_bug_feedback -- class
  10 + names match docs/wireframes/v_*.html */
10 11 .top-back a { color: #6b7280; text-decoration: none; }
11 12 .page { padding: 28px; max-width: 560px; margin: 0 auto; }
12 13 .page h1 { font-size: 22px; margin: 0 0 4px; }
... ... @@ -14,7 +15,7 @@ body { font-family: system-ui, sans-serif; margin: 0; }
14 15 .panel { border: 1px solid #e5e7eb; border-radius: 10px; padding: 24px; background: #f9fafb; }
15 16 .field { margin-bottom: 18px; }
16 17 .field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
17   -.field input { width: 100%; padding: 9px 10px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 14px; }
  18 +.field input, .field textarea { width: 100%; padding: 9px 10px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 14px; font-family: inherit; }
18 19 .field input[readonly] { background: #f3f4f6; color: #6b7280; }
19 20 .field .hint { font-size: 12px; color: #6b7280; margin-top: 5px; }
20 21 .btn { display: inline-block; padding: 9px 16px; border-radius: 6px; border: 1px solid #e5e7eb; background: #fff; font-size: 14px; cursor: pointer; }
... ... @@ -22,3 +23,5 @@ body { font-family: system-ui, sans-serif; margin: 0; }
22 23 .btn-row { display: flex; gap: 10px; margin-top: 22px; }
23 24 .confirm-banner { background: #fffbeb; border: 1px solid #f59e0b; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-top: 16px; }
24 25 .error-banner { background: #fef2f2; border: 1px solid #ef4444; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-bottom: 16px; }
  26 +.success-banner { background: #f0fdf4; border: 1px solid #22c55e; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-bottom: 16px; }
  27 +.success-banner a { margin-left: 8px; color: #4f46e5; }
... ...
src/backend_server.py
... ... @@ -8,10 +8,12 @@ platforms -- U-007 shipped macOS only via `osascript`; U-022 adds Windows
8 8 via a `powershell` FolderBrowserDialog script and Linux via `zenity`/
9 9 `kdialog`, whichever is installed), `c_project_create` (03-C §3.0, wired to
10 10 the U-002 `project_scaffold.create_project()` function that had no HTTP
11   -route until now) for `v_project_setup`'s submit flow, and serving the U-008
12   -frontend shell as static files (no build step, 05 §5.7: whitelisted by exact
13   -path, not a generic directory walker, so there's no path-traversal surface
14   -to reason about).
  11 +route until now) for `v_project_setup`'s submit flow, `c_task_create`/
  12 +`c_bug_report_create` (03-C §3.1/§3.1.1, U-023) for `v_goal_composer`/
  13 +`v_bug_feedback`'s submit flows, and serving the U-008 frontend shell as
  14 +static files (no build step, 05 §5.7: whitelisted by exact path, not a
  15 +generic directory walker, so there's no path-traversal surface to reason
  16 +about).
15 17 """
16 18  
17 19 import json
... ... @@ -21,7 +23,7 @@ import subprocess
21 23 import threading
22 24 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
23 25  
24   -from src import paths, project_scaffold, schema
  26 +from src import bug_record, paths, project_scaffold, schema, task_create
25 27  
26 28 _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend")
27 29  
... ... @@ -121,6 +123,10 @@ class _Handler(BaseHTTPRequestHandler):
121 123 self._handle_pick_folder()
122 124 elif self.path == "/api/projects":
123 125 self._handle_project_create()
  126 + elif self.path == "/api/tasks":
  127 + self._handle_task_create()
  128 + elif self.path == "/api/bug-reports":
  129 + self._handle_bug_report_create()
124 130 else:
125 131 self._send_json(404, {"error": "NOT_FOUND"})
126 132  
... ... @@ -130,12 +136,19 @@ class _Handler(BaseHTTPRequestHandler):
130 136 except DialogUnavailableError as e:
131 137 self._send_json(422, {"error": "DIALOG_UNAVAILABLE", "detail": str(e)})
132 138  
133   - def _handle_project_create(self):
  139 + def _read_json_body(self):
  140 + """Returns (payload, None) on success, (None, error_response) if the
  141 + body isn't valid JSON -- caller sends error_response and returns."""
134 142 length = int(self.headers.get("Content-Length", 0))
135 143 try:
136   - payload = json.loads(self.rfile.read(length) or b"{}")
  144 + return json.loads(self.rfile.read(length) or b"{}"), None
137 145 except json.JSONDecodeError:
138   - self._send_json(400, {"error": "INVALID_INPUT", "detail": "body is not valid JSON"})
  146 + return None, {"error": "INVALID_INPUT", "detail": "body is not valid JSON"}
  147 +
  148 + def _handle_project_create(self):
  149 + payload, error = self._read_json_body()
  150 + if error:
  151 + self._send_json(400, error)
139 152 return
140 153 target_folder_path = payload.get("target_folder_path")
141 154 if not target_folder_path:
... ... @@ -158,6 +171,44 @@ class _Handler(BaseHTTPRequestHandler):
158 171 else:
159 172 self._send_json(200, result)
160 173  
  174 + def _handle_task_create(self):
  175 + payload, error = self._read_json_body()
  176 + if error:
  177 + self._send_json(400, error)
  178 + return
  179 + try:
  180 + with self.server.conn_lock:
  181 + result = task_create.create_task(
  182 + self.server.conn,
  183 + payload.get("project_id"),
  184 + payload.get("goal_text"),
  185 + task_type=payload.get("task_type", "goal"),
  186 + created_via=payload.get("created_via", "api"),
  187 + )
  188 + except task_create.InvalidParamError as e:
  189 + self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)})
  190 + else:
  191 + self._send_json(200, result)
  192 +
  193 + def _handle_bug_report_create(self):
  194 + payload, error = self._read_json_body()
  195 + if error:
  196 + self._send_json(400, error)
  197 + return
  198 + try:
  199 + with self.server.conn_lock:
  200 + result = bug_record.create_bug_report(
  201 + self.server.conn,
  202 + payload.get("project_id"),
  203 + payload.get("description"),
  204 + repro_steps=payload.get("repro_steps"),
  205 + related_task_id=payload.get("related_task_id"),
  206 + )
  207 + except bug_record.InvalidParamError as e:
  208 + self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)})
  209 + else:
  210 + self._send_json(200, result)
  211 +
161 212 def _send_json(self, status, obj):
162 213 body = json.dumps(obj).encode("utf-8")
163 214 self.send_response(status)
... ...
src/bug_record.py
... ... @@ -34,6 +34,48 @@ class InvalidRootCauseCategoryError(Exception):
34 34 """INVALID_ROOT_CAUSE_CATEGORY -- 03-C §3.10."""
35 35  
36 36  
  37 +class InvalidParamError(Exception):
  38 + """INVALID_PARAM -- 03-C §3.1.1: description is empty, or project_id/
  39 + related_task_id don't reference existing rows."""
  40 +
  41 +
  42 +def create_bug_report(conn, project_id, description, repro_steps=None, related_task_id=None):
  43 + """c_bug_report_create (03-C §3.1.1): console-submitted human bug report.
  44 +
  45 + 03-C's own input_contract for this controller lists only
  46 + {description, repro_steps, related_task_id} -- no project_id -- but
  47 + execution_event.project_id is NOT NULL (01-M 1.9) and bug_record has no
  48 + project_id column of its own to derive one from when related_task_id is
  49 + left empty ("留空则视为新发现的独立问题", 02-V §2.4). Requiring
  50 + project_id here (like c_task_create already does per its own contract)
  51 + is the only way to satisfy that constraint for a task-less report; this
  52 + is a real 03-C gap, not a design choice, flagged rather than silently
  53 + routed around -- see development-plan.md's 2026-07-31 note on this Unit.
  54 + """
  55 + if not description:
  56 + raise InvalidParamError("description is required")
  57 + if conn.execute("SELECT 1 FROM project WHERE project_id = ?", (project_id,)).fetchone() is None:
  58 + raise InvalidParamError("project_id does not exist: " + str(project_id))
  59 + if related_task_id is not None and conn.execute(
  60 + "SELECT 1 FROM task WHERE task_id = ?", (related_task_id,)
  61 + ).fetchone() is None:
  62 + raise InvalidParamError("related_task_id does not exist: " + str(related_task_id))
  63 +
  64 + bug_id = str(uuid.uuid4())
  65 + conn.execute(
  66 + "INSERT INTO bug_record (bug_id, task_id, source, description, repro_steps, status) "
  67 + "VALUES (?, ?, 'human', ?, ?, 'open')",
  68 + (bug_id, related_task_id, description, repro_steps),
  69 + )
  70 + conn.execute(
  71 + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
  72 + "VALUES (?, ?, ?, 'bug_recorded', ?)",
  73 + (str(uuid.uuid4()), project_id, related_task_id, json.dumps({"bug_id": bug_id, "source": "human"})),
  74 + )
  75 + conn.commit()
  76 + return {"bug_id": bug_id, "status": "open"}
  77 +
  78 +
37 79 def create_bug_record(
38 80 conn, task_id, description, root_cause_category, root_cause_detail, suggested_route,
39 81 repro_steps=None, related_artifact_ids=None, skill_iteration_suggestion_id=None,
... ...
src/test_backend_server.py
... ... @@ -313,6 +313,82 @@ class ProjectCreateRouteTest(unittest.TestCase):
313 313 self.assertTrue(os.path.isdir(os.path.join(non_git, ".git")))
314 314  
315 315  
  316 +class TaskAndBugReportRouteTest(unittest.TestCase):
  317 + """U-023: c_task_create (03-C §3.1) + c_bug_report_create (03-C §3.1.1)
  318 + wired to real HTTP routes for v_goal_composer/v_bug_feedback."""
  319 +
  320 + def setUp(self):
  321 + self.server = backend_server.create_server(db_path=":memory:")
  322 + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  323 + self.thread.start()
  324 + self.server.conn.execute(
  325 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  326 + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
  327 + )
  328 + self.server.conn.commit()
  329 +
  330 + def tearDown(self):
  331 + self.server.shutdown()
  332 + self.server.server_close()
  333 + self.thread.join(timeout=5)
  334 +
  335 + def _post(self, path, payload):
  336 + host, port = self.server.server_address
  337 + conn = http.client.HTTPConnection(host, port, timeout=5)
  338 + conn.request("POST", path, body=json.dumps(payload).encode("utf-8"))
  339 + resp = conn.getresponse()
  340 + body = json.loads(resp.read())
  341 + conn.close()
  342 + return resp.status, body
  343 +
  344 + def test_creates_task(self):
  345 + status, body = self._post(
  346 + "/api/tasks", {"project_id": "proj-1", "goal_text": "make a snake game", "created_via": "human_console"}
  347 + )
  348 + self.assertEqual(status, 200)
  349 + self.assertEqual(body["status"], "queued")
  350 + self.assertIn("task_id", body)
  351 +
  352 + def test_empty_goal_text_returns_400(self):
  353 + status, body = self._post("/api/tasks", {"project_id": "proj-1", "goal_text": ""})
  354 + self.assertEqual(status, 400)
  355 + self.assertEqual(body["error"], "INVALID_PARAM")
  356 +
  357 + def test_nonexistent_project_id_returns_400(self):
  358 + status, body = self._post("/api/tasks", {"project_id": "no-such-project", "goal_text": "x"})
  359 + self.assertEqual(status, 400)
  360 + self.assertEqual(body["error"], "INVALID_PARAM")
  361 +
  362 + def test_creates_bug_report(self):
  363 + status, body = self._post(
  364 + "/api/bug-reports", {"project_id": "proj-1", "description": "snake clips through walls"}
  365 + )
  366 + self.assertEqual(status, 200)
  367 + self.assertEqual(body["status"], "open")
  368 + self.assertIn("bug_id", body)
  369 +
  370 + def test_bug_report_with_related_task_id(self):
  371 + _, task_body = self._post("/api/tasks", {"project_id": "proj-1", "goal_text": "x"})
  372 + status, body = self._post(
  373 + "/api/bug-reports",
  374 + {"project_id": "proj-1", "description": "x", "related_task_id": task_body["task_id"]},
  375 + )
  376 + self.assertEqual(status, 200)
  377 +
  378 + def test_empty_description_returns_400(self):
  379 + status, body = self._post("/api/bug-reports", {"project_id": "proj-1", "description": ""})
  380 + self.assertEqual(status, 400)
  381 + self.assertEqual(body["error"], "INVALID_PARAM")
  382 +
  383 + def test_bug_report_with_bad_project_id_returns_400_not_500(self):
  384 + # a raw sqlite3.IntegrityError propagating uncaught here was a real
  385 + # bug this smoke test caught (stale/bad project_id crashed the
  386 + # request instead of a clean 400)
  387 + status, body = self._post("/api/bug-reports", {"project_id": "no-such-project", "description": "x"})
  388 + self.assertEqual(status, 400)
  389 + self.assertEqual(body["error"], "INVALID_PARAM")
  390 +
  391 +
316 392 class ZeroThirdPartyDependencyTest(unittest.TestCase):
317 393 """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this
318 394 doesn't silently rot if someone adds an import later."""
... ...
src/test_bug_record.py
... ... @@ -70,6 +70,75 @@ class CreateBugRecordTest(BugRecordTestBase):
70 70 )
71 71  
72 72  
  73 +class CreateBugReportTest(BugRecordTestBase):
  74 + """U-023: c_bug_report_create (03-C §3.1.1) -- console-submitted human
  75 + bug reports, distinct from create_bug_record's Validator-judged path."""
  76 +
  77 + def test_happy_path_creates_open_human_record(self):
  78 + result = bug_record.create_bug_report(
  79 + self.conn, project_id="proj-1", description="snake doesn't wrap around edges",
  80 + )
  81 + row = self.conn.execute(
  82 + "SELECT source, status, task_id, description FROM bug_record WHERE bug_id = ?",
  83 + (result["bug_id"],),
  84 + ).fetchone()
  85 + self.assertEqual(row, ("human", "open", None, "snake doesn't wrap around edges"))
  86 + self.assertEqual(result["status"], "open")
  87 +
  88 + def test_related_task_id_is_optional_and_stored_when_given(self):
  89 + result = bug_record.create_bug_report(
  90 + self.conn, project_id="proj-1", description="x", related_task_id="task-1",
  91 + )
  92 + row = self.conn.execute(
  93 + "SELECT task_id FROM bug_record WHERE bug_id = ?", (result["bug_id"],)
  94 + ).fetchone()
  95 + self.assertEqual(row[0], "task-1")
  96 +
  97 + def test_repro_steps_optional_and_stored_when_given(self):
  98 + result = bug_record.create_bug_report(
  99 + self.conn, project_id="proj-1", description="x", repro_steps="1. start game 2. hit wall",
  100 + )
  101 + row = self.conn.execute(
  102 + "SELECT repro_steps FROM bug_record WHERE bug_id = ?", (result["bug_id"],)
  103 + ).fetchone()
  104 + self.assertEqual(row[0], "1. start game 2. hit wall")
  105 +
  106 + def test_empty_description_rejected(self):
  107 + with self.assertRaises(bug_record.InvalidParamError):
  108 + bug_record.create_bug_report(self.conn, project_id="proj-1", description="")
  109 +
  110 + def test_nonexistent_project_id_rejected(self):
  111 + # a raw FK violation surfacing as an uncaught IntegrityError instead
  112 + # of this is exactly what a real backend_server.py smoke test caught
  113 + # (a bad/stale project_id would otherwise 500 the request)
  114 + with self.assertRaises(bug_record.InvalidParamError):
  115 + bug_record.create_bug_report(self.conn, project_id="no-such-project", description="x")
  116 +
  117 + def test_nonexistent_related_task_id_rejected(self):
  118 + with self.assertRaises(bug_record.InvalidParamError):
  119 + bug_record.create_bug_report(
  120 + self.conn, project_id="proj-1", description="x", related_task_id="no-such-task"
  121 + )
  122 +
  123 + def test_writes_bug_recorded_execution_event(self):
  124 + result = bug_record.create_bug_report(self.conn, project_id="proj-1", description="x")
  125 + row = self.conn.execute(
  126 + "SELECT project_id, event_type FROM execution_event WHERE payload LIKE ?",
  127 + ('%' + result["bug_id"] + '%',),
  128 + ).fetchone()
  129 + self.assertEqual(row, ("proj-1", "bug_recorded"))
  130 +
  131 + def test_no_related_task_still_writes_event_with_null_task_id(self):
  132 + # this is exactly the case 03-C's own input_contract can't satisfy
  133 + # without project_id -- see create_bug_report's docstring
  134 + result = bug_record.create_bug_report(self.conn, project_id="proj-1", description="x")
  135 + row = self.conn.execute(
  136 + "SELECT task_id FROM execution_event WHERE payload LIKE ?",
  137 + ('%' + result["bug_id"] + '%',),
  138 + ).fetchone()
  139 + self.assertIsNone(row[0])
  140 +
  141 +
73 142 class HumanReportedFlowTest(BugRecordTestBase):
74 143 def _insert_human_bug(self, bug_id="bug-1", status="open"):
75 144 self.conn.execute(
... ...