Commit 7c720ab83b4bbe99b52b3b17331626fa16834de7
1 parent
a5f1618e
Add v_project_list and v_goal_list CRUD (user-requested scope expansion)
Two new console pages requested directly by the user, beyond 02-V's
original declared non-goal ("不做完整多项目管理 UI(项目列表/切换)"):
- v_project_list: full project list + rename + archive/reactivate.
"Import an existing directory with real code" needs no new code --
project_scaffold.create_project() already only ever adds .graphlemon/
inside the target folder. New: src/project_query.py (list/get/update),
GET/PUT/DELETE /api/projects[/:id]. DELETE archives (status='archived'),
never a physical row delete -- project_id is FK-referenced from task/
artifact/bug_record/etc., and 01-M's own active/archived enum already
models this as soft-delete.
- v_goal_list: extends the existing v_task_board/v_task_detail rather than
duplicating them. Added sort/order to task_query.list_tasks() (column
whitelist, no injection surface) and edit/delete on v_task_detail, both
restricted to status='queued' (task_query.TaskNotEditableError, HTTP 409)
-- editing or deleting a goal after real work has referenced it would
leave that history pointing at something that no longer matches reality.
Implemented directly rather than through graphlemon's own self-hosting
loop -- confirmed with the user first that implement_code() always uses a
hardcoded single-node manifest (no plan_work_graph() call for Coder), so a
multi-page/multi-endpoint feature like this would've been one unreviewable
Coder session with no way to decompose it.
Real bug found on first verification pass (not caught by the existing
_insert_task test helper, which never writes execution_event): a real
task_create.create_task() always writes an execution_event row in the same
transaction (01-M's FK, no ON DELETE CASCADE) -- even a never-touched
queued task already has one, so DELETE FROM task raised
sqlite3.IntegrityError. Fixed by clearing the task's own execution_event
rows first; new regression test goes through the real create_task() path.
474 tests green. Verified end-to-end against a real running server (every
new endpoint exercised via curl) since the Chrome extension is still
unavailable this session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
9 changed files
with
832 additions
and
11 deletions
docs/development-plan.md
| @@ -460,3 +460,16 @@ U-041(回滚路径实现,含 §10 提到的 `git revert` 冲突处理)是 | @@ -460,3 +460,16 @@ U-041(回滚路径实现,含 §10 提到的 `git revert` 冲突处理)是 | ||
| 460 | ## 4. 与 06-验收与测试锚点.md 的对应关系 | 460 | ## 4. 与 06-验收与测试锚点.md 的对应关系 |
| 461 | 461 | ||
| 462 | 本文档不重新定义验收标准,Unit 的 Done 标准直接引用 06 节已有的 REQ-01~14 用例(happy/boundary/failure)。如果后续 06 节的验收用例发生变更,本文档对应 Unit 的 Done 标准需要同步更新,避免两份文档各说各话。 | 462 | 本文档不重新定义验收标准,Unit 的 Done 标准直接引用 06 节已有的 REQ-01~14 用例(happy/boundary/failure)。如果后续 06 节的验收用例发生变更,本文档对应 Unit 的 Done 标准需要同步更新,避免两份文档各说各话。 |
| 463 | + | ||
| 464 | +--- | ||
| 465 | + | ||
| 466 | +## 5. PH0-PH5 之外:用户直接请求的控制台功能扩展 | ||
| 467 | + | ||
| 468 | +PH0-PH5(45 个 Unit)全部完成、U-042 真实自举压测进行中期间,用户直接向 Claude Code(不经过 graphlemon 自己的 Planner/Coder/Validator 闭环,原因见下)提出了两个新的控制台页面需求,是对 02-V 原有非目标"不做完整多项目管理 UI(项目列表/切换)"的明确、有意突破,不是发现遗漏后的补充。 | ||
| 469 | + | ||
| 470 | +**为什么不走 graphlemon 自己的自举闭环去实现这个需求**:讨论过程中确认了当前 `implement_code()`(Coder 阶段)永远用一个写死的单节点 `graph_manifest`,从不调用 `plan_work_graph()` 规划自己的 Work Graph——不管 SDD 多详细,整个实现都只交给一次 Coder 会话,没有按视图/模块拆分执行的能力(U-031 当时明确把"造一个 Coder 图规划器"排除在集成范围之外)。这个需求涉及两个新页面、6+ 个新后端接口,用一次不可拆分的 Coder 会话去做风险高、不可控,所以直接由 Claude Code 实现,不走自举闭环。 | ||
| 471 | + | ||
| 472 | +- **`v_project_list`(新增)**:项目列表 + 增删改查。"新建/导入项目"复用已有的 `v_project_setup`/`c_project_create` 流程——`project_scaffold.create_project()` 原本就只在目标目录下新增 `.graphlemon/`,不碰任何已有代码,"导入一个已有代码的目录"这条诉求不需要新代码。真正新增的是读/改半边:`src/project_query.py`(`list_projects`/`get_project`/`update_project`),新增 HTTP `GET /api/projects`、`GET /api/projects/:id`、`PUT /api/projects/:id`、`DELETE /api/projects/:id`(`DELETE` 语义是把 `status` 置为 `archived`,不是物理删除行——`project_id` 被 `task`/`artifact`/`bug_record` 等大量外键引用,物理删除要么违反约束要么需要级联删掉这些本该保留的审计历史,01-M 自己的 `active`/`archived` 枚举本来就是软删除的设计)。 | ||
| 473 | +- **`v_goal_list`(对已有 `v_task_board`/`v_task_detail` 的扩展,非全新页面)**:搜索/筛选已有,新增排序(`task_query.list_tasks()` 加 `sort`/`order` 参数,白名单校验列名防注入);`v_task_detail` 新增"编辑目标"/"删除任务",仅在 `status='queued'` 时出现——一旦 Planner/Coder 已经基于这个 goal_text 产出过真实 artifact/work_graph_run,改写或删除就会让审计记录指向一个不存在的目标,这正是这个代码库一直在避免的不可追溯漂移,所以 `task_query.update_task_goal_text()`/`delete_task()` 都在非 `queued` 状态下拒绝(`TaskNotEditableError`,HTTP 409)。 | ||
| 474 | +- **真实验证时发现并修复一个真 bug**:`delete_task()` 第一版对一个真实创建的(走 `task_create.create_task()`,不是测试自己手写的 INSERT)`queued` 任务执行 `DELETE FROM task` 时,真实抛出 `sqlite3.IntegrityError`——`task_create.create_task()` 自己在同一个事务里总会写一条 `execution_event` 行(01-M 自己的外键,没有 `ON DELETE CASCADE`),哪怕这个任务从没被 Planner/Coder/Validator 碰过,也已经至少有一条自己创建时留下的事件。补上了同事务内先删这条任务自己的 `execution_event`(`queued` 态下不可能有真正的 artifact/bug_record/work_graph_run,所以这是唯一可能挡路的外键)。这次 bug 是测试自己的 `_insert_task` 辅助函数(手写 INSERT,从不写 `execution_event`)从没暴露过的——单测通过不代表真的能用,加了一个走真实 `task_create.create_task()` 路径的回归测试锁死。 | ||
| 475 | +- **验证方式**:`src/test_project_query.py`(12 个用例)、`src/test_task_query.py` 新增的排序/编辑/删除用例(含上面这条真实路径回归)、`src/test_backend_server.py` 新增两个真实 HTTP 路由测试类(`ProjectListAndCrudRouteTest`/`TaskCrudRouteTest`,真实起服务器+真实请求,不 mock)。浏览器扩展本次仍不可用,额外对着真实运行的服务器用 `curl` 走了一遍全部新增接口(含前面提到的真删除 bug 就是这一步抓到的)。474 个测试全绿。 |
frontend/app.js
| @@ -76,6 +76,7 @@ function connectLogTailSocket(nodeExecId, { onChunk, onClose }) { | @@ -76,6 +76,7 @@ function connectLogTailSocket(nodeExecId, { onChunk, onClose }) { | ||
| 76 | 76 | ||
| 77 | const routes = { | 77 | const routes = { |
| 78 | "#/": { render: renderDashboard, mount: mountDashboard }, | 78 | "#/": { render: renderDashboard, mount: mountDashboard }, |
| 79 | + "#/projects": { render: renderProjectList, mount: mountProjectList }, | ||
| 79 | "#/tasks": { render: renderTaskBoard, mount: mountTaskBoard }, | 80 | "#/tasks": { render: renderTaskBoard, mount: mountTaskBoard }, |
| 80 | "#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser }, | 81 | "#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser }, |
| 81 | "#/skills": { render: renderSkillLibrary, mount: mountSkillLibrary }, | 82 | "#/skills": { render: renderSkillLibrary, mount: mountSkillLibrary }, |
| @@ -132,6 +133,109 @@ function render() { | @@ -132,6 +133,109 @@ function render() { | ||
| 132 | window.addEventListener("hashchange", render); | 133 | window.addEventListener("hashchange", render); |
| 133 | window.addEventListener("DOMContentLoaded", render); | 134 | window.addEventListener("DOMContentLoaded", render); |
| 134 | 135 | ||
| 136 | +// ---- v_project_list (2026-08-03): project list + CRUD ---- | ||
| 137 | +// 02-V originally declared "不做完整多项目管理 UI(项目列表/切换)" as an | ||
| 138 | +// MVP non-goal -- this page is a deliberate, explicit scope expansion | ||
| 139 | +// beyond that, requested directly by the user. Creation still goes through | ||
| 140 | +// v_project_setup's existing flow (pick a folder, optionally git-init it, | ||
| 141 | +// project_scaffold.create_project() already handles a folder that already | ||
| 142 | +// has real code in it correctly -- it only ever adds .graphlemon/ inside, | ||
| 143 | +// nothing about "importing" needs to be built separately). This page adds | ||
| 144 | +// the missing read/update/archive half: list every project, rename, | ||
| 145 | +// archive/reactivate, and select one as "current" to work in. | ||
| 146 | + | ||
| 147 | +function renderProjectList() { | ||
| 148 | + return ` | ||
| 149 | + <div class="page page-wide"> | ||
| 150 | + <h1>项目</h1> | ||
| 151 | + <div class="actions" style="margin-top:0;"> | ||
| 152 | + <a class="btn btn-primary" href="#/setup">+ 新建 / 导入项目</a> | ||
| 153 | + </div> | ||
| 154 | + <table class="data-table"> | ||
| 155 | + <thead> | ||
| 156 | + <tr><th>名称</th><th>目标目录</th><th>状态</th><th>Goal 数</th><th>创建时间</th><th></th></tr> | ||
| 157 | + </thead> | ||
| 158 | + <tbody id="pl_rows"></tbody> | ||
| 159 | + </table> | ||
| 160 | + </div> | ||
| 161 | + `; | ||
| 162 | +} | ||
| 163 | + | ||
| 164 | +function mountProjectList() { | ||
| 165 | + const rowsEl = document.getElementById("pl_rows"); | ||
| 166 | + | ||
| 167 | + async function refresh() { | ||
| 168 | + const resp = await fetch("/api/projects"); | ||
| 169 | + const projects = await resp.json(); | ||
| 170 | + if (projects.length === 0) { | ||
| 171 | + rowsEl.innerHTML = '<tr><td colspan="6">还没有项目,<a href="#/setup">创建一个</a></td></tr>'; | ||
| 172 | + return; | ||
| 173 | + } | ||
| 174 | + rowsEl.innerHTML = projects.map((p) => ( | ||
| 175 | + '<tr data-project-id="' + p.project_id + '">' | ||
| 176 | + + '<td><span class="pl-name-view" data-project-id="' + p.project_id + '">' + escapeHtml(p.name) + "</span>" | ||
| 177 | + + '<span class="pl-name-edit" data-project-id="' + p.project_id + '" style="display:none;">' | ||
| 178 | + + '<input type="text" class="pl-name-input" value="' + escapeHtml(p.name) + '">' | ||
| 179 | + + '<button class="btn" data-action="save-rename" data-project-id="' + p.project_id + '" type="button">✓</button>' | ||
| 180 | + + '<button class="btn" data-action="cancel-rename" data-project-id="' + p.project_id + '" type="button">✕</button>' | ||
| 181 | + + "</span></td>" | ||
| 182 | + + "<td>" + escapeHtml(p.target_folder_path) + "</td>" | ||
| 183 | + + '<td><span class="badge ' + (p.status === "active" ? "done" : "") + '">' + p.status + "</span></td>" | ||
| 184 | + + "<td>" + p.goal_count + "</td>" | ||
| 185 | + + "<td>" + escapeHtml(p.created_at) + "</td>" | ||
| 186 | + + "<td>" | ||
| 187 | + + '<button class="btn" data-action="select" data-project-id="' + p.project_id + '" type="button">选择</button> ' | ||
| 188 | + + '<button class="btn" data-action="rename" data-project-id="' + p.project_id + '" type="button">重命名</button> ' | ||
| 189 | + + '<button class="btn' + (p.status === "active" ? " btn-danger" : "") + '" data-action="toggle-status" ' | ||
| 190 | + + 'data-project-id="' + p.project_id + '" data-current-status="' + p.status + '" type="button">' | ||
| 191 | + + (p.status === "active" ? "归档" : "恢复") | ||
| 192 | + + "</button>" | ||
| 193 | + + "</td>" | ||
| 194 | + + "</tr>" | ||
| 195 | + )).join(""); | ||
| 196 | + | ||
| 197 | + for (const btn of rowsEl.querySelectorAll('button[data-action="select"]')) { | ||
| 198 | + btn.addEventListener("click", () => { | ||
| 199 | + setCurrentProjectId(btn.dataset.projectId); | ||
| 200 | + window.location.hash = "#/tasks"; | ||
| 201 | + }); | ||
| 202 | + } | ||
| 203 | + for (const btn of rowsEl.querySelectorAll('button[data-action="rename"]')) { | ||
| 204 | + btn.addEventListener("click", () => { | ||
| 205 | + const id = btn.dataset.projectId; | ||
| 206 | + rowsEl.querySelector('.pl-name-view[data-project-id="' + id + '"]').style.display = "none"; | ||
| 207 | + rowsEl.querySelector('.pl-name-edit[data-project-id="' + id + '"]').style.display = "inline-block"; | ||
| 208 | + }); | ||
| 209 | + } | ||
| 210 | + for (const span of rowsEl.querySelectorAll(".pl-name-edit")) { | ||
| 211 | + const id = span.dataset.projectId; | ||
| 212 | + span.querySelector('[data-action="cancel-rename"]').addEventListener("click", () => { | ||
| 213 | + span.style.display = "none"; | ||
| 214 | + rowsEl.querySelector('.pl-name-view[data-project-id="' + id + '"]').style.display = ""; | ||
| 215 | + }); | ||
| 216 | + span.querySelector('[data-action="save-rename"]').addEventListener("click", () => { | ||
| 217 | + const newName = span.querySelector(".pl-name-input").value.trim(); | ||
| 218 | + if (!newName) return; | ||
| 219 | + fetch("/api/projects/" + encodeURIComponent(id), { | ||
| 220 | + method: "PUT", | ||
| 221 | + body: JSON.stringify({ name: newName }), | ||
| 222 | + }).then(refresh); | ||
| 223 | + }); | ||
| 224 | + } | ||
| 225 | + for (const btn of rowsEl.querySelectorAll('button[data-action="toggle-status"]')) { | ||
| 226 | + btn.addEventListener("click", () => { | ||
| 227 | + const nextStatus = btn.dataset.currentStatus === "active" ? "archived" : "active"; | ||
| 228 | + fetch("/api/projects/" + encodeURIComponent(btn.dataset.projectId), { | ||
| 229 | + method: "PUT", | ||
| 230 | + body: JSON.stringify({ status: nextStatus }), | ||
| 231 | + }).then(refresh); | ||
| 232 | + }); | ||
| 233 | + } | ||
| 234 | + } | ||
| 235 | + | ||
| 236 | + refresh(); | ||
| 237 | +} | ||
| 238 | + | ||
| 135 | // ---- v_project_setup (02-V §2.1): create/switch project ---- | 239 | // ---- v_project_setup (02-V §2.1): create/switch project ---- |
| 136 | 240 | ||
| 137 | function renderProjectSetup() { | 241 | function renderProjectSetup() { |
| @@ -585,6 +689,9 @@ function renderTaskBoard() { | @@ -585,6 +689,9 @@ function renderTaskBoard() { | ||
| 585 | return ` | 689 | return ` |
| 586 | <div class="page page-wide"> | 690 | <div class="page page-wide"> |
| 587 | <h1>任务看板</h1> | 691 | <h1>任务看板</h1> |
| 692 | + <div class="actions" style="margin-top:0;"> | ||
| 693 | + <a class="btn btn-primary" href="#/goals/new">+ 提出新目标</a> | ||
| 694 | + </div> | ||
| 588 | <div class="filters"> | 695 | <div class="filters"> |
| 589 | <input type="text" id="tb_keyword" placeholder="搜索 goal_text" maxlength="128"> | 696 | <input type="text" id="tb_keyword" placeholder="搜索 goal_text" maxlength="128"> |
| 590 | <select id="tb_status"> | 697 | <select id="tb_status"> |
| @@ -597,6 +704,14 @@ function renderTaskBoard() { | @@ -597,6 +704,14 @@ function renderTaskBoard() { | ||
| 597 | <option value="coder">coder</option> | 704 | <option value="coder">coder</option> |
| 598 | <option value="validator">validator</option> | 705 | <option value="validator">validator</option> |
| 599 | </select> | 706 | </select> |
| 707 | + <select id="tb_sort"> | ||
| 708 | + <option value="updated_at:desc">按更新时间(新→旧)</option> | ||
| 709 | + <option value="updated_at:asc">按更新时间(旧→新)</option> | ||
| 710 | + <option value="created_at:desc">按创建时间(新→旧)</option> | ||
| 711 | + <option value="created_at:asc">按创建时间(旧→新)</option> | ||
| 712 | + <option value="goal_text:asc">按 goal_text(A→Z)</option> | ||
| 713 | + <option value="status:asc">按状态</option> | ||
| 714 | + </select> | ||
| 600 | </div> | 715 | </div> |
| 601 | <table class="data-table"> | 716 | <table class="data-table"> |
| 602 | <thead> | 717 | <thead> |
| @@ -615,12 +730,16 @@ function mountTaskBoard() { | @@ -615,12 +730,16 @@ function mountTaskBoard() { | ||
| 615 | const keywordEl = document.getElementById("tb_keyword"); | 730 | const keywordEl = document.getElementById("tb_keyword"); |
| 616 | const statusEl = document.getElementById("tb_status"); | 731 | const statusEl = document.getElementById("tb_status"); |
| 617 | const orgNodeEl = document.getElementById("tb_org_node"); | 732 | const orgNodeEl = document.getElementById("tb_org_node"); |
| 733 | + const sortEl = document.getElementById("tb_sort"); | ||
| 618 | 734 | ||
| 619 | async function refresh() { | 735 | async function refresh() { |
| 620 | const params = new URLSearchParams({ project_id: projectId }); | 736 | const params = new URLSearchParams({ project_id: projectId }); |
| 621 | if (keywordEl.value.trim()) params.set("keyword", keywordEl.value.trim()); | 737 | if (keywordEl.value.trim()) params.set("keyword", keywordEl.value.trim()); |
| 622 | if (statusEl.value) params.set("status", statusEl.value); | 738 | if (statusEl.value) params.set("status", statusEl.value); |
| 623 | if (orgNodeEl.value) params.set("org_node", orgNodeEl.value); | 739 | if (orgNodeEl.value) params.set("org_node", orgNodeEl.value); |
| 740 | + const [sortField, sortOrder] = sortEl.value.split(":"); | ||
| 741 | + params.set("sort", sortField); | ||
| 742 | + params.set("order", sortOrder); | ||
| 624 | const resp = await fetch("/api/tasks?" + params.toString()); | 743 | const resp = await fetch("/api/tasks?" + params.toString()); |
| 625 | const tasks = await resp.json(); | 744 | const tasks = await resp.json(); |
| 626 | if (tasks.length === 0) { | 745 | if (tasks.length === 0) { |
| @@ -647,6 +766,7 @@ function mountTaskBoard() { | @@ -647,6 +766,7 @@ function mountTaskBoard() { | ||
| 647 | keywordEl.addEventListener("input", refresh); | 766 | keywordEl.addEventListener("input", refresh); |
| 648 | statusEl.addEventListener("change", refresh); | 767 | statusEl.addEventListener("change", refresh); |
| 649 | orgNodeEl.addEventListener("change", refresh); | 768 | orgNodeEl.addEventListener("change", refresh); |
| 769 | + sortEl.addEventListener("change", refresh); | ||
| 650 | refresh(); | 770 | refresh(); |
| 651 | 771 | ||
| 652 | let wasDisconnected = false; | 772 | let wasDisconnected = false; |
| @@ -933,8 +1053,36 @@ function mountTaskDetail(taskId) { | @@ -933,8 +1053,36 @@ function mountTaskDetail(taskId) { | ||
| 933 | ).join("") + "</ul>" | 1053 | ).join("") + "</ul>" |
| 934 | ) : '<div class="hint">暂无 Bug 记录</div>'; | 1054 | ) : '<div class="hint">暂无 Bug 记录</div>'; |
| 935 | 1055 | ||
| 1056 | + // v_goal_list CRUD (2026-08-03): editing/deleting a goal is only safe | ||
| 1057 | + // while it's still 'queued' -- once real work has started, artifacts/ | ||
| 1058 | + // bug_records/work_graph_runs all reference this task_id, and the | ||
| 1059 | + // backend (task_query.TaskNotEditableError) rejects both operations | ||
| 1060 | + // past that point anyway; hiding the controls here just avoids | ||
| 1061 | + // presenting an action that would visibly fail. | ||
| 1062 | + const goalCrudHtml = task.status !== "queued" ? "" : ( | ||
| 1063 | + '<div class="btn-row" style="margin:0 0 16px;">' | ||
| 1064 | + + '<button class="btn" id="btn_edit_goal_text" type="button">编辑目标</button>' | ||
| 1065 | + + '<button class="btn btn-danger" id="btn_delete_goal" type="button">删除任务</button>' | ||
| 1066 | + + "</div>" | ||
| 1067 | + + '<div class="panel" id="edit_goal_panel" style="display:none;">' | ||
| 1068 | + + '<div class="field"><label>目标描述(goal_text)</label>' | ||
| 1069 | + + '<textarea id="edit_goal_textarea" rows="4">' + escapeHtml(task.goal_text) + "</textarea></div>" | ||
| 1070 | + + '<div id="edit_goal_error" class="error-banner" style="display:none;"></div>' | ||
| 1071 | + + '<div class="btn-row">' | ||
| 1072 | + + '<button class="btn" id="btn_cancel_edit_goal" type="button">取消</button>' | ||
| 1073 | + + '<button class="btn btn-primary" id="btn_save_goal_text" type="button">保存</button>' | ||
| 1074 | + + "</div></div>" | ||
| 1075 | + + '<div class="confirm-banner" id="delete_goal_confirm" style="display:none;">' | ||
| 1076 | + + "确认删除这个任务?此操作不可撤销。" | ||
| 1077 | + + '<div class="btn-row" style="margin-top:10px;">' | ||
| 1078 | + + '<button class="btn" id="btn_cancel_delete_goal" type="button">取消</button>' | ||
| 1079 | + + '<button class="btn btn-danger" id="btn_confirm_delete_goal" type="button">确认删除</button>' | ||
| 1080 | + + "</div></div>" | ||
| 1081 | + ); | ||
| 1082 | + | ||
| 936 | content.innerHTML = ( | 1083 | content.innerHTML = ( |
| 937 | "<h1>" + escapeHtml(task.goal_text) + "</h1>" | 1084 | "<h1>" + escapeHtml(task.goal_text) + "</h1>" |
| 1085 | + + goalCrudHtml | ||
| 938 | + alertHtml | 1086 | + alertHtml |
| 939 | + '<div class="meta-row">' | 1087 | + '<div class="meta-row">' |
| 940 | + "<div><b>STATUS</b>" + escapeHtml(task.status) + "</div>" | 1088 | + "<div><b>STATUS</b>" + escapeHtml(task.status) + "</div>" |
| @@ -977,6 +1125,56 @@ function mountTaskDetail(taskId) { | @@ -977,6 +1125,56 @@ function mountTaskDetail(taskId) { | ||
| 977 | refresh(); | 1125 | refresh(); |
| 978 | }); | 1126 | }); |
| 979 | } | 1127 | } |
| 1128 | + | ||
| 1129 | + const editBtn = document.getElementById("btn_edit_goal_text"); | ||
| 1130 | + const deleteBtn = document.getElementById("btn_delete_goal"); | ||
| 1131 | + if (editBtn) { | ||
| 1132 | + const editPanel = document.getElementById("edit_goal_panel"); | ||
| 1133 | + const deleteConfirm = document.getElementById("delete_goal_confirm"); | ||
| 1134 | + const textarea = document.getElementById("edit_goal_textarea"); | ||
| 1135 | + const errorBox = document.getElementById("edit_goal_error"); | ||
| 1136 | + | ||
| 1137 | + editBtn.addEventListener("click", () => { | ||
| 1138 | + deleteConfirm.style.display = "none"; | ||
| 1139 | + editPanel.style.display = "block"; | ||
| 1140 | + }); | ||
| 1141 | + document.getElementById("btn_cancel_edit_goal").addEventListener("click", () => { | ||
| 1142 | + editPanel.style.display = "none"; | ||
| 1143 | + }); | ||
| 1144 | + document.getElementById("btn_save_goal_text").addEventListener("click", async () => { | ||
| 1145 | + errorBox.style.display = "none"; | ||
| 1146 | + const goalText = textarea.value.trim(); | ||
| 1147 | + if (!goalText) { | ||
| 1148 | + errorBox.textContent = "目标描述不能为空"; | ||
| 1149 | + errorBox.style.display = "block"; | ||
| 1150 | + return; | ||
| 1151 | + } | ||
| 1152 | + const resp = await fetch("/api/tasks/" + taskId, { | ||
| 1153 | + method: "PUT", | ||
| 1154 | + body: JSON.stringify({ goal_text: goalText }), | ||
| 1155 | + }); | ||
| 1156 | + const body = await resp.json(); | ||
| 1157 | + if (resp.status !== 200) { | ||
| 1158 | + errorBox.textContent = body.error + (body.detail ? ":" + body.detail : ""); | ||
| 1159 | + errorBox.style.display = "block"; | ||
| 1160 | + return; | ||
| 1161 | + } | ||
| 1162 | + editPanel.style.display = "none"; | ||
| 1163 | + refresh(); | ||
| 1164 | + }); | ||
| 1165 | + | ||
| 1166 | + deleteBtn.addEventListener("click", () => { | ||
| 1167 | + editPanel.style.display = "none"; | ||
| 1168 | + deleteConfirm.style.display = "block"; | ||
| 1169 | + }); | ||
| 1170 | + document.getElementById("btn_cancel_delete_goal").addEventListener("click", () => { | ||
| 1171 | + deleteConfirm.style.display = "none"; | ||
| 1172 | + }); | ||
| 1173 | + document.getElementById("btn_confirm_delete_goal").addEventListener("click", async () => { | ||
| 1174 | + await fetch("/api/tasks/" + taskId, { method: "DELETE" }); | ||
| 1175 | + window.location.hash = "#/tasks"; | ||
| 1176 | + }); | ||
| 1177 | + } | ||
| 980 | } | 1178 | } |
| 981 | 1179 | ||
| 982 | async function refresh() { | 1180 | async function refresh() { |
frontend/index.html
| @@ -8,6 +8,7 @@ | @@ -8,6 +8,7 @@ | ||
| 8 | <body> | 8 | <body> |
| 9 | <nav id="nav"> | 9 | <nav id="nav"> |
| 10 | <a href="#/">Dashboard</a> | 10 | <a href="#/">Dashboard</a> |
| 11 | + <a href="#/projects">Projects</a> | ||
| 11 | <a href="#/tasks">Tasks</a> | 12 | <a href="#/tasks">Tasks</a> |
| 12 | <a href="#/artifacts">Artifacts</a> | 13 | <a href="#/artifacts">Artifacts</a> |
| 13 | <a href="#/skills">Skills</a> | 14 | <a href="#/skills">Skills</a> |
src/backend_server.py
| @@ -38,7 +38,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | @@ -38,7 +38,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | ||
| 38 | 38 | ||
| 39 | from src import ( | 39 | from src import ( |
| 40 | artifact_query, budget_guardrail, bug_record, deployment_query, execution_log, | 40 | artifact_query, budget_guardrail, bug_record, deployment_query, execution_log, |
| 41 | - org_loop_route, paths, project_scaffold, schema, settings_config, | 41 | + org_loop_route, paths, project_query, project_scaffold, schema, settings_config, |
| 42 | skill_query, skill_update, task_create, task_query, ws_subscribe, | 42 | skill_query, skill_update, task_create, task_query, ws_subscribe, |
| 43 | ) | 43 | ) |
| 44 | 44 | ||
| @@ -121,6 +121,7 @@ def _pick_folder_linux(): | @@ -121,6 +121,7 @@ def _pick_folder_linux(): | ||
| 121 | 121 | ||
| 122 | 122 | ||
| 123 | _WS_PROJECTS_PREFIX = "/api/ws/projects/" | 123 | _WS_PROJECTS_PREFIX = "/api/ws/projects/" |
| 124 | +_PROJECTS_PREFIX = "/api/projects/" | ||
| 124 | _TASKS_PREFIX = "/api/tasks/" | 125 | _TASKS_PREFIX = "/api/tasks/" |
| 125 | _WORK_NODES_PREFIX = "/api/work-nodes/" | 126 | _WORK_NODES_PREFIX = "/api/work-nodes/" |
| 126 | _WS_WORK_NODES_PREFIX = "/api/ws/work-nodes/" | 127 | _WS_WORK_NODES_PREFIX = "/api/ws/work-nodes/" |
| @@ -143,6 +144,14 @@ class _Handler(BaseHTTPRequestHandler): | @@ -143,6 +144,14 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 143 | if parsed.path.startswith(_WS_PROJECTS_PREFIX): | 144 | if parsed.path.startswith(_WS_PROJECTS_PREFIX): |
| 144 | self._handle_ws_subscribe(parsed.path, parsed.query) | 145 | self._handle_ws_subscribe(parsed.path, parsed.query) |
| 145 | return | 146 | return |
| 147 | + if parsed.path == "/api/projects": | ||
| 148 | + self._handle_project_list() | ||
| 149 | + return | ||
| 150 | + if parsed.path.startswith(_PROJECTS_PREFIX): | ||
| 151 | + project_id = parsed.path[len(_PROJECTS_PREFIX):] | ||
| 152 | + if project_id and "/" not in project_id: | ||
| 153 | + self._handle_project_get(project_id) | ||
| 154 | + return | ||
| 146 | if parsed.path == "/api/tasks": | 155 | if parsed.path == "/api/tasks": |
| 147 | self._handle_task_list(parsed.query) | 156 | self._handle_task_list(parsed.query) |
| 148 | return | 157 | return |
| @@ -232,6 +241,18 @@ class _Handler(BaseHTTPRequestHandler): | @@ -232,6 +241,18 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 232 | self._handle_budget_update() | 241 | self._handle_budget_update() |
| 233 | elif self.path == "/api/settings/log-retention": | 242 | elif self.path == "/api/settings/log-retention": |
| 234 | self._handle_log_retention_update() | 243 | self._handle_log_retention_update() |
| 244 | + elif self.path.startswith(_PROJECTS_PREFIX) and "/" not in self.path[len(_PROJECTS_PREFIX):]: | ||
| 245 | + self._handle_project_update(self.path[len(_PROJECTS_PREFIX):]) | ||
| 246 | + elif self.path.startswith(_TASKS_PREFIX) and "/" not in self.path[len(_TASKS_PREFIX):]: | ||
| 247 | + self._handle_task_update(self.path[len(_TASKS_PREFIX):]) | ||
| 248 | + else: | ||
| 249 | + self._send_json(404, {"error": "NOT_FOUND"}) | ||
| 250 | + | ||
| 251 | + def do_DELETE(self): | ||
| 252 | + if self.path.startswith(_PROJECTS_PREFIX) and "/" not in self.path[len(_PROJECTS_PREFIX):]: | ||
| 253 | + self._handle_project_delete(self.path[len(_PROJECTS_PREFIX):]) | ||
| 254 | + elif self.path.startswith(_TASKS_PREFIX) and "/" not in self.path[len(_TASKS_PREFIX):]: | ||
| 255 | + self._handle_task_delete(self.path[len(_TASKS_PREFIX):]) | ||
| 235 | else: | 256 | else: |
| 236 | self._send_json(404, {"error": "NOT_FOUND"}) | 257 | self._send_json(404, {"error": "NOT_FOUND"}) |
| 237 | 258 | ||
| @@ -292,6 +313,49 @@ class _Handler(BaseHTTPRequestHandler): | @@ -292,6 +313,49 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 292 | else: | 313 | else: |
| 293 | self._send_json(200, result) | 314 | self._send_json(200, result) |
| 294 | 315 | ||
| 316 | + def _handle_project_list(self): | ||
| 317 | + with self.server.conn_lock: | ||
| 318 | + result = project_query.list_projects(self.server.conn) | ||
| 319 | + self._send_json(200, result) | ||
| 320 | + | ||
| 321 | + def _handle_project_get(self, project_id): | ||
| 322 | + try: | ||
| 323 | + with self.server.conn_lock: | ||
| 324 | + result = project_query.get_project(self.server.conn, project_id) | ||
| 325 | + except project_query.ProjectNotFoundError: | ||
| 326 | + self._send_json(404, {"error": "NOT_FOUND"}) | ||
| 327 | + else: | ||
| 328 | + self._send_json(200, result) | ||
| 329 | + | ||
| 330 | + def _handle_project_update(self, project_id): | ||
| 331 | + payload, error = self._read_json_body() | ||
| 332 | + if error: | ||
| 333 | + self._send_json(400, error) | ||
| 334 | + return | ||
| 335 | + try: | ||
| 336 | + with self.server.conn_lock: | ||
| 337 | + result = project_query.update_project( | ||
| 338 | + self.server.conn, project_id, name=payload.get("name"), status=payload.get("status"), | ||
| 339 | + ) | ||
| 340 | + except project_query.ProjectNotFoundError: | ||
| 341 | + self._send_json(404, {"error": "NOT_FOUND"}) | ||
| 342 | + except project_query.InvalidParamError as e: | ||
| 343 | + self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)}) | ||
| 344 | + else: | ||
| 345 | + self._send_json(200, result) | ||
| 346 | + | ||
| 347 | + def _handle_project_delete(self, project_id): | ||
| 348 | + """"Delete" archives (project.status='archived') rather than | ||
| 349 | + removing the row -- see project_query.py's own docstring for why a | ||
| 350 | + real DELETE isn't safe here (FK-referenced history).""" | ||
| 351 | + try: | ||
| 352 | + with self.server.conn_lock: | ||
| 353 | + result = project_query.update_project(self.server.conn, project_id, status="archived") | ||
| 354 | + except project_query.ProjectNotFoundError: | ||
| 355 | + self._send_json(404, {"error": "NOT_FOUND"}) | ||
| 356 | + else: | ||
| 357 | + self._send_json(200, result) | ||
| 358 | + | ||
| 295 | def _handle_task_create(self): | 359 | def _handle_task_create(self): |
| 296 | payload, error = self._read_json_body() | 360 | payload, error = self._read_json_body() |
| 297 | if error: | 361 | if error: |
| @@ -336,14 +400,20 @@ class _Handler(BaseHTTPRequestHandler): | @@ -336,14 +400,20 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 336 | if not project_id: | 400 | if not project_id: |
| 337 | self._send_json(400, {"error": "INVALID_INPUT", "detail": "project_id is required"}) | 401 | self._send_json(400, {"error": "INVALID_INPUT", "detail": "project_id is required"}) |
| 338 | return | 402 | return |
| 339 | - with self.server.conn_lock: | ||
| 340 | - result = task_query.list_tasks( | ||
| 341 | - self.server.conn, project_id, | ||
| 342 | - status=params.get("status", [None])[0], | ||
| 343 | - org_node=params.get("org_node", [None])[0], | ||
| 344 | - keyword=params.get("keyword", [None])[0], | ||
| 345 | - ) | ||
| 346 | - self._send_json(200, result) | 403 | + try: |
| 404 | + with self.server.conn_lock: | ||
| 405 | + result = task_query.list_tasks( | ||
| 406 | + self.server.conn, project_id, | ||
| 407 | + status=params.get("status", [None])[0], | ||
| 408 | + org_node=params.get("org_node", [None])[0], | ||
| 409 | + keyword=params.get("keyword", [None])[0], | ||
| 410 | + sort=params.get("sort", [None])[0], | ||
| 411 | + order=params.get("order", [None])[0], | ||
| 412 | + ) | ||
| 413 | + except task_query.InvalidParamError as e: | ||
| 414 | + self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)}) | ||
| 415 | + else: | ||
| 416 | + self._send_json(200, result) | ||
| 347 | 417 | ||
| 348 | def _handle_task_get(self, task_id): | 418 | def _handle_task_get(self, task_id): |
| 349 | try: | 419 | try: |
| @@ -354,6 +424,36 @@ class _Handler(BaseHTTPRequestHandler): | @@ -354,6 +424,36 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 354 | else: | 424 | else: |
| 355 | self._send_json(200, result) | 425 | self._send_json(200, result) |
| 356 | 426 | ||
| 427 | + def _handle_task_update(self, task_id): | ||
| 428 | + payload, error = self._read_json_body() | ||
| 429 | + if error: | ||
| 430 | + self._send_json(400, error) | ||
| 431 | + return | ||
| 432 | + goal_text = payload.get("goal_text") | ||
| 433 | + if not goal_text: | ||
| 434 | + self._send_json(400, {"error": "INVALID_PARAM", "detail": "goal_text is required"}) | ||
| 435 | + return | ||
| 436 | + try: | ||
| 437 | + with self.server.conn_lock: | ||
| 438 | + result = task_query.update_task_goal_text(self.server.conn, task_id, goal_text) | ||
| 439 | + except task_query.TaskNotFoundError: | ||
| 440 | + self._send_json(404, {"error": "NOT_FOUND"}) | ||
| 441 | + except task_query.TaskNotEditableError as e: | ||
| 442 | + self._send_json(409, {"error": "TASK_NOT_EDITABLE", "detail": str(e)}) | ||
| 443 | + else: | ||
| 444 | + self._send_json(200, result) | ||
| 445 | + | ||
| 446 | + def _handle_task_delete(self, task_id): | ||
| 447 | + try: | ||
| 448 | + with self.server.conn_lock: | ||
| 449 | + task_query.delete_task(self.server.conn, task_id) | ||
| 450 | + except task_query.TaskNotFoundError: | ||
| 451 | + self._send_json(404, {"error": "NOT_FOUND"}) | ||
| 452 | + except task_query.TaskNotEditableError as e: | ||
| 453 | + self._send_json(409, {"error": "TASK_NOT_EDITABLE", "detail": str(e)}) | ||
| 454 | + else: | ||
| 455 | + self._send_json(200, {"task_id": task_id, "deleted": True}) | ||
| 456 | + | ||
| 357 | def _handle_task_route(self, task_id): | 457 | def _handle_task_route(self, task_id): |
| 358 | """c_org_loop_route (03-C §3.11) -- the underlying route_org_loop() | 458 | """c_org_loop_route (03-C §3.11) -- the underlying route_org_loop() |
| 359 | was built in U-016 with no HTTP route until now; v_task_detail's | 459 | was built in U-016 with no HTTP route until now; v_task_detail's |
src/project_query.py
0 → 100644
| 1 | +"""c_project_list / c_project_get / c_project_update (2026-08-03, v_project_list): | ||
| 2 | +02-V originally declared "不做完整多项目管理 UI(项目列表/切换)" as an MVP | ||
| 3 | +non-goal -- this is a deliberate, explicit scope expansion beyond that, | ||
| 4 | +requested directly by the user rather than found while implementing | ||
| 5 | +something else. Same read/update-query pattern as task_query.py. | ||
| 6 | + | ||
| 7 | +"Delete" maps to `project.status = 'archived'`, never a physical row | ||
| 8 | +delete -- 01-M's own status enum (`active`/`archived`) already models this | ||
| 9 | +as a soft-delete concept, and `project_id` is referenced via FK from | ||
| 10 | +task/artifact/bug_record/work_graph_run/etc.; a literal DELETE would either | ||
| 11 | +violate those constraints or need a destructive cascade that throws away | ||
| 12 | +real audit history this codebase otherwise goes out of its way to | ||
| 13 | +preserve (Git-tracked artifacts, bug records, ...). `c_project_create` | ||
| 14 | +(project_scaffold.py) already handles "point at a folder that already has | ||
| 15 | +real code in it" correctly -- it only ever creates `.graphlemon/` inside | ||
| 16 | +the target folder and never touches anything else there -- so importing an | ||
| 17 | +existing codebase needs no changes here, just this module's read/update | ||
| 18 | +side. | ||
| 19 | +""" | ||
| 20 | + | ||
| 21 | +_VALID_STATUSES = ("active", "archived") | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +class ProjectNotFoundError(Exception): | ||
| 25 | + """NOT_FOUND.""" | ||
| 26 | + | ||
| 27 | + | ||
| 28 | +class InvalidParamError(Exception): | ||
| 29 | + """INVALID_PARAM.""" | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +def list_projects(conn): | ||
| 33 | + rows = conn.execute( | ||
| 34 | + "SELECT project_id, name, target_folder_path, has_git_repo, status, created_at " | ||
| 35 | + "FROM project ORDER BY created_at DESC" | ||
| 36 | + ).fetchall() | ||
| 37 | + projects = [] | ||
| 38 | + for r in rows: | ||
| 39 | + goal_count = conn.execute("SELECT COUNT(*) FROM task WHERE project_id = ?", (r[0],)).fetchone()[0] | ||
| 40 | + projects.append({ | ||
| 41 | + "project_id": r[0], "name": r[1], "target_folder_path": r[2], | ||
| 42 | + "has_git_repo": bool(r[3]), "status": r[4], "created_at": r[5], "goal_count": goal_count, | ||
| 43 | + }) | ||
| 44 | + return projects | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +def get_project(conn, project_id): | ||
| 48 | + row = conn.execute( | ||
| 49 | + "SELECT project_id, name, target_folder_path, has_git_repo, graphlemon_subdir_initialized, " | ||
| 50 | + "status, created_at FROM project WHERE project_id = ?", | ||
| 51 | + (project_id,), | ||
| 52 | + ).fetchone() | ||
| 53 | + if row is None: | ||
| 54 | + raise ProjectNotFoundError(project_id) | ||
| 55 | + goal_count = conn.execute("SELECT COUNT(*) FROM task WHERE project_id = ?", (project_id,)).fetchone()[0] | ||
| 56 | + return { | ||
| 57 | + "project_id": row[0], "name": row[1], "target_folder_path": row[2], | ||
| 58 | + "has_git_repo": bool(row[3]), "graphlemon_subdir_initialized": bool(row[4]), | ||
| 59 | + "status": row[5], "created_at": row[6], "goal_count": goal_count, | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + | ||
| 63 | +def update_project(conn, project_id, name=None, status=None): | ||
| 64 | + """Returns the updated project (same shape as get_project). Either | ||
| 65 | + field may be omitted; a call with neither is a harmless no-op read.""" | ||
| 66 | + if conn.execute("SELECT 1 FROM project WHERE project_id = ?", (project_id,)).fetchone() is None: | ||
| 67 | + raise ProjectNotFoundError(project_id) | ||
| 68 | + if status is not None and status not in _VALID_STATUSES: | ||
| 69 | + raise InvalidParamError("status must be one of {}, got {!r}".format(_VALID_STATUSES, status)) | ||
| 70 | + if name is not None: | ||
| 71 | + if not name.strip(): | ||
| 72 | + raise InvalidParamError("name cannot be blank") | ||
| 73 | + conn.execute("UPDATE project SET name = ? WHERE project_id = ?", (name, project_id)) | ||
| 74 | + if status is not None: | ||
| 75 | + conn.execute("UPDATE project SET status = ? WHERE project_id = ?", (status, project_id)) | ||
| 76 | + conn.commit() | ||
| 77 | + return get_project(conn, project_id) |
src/task_query.py
| @@ -13,8 +13,21 @@ forcing the frontend to make N more round trips. | @@ -13,8 +13,21 @@ forcing the frontend to make N more round trips. | ||
| 13 | 13 | ||
| 14 | import json | 14 | import json |
| 15 | 15 | ||
| 16 | +_SORT_COLUMNS = ("created_at", "updated_at", "goal_text", "status") | ||
| 16 | 17 | ||
| 17 | -def list_tasks(conn, project_id, status=None, org_node=None, keyword=None): | 18 | + |
| 19 | +class InvalidParamError(Exception): | ||
| 20 | + """INVALID_PARAM -- e.g. an unrecognized `sort` column.""" | ||
| 21 | + | ||
| 22 | + | ||
| 23 | +def list_tasks(conn, project_id, status=None, org_node=None, keyword=None, sort=None, order=None): | ||
| 24 | + """`sort`/`order` (2026-08-03, v_goal_list): defaults preserve the | ||
| 25 | + original `updated_at DESC` behavior every existing caller (v_task_board) | ||
| 26 | + already depends on. `sort` is validated against a fixed column | ||
| 27 | + whitelist and never interpolated as anything other than one of those | ||
| 28 | + literal strings -- this is the one query in this module built as a | ||
| 29 | + dynamic ORDER BY, so it's worth being explicit that user input never | ||
| 30 | + reaches the SQL text itself, only a pre-approved column name.""" | ||
| 18 | query = ( | 31 | query = ( |
| 19 | "SELECT task_id, goal_text, task_type, status, assigned_org_node, loop_round, " | 32 | "SELECT task_id, goal_text, task_type, status, assigned_org_node, loop_round, " |
| 20 | "created_at, updated_at FROM task WHERE project_id = ?" | 33 | "created_at, updated_at FROM task WHERE project_id = ?" |
| @@ -29,7 +42,13 @@ def list_tasks(conn, project_id, status=None, org_node=None, keyword=None): | @@ -29,7 +42,13 @@ def list_tasks(conn, project_id, status=None, org_node=None, keyword=None): | ||
| 29 | if keyword: | 42 | if keyword: |
| 30 | query += " AND goal_text LIKE ?" | 43 | query += " AND goal_text LIKE ?" |
| 31 | params.append("%" + keyword + "%") | 44 | params.append("%" + keyword + "%") |
| 32 | - query += " ORDER BY updated_at DESC" | 45 | + |
| 46 | + sort_column = sort or "updated_at" | ||
| 47 | + if sort_column not in _SORT_COLUMNS: | ||
| 48 | + raise InvalidParamError("sort must be one of {}, got {!r}".format(_SORT_COLUMNS, sort)) | ||
| 49 | + sort_direction = "ASC" if (order or "desc").lower() == "asc" else "DESC" | ||
| 50 | + query += " ORDER BY {} {}".format(sort_column, sort_direction) | ||
| 51 | + | ||
| 33 | rows = conn.execute(query, params).fetchall() | 52 | rows = conn.execute(query, params).fetchall() |
| 34 | return [ | 53 | return [ |
| 35 | { | 54 | { |
| @@ -44,6 +63,55 @@ class TaskNotFoundError(Exception): | @@ -44,6 +63,55 @@ class TaskNotFoundError(Exception): | ||
| 44 | """NOT_FOUND -- 03-C §3.2: c_task_get on a nonexistent task_id.""" | 63 | """NOT_FOUND -- 03-C §3.2: c_task_get on a nonexistent task_id.""" |
| 45 | 64 | ||
| 46 | 65 | ||
| 66 | +class TaskNotEditableError(Exception): | ||
| 67 | + """A goal's `goal_text` can only be edited or deleted while it's still | ||
| 68 | + `queued` -- once Planner/Coder/Validator has started real work from it | ||
| 69 | + (real artifacts, work_graph_runs, bug_records all reference task_id), | ||
| 70 | + changing or removing the goal out from under that history would leave | ||
| 71 | + those records pointing at a goal that no longer matches what was | ||
| 72 | + actually built, which is exactly the kind of untraceable drift this | ||
| 73 | + codebase otherwise goes out of its way to avoid.""" | ||
| 74 | + | ||
| 75 | + | ||
| 76 | +def update_task_goal_text(conn, task_id, goal_text): | ||
| 77 | + row = conn.execute("SELECT status FROM task WHERE task_id = ?", (task_id,)).fetchone() | ||
| 78 | + if row is None: | ||
| 79 | + raise TaskNotFoundError(task_id) | ||
| 80 | + if row[0] != "queued": | ||
| 81 | + raise TaskNotEditableError( | ||
| 82 | + "task {} is status={}, goal_text can only be edited while queued".format(task_id, row[0]) | ||
| 83 | + ) | ||
| 84 | + conn.execute( | ||
| 85 | + "UPDATE task SET goal_text = ?, updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", (goal_text, task_id) | ||
| 86 | + ) | ||
| 87 | + conn.commit() | ||
| 88 | + return get_task(conn, task_id) | ||
| 89 | + | ||
| 90 | + | ||
| 91 | +def delete_task(conn, task_id): | ||
| 92 | + row = conn.execute("SELECT status FROM task WHERE task_id = ?", (task_id,)).fetchone() | ||
| 93 | + if row is None: | ||
| 94 | + raise TaskNotFoundError(task_id) | ||
| 95 | + if row[0] != "queued": | ||
| 96 | + raise TaskNotEditableError( | ||
| 97 | + "task {} is status={}, can only be deleted while queued".format(task_id, row[0]) | ||
| 98 | + ) | ||
| 99 | + # a real DELETE against a real queued task raised sqlite3.IntegrityError | ||
| 100 | + # (2026-08-03, found on first real verification pass) -- task_create. | ||
| 101 | + # create_task() always writes an execution_event row in the same | ||
| 102 | + # transaction as the task itself (01-M's own FK, no ON DELETE CASCADE), | ||
| 103 | + # so even a task that has never been touched by Planner/Coder/Validator | ||
| 104 | + # already has at least one execution_event referencing it. Those rows | ||
| 105 | + # are this task's own creation-time bookkeeping, not independent audit | ||
| 106 | + # content worth preserving once the task itself is gone -- a queued | ||
| 107 | + # task can't yet have any artifact/bug_record/work_graph_run rows | ||
| 108 | + # (those only start existing once real work begins), so this is the | ||
| 109 | + # only FK that can actually be in the way here. | ||
| 110 | + conn.execute("DELETE FROM execution_event WHERE task_id = ?", (task_id,)) | ||
| 111 | + conn.execute("DELETE FROM task WHERE task_id = ?", (task_id,)) | ||
| 112 | + conn.commit() | ||
| 113 | + | ||
| 114 | + | ||
| 47 | def get_task(conn, task_id): | 115 | def get_task(conn, task_id): |
| 48 | row = conn.execute( | 116 | row = conn.execute( |
| 49 | "SELECT task_id, project_id, goal_text, task_type, origin_ref, created_via, status, route, " | 117 | "SELECT task_id, project_id, goal_text, task_type, origin_ref, created_via, status, route, " |
src/test_backend_server.py
| @@ -18,6 +18,7 @@ import tempfile | @@ -18,6 +18,7 @@ import tempfile | ||
| 18 | import threading | 18 | import threading |
| 19 | import time | 19 | import time |
| 20 | import unittest | 20 | import unittest |
| 21 | +import urllib.parse | ||
| 21 | from unittest.mock import patch | 22 | from unittest.mock import patch |
| 22 | 23 | ||
| 23 | from src import backend_server, execution_log, skill_update, ws_subscribe | 24 | from src import backend_server, execution_log, skill_update, ws_subscribe |
| @@ -350,6 +351,176 @@ class ProjectCreateRouteTest(unittest.TestCase): | @@ -350,6 +351,176 @@ class ProjectCreateRouteTest(unittest.TestCase): | ||
| 350 | self.assertTrue(os.path.isdir(os.path.join(non_git, ".git"))) | 351 | self.assertTrue(os.path.isdir(os.path.join(non_git, ".git"))) |
| 351 | 352 | ||
| 352 | 353 | ||
| 354 | +class ProjectListAndCrudRouteTest(unittest.TestCase): | ||
| 355 | + """v_project_list (2026-08-03): GET /api/projects (list), GET/PUT/DELETE | ||
| 356 | + /api/projects/:id -- project_query.py's own logic is already covered by | ||
| 357 | + test_project_query.py, this only exercises the HTTP wiring.""" | ||
| 358 | + | ||
| 359 | + def setUp(self): | ||
| 360 | + self.server = backend_server.create_server(db_path=":memory:") | ||
| 361 | + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) | ||
| 362 | + self.thread.start() | ||
| 363 | + self.server.conn.execute( | ||
| 364 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | ||
| 365 | + "VALUES ('proj-1', 'P1', '/tmp/proj-1', 1, 'active')" | ||
| 366 | + ) | ||
| 367 | + self.server.conn.commit() | ||
| 368 | + | ||
| 369 | + def tearDown(self): | ||
| 370 | + self.server.shutdown() | ||
| 371 | + self.server.server_close() | ||
| 372 | + self.thread.join(timeout=5) | ||
| 373 | + | ||
| 374 | + def _get(self, path): | ||
| 375 | + host, port = self.server.server_address | ||
| 376 | + conn = http.client.HTTPConnection(host, port, timeout=5) | ||
| 377 | + conn.request("GET", path) | ||
| 378 | + resp = conn.getresponse() | ||
| 379 | + body = json.loads(resp.read()) | ||
| 380 | + conn.close() | ||
| 381 | + return resp.status, body | ||
| 382 | + | ||
| 383 | + def _put(self, path, payload): | ||
| 384 | + host, port = self.server.server_address | ||
| 385 | + conn = http.client.HTTPConnection(host, port, timeout=5) | ||
| 386 | + conn.request("PUT", path, body=json.dumps(payload).encode("utf-8")) | ||
| 387 | + resp = conn.getresponse() | ||
| 388 | + body = json.loads(resp.read()) | ||
| 389 | + conn.close() | ||
| 390 | + return resp.status, body | ||
| 391 | + | ||
| 392 | + def _delete(self, path): | ||
| 393 | + host, port = self.server.server_address | ||
| 394 | + conn = http.client.HTTPConnection(host, port, timeout=5) | ||
| 395 | + conn.request("DELETE", path) | ||
| 396 | + resp = conn.getresponse() | ||
| 397 | + body = json.loads(resp.read()) | ||
| 398 | + conn.close() | ||
| 399 | + return resp.status, body | ||
| 400 | + | ||
| 401 | + def test_list_returns_all_projects(self): | ||
| 402 | + status, body = self._get("/api/projects") | ||
| 403 | + self.assertEqual(status, 200) | ||
| 404 | + self.assertEqual([p["project_id"] for p in body], ["proj-1"]) | ||
| 405 | + | ||
| 406 | + def test_get_returns_project_detail(self): | ||
| 407 | + status, body = self._get("/api/projects/proj-1") | ||
| 408 | + self.assertEqual(status, 200) | ||
| 409 | + self.assertEqual(body["name"], "P1") | ||
| 410 | + | ||
| 411 | + def test_get_nonexistent_returns_404(self): | ||
| 412 | + status, body = self._get("/api/projects/no-such-project") | ||
| 413 | + self.assertEqual(status, 404) | ||
| 414 | + | ||
| 415 | + def test_put_renames_project(self): | ||
| 416 | + status, body = self._put("/api/projects/proj-1", {"name": "renamed"}) | ||
| 417 | + self.assertEqual(status, 200) | ||
| 418 | + self.assertEqual(body["name"], "renamed") | ||
| 419 | + | ||
| 420 | + def test_delete_archives_not_removes(self): | ||
| 421 | + status, body = self._delete("/api/projects/proj-1") | ||
| 422 | + self.assertEqual(status, 200) | ||
| 423 | + self.assertEqual(body["status"], "archived") | ||
| 424 | + status, body = self._get("/api/projects/proj-1") | ||
| 425 | + self.assertEqual(status, 200) # row still exists | ||
| 426 | + | ||
| 427 | + | ||
| 428 | +class TaskCrudRouteTest(unittest.TestCase): | ||
| 429 | + """v_goal_list (2026-08-03): sort/order on GET /api/tasks, PUT/DELETE | ||
| 430 | + /api/tasks/:id -- task_query.py's own logic is already covered by | ||
| 431 | + test_task_query.py, this only exercises the HTTP wiring.""" | ||
| 432 | + | ||
| 433 | + def setUp(self): | ||
| 434 | + self.server = backend_server.create_server(db_path=":memory:") | ||
| 435 | + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) | ||
| 436 | + self.thread.start() | ||
| 437 | + self.server.conn.execute( | ||
| 438 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | ||
| 439 | + "VALUES ('proj-1', 'P1', '/tmp/proj-1', 1, 'active')" | ||
| 440 | + ) | ||
| 441 | + self.server.conn.execute( | ||
| 442 | + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " | ||
| 443 | + "VALUES ('task-1', 'proj-1', 'old goal', 'goal', 'queued', 'planner')" | ||
| 444 | + ) | ||
| 445 | + self.server.conn.commit() | ||
| 446 | + | ||
| 447 | + def tearDown(self): | ||
| 448 | + self.server.shutdown() | ||
| 449 | + self.server.server_close() | ||
| 450 | + self.thread.join(timeout=5) | ||
| 451 | + | ||
| 452 | + def _get(self, path): | ||
| 453 | + host, port = self.server.server_address | ||
| 454 | + conn = http.client.HTTPConnection(host, port, timeout=5) | ||
| 455 | + conn.request("GET", path) | ||
| 456 | + resp = conn.getresponse() | ||
| 457 | + body = json.loads(resp.read()) | ||
| 458 | + conn.close() | ||
| 459 | + return resp.status, body | ||
| 460 | + | ||
| 461 | + def _put(self, path, payload): | ||
| 462 | + host, port = self.server.server_address | ||
| 463 | + conn = http.client.HTTPConnection(host, port, timeout=5) | ||
| 464 | + conn.request("PUT", path, body=json.dumps(payload).encode("utf-8")) | ||
| 465 | + resp = conn.getresponse() | ||
| 466 | + body = json.loads(resp.read()) | ||
| 467 | + conn.close() | ||
| 468 | + return resp.status, body | ||
| 469 | + | ||
| 470 | + def _delete(self, path): | ||
| 471 | + host, port = self.server.server_address | ||
| 472 | + conn = http.client.HTTPConnection(host, port, timeout=5) | ||
| 473 | + conn.request("DELETE", path) | ||
| 474 | + resp = conn.getresponse() | ||
| 475 | + body = json.loads(resp.read()) | ||
| 476 | + conn.close() | ||
| 477 | + return resp.status, body | ||
| 478 | + | ||
| 479 | + def test_list_invalid_sort_returns_400(self): | ||
| 480 | + status, body = self._get("/api/tasks?project_id=proj-1&sort=" + urllib.parse.quote("x;DROP TABLE task")) | ||
| 481 | + self.assertEqual(status, 400) | ||
| 482 | + self.assertEqual(body["error"], "INVALID_PARAM") | ||
| 483 | + | ||
| 484 | + def test_list_sort_asc_by_goal_text(self): | ||
| 485 | + self.server.conn.execute( | ||
| 486 | + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " | ||
| 487 | + "VALUES ('task-2', 'proj-1', 'aaa', 'goal', 'queued', 'planner')" | ||
| 488 | + ) | ||
| 489 | + self.server.conn.commit() | ||
| 490 | + status, body = self._get("/api/tasks?project_id=proj-1&sort=goal_text&order=asc") | ||
| 491 | + self.assertEqual(status, 200) | ||
| 492 | + self.assertEqual([t["task_id"] for t in body], ["task-2", "task-1"]) # aaa, old goal | ||
| 493 | + | ||
| 494 | + def test_put_edits_goal_text_while_queued(self): | ||
| 495 | + status, body = self._put("/api/tasks/task-1", {"goal_text": "new goal"}) | ||
| 496 | + self.assertEqual(status, 200) | ||
| 497 | + self.assertEqual(body["goal_text"], "new goal") | ||
| 498 | + | ||
| 499 | + def test_put_missing_goal_text_returns_400(self): | ||
| 500 | + status, body = self._put("/api/tasks/task-1", {}) | ||
| 501 | + self.assertEqual(status, 400) | ||
| 502 | + | ||
| 503 | + def test_put_on_non_queued_task_returns_409(self): | ||
| 504 | + self.server.conn.execute("UPDATE task SET status = 'coding' WHERE task_id = 'task-1'") | ||
| 505 | + self.server.conn.commit() | ||
| 506 | + status, body = self._put("/api/tasks/task-1", {"goal_text": "new goal"}) | ||
| 507 | + self.assertEqual(status, 409) | ||
| 508 | + self.assertEqual(body["error"], "TASK_NOT_EDITABLE") | ||
| 509 | + | ||
| 510 | + def test_delete_removes_queued_task(self): | ||
| 511 | + status, body = self._delete("/api/tasks/task-1") | ||
| 512 | + self.assertEqual(status, 200) | ||
| 513 | + status, _ = self._get("/api/tasks/task-1") | ||
| 514 | + self.assertEqual(status, 404) | ||
| 515 | + | ||
| 516 | + def test_delete_on_non_queued_task_returns_409(self): | ||
| 517 | + self.server.conn.execute("UPDATE task SET status = 'done' WHERE task_id = 'task-1'") | ||
| 518 | + self.server.conn.commit() | ||
| 519 | + status, body = self._delete("/api/tasks/task-1") | ||
| 520 | + self.assertEqual(status, 409) | ||
| 521 | + self.assertEqual(body["error"], "TASK_NOT_EDITABLE") | ||
| 522 | + | ||
| 523 | + | ||
| 353 | class TaskAndBugReportRouteTest(unittest.TestCase): | 524 | class TaskAndBugReportRouteTest(unittest.TestCase): |
| 354 | """U-023: c_task_create (03-C §3.1) + c_bug_report_create (03-C §3.1.1) | 525 | """U-023: c_task_create (03-C §3.1) + c_bug_report_create (03-C §3.1.1) |
| 355 | wired to real HTTP routes for v_goal_composer/v_bug_feedback.""" | 526 | wired to real HTTP routes for v_goal_composer/v_bug_feedback.""" |
src/test_project_query.py
0 → 100644
| 1 | +"""v_project_list tests: c_project_list/c_project_get/c_project_update | ||
| 2 | +(2026-08-03), real sqlite (no mocking needed). | ||
| 3 | + | ||
| 4 | +Run: python3 -m unittest src.test_project_query -v | ||
| 5 | +""" | ||
| 6 | + | ||
| 7 | +import unittest | ||
| 8 | + | ||
| 9 | +from src import project_query, schema | ||
| 10 | + | ||
| 11 | + | ||
| 12 | +class ProjectQueryTestBase(unittest.TestCase): | ||
| 13 | + def setUp(self): | ||
| 14 | + self.conn = schema.connect(":memory:") | ||
| 15 | + schema.create_schema(self.conn) | ||
| 16 | + | ||
| 17 | + def tearDown(self): | ||
| 18 | + self.conn.close() | ||
| 19 | + | ||
| 20 | + def _insert_project(self, project_id, name="P", target_folder_path=None, status="active"): | ||
| 21 | + self.conn.execute( | ||
| 22 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | ||
| 23 | + "VALUES (?, ?, ?, 1, ?)", | ||
| 24 | + (project_id, name, target_folder_path or ("/tmp/" + project_id), status), | ||
| 25 | + ) | ||
| 26 | + self.conn.commit() | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +class ListProjectsTest(ProjectQueryTestBase): | ||
| 30 | + def test_lists_all_projects_with_goal_count(self): | ||
| 31 | + self._insert_project("proj-1") | ||
| 32 | + self._insert_project("proj-2") | ||
| 33 | + self.conn.execute( | ||
| 34 | + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " | ||
| 35 | + "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'queued', 'planner')" | ||
| 36 | + ) | ||
| 37 | + self.conn.commit() | ||
| 38 | + | ||
| 39 | + result = project_query.list_projects(self.conn) | ||
| 40 | + by_id = {p["project_id"]: p for p in result} | ||
| 41 | + self.assertEqual(by_id["proj-1"]["goal_count"], 1) | ||
| 42 | + self.assertEqual(by_id["proj-2"]["goal_count"], 0) | ||
| 43 | + | ||
| 44 | + def test_includes_archived_projects(self): | ||
| 45 | + self._insert_project("proj-1", status="archived") | ||
| 46 | + result = project_query.list_projects(self.conn) | ||
| 47 | + self.assertEqual(result[0]["status"], "archived") | ||
| 48 | + | ||
| 49 | + | ||
| 50 | +class GetProjectTest(ProjectQueryTestBase): | ||
| 51 | + def test_returns_project_detail(self): | ||
| 52 | + self._insert_project("proj-1", name="My Project", target_folder_path="/tmp/my-project") | ||
| 53 | + result = project_query.get_project(self.conn, "proj-1") | ||
| 54 | + self.assertEqual(result["name"], "My Project") | ||
| 55 | + self.assertEqual(result["target_folder_path"], "/tmp/my-project") | ||
| 56 | + self.assertEqual(result["goal_count"], 0) | ||
| 57 | + | ||
| 58 | + def test_nonexistent_project_raises_not_found(self): | ||
| 59 | + with self.assertRaises(project_query.ProjectNotFoundError): | ||
| 60 | + project_query.get_project(self.conn, "no-such-project") | ||
| 61 | + | ||
| 62 | + | ||
| 63 | +class UpdateProjectTest(ProjectQueryTestBase): | ||
| 64 | + def test_renames_project(self): | ||
| 65 | + self._insert_project("proj-1", name="old name") | ||
| 66 | + result = project_query.update_project(self.conn, "proj-1", name="new name") | ||
| 67 | + self.assertEqual(result["name"], "new name") | ||
| 68 | + | ||
| 69 | + def test_archives_project(self): | ||
| 70 | + self._insert_project("proj-1", status="active") | ||
| 71 | + result = project_query.update_project(self.conn, "proj-1", status="archived") | ||
| 72 | + self.assertEqual(result["status"], "archived") | ||
| 73 | + | ||
| 74 | + def test_reactivates_archived_project(self): | ||
| 75 | + self._insert_project("proj-1", status="archived") | ||
| 76 | + result = project_query.update_project(self.conn, "proj-1", status="active") | ||
| 77 | + self.assertEqual(result["status"], "active") | ||
| 78 | + | ||
| 79 | + def test_invalid_status_rejected(self): | ||
| 80 | + self._insert_project("proj-1") | ||
| 81 | + with self.assertRaises(project_query.InvalidParamError): | ||
| 82 | + project_query.update_project(self.conn, "proj-1", status="deleted") | ||
| 83 | + | ||
| 84 | + def test_blank_name_rejected(self): | ||
| 85 | + self._insert_project("proj-1") | ||
| 86 | + with self.assertRaises(project_query.InvalidParamError): | ||
| 87 | + project_query.update_project(self.conn, "proj-1", name=" ") | ||
| 88 | + | ||
| 89 | + def test_nonexistent_project_raises_not_found(self): | ||
| 90 | + with self.assertRaises(project_query.ProjectNotFoundError): | ||
| 91 | + project_query.update_project(self.conn, "no-such-project", name="x") | ||
| 92 | + | ||
| 93 | + | ||
| 94 | +if __name__ == "__main__": | ||
| 95 | + unittest.main() |
src/test_task_query.py
| @@ -175,5 +175,103 @@ class GetTaskTest(TaskQueryTestBase): | @@ -175,5 +175,103 @@ class GetTaskTest(TaskQueryTestBase): | ||
| 175 | self.assertEqual(node["quota_wait_started_at"], "2026-07-31 09:00:00") | 175 | self.assertEqual(node["quota_wait_started_at"], "2026-07-31 09:00:00") |
| 176 | 176 | ||
| 177 | 177 | ||
| 178 | +class ListTasksSortTest(TaskQueryTestBase): | ||
| 179 | + def setUp(self): | ||
| 180 | + super().setUp() | ||
| 181 | + self._insert_task("task-a", goal_text="banana") | ||
| 182 | + self.conn.execute( | ||
| 183 | + "UPDATE task SET created_at = '2026-01-01 00:00:00', updated_at = '2026-01-01 00:00:00' " | ||
| 184 | + "WHERE task_id = 'task-a'" | ||
| 185 | + ) | ||
| 186 | + self._insert_task("task-b", goal_text="apple") | ||
| 187 | + self.conn.execute( | ||
| 188 | + "UPDATE task SET created_at = '2026-01-02 00:00:00', updated_at = '2026-01-02 00:00:00' " | ||
| 189 | + "WHERE task_id = 'task-b'" | ||
| 190 | + ) | ||
| 191 | + self._insert_task("task-c", goal_text="cherry") | ||
| 192 | + self.conn.execute( | ||
| 193 | + "UPDATE task SET created_at = '2026-01-03 00:00:00', updated_at = '2026-01-03 00:00:00' " | ||
| 194 | + "WHERE task_id = 'task-c'" | ||
| 195 | + ) | ||
| 196 | + self.conn.commit() | ||
| 197 | + | ||
| 198 | + def test_default_sort_is_updated_at_desc_unchanged(self): | ||
| 199 | + # existing v_task_board behavior must not shift under it | ||
| 200 | + result = task_query.list_tasks(self.conn, "proj-1") | ||
| 201 | + self.assertEqual([t["task_id"] for t in result], ["task-c", "task-b", "task-a"]) | ||
| 202 | + | ||
| 203 | + def test_sort_by_created_at_asc(self): | ||
| 204 | + result = task_query.list_tasks(self.conn, "proj-1", sort="created_at", order="asc") | ||
| 205 | + self.assertEqual([t["task_id"] for t in result], ["task-a", "task-b", "task-c"]) | ||
| 206 | + | ||
| 207 | + def test_sort_by_goal_text_asc(self): | ||
| 208 | + result = task_query.list_tasks(self.conn, "proj-1", sort="goal_text", order="asc") | ||
| 209 | + self.assertEqual([t["task_id"] for t in result], ["task-b", "task-a", "task-c"]) # apple, banana, cherry | ||
| 210 | + | ||
| 211 | + def test_invalid_sort_column_rejected(self): | ||
| 212 | + with self.assertRaises(task_query.InvalidParamError): | ||
| 213 | + task_query.list_tasks(self.conn, "proj-1", sort="task_id; DROP TABLE task") | ||
| 214 | + | ||
| 215 | + | ||
| 216 | +class UpdateTaskGoalTextTest(TaskQueryTestBase): | ||
| 217 | + def test_queued_task_can_be_edited(self): | ||
| 218 | + self._insert_task("task-1", status="queued", goal_text="old") | ||
| 219 | + result = task_query.update_task_goal_text(self.conn, "task-1", "new goal text") | ||
| 220 | + self.assertEqual(result["goal_text"], "new goal text") | ||
| 221 | + | ||
| 222 | + def test_nonexistent_task_raises_not_found(self): | ||
| 223 | + with self.assertRaises(task_query.TaskNotFoundError): | ||
| 224 | + task_query.update_task_goal_text(self.conn, "no-such-task", "x") | ||
| 225 | + | ||
| 226 | + def test_in_progress_task_cannot_be_edited(self): | ||
| 227 | + self._insert_task("task-1", status="coding") | ||
| 228 | + with self.assertRaises(task_query.TaskNotEditableError): | ||
| 229 | + task_query.update_task_goal_text(self.conn, "task-1", "new text") | ||
| 230 | + | ||
| 231 | + | ||
| 232 | +class DeleteTaskTest(TaskQueryTestBase): | ||
| 233 | + def test_queued_task_can_be_deleted(self): | ||
| 234 | + self._insert_task("task-1", status="queued") | ||
| 235 | + task_query.delete_task(self.conn, "task-1") | ||
| 236 | + self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM task WHERE task_id = 'task-1'").fetchone()[0], 0) | ||
| 237 | + | ||
| 238 | + def test_nonexistent_task_raises_not_found(self): | ||
| 239 | + with self.assertRaises(task_query.TaskNotFoundError): | ||
| 240 | + task_query.delete_task(self.conn, "no-such-task") | ||
| 241 | + | ||
| 242 | + def test_done_task_cannot_be_deleted(self): | ||
| 243 | + self._insert_task("task-1", status="done") | ||
| 244 | + with self.assertRaises(task_query.TaskNotEditableError): | ||
| 245 | + task_query.delete_task(self.conn, "task-1") | ||
| 246 | + self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM task WHERE task_id = 'task-1'").fetchone()[0], 1) | ||
| 247 | + | ||
| 248 | + def test_real_task_create_task_can_still_be_deleted(self): | ||
| 249 | + # 2026-08-03 regression: _insert_task (this file's own raw-INSERT | ||
| 250 | + # helper) never writes an execution_event row, so it never caught | ||
| 251 | + # the real bug -- task_create.create_task() always writes one | ||
| 252 | + # execution_event in the same transaction as the task itself (01-M's | ||
| 253 | + # own FK, no ON DELETE CASCADE), so even a never-touched queued task | ||
| 254 | + # already has one referencing it, and a real DELETE FROM task raised | ||
| 255 | + # sqlite3.IntegrityError until delete_task() also cleared it. | ||
| 256 | + from src import task_create | ||
| 257 | + result = task_create.create_task(self.conn, "proj-1", "a real goal") | ||
| 258 | + self.assertEqual( | ||
| 259 | + self.conn.execute( | ||
| 260 | + "SELECT COUNT(*) FROM execution_event WHERE task_id = ?", (result["task_id"],) | ||
| 261 | + ).fetchone()[0], | ||
| 262 | + 1, | ||
| 263 | + ) | ||
| 264 | + task_query.delete_task(self.conn, result["task_id"]) | ||
| 265 | + self.assertEqual( | ||
| 266 | + self.conn.execute("SELECT COUNT(*) FROM task WHERE task_id = ?", (result["task_id"],)).fetchone()[0], 0 | ||
| 267 | + ) | ||
| 268 | + self.assertEqual( | ||
| 269 | + self.conn.execute( | ||
| 270 | + "SELECT COUNT(*) FROM execution_event WHERE task_id = ?", (result["task_id"],) | ||
| 271 | + ).fetchone()[0], | ||
| 272 | + 0, | ||
| 273 | + ) | ||
| 274 | + | ||
| 275 | + | ||
| 178 | if __name__ == "__main__": | 276 | if __name__ == "__main__": |
| 179 | unittest.main() | 277 | unittest.main() |