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 217 - **U-022 [Value Path]** `v_project_setup` + `c_pick_folder` 全平台补齐(Windows `PowerShell`/Linux `zenity`/`kdialog`)
218 218 - 依赖:U-007, U-008
219 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 221 - 覆盖:02-V `v_project_setup`;03-C `c_pick_folder`;05 节 §5.4.1;REQ-11
221 222  
222 223 - **U-023 [Value Path]** `v_goal_composer` + `v_bug_feedback`
... ...
docs/sdd/unit_matrix.json
... ... @@ -36,7 +36,7 @@
36 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 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 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 41 {"id": "U-024", "phase": "PH3", "sub_phase": "PH3-S2", "name": "c_ws_subscribe + execution_event 实时推送(最小连通版)", "classification": "Value Path", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["execution_event"], "view": [], "controller": ["c_ws_subscribe"], "process": [], "requirement": ["REQ-12"]}, "done": "推送链路本身连通,≤2 秒延迟达成;REQ-12 happy 用例通过(boundary/failure 留给 U-024.5)"},
42 42 {"id": "U-024.5", "phase": "PH3", "sub_phase": "PH3-S2", "name": "REQ-12 完整验收:事件多样性联调(2026-07-30 新增)", "classification": "Value Path", "depends_on": ["U-024", "U-015", "U-016", "U-018", "U-019", "U-020", "U-021"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-12"]}, "done": "REQ-12 全部 happy/boundary/failure 用例通过;PH3 Phase 验收里的 REQ-12 以本 Unit 为准"},
... ...
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 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 12 function render() {
12 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 18 for (const a of document.querySelectorAll("#nav a")) {
17 19 a.classList.toggle("active", a.getAttribute("href") === hash);
... ... @@ -20,3 +22,103 @@ function render() {
20 22  
21 23 window.addEventListener("hashchange", render);
22 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 5 #nav a { text-decoration: none; color: #333; }
6 6 #nav a.active { font-weight: bold; }
7 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 17 import json
13 18 import os
14 19 import platform
15 20 import subprocess
  21 +import threading
16 22 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
17 23  
  24 +from src import paths, project_scaffold, schema
  25 +
18 26 _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend")
19 27  
20 28 # U-008: exact-path whitelist, not a generic static-file server -- the
... ... @@ -32,13 +40,19 @@ class DialogUnavailableError(Exception):
32 40  
33 41  
34 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 56 script = 'POSIX path of (choose folder with prompt "Select a graphlemon project folder")'
43 57 result = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
44 58 if result.returncode != 0:
... ... @@ -48,6 +62,45 @@ def pick_folder():
48 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 104 class _Handler(BaseHTTPRequestHandler):
52 105 def do_GET(self):
53 106 entry = _STATIC_FILES.get(self.path)
... ... @@ -66,6 +119,8 @@ class _Handler(BaseHTTPRequestHandler):
66 119 def do_POST(self):
67 120 if self.path == "/api/system/pick-folder":
68 121 self._handle_pick_folder()
  122 + elif self.path == "/api/projects":
  123 + self._handle_project_create()
69 124 else:
70 125 self._send_json(404, {"error": "NOT_FOUND"})
71 126  
... ... @@ -75,6 +130,34 @@ class _Handler(BaseHTTPRequestHandler):
75 130 except DialogUnavailableError as e:
76 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 161 def _send_json(self, status, obj):
79 162 body = json.dumps(obj).encode("utf-8")
80 163 self.send_response(status)
... ... @@ -87,7 +170,30 @@ class _Handler(BaseHTTPRequestHandler):
87 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 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 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 35 def log_file_path(project_id, task_id, run_id, node_exec_id):
30 36 """01-M 1.13 execution_log_file.file_path convention."""
31 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 271 """
272 272  
273 273  
274   -def connect(db_path=":memory:"):
  274 +def connect(db_path=":memory:", check_same_thread=True):
275 275 """Open a sqlite3 connection with foreign-key enforcement turned on.
276 276  
277 277 SQLite disables FK enforcement by default per-connection; every caller needs
278 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 287 conn.execute("PRAGMA foreign_keys = ON")
282 288 return conn
283 289  
... ...
src/test_backend_server.py
... ... @@ -9,8 +9,10 @@ import ast
9 9 import http.client
10 10 import json
11 11 import os
  12 +import shutil
12 13 import subprocess
13 14 import sys
  15 +import tempfile
14 16 import threading
15 17 import unittest
16 18 from unittest.mock import patch
... ... @@ -23,7 +25,7 @@ class ServerStartStopTest(unittest.TestCase):
23 25 # deliberately hits an unrelated route, not /api/system/pick-folder --
24 26 # that one would pop a real, unmocked osascript dialog on this machine
25 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 29 thread = threading.Thread(target=server.serve_forever, daemon=True)
28 30 thread.start()
29 31 try:
... ... @@ -41,7 +43,7 @@ class ServerStartStopTest(unittest.TestCase):
41 43 self.assertFalse(thread.is_alive())
42 44  
43 45 def test_unknown_route_returns_404(self):
44   - server = backend_server.create_server()
  46 + server = backend_server.create_server(db_path=":memory:")
45 47 thread = threading.Thread(target=server.serve_forever, daemon=True)
46 48 thread.start()
47 49 try:
... ... @@ -67,7 +69,7 @@ class StaticFileServingTest(unittest.TestCase):
67 69 engine, so this only covers what the server is responsible for."""
68 70  
69 71 def setUp(self):
70   - self.server = backend_server.create_server()
  72 + self.server = backend_server.create_server(db_path=":memory:")
71 73 self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
72 74 self.thread.start()
73 75  
... ... @@ -132,11 +134,6 @@ class PickFolderTest(unittest.TestCase):
132 134 result = backend_server.pick_folder()
133 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 137 def test_osascript_failure_other_than_cancel_raises_dialog_unavailable(self):
141 138 with patch("src.backend_server.platform.system", return_value="Darwin"), \
142 139 patch("src.backend_server.subprocess.run") as run:
... ... @@ -146,10 +143,71 @@ class PickFolderTest(unittest.TestCase):
146 143 with self.assertRaises(backend_server.DialogUnavailableError):
147 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 208 class PickFolderRouteTest(unittest.TestCase):
151 209 def setUp(self):
152   - self.server = backend_server.create_server()
  210 + self.server = backend_server.create_server(db_path=":memory:")
153 211 self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
154 212 self.thread.start()
155 213  
... ... @@ -177,13 +235,84 @@ class PickFolderRouteTest(unittest.TestCase):
177 235 self.assertEqual(status, 200)
178 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 241 status, body = self._post("/api/system/pick-folder")
183 242 self.assertEqual(status, 422)
184 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 316 class ZeroThirdPartyDependencyTest(unittest.TestCase):
188 317 """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this
189 318 doesn't silently rot if someone adds an import later."""
... ... @@ -202,7 +331,10 @@ class ZeroThirdPartyDependencyTest(unittest.TestCase):
202 331 top_level_names.add(node.module.split(".")[0])
203 332  
204 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 338 self.assertEqual(non_stdlib, set(), "non-stdlib imports found: {}".format(non_stdlib))
207 339  
208 340  
... ...