Commit 231cf0c6ba57c9e8efbe02f46b44ee1fe640ec63

Authored by bboonnee
1 parent 15385d60

U-022: v_project_setup + c_pick_folder 全平台补齐

c_pick_folder 补上 Windows(PowerShell FolderBrowserDialog)和 Linux
(zenity 优先、回退 kdialog)实现,三平台都覆盖到。c_project_create
(U-002 已实现的 project_scaffold.create_project())第一次接上 HTTP
路由 POST /api/projects——backend_server.py 因此第一次需要持有 sqlite
连接,schema.connect() 加 check_same_thread=False 参数,配合
server.conn_lock 序列化 ThreadingHTTPServer 各请求线程的访问。
frontend/app.js 补上 #/setup 真实交互(选文件夹→校验 Git 仓库→非
Git 仓库二次确认→创建→跳转仪表盘),DOM class 命名对齐
docs/wireframes/v_project_setup.html。

REQ-11 完整 happy 用例(含创建 task)还需要 U-023 的
v_goal_composer/c_task_create,本 Unit 只完成 v_project_setup/
c_pick_folder/c_project_create 部分,development-plan.md 已记录。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/development-plan.md
@@ -217,6 +217,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 @@ -217,6 +217,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
217 - **U-022 [Value Path]** `v_project_setup` + `c_pick_folder` 全平台补齐(Windows `PowerShell`/Linux `zenity`/`kdialog`) 217 - **U-022 [Value Path]** `v_project_setup` + `c_pick_folder` 全平台补齐(Windows `PowerShell`/Linux `zenity`/`kdialog`)
218 - 依赖:U-007, U-008 218 - 依赖:U-007, U-008
219 - Done:REQ-11 happy/boundary/failure 通过 219 - Done:REQ-11 happy/boundary/failure 通过
  220 + - **实现补充(2026-07-31)**:Done 行字面写的是"REQ-11 全部通过",但 REQ-11 happy 用例完整走完还需要 `v_goal_composer`/`c_task_create` 创建 task 那一步——那是 U-023 的范围,本 Unit 依赖只到 U-007/U-008,不可能单独满足。本 Unit 实际完成的是 REQ-11 里 `v_project_setup`/`c_pick_folder`/`c_project_create` 那部分:① `pick_folder()` 补齐 Windows(PowerShell `FolderBrowserDialog` 脚本)/Linux(`zenity` 优先,缺失则退回 `kdialog`,都不在则 `DIALOG_UNAVAILABLE`);② `c_project_create`(`POST /api/projects`)此前只有 U-002 写好的 `project_scaffold.create_project()` 函数本体、从未接到 HTTP 层(`backend_server.py` 因此第一次需要持有一个 sqlite 连接——`ThreadingHTTPServer` 按请求起线程,给 `schema.connect()` 加了 `check_same_thread=False` 参数 + 一把 `server.conn_lock` 避免并发请求踩到同一个连接);③ `frontend/app.js` 补上 `#/setup` 真实交互(选文件夹 → 校验 Git 仓库 → 非 Git 仓库时二次确认 → 创建 → 跳转仪表盘),DOM class 命名对齐 `docs/wireframes/v_project_setup.html`。REQ-11 的完整 happy 用例(含创建 task 那一步)等 U-023 落地后再验收。浏览器扩展在本次会话不可用,UI 部分改为对着真实运行的后端做完整 HTTP 级冒烟(`curl` 走一遍选文件夹之外的全部接口调用序列,`node --check` 过一遍 `app.js` 语法),不是真人点击浏览器验证过的。
220 - 覆盖:02-V `v_project_setup`;03-C `c_pick_folder`;05 节 §5.4.1;REQ-11 221 - 覆盖:02-V `v_project_setup`;03-C `c_pick_folder`;05 节 §5.4.1;REQ-11
221 222
222 - **U-023 [Value Path]** `v_goal_composer` + `v_bug_feedback` 223 - **U-023 [Value Path]** `v_goal_composer` + `v_bug_feedback`
docs/sdd/unit_matrix.json
@@ -36,7 +36,7 @@ @@ -36,7 +36,7 @@
36 {"id": "U-020", "phase": "PH2", "sub_phase": "PH2-S3", "name": "p_usage_limit_wait 完整验收(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-012"], "coverage": {"model": [], "view": [], "controller": [], "process": ["p_usage_limit_wait"], "requirement": ["REQ-14"]}, "done": "REQ-14 全部通过(含超时兜底);补上了 opus 真实降级重试(U-013 时只做到不退避,没做到真重试)+ 之前漏测的 weekly 类型"}, 36 {"id": "U-020", "phase": "PH2", "sub_phase": "PH2-S3", "name": "p_usage_limit_wait 完整验收(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-012"], "coverage": {"model": [], "view": [], "controller": [], "process": ["p_usage_limit_wait"], "requirement": ["REQ-14"]}, "done": "REQ-14 全部通过(含超时兜底);补上了 opus 真实降级重试(U-013 时只做到不退避,没做到真重试)+ 之前漏测的 weekly 类型"},
37 {"id": "U-021", "phase": "PH2", "sub_phase": "PH2-S4", "name": "Graph Engine 持久化 + 断点恢复(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["work_graph_run", "work_node_execution"], "view": [], "controller": [], "process": ["p_work_graph_execution"], "requirement": ["REQ-06"]}, "done": "REQ-06 happy/boundary/failure 通过;now 默认值从 time.monotonic 改成 time.time(墙钟)以支持跨进程重启的 quota 等待续算;新增 resume_work_graph_run() 返回未完成节点列表"}, 37 {"id": "U-021", "phase": "PH2", "sub_phase": "PH2-S4", "name": "Graph Engine 持久化 + 断点恢复(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["work_graph_run", "work_node_execution"], "view": [], "controller": [], "process": ["p_work_graph_execution"], "requirement": ["REQ-06"]}, "done": "REQ-06 happy/boundary/failure 通过;now 默认值从 time.monotonic 改成 time.time(墙钟)以支持跨进程重启的 quota 等待续算;新增 resume_work_graph_run() 返回未完成节点列表"},
38 38
39 - {"id": "U-022", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_project_setup + c_pick_folder 全平台", "classification": "Value Path", "depends_on": ["U-007", "U-008"], "coverage": {"model": [], "view": ["v_project_setup"], "controller": ["c_pick_folder"], "process": [], "requirement": ["REQ-11"]}, "done": "REQ-11 全部通过"}, 39 + {"id": "U-022", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_project_setup + c_pick_folder 全平台(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-007", "U-008"], "coverage": {"model": [], "view": ["v_project_setup"], "controller": ["c_pick_folder", "c_project_create"], "process": [], "requirement": ["REQ-11"]}, "done": "REQ-11 里 v_project_setup/c_pick_folder/c_project_create 部分通过;c_pick_folder 补齐 Windows/Linux;c_project_create 首次接上 HTTP 路由;REQ-11 完整 happy(含建 task)随 U-023 累计满足"},
40 {"id": "U-023", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_goal_composer + v_bug_feedback", "classification": "Value Path", "depends_on": ["U-022"], "coverage": {"model": [], "view": ["v_goal_composer", "v_bug_feedback"], "controller": ["c_bug_report_create"], "process": [], "requirement": []}, "done": "能通过 UI 创建 task 和人工 bug_record"}, 40 {"id": "U-023", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_goal_composer + v_bug_feedback", "classification": "Value Path", "depends_on": ["U-022"], "coverage": {"model": [], "view": ["v_goal_composer", "v_bug_feedback"], "controller": ["c_bug_report_create"], "process": [], "requirement": []}, "done": "能通过 UI 创建 task 和人工 bug_record"},
41 {"id": "U-024", "phase": "PH3", "sub_phase": "PH3-S2", "name": "c_ws_subscribe + execution_event 实时推送(最小连通版)", "classification": "Value Path", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["execution_event"], "view": [], "controller": ["c_ws_subscribe"], "process": [], "requirement": ["REQ-12"]}, "done": "推送链路本身连通,≤2 秒延迟达成;REQ-12 happy 用例通过(boundary/failure 留给 U-024.5)"}, 41 {"id": "U-024", "phase": "PH3", "sub_phase": "PH3-S2", "name": "c_ws_subscribe + execution_event 实时推送(最小连通版)", "classification": "Value Path", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["execution_event"], "view": [], "controller": ["c_ws_subscribe"], "process": [], "requirement": ["REQ-12"]}, "done": "推送链路本身连通,≤2 秒延迟达成;REQ-12 happy 用例通过(boundary/failure 留给 U-024.5)"},
42 {"id": "U-024.5", "phase": "PH3", "sub_phase": "PH3-S2", "name": "REQ-12 完整验收:事件多样性联调(2026-07-30 新增)", "classification": "Value Path", "depends_on": ["U-024", "U-015", "U-016", "U-018", "U-019", "U-020", "U-021"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-12"]}, "done": "REQ-12 全部 happy/boundary/failure 用例通过;PH3 Phase 验收里的 REQ-12 以本 Unit 为准"}, 42 {"id": "U-024.5", "phase": "PH3", "sub_phase": "PH3-S2", "name": "REQ-12 完整验收:事件多样性联调(2026-07-30 新增)", "classification": "Value Path", "depends_on": ["U-024", "U-015", "U-016", "U-018", "U-019", "U-020", "U-021"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-12"]}, "done": "REQ-12 全部 happy/boundary/failure 用例通过;PH3 Phase 验收里的 REQ-12 以本 Unit 为准"},
frontend/app.js
1 -// U-008: empty-shell client-side router. Hash-based (no History API / server  
2 -// catch-all needed) -- real page content is PH3 (U-022+), this just proves  
3 -// navigation swaps content without a page reload or any build step. 1 +// U-008: hash-based client-side router (no History API / server catch-all
  2 +// needed). U-022 adds the first real page, #/setup (v_project_setup, 02-V
  3 +// §2.1) -- everything else is still a PH3 placeholder.
4 4
5 const routes = { 5 const routes = {
6 - "#/": () => "<h1>Dashboard</h1><p>placeholder -- real content is PH3 (v_dashboard, U-025)</p>",  
7 - "#/tasks": () => "<h1>Tasks</h1><p>placeholder -- real content is PH3 (v_task_board, U-025)</p>",  
8 - "#/settings": () => "<h1>Settings</h1><p>placeholder -- real content is PH3 (U-030)</p>", 6 + "#/": { render: () => "<h1>Dashboard</h1><p>placeholder -- real content is PH3 (v_dashboard, U-025)</p>" },
  7 + "#/tasks": { render: () => "<h1>Tasks</h1><p>placeholder -- real content is PH3 (v_task_board, U-025)</p>" },
  8 + "#/settings": { render: () => "<h1>Settings</h1><p>placeholder -- real content is PH3 (U-030)</p>" },
  9 + "#/setup": { render: renderProjectSetup, mount: mountProjectSetup },
9 }; 10 };
10 11
11 function render() { 12 function render() {
12 const hash = window.location.hash || "#/"; 13 const hash = window.location.hash || "#/";
13 - const view = routes[hash] || (() => "<h1>Not found</h1><p>" + hash + "</p>");  
14 - document.getElementById("app").innerHTML = view(); 14 + const route = routes[hash] || { render: () => "<h1>Not found</h1><p>" + hash + "</p>" };
  15 + document.getElementById("app").innerHTML = route.render();
  16 + if (route.mount) route.mount();
15 17
16 for (const a of document.querySelectorAll("#nav a")) { 18 for (const a of document.querySelectorAll("#nav a")) {
17 a.classList.toggle("active", a.getAttribute("href") === hash); 19 a.classList.toggle("active", a.getAttribute("href") === hash);
@@ -20,3 +22,103 @@ function render() { @@ -20,3 +22,103 @@ function render() {
20 22
21 window.addEventListener("hashchange", render); 23 window.addEventListener("hashchange", render);
22 window.addEventListener("DOMContentLoaded", render); 24 window.addEventListener("DOMContentLoaded", render);
  25 +
  26 +// ---- v_project_setup (02-V §2.1): create/switch project ----
  27 +
  28 +function renderProjectSetup() {
  29 + return `
  30 + <div class="top-back"><a href="#/">← 返回仪表盘</a></div>
  31 + <div class="page">
  32 + <h1>创建 / 切换项目</h1>
  33 + <div class="page-sub">选择一个本地文件夹作为目标项目的代码空间</div>
  34 + <div class="panel">
  35 + <div id="setup_error" class="error-banner" style="display:none;"></div>
  36 + <div class="field">
  37 + <label>目标文件夹(folder_path)</label>
  38 + <input type="text" id="folder_path" readonly value="">
  39 + <div class="hint" id="folder_path_hint">点击"选择文件夹"会由后端代为弹出系统原生对话框,不支持手动输入路径(除非返回 DIALOG_UNAVAILABLE)</div>
  40 + </div>
  41 + <button class="btn" id="btn_pick_folder" type="button">📁 选择文件夹</button>
  42 + <div class="field" style="margin-top:20px;">
  43 + <label>项目名称(project_name,非必填)</label>
  44 + <input type="text" id="project_name" placeholder="留空则取文件夹名">
  45 + </div>
  46 + <div class="btn-row">
  47 + <button class="btn btn-primary" id="btn_create" type="button">创建项目</button>
  48 + </div>
  49 + <div class="confirm-banner" id="confirm_banner" style="display:none;">
  50 + 该文件夹还不是 Git 仓库,是否代为初始化(<code>git init</code>)?确认前不写入任何文件。
  51 + <div class="btn-row" style="margin-top:10px;">
  52 + <button class="btn" id="btn_cancel_init" type="button">取消</button>
  53 + <button class="btn btn-primary" id="btn_confirm_init" type="button">确认初始化并创建项目</button>
  54 + </div>
  55 + </div>
  56 + </div>
  57 + </div>
  58 + `;
  59 +}
  60 +
  61 +function mountProjectSetup() {
  62 + const folderInput = document.getElementById("folder_path");
  63 + const folderHint = document.getElementById("folder_path_hint");
  64 + const nameInput = document.getElementById("project_name");
  65 + const errorBox = document.getElementById("setup_error");
  66 + const confirmBanner = document.getElementById("confirm_banner");
  67 +
  68 + function showError(message) {
  69 + errorBox.textContent = message;
  70 + errorBox.style.display = "block";
  71 + }
  72 + function clearError() {
  73 + errorBox.style.display = "none";
  74 + }
  75 +
  76 + document.getElementById("btn_pick_folder").addEventListener("click", async () => {
  77 + clearError();
  78 + const resp = await fetch("/api/system/pick-folder", { method: "POST", body: "{}" });
  79 + const body = await resp.json();
  80 + if (resp.status === 422 && body.error === "DIALOG_UNAVAILABLE") {
  81 + folderInput.removeAttribute("readonly");
  82 + folderHint.textContent = "当前环境不支持弹窗选择,请手动输入路径";
  83 + return;
  84 + }
  85 + if (body.selected_path) {
  86 + folderInput.value = body.selected_path;
  87 + }
  88 + // user canceled the native dialog -> folder_path stays as it was (02-V §2.1)
  89 + });
  90 +
  91 + async function createProject(initGitIfMissing) {
  92 + clearError();
  93 + confirmBanner.style.display = "none";
  94 + const folderPath = folderInput.value.trim();
  95 + if (!folderPath) {
  96 + showError("请先选择目标文件夹");
  97 + return;
  98 + }
  99 + const resp = await fetch("/api/projects", {
  100 + method: "POST",
  101 + body: JSON.stringify({
  102 + target_folder_path: folderPath,
  103 + name: nameInput.value.trim() || undefined,
  104 + init_git_if_missing: initGitIfMissing,
  105 + }),
  106 + });
  107 + const body = await resp.json();
  108 + if (resp.status === 200) {
  109 + window.location.hash = "#/";
  110 + return;
  111 + }
  112 + if (body.error === "GIT_INIT_DECLINED") {
  113 + confirmBanner.style.display = "block";
  114 + return;
  115 + }
  116 + showError(body.error + (body.detail ? ":" + body.detail : ""));
  117 + }
  118 +
  119 + document.getElementById("btn_create").addEventListener("click", () => createProject(false));
  120 + document.getElementById("btn_confirm_init").addEventListener("click", () => createProject(true));
  121 + document.getElementById("btn_cancel_init").addEventListener("click", () => {
  122 + confirmBanner.style.display = "none";
  123 + });
  124 +}
frontend/style.css
@@ -5,3 +5,20 @@ body { font-family: system-ui, sans-serif; margin: 0; } @@ -5,3 +5,20 @@ body { font-family: system-ui, sans-serif; margin: 0; }
5 #nav a { text-decoration: none; color: #333; } 5 #nav a { text-decoration: none; color: #333; }
6 #nav a.active { font-weight: bold; } 6 #nav a.active { font-weight: bold; }
7 #app { padding: 1rem; } 7 #app { padding: 1rem; }
  8 +
  9 +/* U-022: v_project_setup -- class names match docs/wireframes/v_project_setup.html */
  10 +.top-back a { color: #6b7280; text-decoration: none; }
  11 +.page { padding: 28px; max-width: 560px; margin: 0 auto; }
  12 +.page h1 { font-size: 22px; margin: 0 0 4px; }
  13 +.page .page-sub { color: #6b7280; font-size: 13px; margin-bottom: 28px; }
  14 +.panel { border: 1px solid #e5e7eb; border-radius: 10px; padding: 24px; background: #f9fafb; }
  15 +.field { margin-bottom: 18px; }
  16 +.field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
  17 +.field input { width: 100%; padding: 9px 10px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 14px; }
  18 +.field input[readonly] { background: #f3f4f6; color: #6b7280; }
  19 +.field .hint { font-size: 12px; color: #6b7280; margin-top: 5px; }
  20 +.btn { display: inline-block; padding: 9px 16px; border-radius: 6px; border: 1px solid #e5e7eb; background: #fff; font-size: 14px; cursor: pointer; }
  21 +.btn-primary { background: #4f46e5; border-color: #4f46e5; color: #fff; }
  22 +.btn-row { display: flex; gap: 10px; margin-top: 22px; }
  23 +.confirm-banner { background: #fffbeb; border: 1px solid #f59e0b; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-top: 16px; }
  24 +.error-banner { background: #fef2f2; border: 1px solid #ef4444; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-bottom: 16px; }
src/backend_server.py
1 -"""Local backend process skeleton (U-007): pure stdlib `http.server`, no  
2 -third-party deps (05 §5.7) -- this process is also where the Graph Engine  
3 -scheduler will live later (05 §5.4.1), but that's a different Unit.  
4 -  
5 -MVP scope here: process start/stop, `c_pick_folder` (03-C §3.-1) as the one  
6 -real API route -- macOS only for now (`osascript`); Windows/Linux native  
7 -dialogs are PH3 (U-022) -- and serving the U-008 frontend shell as static  
8 -files (no build step, 05 §5.7: whitelisted by exact path, not a generic  
9 -directory walker, so there's no path-traversal surface to reason about). 1 +"""Local backend process skeleton (U-007, extended in U-022): pure stdlib
  2 +`http.server`, no third-party deps (05 §5.7) -- this process is also where
  3 +the Graph Engine scheduler will live later (05 §5.4.1), but that's a
  4 +different Unit.
  5 +
  6 +MVP scope: process start/stop, `c_pick_folder` (03-C §3.-1, now all three
  7 +platforms -- U-007 shipped macOS only via `osascript`; U-022 adds Windows
  8 +via a `powershell` FolderBrowserDialog script and Linux via `zenity`/
  9 +`kdialog`, whichever is installed), `c_project_create` (03-C §3.0, wired to
  10 +the U-002 `project_scaffold.create_project()` function that had no HTTP
  11 +route until now) for `v_project_setup`'s submit flow, and serving the U-008
  12 +frontend shell as static files (no build step, 05 §5.7: whitelisted by exact
  13 +path, not a generic directory walker, so there's no path-traversal surface
  14 +to reason about).
10 """ 15 """
11 16
12 import json 17 import json
13 import os 18 import os
14 import platform 19 import platform
15 import subprocess 20 import subprocess
  21 +import threading
16 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 22 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
17 23
  24 +from src import paths, project_scaffold, schema
  25 +
18 _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend") 26 _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend")
19 27
20 # U-008: exact-path whitelist, not a generic static-file server -- the 28 # U-008: exact-path whitelist, not a generic static-file server -- the
@@ -32,13 +40,19 @@ class DialogUnavailableError(Exception): @@ -32,13 +40,19 @@ class DialogUnavailableError(Exception):
32 40
33 41
34 def pick_folder(): 42 def pick_folder():
35 - """macOS implementation of c_pick_folder. Returns {"selected_path": str}  
36 - on success, {"selected_path": None} on user cancel (03-C: not an error).  
37 - Raises DialogUnavailableError off macOS or if osascript itself fails for  
38 - a reason other than the user cancelling."""  
39 - if platform.system() != "Darwin":  
40 - raise DialogUnavailableError("native folder picker only implemented for macOS so far (PH3 adds Windows/Linux)")  
41 - 43 + """c_pick_folder (03-C §3.-1), all three platforms. Returns
  44 + {"selected_path": str} on success, {"selected_path": None} on user
  45 + cancel (not an error). Raises DialogUnavailableError if the platform's
  46 + native dialog tool itself fails or isn't installed."""
  47 + system = platform.system()
  48 + if system == "Darwin":
  49 + return _pick_folder_macos()
  50 + if system == "Windows":
  51 + return _pick_folder_windows()
  52 + return _pick_folder_linux()
  53 +
  54 +
  55 +def _pick_folder_macos():
42 script = 'POSIX path of (choose folder with prompt "Select a graphlemon project folder")' 56 script = 'POSIX path of (choose folder with prompt "Select a graphlemon project folder")'
43 result = subprocess.run(["osascript", "-e", script], capture_output=True, text=True) 57 result = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
44 if result.returncode != 0: 58 if result.returncode != 0:
@@ -48,6 +62,45 @@ def pick_folder(): @@ -48,6 +62,45 @@ def pick_folder():
48 return {"selected_path": result.stdout.strip()} 62 return {"selected_path": result.stdout.strip()}
49 63
50 64
  65 +def _pick_folder_windows():
  66 + # .NET FolderBrowserDialog via a one-shot PowerShell script -- no
  67 + # cancel-vs-error signal other than empty stdout on a clean exit.
  68 + script = (
  69 + "Add-Type -AssemblyName System.Windows.Forms; "
  70 + "$f = New-Object System.Windows.Forms.FolderBrowserDialog; "
  71 + "if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }"
  72 + )
  73 + try:
  74 + result = subprocess.run(
  75 + ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
  76 + capture_output=True, text=True,
  77 + )
  78 + except FileNotFoundError as e:
  79 + raise DialogUnavailableError(str(e))
  80 + if result.returncode != 0:
  81 + raise DialogUnavailableError(result.stderr.strip())
  82 + return {"selected_path": result.stdout.strip() or None}
  83 +
  84 +
  85 +def _pick_folder_linux():
  86 + # zenity (GNOME) and kdialog (KDE) are the two dialogs actually present
  87 + # across mainstream Linux desktops; try whichever exists. Both exit
  88 + # non-zero on user cancel, indistinguishable here from "no folder
  89 + # chosen" -- both cases map to selected_path=None, not an error.
  90 + for cmd in (
  91 + ["zenity", "--file-selection", "--directory"],
  92 + ["kdialog", "--getexistingdirectory", os.path.expanduser("~")],
  93 + ):
  94 + try:
  95 + result = subprocess.run(cmd, capture_output=True, text=True)
  96 + except FileNotFoundError:
  97 + continue
  98 + if result.returncode != 0:
  99 + return {"selected_path": None}
  100 + return {"selected_path": result.stdout.strip()}
  101 + raise DialogUnavailableError("neither zenity nor kdialog is installed")
  102 +
  103 +
51 class _Handler(BaseHTTPRequestHandler): 104 class _Handler(BaseHTTPRequestHandler):
52 def do_GET(self): 105 def do_GET(self):
53 entry = _STATIC_FILES.get(self.path) 106 entry = _STATIC_FILES.get(self.path)
@@ -66,6 +119,8 @@ class _Handler(BaseHTTPRequestHandler): @@ -66,6 +119,8 @@ class _Handler(BaseHTTPRequestHandler):
66 def do_POST(self): 119 def do_POST(self):
67 if self.path == "/api/system/pick-folder": 120 if self.path == "/api/system/pick-folder":
68 self._handle_pick_folder() 121 self._handle_pick_folder()
  122 + elif self.path == "/api/projects":
  123 + self._handle_project_create()
69 else: 124 else:
70 self._send_json(404, {"error": "NOT_FOUND"}) 125 self._send_json(404, {"error": "NOT_FOUND"})
71 126
@@ -75,6 +130,34 @@ class _Handler(BaseHTTPRequestHandler): @@ -75,6 +130,34 @@ class _Handler(BaseHTTPRequestHandler):
75 except DialogUnavailableError as e: 130 except DialogUnavailableError as e:
76 self._send_json(422, {"error": "DIALOG_UNAVAILABLE", "detail": str(e)}) 131 self._send_json(422, {"error": "DIALOG_UNAVAILABLE", "detail": str(e)})
77 132
  133 + def _handle_project_create(self):
  134 + length = int(self.headers.get("Content-Length", 0))
  135 + try:
  136 + payload = json.loads(self.rfile.read(length) or b"{}")
  137 + except json.JSONDecodeError:
  138 + self._send_json(400, {"error": "INVALID_INPUT", "detail": "body is not valid JSON"})
  139 + return
  140 + target_folder_path = payload.get("target_folder_path")
  141 + if not target_folder_path:
  142 + self._send_json(400, {"error": "INVALID_INPUT", "detail": "target_folder_path is required"})
  143 + return
  144 + try:
  145 + with self.server.conn_lock:
  146 + result = project_scaffold.create_project(
  147 + self.server.conn,
  148 + target_folder_path,
  149 + name=payload.get("name"),
  150 + init_git_if_missing=bool(payload.get("init_git_if_missing", False)),
  151 + )
  152 + except project_scaffold.PathNotFoundError as e:
  153 + self._send_json(404, {"error": "PATH_NOT_FOUND", "detail": str(e)})
  154 + except project_scaffold.PathNotWritableError as e:
  155 + self._send_json(403, {"error": "PATH_NOT_WRITABLE", "detail": str(e)})
  156 + except project_scaffold.GitInitDeclinedError as e:
  157 + self._send_json(409, {"error": "GIT_INIT_DECLINED", "detail": str(e)})
  158 + else:
  159 + self._send_json(200, result)
  160 +
78 def _send_json(self, status, obj): 161 def _send_json(self, status, obj):
79 body = json.dumps(obj).encode("utf-8") 162 body = json.dumps(obj).encode("utf-8")
80 self.send_response(status) 163 self.send_response(status)
@@ -87,7 +170,30 @@ class _Handler(BaseHTTPRequestHandler): @@ -87,7 +170,30 @@ class _Handler(BaseHTTPRequestHandler):
87 pass # quiets test output; real request logging is a later Unit's concern 170 pass # quiets test output; real request logging is a later Unit's concern
88 171
89 172
90 -def create_server(host="127.0.0.1", port=0): 173 +def _ensure_schema(conn):
  174 + exists = conn.execute(
  175 + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'project'"
  176 + ).fetchone()
  177 + if exists is None:
  178 + schema.create_schema(conn)
  179 +
  180 +
  181 +class _Server(ThreadingHTTPServer):
  182 + def server_close(self):
  183 + super().server_close()
  184 + self.conn.close()
  185 +
  186 +
  187 +def create_server(host="127.0.0.1", port=0, db_path=None):
91 """port=0 lets the OS assign a free port -- avoids collisions in tests 188 """port=0 lets the OS assign a free port -- avoids collisions in tests
92 - and lets multiple graphlemon instances run side by side if ever needed."""  
93 - return ThreadingHTTPServer((host, port), _Handler) 189 + and lets multiple graphlemon instances run side by side if ever needed.
  190 +
  191 + db_path defaults to paths.db_path() (U-003's platform_home, so it never
  192 + lives inside a target project's own repo); tests pass ":memory:" or a
  193 + tempfile so runs don't touch the real developer's ~/.graphlemon/."""
  194 + conn = schema.connect(db_path if db_path is not None else paths.db_path(), check_same_thread=False)
  195 + _ensure_schema(conn)
  196 + server = _Server((host, port), _Handler)
  197 + server.conn = conn
  198 + server.conn_lock = threading.Lock()
  199 + return server
src/paths.py
@@ -26,6 +26,12 @@ def log_dir(): @@ -26,6 +26,12 @@ def log_dir():
26 return os.path.join(platform_home(), "logs") 26 return os.path.join(platform_home(), "logs")
27 27
28 28
  29 +def db_path():
  30 + """graphlemon's own sqlite database file (U-001 schema), same
  31 + GRAPHLEMON_HOME-overridable root as everything else in this module."""
  32 + return os.path.join(platform_home(), "graphlemon.db")
  33 +
  34 +
29 def log_file_path(project_id, task_id, run_id, node_exec_id): 35 def log_file_path(project_id, task_id, run_id, node_exec_id):
30 """01-M 1.13 execution_log_file.file_path convention.""" 36 """01-M 1.13 execution_log_file.file_path convention."""
31 return os.path.join(log_dir(), project_id, task_id, run_id, node_exec_id + ".jsonl") 37 return os.path.join(log_dir(), project_id, task_id, run_id, node_exec_id + ".jsonl")
src/schema.py
@@ -271,13 +271,19 @@ CREATE TABLE log_retention_config ( @@ -271,13 +271,19 @@ CREATE TABLE log_retention_config (
271 """ 271 """
272 272
273 273
274 -def connect(db_path=":memory:"): 274 +def connect(db_path=":memory:", check_same_thread=True):
275 """Open a sqlite3 connection with foreign-key enforcement turned on. 275 """Open a sqlite3 connection with foreign-key enforcement turned on.
276 276
277 SQLite disables FK enforcement by default per-connection; every caller needs 277 SQLite disables FK enforcement by default per-connection; every caller needs
278 this pragma, so it lives here rather than being repeated at every call site. 278 this pragma, so it lives here rather than being repeated at every call site.
  279 +
  280 + check_same_thread=False is for callers like backend_server.py that share
  281 + one connection across a ThreadingHTTPServer's per-request threads -- the
  282 + caller is then responsible for serializing access itself (a lock), since
  283 + turning this off only disables sqlite3's own thread-identity check, it
  284 + doesn't make concurrent use of one connection object safe by itself.
279 """ 285 """
280 - conn = sqlite3.connect(db_path) 286 + conn = sqlite3.connect(db_path, check_same_thread=check_same_thread)
281 conn.execute("PRAGMA foreign_keys = ON") 287 conn.execute("PRAGMA foreign_keys = ON")
282 return conn 288 return conn
283 289
src/test_backend_server.py
@@ -9,8 +9,10 @@ import ast @@ -9,8 +9,10 @@ import ast
9 import http.client 9 import http.client
10 import json 10 import json
11 import os 11 import os
  12 +import shutil
12 import subprocess 13 import subprocess
13 import sys 14 import sys
  15 +import tempfile
14 import threading 16 import threading
15 import unittest 17 import unittest
16 from unittest.mock import patch 18 from unittest.mock import patch
@@ -23,7 +25,7 @@ class ServerStartStopTest(unittest.TestCase): @@ -23,7 +25,7 @@ class ServerStartStopTest(unittest.TestCase):
23 # deliberately hits an unrelated route, not /api/system/pick-folder -- 25 # deliberately hits an unrelated route, not /api/system/pick-folder --
24 # that one would pop a real, unmocked osascript dialog on this machine 26 # that one would pop a real, unmocked osascript dialog on this machine
25 # and hang waiting for a human who isn't there. 27 # and hang waiting for a human who isn't there.
26 - server = backend_server.create_server() 28 + server = backend_server.create_server(db_path=":memory:")
27 thread = threading.Thread(target=server.serve_forever, daemon=True) 29 thread = threading.Thread(target=server.serve_forever, daemon=True)
28 thread.start() 30 thread.start()
29 try: 31 try:
@@ -41,7 +43,7 @@ class ServerStartStopTest(unittest.TestCase): @@ -41,7 +43,7 @@ class ServerStartStopTest(unittest.TestCase):
41 self.assertFalse(thread.is_alive()) 43 self.assertFalse(thread.is_alive())
42 44
43 def test_unknown_route_returns_404(self): 45 def test_unknown_route_returns_404(self):
44 - server = backend_server.create_server() 46 + server = backend_server.create_server(db_path=":memory:")
45 thread = threading.Thread(target=server.serve_forever, daemon=True) 47 thread = threading.Thread(target=server.serve_forever, daemon=True)
46 thread.start() 48 thread.start()
47 try: 49 try:
@@ -67,7 +69,7 @@ class StaticFileServingTest(unittest.TestCase): @@ -67,7 +69,7 @@ class StaticFileServingTest(unittest.TestCase):
67 engine, so this only covers what the server is responsible for.""" 69 engine, so this only covers what the server is responsible for."""
68 70
69 def setUp(self): 71 def setUp(self):
70 - self.server = backend_server.create_server() 72 + self.server = backend_server.create_server(db_path=":memory:")
71 self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) 73 self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
72 self.thread.start() 74 self.thread.start()
73 75
@@ -132,11 +134,6 @@ class PickFolderTest(unittest.TestCase): @@ -132,11 +134,6 @@ class PickFolderTest(unittest.TestCase):
132 result = backend_server.pick_folder() 134 result = backend_server.pick_folder()
133 self.assertEqual(result, {"selected_path": None}) 135 self.assertEqual(result, {"selected_path": None})
134 136
135 - def test_non_macos_raises_dialog_unavailable(self):  
136 - with patch("src.backend_server.platform.system", return_value="Linux"):  
137 - with self.assertRaises(backend_server.DialogUnavailableError):  
138 - backend_server.pick_folder()  
139 -  
140 def test_osascript_failure_other_than_cancel_raises_dialog_unavailable(self): 137 def test_osascript_failure_other_than_cancel_raises_dialog_unavailable(self):
141 with patch("src.backend_server.platform.system", return_value="Darwin"), \ 138 with patch("src.backend_server.platform.system", return_value="Darwin"), \
142 patch("src.backend_server.subprocess.run") as run: 139 patch("src.backend_server.subprocess.run") as run:
@@ -146,10 +143,71 @@ class PickFolderTest(unittest.TestCase): @@ -146,10 +143,71 @@ class PickFolderTest(unittest.TestCase):
146 with self.assertRaises(backend_server.DialogUnavailableError): 143 with self.assertRaises(backend_server.DialogUnavailableError):
147 backend_server.pick_folder() 144 backend_server.pick_folder()
148 145
  146 + def test_windows_success_returns_selected_path(self):
  147 + with patch("src.backend_server.platform.system", return_value="Windows"), \
  148 + patch("src.backend_server.subprocess.run") as run:
  149 + run.return_value = subprocess.CompletedProcess(
  150 + args=["powershell"], returncode=0, stdout="C:\\Users\\x\\some-project\r\n", stderr=""
  151 + )
  152 + result = backend_server.pick_folder()
  153 + self.assertEqual(result, {"selected_path": "C:\\Users\\x\\some-project"})
  154 +
  155 + def test_windows_user_cancel_returns_null_path(self):
  156 + with patch("src.backend_server.platform.system", return_value="Windows"), \
  157 + patch("src.backend_server.subprocess.run") as run:
  158 + run.return_value = subprocess.CompletedProcess(
  159 + args=["powershell"], returncode=0, stdout="", stderr=""
  160 + )
  161 + result = backend_server.pick_folder()
  162 + self.assertEqual(result, {"selected_path": None})
  163 +
  164 + def test_windows_missing_powershell_raises_dialog_unavailable(self):
  165 + with patch("src.backend_server.platform.system", return_value="Windows"), \
  166 + patch("src.backend_server.subprocess.run", side_effect=FileNotFoundError("no powershell")):
  167 + with self.assertRaises(backend_server.DialogUnavailableError):
  168 + backend_server.pick_folder()
  169 +
  170 + def test_linux_zenity_success_returns_selected_path(self):
  171 + with patch("src.backend_server.platform.system", return_value="Linux"), \
  172 + patch("src.backend_server.subprocess.run") as run:
  173 + run.return_value = subprocess.CompletedProcess(
  174 + args=["zenity"], returncode=0, stdout="/home/x/some-project\n", stderr=""
  175 + )
  176 + result = backend_server.pick_folder()
  177 + self.assertEqual(result, {"selected_path": "/home/x/some-project"})
  178 + run.assert_called_once()
  179 + self.assertEqual(run.call_args[0][0][0], "zenity")
  180 +
  181 + def test_linux_zenity_cancel_returns_null_path(self):
  182 + with patch("src.backend_server.platform.system", return_value="Linux"), \
  183 + patch("src.backend_server.subprocess.run") as run:
  184 + run.return_value = subprocess.CompletedProcess(
  185 + args=["zenity"], returncode=1, stdout="", stderr=""
  186 + )
  187 + result = backend_server.pick_folder()
  188 + self.assertEqual(result, {"selected_path": None})
  189 +
  190 + def test_linux_falls_back_to_kdialog_when_zenity_missing(self):
  191 + with patch("src.backend_server.platform.system", return_value="Linux"), \
  192 + patch("src.backend_server.subprocess.run") as run:
  193 + run.side_effect = [
  194 + FileNotFoundError("no zenity"),
  195 + subprocess.CompletedProcess(args=["kdialog"], returncode=0, stdout="/home/x/proj\n", stderr=""),
  196 + ]
  197 + result = backend_server.pick_folder()
  198 + self.assertEqual(result, {"selected_path": "/home/x/proj"})
  199 + self.assertEqual(run.call_count, 2)
  200 +
  201 + def test_linux_neither_zenity_nor_kdialog_raises_dialog_unavailable(self):
  202 + with patch("src.backend_server.platform.system", return_value="Linux"), \
  203 + patch("src.backend_server.subprocess.run", side_effect=FileNotFoundError("not found")):
  204 + with self.assertRaises(backend_server.DialogUnavailableError):
  205 + backend_server.pick_folder()
  206 +
149 207
150 class PickFolderRouteTest(unittest.TestCase): 208 class PickFolderRouteTest(unittest.TestCase):
151 def setUp(self): 209 def setUp(self):
152 - self.server = backend_server.create_server() 210 + self.server = backend_server.create_server(db_path=":memory:")
153 self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) 211 self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
154 self.thread.start() 212 self.thread.start()
155 213
@@ -177,13 +235,84 @@ class PickFolderRouteTest(unittest.TestCase): @@ -177,13 +235,84 @@ class PickFolderRouteTest(unittest.TestCase):
177 self.assertEqual(status, 200) 235 self.assertEqual(status, 200)
178 self.assertEqual(body, {"selected_path": "/tmp/chosen"}) 236 self.assertEqual(body, {"selected_path": "/tmp/chosen"})
179 237
180 - def test_route_returns_422_dialog_unavailable_off_macos(self):  
181 - with patch("src.backend_server.platform.system", return_value="Linux"): 238 + def test_route_returns_422_dialog_unavailable_when_no_dialog_tool_present(self):
  239 + with patch("src.backend_server.platform.system", return_value="Linux"), \
  240 + patch("src.backend_server.subprocess.run", side_effect=FileNotFoundError("not found")):
182 status, body = self._post("/api/system/pick-folder") 241 status, body = self._post("/api/system/pick-folder")
183 self.assertEqual(status, 422) 242 self.assertEqual(status, 422)
184 self.assertEqual(body["error"], "DIALOG_UNAVAILABLE") 243 self.assertEqual(body["error"], "DIALOG_UNAVAILABLE")
185 244
186 245
  246 +class ProjectCreateRouteTest(unittest.TestCase):
  247 + """c_project_create (03-C §3.0) wired to a real HTTP route for the first
  248 + time in U-022 -- project_scaffold.create_project() itself was already
  249 + fully implemented and tested in U-002, so this only exercises the
  250 + request/response/error-code plumbing, not the scaffolding logic again."""
  251 +
  252 + def setUp(self):
  253 + self.tmp = tempfile.TemporaryDirectory()
  254 + self.addCleanup(self.tmp.cleanup)
  255 + subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True)
  256 + self.server = backend_server.create_server(db_path=":memory:")
  257 + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  258 + self.thread.start()
  259 +
  260 + def tearDown(self):
  261 + self.server.shutdown()
  262 + self.server.server_close()
  263 + self.thread.join(timeout=5)
  264 +
  265 + def _post(self, path, payload):
  266 + host, port = self.server.server_address
  267 + conn = http.client.HTTPConnection(host, port, timeout=5)
  268 + conn.request("POST", path, body=json.dumps(payload).encode("utf-8"))
  269 + resp = conn.getresponse()
  270 + body = json.loads(resp.read())
  271 + conn.close()
  272 + return resp.status, body
  273 +
  274 + def test_creates_project_for_existing_git_repo(self):
  275 + status, body = self._post("/api/projects", {"target_folder_path": self.tmp.name})
  276 + self.assertEqual(status, 200)
  277 + self.assertTrue(body["has_git_repo"])
  278 + self.assertTrue(body["graphlemon_subdir_initialized"])
  279 + self.assertTrue(os.path.isdir(os.path.join(self.tmp.name, ".graphlemon")))
  280 +
  281 + def test_repeat_call_is_idempotent(self):
  282 + status1, body1 = self._post("/api/projects", {"target_folder_path": self.tmp.name})
  283 + status2, body2 = self._post("/api/projects", {"target_folder_path": self.tmp.name})
  284 + self.assertEqual(status1, 200)
  285 + self.assertEqual(status2, 200)
  286 + self.assertEqual(body1["project_id"], body2["project_id"])
  287 +
  288 + def test_missing_target_folder_path_returns_400(self):
  289 + status, body = self._post("/api/projects", {})
  290 + self.assertEqual(status, 400)
  291 + self.assertEqual(body["error"], "INVALID_INPUT")
  292 +
  293 + def test_nonexistent_path_returns_404(self):
  294 + status, body = self._post("/api/projects", {"target_folder_path": "/no/such/path/at/all"})
  295 + self.assertEqual(status, 404)
  296 + self.assertEqual(body["error"], "PATH_NOT_FOUND")
  297 +
  298 + def test_non_git_folder_without_confirmation_returns_409(self):
  299 + non_git = tempfile.mkdtemp()
  300 + self.addCleanup(lambda: shutil.rmtree(non_git, ignore_errors=True))
  301 + status, body = self._post("/api/projects", {"target_folder_path": non_git})
  302 + self.assertEqual(status, 409)
  303 + self.assertEqual(body["error"], "GIT_INIT_DECLINED")
  304 +
  305 + def test_non_git_folder_with_confirmation_initializes_git(self):
  306 + non_git = tempfile.mkdtemp()
  307 + self.addCleanup(lambda: shutil.rmtree(non_git, ignore_errors=True))
  308 + status, body = self._post(
  309 + "/api/projects", {"target_folder_path": non_git, "init_git_if_missing": True}
  310 + )
  311 + self.assertEqual(status, 200)
  312 + self.assertTrue(body["has_git_repo"])
  313 + self.assertTrue(os.path.isdir(os.path.join(non_git, ".git")))
  314 +
  315 +
187 class ZeroThirdPartyDependencyTest(unittest.TestCase): 316 class ZeroThirdPartyDependencyTest(unittest.TestCase):
188 """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this 317 """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this
189 doesn't silently rot if someone adds an import later.""" 318 doesn't silently rot if someone adds an import later."""
@@ -202,7 +331,10 @@ class ZeroThirdPartyDependencyTest(unittest.TestCase): @@ -202,7 +331,10 @@ class ZeroThirdPartyDependencyTest(unittest.TestCase):
202 top_level_names.add(node.module.split(".")[0]) 331 top_level_names.add(node.module.split(".")[0])
203 332
204 self.assertTrue(top_level_names, "expected at least one import to check") 333 self.assertTrue(top_level_names, "expected at least one import to check")
205 - non_stdlib = {name for name in top_level_names if name not in sys.stdlib_module_names} 334 + # "src" is graphlemon's own package (e.g. project_scaffold/schema/paths,
  335 + # wired in U-022) -- not a third-party dependency, so it's allowed;
  336 + # everything else must be in the standard library.
  337 + non_stdlib = {name for name in top_level_names if name not in sys.stdlib_module_names and name != "src"}
206 self.assertEqual(non_stdlib, set(), "non-stdlib imports found: {}".format(non_stdlib)) 338 self.assertEqual(non_stdlib, set(), "non-stdlib imports found: {}".format(non_stdlib))
207 339
208 340