Commit cacd03bf6f856a6569c296312f4bc38978173faa
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>
Showing
9 changed files
with
448 additions
and
15 deletions
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( | ... | ... |