Commit 7c720ab83b4bbe99b52b3b17331626fa16834de7

Authored by bboonnee
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>
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()