From 7c720ab83b4bbe99b52b3b17331626fa16834de7 Mon Sep 17 00:00:00 2001 From: bboonnee Date: Mon, 3 Aug 2026 14:24:12 +0800 Subject: [PATCH] Add v_project_list and v_goal_list CRUD (user-requested scope expansion) --- docs/development-plan.md | 13 +++++++++++++ frontend/app.js | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/index.html | 1 + src/backend_server.py | 118 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- src/project_query.py | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/task_query.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- src/test_backend_server.py | 171 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/test_project_query.py | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/test_task_query.py | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 832 insertions(+), 11 deletions(-) create mode 100644 src/project_query.py create mode 100644 src/test_project_query.py diff --git a/docs/development-plan.md b/docs/development-plan.md index b1a0a2f..94cdcbf 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -460,3 +460,16 @@ U-041(回滚路径实现,含 §10 提到的 `git revert` 冲突处理)是 ## 4. 与 06-验收与测试锚点.md 的对应关系 本文档不重新定义验收标准,Unit 的 Done 标准直接引用 06 节已有的 REQ-01~14 用例(happy/boundary/failure)。如果后续 06 节的验收用例发生变更,本文档对应 Unit 的 Done 标准需要同步更新,避免两份文档各说各话。 + +--- + +## 5. PH0-PH5 之外:用户直接请求的控制台功能扩展 + +PH0-PH5(45 个 Unit)全部完成、U-042 真实自举压测进行中期间,用户直接向 Claude Code(不经过 graphlemon 自己的 Planner/Coder/Validator 闭环,原因见下)提出了两个新的控制台页面需求,是对 02-V 原有非目标"不做完整多项目管理 UI(项目列表/切换)"的明确、有意突破,不是发现遗漏后的补充。 + +**为什么不走 graphlemon 自己的自举闭环去实现这个需求**:讨论过程中确认了当前 `implement_code()`(Coder 阶段)永远用一个写死的单节点 `graph_manifest`,从不调用 `plan_work_graph()` 规划自己的 Work Graph——不管 SDD 多详细,整个实现都只交给一次 Coder 会话,没有按视图/模块拆分执行的能力(U-031 当时明确把"造一个 Coder 图规划器"排除在集成范围之外)。这个需求涉及两个新页面、6+ 个新后端接口,用一次不可拆分的 Coder 会话去做风险高、不可控,所以直接由 Claude Code 实现,不走自举闭环。 + +- **`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` 枚举本来就是软删除的设计)。 +- **`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)。 +- **真实验证时发现并修复一个真 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()` 路径的回归测试锁死。 +- **验证方式**:`src/test_project_query.py`(12 个用例)、`src/test_task_query.py` 新增的排序/编辑/删除用例(含上面这条真实路径回归)、`src/test_backend_server.py` 新增两个真实 HTTP 路由测试类(`ProjectListAndCrudRouteTest`/`TaskCrudRouteTest`,真实起服务器+真实请求,不 mock)。浏览器扩展本次仍不可用,额外对着真实运行的服务器用 `curl` 走了一遍全部新增接口(含前面提到的真删除 bug 就是这一步抓到的)。474 个测试全绿。 diff --git a/frontend/app.js b/frontend/app.js index 31e396b..4b1cc1f 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -76,6 +76,7 @@ function connectLogTailSocket(nodeExecId, { onChunk, onClose }) { const routes = { "#/": { render: renderDashboard, mount: mountDashboard }, + "#/projects": { render: renderProjectList, mount: mountProjectList }, "#/tasks": { render: renderTaskBoard, mount: mountTaskBoard }, "#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser }, "#/skills": { render: renderSkillLibrary, mount: mountSkillLibrary }, @@ -132,6 +133,109 @@ function render() { window.addEventListener("hashchange", render); window.addEventListener("DOMContentLoaded", render); +// ---- v_project_list (2026-08-03): project list + CRUD ---- +// 02-V originally declared "不做完整多项目管理 UI(项目列表/切换)" as an +// MVP non-goal -- this page is a deliberate, explicit scope expansion +// beyond that, requested directly by the user. Creation still goes through +// v_project_setup's existing flow (pick a folder, optionally git-init it, +// project_scaffold.create_project() already handles a folder that already +// has real code in it correctly -- it only ever adds .graphlemon/ inside, +// nothing about "importing" needs to be built separately). This page adds +// the missing read/update/archive half: list every project, rename, +// archive/reactivate, and select one as "current" to work in. + +function renderProjectList() { + return ` +
+

项目

+ + + + + + +
名称目标目录状态Goal 数创建时间
+
+ `; +} + +function mountProjectList() { + const rowsEl = document.getElementById("pl_rows"); + + async function refresh() { + const resp = await fetch("/api/projects"); + const projects = await resp.json(); + if (projects.length === 0) { + rowsEl.innerHTML = '还没有项目,创建一个'; + return; + } + rowsEl.innerHTML = projects.map((p) => ( + '' + + '' + escapeHtml(p.name) + "" + + '" + + "" + escapeHtml(p.target_folder_path) + "" + + '' + p.status + "" + + "" + p.goal_count + "" + + "" + escapeHtml(p.created_at) + "" + + "" + + ' ' + + ' ' + + '" + + "" + + "" + )).join(""); + + for (const btn of rowsEl.querySelectorAll('button[data-action="select"]')) { + btn.addEventListener("click", () => { + setCurrentProjectId(btn.dataset.projectId); + window.location.hash = "#/tasks"; + }); + } + for (const btn of rowsEl.querySelectorAll('button[data-action="rename"]')) { + btn.addEventListener("click", () => { + const id = btn.dataset.projectId; + rowsEl.querySelector('.pl-name-view[data-project-id="' + id + '"]').style.display = "none"; + rowsEl.querySelector('.pl-name-edit[data-project-id="' + id + '"]').style.display = "inline-block"; + }); + } + for (const span of rowsEl.querySelectorAll(".pl-name-edit")) { + const id = span.dataset.projectId; + span.querySelector('[data-action="cancel-rename"]').addEventListener("click", () => { + span.style.display = "none"; + rowsEl.querySelector('.pl-name-view[data-project-id="' + id + '"]').style.display = ""; + }); + span.querySelector('[data-action="save-rename"]').addEventListener("click", () => { + const newName = span.querySelector(".pl-name-input").value.trim(); + if (!newName) return; + fetch("/api/projects/" + encodeURIComponent(id), { + method: "PUT", + body: JSON.stringify({ name: newName }), + }).then(refresh); + }); + } + for (const btn of rowsEl.querySelectorAll('button[data-action="toggle-status"]')) { + btn.addEventListener("click", () => { + const nextStatus = btn.dataset.currentStatus === "active" ? "archived" : "active"; + fetch("/api/projects/" + encodeURIComponent(btn.dataset.projectId), { + method: "PUT", + body: JSON.stringify({ status: nextStatus }), + }).then(refresh); + }); + } + } + + refresh(); +} + // ---- v_project_setup (02-V §2.1): create/switch project ---- function renderProjectSetup() { @@ -585,6 +689,9 @@ function renderTaskBoard() { return `

任务看板

+
+
@@ -615,12 +730,16 @@ function mountTaskBoard() { const keywordEl = document.getElementById("tb_keyword"); const statusEl = document.getElementById("tb_status"); const orgNodeEl = document.getElementById("tb_org_node"); + const sortEl = document.getElementById("tb_sort"); async function refresh() { const params = new URLSearchParams({ project_id: projectId }); if (keywordEl.value.trim()) params.set("keyword", keywordEl.value.trim()); if (statusEl.value) params.set("status", statusEl.value); if (orgNodeEl.value) params.set("org_node", orgNodeEl.value); + const [sortField, sortOrder] = sortEl.value.split(":"); + params.set("sort", sortField); + params.set("order", sortOrder); const resp = await fetch("/api/tasks?" + params.toString()); const tasks = await resp.json(); if (tasks.length === 0) { @@ -647,6 +766,7 @@ function mountTaskBoard() { keywordEl.addEventListener("input", refresh); statusEl.addEventListener("change", refresh); orgNodeEl.addEventListener("change", refresh); + sortEl.addEventListener("change", refresh); refresh(); let wasDisconnected = false; @@ -933,8 +1053,36 @@ function mountTaskDetail(taskId) { ).join("") + "" ) : '
暂无 Bug 记录
'; + // v_goal_list CRUD (2026-08-03): editing/deleting a goal is only safe + // while it's still 'queued' -- once real work has started, artifacts/ + // bug_records/work_graph_runs all reference this task_id, and the + // backend (task_query.TaskNotEditableError) rejects both operations + // past that point anyway; hiding the controls here just avoids + // presenting an action that would visibly fail. + const goalCrudHtml = task.status !== "queued" ? "" : ( + '
' + + '' + + '' + + "
" + + '" + + '" + ); + content.innerHTML = ( "

" + escapeHtml(task.goal_text) + "

" + + goalCrudHtml + alertHtml + '
' + "
STATUS" + escapeHtml(task.status) + "
" @@ -977,6 +1125,56 @@ function mountTaskDetail(taskId) { refresh(); }); } + + const editBtn = document.getElementById("btn_edit_goal_text"); + const deleteBtn = document.getElementById("btn_delete_goal"); + if (editBtn) { + const editPanel = document.getElementById("edit_goal_panel"); + const deleteConfirm = document.getElementById("delete_goal_confirm"); + const textarea = document.getElementById("edit_goal_textarea"); + const errorBox = document.getElementById("edit_goal_error"); + + editBtn.addEventListener("click", () => { + deleteConfirm.style.display = "none"; + editPanel.style.display = "block"; + }); + document.getElementById("btn_cancel_edit_goal").addEventListener("click", () => { + editPanel.style.display = "none"; + }); + document.getElementById("btn_save_goal_text").addEventListener("click", async () => { + errorBox.style.display = "none"; + const goalText = textarea.value.trim(); + if (!goalText) { + errorBox.textContent = "目标描述不能为空"; + errorBox.style.display = "block"; + return; + } + const resp = await fetch("/api/tasks/" + taskId, { + method: "PUT", + body: JSON.stringify({ goal_text: goalText }), + }); + const body = await resp.json(); + if (resp.status !== 200) { + errorBox.textContent = body.error + (body.detail ? ":" + body.detail : ""); + errorBox.style.display = "block"; + return; + } + editPanel.style.display = "none"; + refresh(); + }); + + deleteBtn.addEventListener("click", () => { + editPanel.style.display = "none"; + deleteConfirm.style.display = "block"; + }); + document.getElementById("btn_cancel_delete_goal").addEventListener("click", () => { + deleteConfirm.style.display = "none"; + }); + document.getElementById("btn_confirm_delete_goal").addEventListener("click", async () => { + await fetch("/api/tasks/" + taskId, { method: "DELETE" }); + window.location.hash = "#/tasks"; + }); + } } async function refresh() { diff --git a/frontend/index.html b/frontend/index.html index 0d6b039..f847423 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,7 @@