project_scaffold.py
4.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
"""c_project_create (03-C §3.0): register/scaffold a target project.
U-002 scope: the directory-scaffolding + DB-persistence half of c_project_create --
no HTTP endpoint yet (that's U-007); this is the plain function U-007 will wire up.
Target-project directory convention is architecture doc §13.2, NOT §7.3 (see
development-plan.md's 2026-07-31 correction: §7.3 describes graphlemon's own
platform repo, not a target project). c_project_create only ever creates
`.graphlemon/` inside the target folder; a target project's own `src/` and
everything else in it is left untouched.
"""
import os
import subprocess
import uuid
from src import agent_profile
_GIT_IDENTITY = [
"-c", "user.name=graphlemon",
"-c", "user.email=graphlemon@localhost",
]
class PathNotFoundError(Exception):
"""PATH_NOT_FOUND -- 03-C §3.0: target_folder_path does not exist."""
class PathNotWritableError(Exception):
"""PATH_NOT_WRITABLE -- 03-C §3.0: no write permission on target_folder_path."""
class GitInitDeclinedError(Exception):
"""GIT_INIT_DECLINED -- 03-C §3.0: not a git repo and caller didn't ask to init one.
02-V §2.1: the UI must get explicit user confirmation before setting
init_git_if_missing=True; this function never silently runs `git init`.
"""
def _run_git(args, cwd):
subprocess.run(["git"] + args, cwd=cwd, check=True, capture_output=True, text=True)
def create_project(conn, target_folder_path, name=None, init_git_if_missing=False):
"""Implements c_project_create (03-C §3.0).
Returns {"project_id", "has_git_repo", "graphlemon_subdir_initialized"}.
Idempotent: a second call with the same target_folder_path returns the
existing row untouched (01-M 1.0 幂等约束) -- no re-validation, no repeat
side effects, matching REQ-11's failure-case wording.
"""
existing = conn.execute(
"SELECT project_id, has_git_repo, graphlemon_subdir_initialized "
"FROM project WHERE target_folder_path = ?",
(target_folder_path,),
).fetchone()
if existing is not None:
return {
"project_id": existing[0],
"has_git_repo": bool(existing[1]),
"graphlemon_subdir_initialized": bool(existing[2]),
}
if not os.path.isdir(target_folder_path):
raise PathNotFoundError(target_folder_path)
if not os.access(target_folder_path, os.W_OK):
raise PathNotWritableError(target_folder_path)
has_git_repo = os.path.isdir(os.path.join(target_folder_path, ".git"))
if not has_git_repo:
if not init_git_if_missing:
raise GitInitDeclinedError(target_folder_path)
_run_git(["init"], cwd=target_folder_path)
has_git_repo = True
graphlemon_subdir = os.path.join(target_folder_path, ".graphlemon")
os.makedirs(graphlemon_subdir, exist_ok=True)
# git doesn't track empty directories; this is only so the init commit has
# something under .graphlemon/ to record. Real content (runs/<task_id>/...)
# arrives once tasks actually execute (01-M 1.5 artifact.file_path).
open(os.path.join(graphlemon_subdir, ".gitkeep"), "w").close()
# Without this, the first task ever triggered on this project fails
# immediately: `claude --agent planner` has nothing to find in this cwd
# (org_node_trigger.py's own docstring already says the caller is
# responsible for deploying this first, but until now nothing did --
# self-target usage never noticed because graphlemon's own .claude/agents/
# is already committed as part of its own repo).
agent_profile.deploy_agent_definitions(target_folder_path)
_run_git(["add", ".graphlemon", ".claude"], cwd=target_folder_path)
_run_git(
_GIT_IDENTITY + ["commit", "-m", "graphlemon: initialize .graphlemon/ workspace + deploy agent definitions"],
cwd=target_folder_path,
)
project_id = str(uuid.uuid4())
project_name = name or os.path.basename(os.path.normpath(target_folder_path))
conn.execute(
"INSERT INTO project "
"(project_id, name, target_folder_path, has_git_repo, graphlemon_subdir_initialized, status) "
"VALUES (?, ?, ?, ?, 1, 'active')",
(project_id, project_name, target_folder_path, int(has_git_repo)),
)
conn.commit()
return {
"project_id": project_id,
"has_git_repo": has_git_repo,
"graphlemon_subdir_initialized": True,
}