autoforge-ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/check-code.md +32 -0
- package/.claude/commands/checkpoint.md +40 -0
- package/.claude/commands/create-spec.md +613 -0
- package/.claude/commands/expand-project.md +234 -0
- package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
- package/.claude/commands/review-pr.md +75 -0
- package/.claude/templates/app_spec.template.txt +331 -0
- package/.claude/templates/coding_prompt.template.md +265 -0
- package/.claude/templates/initializer_prompt.template.md +354 -0
- package/.claude/templates/testing_prompt.template.md +146 -0
- package/.env.example +64 -0
- package/LICENSE.md +676 -0
- package/README.md +423 -0
- package/agent.py +444 -0
- package/api/__init__.py +10 -0
- package/api/database.py +536 -0
- package/api/dependency_resolver.py +449 -0
- package/api/migration.py +156 -0
- package/auth.py +83 -0
- package/autoforge_paths.py +315 -0
- package/autonomous_agent_demo.py +293 -0
- package/bin/autoforge.js +3 -0
- package/client.py +607 -0
- package/env_constants.py +27 -0
- package/examples/OPTIMIZE_CONFIG.md +230 -0
- package/examples/README.md +531 -0
- package/examples/org_config.yaml +172 -0
- package/examples/project_allowed_commands.yaml +139 -0
- package/lib/cli.js +791 -0
- package/mcp_server/__init__.py +1 -0
- package/mcp_server/feature_mcp.py +988 -0
- package/package.json +53 -0
- package/parallel_orchestrator.py +1800 -0
- package/progress.py +247 -0
- package/prompts.py +427 -0
- package/pyproject.toml +17 -0
- package/rate_limit_utils.py +132 -0
- package/registry.py +614 -0
- package/requirements-prod.txt +14 -0
- package/security.py +959 -0
- package/server/__init__.py +17 -0
- package/server/main.py +261 -0
- package/server/routers/__init__.py +32 -0
- package/server/routers/agent.py +177 -0
- package/server/routers/assistant_chat.py +327 -0
- package/server/routers/devserver.py +309 -0
- package/server/routers/expand_project.py +239 -0
- package/server/routers/features.py +746 -0
- package/server/routers/filesystem.py +514 -0
- package/server/routers/projects.py +524 -0
- package/server/routers/schedules.py +356 -0
- package/server/routers/settings.py +127 -0
- package/server/routers/spec_creation.py +357 -0
- package/server/routers/terminal.py +453 -0
- package/server/schemas.py +593 -0
- package/server/services/__init__.py +36 -0
- package/server/services/assistant_chat_session.py +496 -0
- package/server/services/assistant_database.py +304 -0
- package/server/services/chat_constants.py +57 -0
- package/server/services/dev_server_manager.py +557 -0
- package/server/services/expand_chat_session.py +399 -0
- package/server/services/process_manager.py +657 -0
- package/server/services/project_config.py +475 -0
- package/server/services/scheduler_service.py +683 -0
- package/server/services/spec_chat_session.py +502 -0
- package/server/services/terminal_manager.py +756 -0
- package/server/utils/__init__.py +1 -0
- package/server/utils/process_utils.py +134 -0
- package/server/utils/project_helpers.py +32 -0
- package/server/utils/validation.py +54 -0
- package/server/websocket.py +903 -0
- package/start.py +456 -0
- package/ui/dist/assets/index-8W_wmZzz.js +168 -0
- package/ui/dist/assets/index-B47Ubhox.css +1 -0
- package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
- package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
- package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
- package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
- package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
- package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/ollama.png +0 -0
- package/ui/dist/vite.svg +6 -0
- package/ui/package.json +57 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Projects Router
|
|
3
|
+
===============
|
|
4
|
+
|
|
5
|
+
API endpoints for project management.
|
|
6
|
+
Uses project registry for path lookups instead of fixed generations/ directory.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, HTTPException
|
|
16
|
+
|
|
17
|
+
from ..schemas import (
|
|
18
|
+
ProjectCreate,
|
|
19
|
+
ProjectDetail,
|
|
20
|
+
ProjectPrompts,
|
|
21
|
+
ProjectPromptsUpdate,
|
|
22
|
+
ProjectSettingsUpdate,
|
|
23
|
+
ProjectStats,
|
|
24
|
+
ProjectSummary,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Lazy imports to avoid circular dependencies
|
|
28
|
+
# These are initialized by _init_imports() before first use.
|
|
29
|
+
_imports_initialized = False
|
|
30
|
+
_check_spec_exists: Callable[..., Any] | None = None
|
|
31
|
+
_scaffold_project_prompts: Callable[..., Any] | None = None
|
|
32
|
+
_get_project_prompts_dir: Callable[..., Any] | None = None
|
|
33
|
+
_count_passing_tests: Callable[..., Any] | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _init_imports():
|
|
37
|
+
"""Lazy import of project-level modules."""
|
|
38
|
+
global _imports_initialized, _check_spec_exists
|
|
39
|
+
global _scaffold_project_prompts, _get_project_prompts_dir
|
|
40
|
+
global _count_passing_tests
|
|
41
|
+
|
|
42
|
+
if _imports_initialized:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
import sys
|
|
46
|
+
root = Path(__file__).parent.parent.parent
|
|
47
|
+
if str(root) not in sys.path:
|
|
48
|
+
sys.path.insert(0, str(root))
|
|
49
|
+
|
|
50
|
+
from progress import count_passing_tests
|
|
51
|
+
from prompts import get_project_prompts_dir, scaffold_project_prompts
|
|
52
|
+
from start import check_spec_exists
|
|
53
|
+
|
|
54
|
+
_check_spec_exists = check_spec_exists
|
|
55
|
+
_scaffold_project_prompts = scaffold_project_prompts
|
|
56
|
+
_get_project_prompts_dir = get_project_prompts_dir
|
|
57
|
+
_count_passing_tests = count_passing_tests
|
|
58
|
+
_imports_initialized = True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_registry_functions():
|
|
62
|
+
"""Get registry functions with lazy import."""
|
|
63
|
+
import sys
|
|
64
|
+
root = Path(__file__).parent.parent.parent
|
|
65
|
+
if str(root) not in sys.path:
|
|
66
|
+
sys.path.insert(0, str(root))
|
|
67
|
+
|
|
68
|
+
from registry import (
|
|
69
|
+
get_project_concurrency,
|
|
70
|
+
get_project_path,
|
|
71
|
+
list_registered_projects,
|
|
72
|
+
register_project,
|
|
73
|
+
set_project_concurrency,
|
|
74
|
+
unregister_project,
|
|
75
|
+
validate_project_path,
|
|
76
|
+
)
|
|
77
|
+
return (
|
|
78
|
+
register_project,
|
|
79
|
+
unregister_project,
|
|
80
|
+
get_project_path,
|
|
81
|
+
list_registered_projects,
|
|
82
|
+
validate_project_path,
|
|
83
|
+
get_project_concurrency,
|
|
84
|
+
set_project_concurrency,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
router = APIRouter(prefix="/api/projects", tags=["projects"])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def validate_project_name(name: str) -> str:
|
|
92
|
+
"""Validate and sanitize project name to prevent path traversal."""
|
|
93
|
+
if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
|
|
94
|
+
raise HTTPException(
|
|
95
|
+
status_code=400,
|
|
96
|
+
detail="Invalid project name. Use only letters, numbers, hyphens, and underscores (1-50 chars)."
|
|
97
|
+
)
|
|
98
|
+
return name
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_project_stats(project_dir: Path) -> ProjectStats:
|
|
102
|
+
"""Get statistics for a project."""
|
|
103
|
+
_init_imports()
|
|
104
|
+
assert _count_passing_tests is not None # guaranteed by _init_imports()
|
|
105
|
+
passing, in_progress, total = _count_passing_tests(project_dir)
|
|
106
|
+
percentage = (passing / total * 100) if total > 0 else 0.0
|
|
107
|
+
return ProjectStats(
|
|
108
|
+
passing=passing,
|
|
109
|
+
in_progress=in_progress,
|
|
110
|
+
total=total,
|
|
111
|
+
percentage=round(percentage, 1)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@router.get("", response_model=list[ProjectSummary])
|
|
116
|
+
async def list_projects():
|
|
117
|
+
"""List all registered projects."""
|
|
118
|
+
_init_imports()
|
|
119
|
+
assert _check_spec_exists is not None # guaranteed by _init_imports()
|
|
120
|
+
(_, _, _, list_registered_projects, validate_project_path,
|
|
121
|
+
get_project_concurrency, _) = _get_registry_functions()
|
|
122
|
+
|
|
123
|
+
projects = list_registered_projects()
|
|
124
|
+
result = []
|
|
125
|
+
|
|
126
|
+
for name, info in projects.items():
|
|
127
|
+
project_dir = Path(info["path"])
|
|
128
|
+
|
|
129
|
+
# Skip if path no longer exists
|
|
130
|
+
is_valid, _ = validate_project_path(project_dir)
|
|
131
|
+
if not is_valid:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
has_spec = _check_spec_exists(project_dir)
|
|
135
|
+
stats = get_project_stats(project_dir)
|
|
136
|
+
|
|
137
|
+
result.append(ProjectSummary(
|
|
138
|
+
name=name,
|
|
139
|
+
path=info["path"],
|
|
140
|
+
has_spec=has_spec,
|
|
141
|
+
stats=stats,
|
|
142
|
+
default_concurrency=info.get("default_concurrency", 3),
|
|
143
|
+
))
|
|
144
|
+
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@router.post("", response_model=ProjectSummary)
|
|
149
|
+
async def create_project(project: ProjectCreate):
|
|
150
|
+
"""Create a new project at the specified path."""
|
|
151
|
+
_init_imports()
|
|
152
|
+
assert _scaffold_project_prompts is not None # guaranteed by _init_imports()
|
|
153
|
+
(register_project, _, get_project_path, list_registered_projects,
|
|
154
|
+
_, _, _) = _get_registry_functions()
|
|
155
|
+
|
|
156
|
+
name = validate_project_name(project.name)
|
|
157
|
+
project_path = Path(project.path).resolve()
|
|
158
|
+
|
|
159
|
+
# Check if project name already registered
|
|
160
|
+
existing = get_project_path(name)
|
|
161
|
+
if existing:
|
|
162
|
+
raise HTTPException(
|
|
163
|
+
status_code=409,
|
|
164
|
+
detail=f"Project '{name}' already exists at {existing}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Check if path already registered under a different name
|
|
168
|
+
all_projects = list_registered_projects()
|
|
169
|
+
for existing_name, info in all_projects.items():
|
|
170
|
+
existing_path = Path(info["path"]).resolve()
|
|
171
|
+
# Case-insensitive comparison on Windows
|
|
172
|
+
if sys.platform == "win32":
|
|
173
|
+
paths_match = str(existing_path).lower() == str(project_path).lower()
|
|
174
|
+
else:
|
|
175
|
+
paths_match = existing_path == project_path
|
|
176
|
+
|
|
177
|
+
if paths_match:
|
|
178
|
+
raise HTTPException(
|
|
179
|
+
status_code=409,
|
|
180
|
+
detail=f"Path '{project_path}' is already registered as project '{existing_name}'"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Security: Check if path is in a blocked location
|
|
184
|
+
from .filesystem import is_path_blocked
|
|
185
|
+
if is_path_blocked(project_path):
|
|
186
|
+
raise HTTPException(
|
|
187
|
+
status_code=403,
|
|
188
|
+
detail="Cannot create project in system or sensitive directory"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Validate the path is usable
|
|
192
|
+
if project_path.exists():
|
|
193
|
+
if not project_path.is_dir():
|
|
194
|
+
raise HTTPException(
|
|
195
|
+
status_code=400,
|
|
196
|
+
detail="Path exists but is not a directory"
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
# Create the directory
|
|
200
|
+
try:
|
|
201
|
+
project_path.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
except OSError as e:
|
|
203
|
+
raise HTTPException(
|
|
204
|
+
status_code=500,
|
|
205
|
+
detail=f"Failed to create directory: {e}"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Scaffold prompts
|
|
209
|
+
_scaffold_project_prompts(project_path)
|
|
210
|
+
|
|
211
|
+
# Register in registry
|
|
212
|
+
try:
|
|
213
|
+
register_project(name, project_path)
|
|
214
|
+
except Exception as e:
|
|
215
|
+
raise HTTPException(
|
|
216
|
+
status_code=500,
|
|
217
|
+
detail=f"Failed to register project: {e}"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return ProjectSummary(
|
|
221
|
+
name=name,
|
|
222
|
+
path=project_path.as_posix(),
|
|
223
|
+
has_spec=False, # Just created, no spec yet
|
|
224
|
+
stats=ProjectStats(passing=0, total=0, percentage=0.0),
|
|
225
|
+
default_concurrency=3,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@router.get("/{name}", response_model=ProjectDetail)
|
|
230
|
+
async def get_project(name: str):
|
|
231
|
+
"""Get detailed information about a project."""
|
|
232
|
+
_init_imports()
|
|
233
|
+
assert _check_spec_exists is not None # guaranteed by _init_imports()
|
|
234
|
+
assert _get_project_prompts_dir is not None # guaranteed by _init_imports()
|
|
235
|
+
(_, _, get_project_path, _, _, get_project_concurrency, _) = _get_registry_functions()
|
|
236
|
+
|
|
237
|
+
name = validate_project_name(name)
|
|
238
|
+
project_dir = get_project_path(name)
|
|
239
|
+
|
|
240
|
+
if not project_dir:
|
|
241
|
+
raise HTTPException(status_code=404, detail=f"Project '{name}' not found in registry")
|
|
242
|
+
|
|
243
|
+
if not project_dir.exists():
|
|
244
|
+
raise HTTPException(status_code=404, detail=f"Project directory no longer exists: {project_dir}")
|
|
245
|
+
|
|
246
|
+
has_spec = _check_spec_exists(project_dir)
|
|
247
|
+
stats = get_project_stats(project_dir)
|
|
248
|
+
prompts_dir = _get_project_prompts_dir(project_dir)
|
|
249
|
+
|
|
250
|
+
return ProjectDetail(
|
|
251
|
+
name=name,
|
|
252
|
+
path=project_dir.as_posix(),
|
|
253
|
+
has_spec=has_spec,
|
|
254
|
+
stats=stats,
|
|
255
|
+
prompts_dir=str(prompts_dir),
|
|
256
|
+
default_concurrency=get_project_concurrency(name),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@router.delete("/{name}")
|
|
261
|
+
async def delete_project(name: str, delete_files: bool = False):
|
|
262
|
+
"""
|
|
263
|
+
Delete a project from the registry.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
name: Project name to delete
|
|
267
|
+
delete_files: If True, also delete the project directory and files
|
|
268
|
+
"""
|
|
269
|
+
_init_imports()
|
|
270
|
+
(_, unregister_project, get_project_path, _, _, _, _) = _get_registry_functions()
|
|
271
|
+
|
|
272
|
+
name = validate_project_name(name)
|
|
273
|
+
project_dir = get_project_path(name)
|
|
274
|
+
|
|
275
|
+
if not project_dir:
|
|
276
|
+
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
|
277
|
+
|
|
278
|
+
# Check if agent is running
|
|
279
|
+
from autoforge_paths import has_agent_running
|
|
280
|
+
if has_agent_running(project_dir):
|
|
281
|
+
raise HTTPException(
|
|
282
|
+
status_code=409,
|
|
283
|
+
detail="Cannot delete project while agent is running. Stop the agent first."
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# Optionally delete files
|
|
287
|
+
if delete_files and project_dir.exists():
|
|
288
|
+
try:
|
|
289
|
+
shutil.rmtree(project_dir)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
raise HTTPException(status_code=500, detail=f"Failed to delete project files: {e}")
|
|
292
|
+
|
|
293
|
+
# Unregister from registry
|
|
294
|
+
unregister_project(name)
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
"success": True,
|
|
298
|
+
"message": f"Project '{name}' deleted" + (" (files removed)" if delete_files else " (files preserved)")
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@router.get("/{name}/prompts", response_model=ProjectPrompts)
|
|
303
|
+
async def get_project_prompts(name: str):
|
|
304
|
+
"""Get the content of project prompt files."""
|
|
305
|
+
_init_imports()
|
|
306
|
+
assert _get_project_prompts_dir is not None # guaranteed by _init_imports()
|
|
307
|
+
(_, _, get_project_path, _, _, _, _) = _get_registry_functions()
|
|
308
|
+
|
|
309
|
+
name = validate_project_name(name)
|
|
310
|
+
project_dir = get_project_path(name)
|
|
311
|
+
|
|
312
|
+
if not project_dir:
|
|
313
|
+
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
|
314
|
+
|
|
315
|
+
if not project_dir.exists():
|
|
316
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
317
|
+
|
|
318
|
+
prompts_dir: Path = _get_project_prompts_dir(project_dir)
|
|
319
|
+
|
|
320
|
+
def read_file(filename: str) -> str:
|
|
321
|
+
filepath = prompts_dir / filename
|
|
322
|
+
if filepath.exists():
|
|
323
|
+
try:
|
|
324
|
+
return filepath.read_text(encoding="utf-8")
|
|
325
|
+
except Exception:
|
|
326
|
+
return ""
|
|
327
|
+
return ""
|
|
328
|
+
|
|
329
|
+
return ProjectPrompts(
|
|
330
|
+
app_spec=read_file("app_spec.txt"),
|
|
331
|
+
initializer_prompt=read_file("initializer_prompt.md"),
|
|
332
|
+
coding_prompt=read_file("coding_prompt.md"),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@router.put("/{name}/prompts")
|
|
337
|
+
async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate):
|
|
338
|
+
"""Update project prompt files."""
|
|
339
|
+
_init_imports()
|
|
340
|
+
assert _get_project_prompts_dir is not None # guaranteed by _init_imports()
|
|
341
|
+
(_, _, get_project_path, _, _, _, _) = _get_registry_functions()
|
|
342
|
+
|
|
343
|
+
name = validate_project_name(name)
|
|
344
|
+
project_dir = get_project_path(name)
|
|
345
|
+
|
|
346
|
+
if not project_dir:
|
|
347
|
+
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
|
348
|
+
|
|
349
|
+
if not project_dir.exists():
|
|
350
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
351
|
+
|
|
352
|
+
prompts_dir = _get_project_prompts_dir(project_dir)
|
|
353
|
+
prompts_dir.mkdir(parents=True, exist_ok=True)
|
|
354
|
+
|
|
355
|
+
def write_file(filename: str, content: str | None):
|
|
356
|
+
if content is not None:
|
|
357
|
+
filepath = prompts_dir / filename
|
|
358
|
+
filepath.write_text(content, encoding="utf-8")
|
|
359
|
+
|
|
360
|
+
write_file("app_spec.txt", prompts.app_spec)
|
|
361
|
+
write_file("initializer_prompt.md", prompts.initializer_prompt)
|
|
362
|
+
write_file("coding_prompt.md", prompts.coding_prompt)
|
|
363
|
+
|
|
364
|
+
return {"success": True, "message": "Prompts updated"}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@router.get("/{name}/stats", response_model=ProjectStats)
|
|
368
|
+
async def get_project_stats_endpoint(name: str):
|
|
369
|
+
"""Get current progress statistics for a project."""
|
|
370
|
+
_init_imports()
|
|
371
|
+
(_, _, get_project_path, _, _, _, _) = _get_registry_functions()
|
|
372
|
+
|
|
373
|
+
name = validate_project_name(name)
|
|
374
|
+
project_dir = get_project_path(name)
|
|
375
|
+
|
|
376
|
+
if not project_dir:
|
|
377
|
+
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
|
378
|
+
|
|
379
|
+
if not project_dir.exists():
|
|
380
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
381
|
+
|
|
382
|
+
return get_project_stats(project_dir)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@router.post("/{name}/reset")
|
|
386
|
+
async def reset_project(name: str, full_reset: bool = False):
|
|
387
|
+
"""
|
|
388
|
+
Reset a project to its initial state.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
name: Project name to reset
|
|
392
|
+
full_reset: If True, also delete prompts/ directory (triggers setup wizard)
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Dictionary with list of deleted files and reset type
|
|
396
|
+
"""
|
|
397
|
+
_init_imports()
|
|
398
|
+
(_, _, get_project_path, _, _, _, _) = _get_registry_functions()
|
|
399
|
+
|
|
400
|
+
name = validate_project_name(name)
|
|
401
|
+
project_dir = get_project_path(name)
|
|
402
|
+
|
|
403
|
+
if not project_dir:
|
|
404
|
+
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
|
405
|
+
|
|
406
|
+
if not project_dir.exists():
|
|
407
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
408
|
+
|
|
409
|
+
# Check if agent is running
|
|
410
|
+
from autoforge_paths import has_agent_running
|
|
411
|
+
if has_agent_running(project_dir):
|
|
412
|
+
raise HTTPException(
|
|
413
|
+
status_code=409,
|
|
414
|
+
detail="Cannot reset project while agent is running. Stop the agent first."
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# Dispose of database engines to release file locks (required on Windows)
|
|
418
|
+
# Import here to avoid circular imports
|
|
419
|
+
from api.database import dispose_engine as dispose_features_engine
|
|
420
|
+
from server.services.assistant_database import dispose_engine as dispose_assistant_engine
|
|
421
|
+
|
|
422
|
+
dispose_features_engine(project_dir)
|
|
423
|
+
dispose_assistant_engine(project_dir)
|
|
424
|
+
|
|
425
|
+
deleted_files: list[str] = []
|
|
426
|
+
|
|
427
|
+
from autoforge_paths import (
|
|
428
|
+
get_assistant_db_path,
|
|
429
|
+
get_claude_assistant_settings_path,
|
|
430
|
+
get_claude_settings_path,
|
|
431
|
+
get_features_db_path,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# Build list of files to delete using path helpers (finds files at current location)
|
|
435
|
+
# Plus explicit old-location fallbacks for backward compatibility
|
|
436
|
+
db_path = get_features_db_path(project_dir)
|
|
437
|
+
asst_path = get_assistant_db_path(project_dir)
|
|
438
|
+
reset_files: list[Path] = [
|
|
439
|
+
db_path,
|
|
440
|
+
db_path.with_suffix(".db-wal"),
|
|
441
|
+
db_path.with_suffix(".db-shm"),
|
|
442
|
+
asst_path,
|
|
443
|
+
asst_path.with_suffix(".db-wal"),
|
|
444
|
+
asst_path.with_suffix(".db-shm"),
|
|
445
|
+
get_claude_settings_path(project_dir),
|
|
446
|
+
get_claude_assistant_settings_path(project_dir),
|
|
447
|
+
# Also clean old root-level locations if they exist
|
|
448
|
+
project_dir / "features.db",
|
|
449
|
+
project_dir / "features.db-wal",
|
|
450
|
+
project_dir / "features.db-shm",
|
|
451
|
+
project_dir / "assistant.db",
|
|
452
|
+
project_dir / "assistant.db-wal",
|
|
453
|
+
project_dir / "assistant.db-shm",
|
|
454
|
+
project_dir / ".claude_settings.json",
|
|
455
|
+
project_dir / ".claude_assistant_settings.json",
|
|
456
|
+
]
|
|
457
|
+
|
|
458
|
+
for file_path in reset_files:
|
|
459
|
+
if file_path.exists():
|
|
460
|
+
try:
|
|
461
|
+
relative = file_path.relative_to(project_dir)
|
|
462
|
+
file_path.unlink()
|
|
463
|
+
deleted_files.append(str(relative))
|
|
464
|
+
except Exception as e:
|
|
465
|
+
raise HTTPException(status_code=500, detail=f"Failed to delete {file_path.name}: {e}")
|
|
466
|
+
|
|
467
|
+
# Full reset: also delete prompts directory
|
|
468
|
+
if full_reset:
|
|
469
|
+
from autoforge_paths import get_prompts_dir
|
|
470
|
+
# Delete prompts from both possible locations
|
|
471
|
+
for prompts_dir in [get_prompts_dir(project_dir), project_dir / "prompts"]:
|
|
472
|
+
if prompts_dir.exists():
|
|
473
|
+
try:
|
|
474
|
+
relative = prompts_dir.relative_to(project_dir)
|
|
475
|
+
shutil.rmtree(prompts_dir)
|
|
476
|
+
deleted_files.append(f"{relative}/")
|
|
477
|
+
except Exception as e:
|
|
478
|
+
raise HTTPException(status_code=500, detail=f"Failed to delete prompts: {e}")
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
"success": True,
|
|
482
|
+
"reset_type": "full" if full_reset else "quick",
|
|
483
|
+
"deleted_files": deleted_files,
|
|
484
|
+
"message": f"Project '{name}' has been reset" + (" (full reset)" if full_reset else " (quick reset)")
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@router.patch("/{name}/settings", response_model=ProjectDetail)
|
|
489
|
+
async def update_project_settings(name: str, settings: ProjectSettingsUpdate):
|
|
490
|
+
"""Update project-level settings (concurrency, etc.)."""
|
|
491
|
+
_init_imports()
|
|
492
|
+
assert _check_spec_exists is not None # guaranteed by _init_imports()
|
|
493
|
+
assert _get_project_prompts_dir is not None # guaranteed by _init_imports()
|
|
494
|
+
(_, _, get_project_path, _, _, get_project_concurrency,
|
|
495
|
+
set_project_concurrency) = _get_registry_functions()
|
|
496
|
+
|
|
497
|
+
name = validate_project_name(name)
|
|
498
|
+
project_dir = get_project_path(name)
|
|
499
|
+
|
|
500
|
+
if not project_dir:
|
|
501
|
+
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
|
502
|
+
|
|
503
|
+
if not project_dir.exists():
|
|
504
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
505
|
+
|
|
506
|
+
# Update concurrency if provided
|
|
507
|
+
if settings.default_concurrency is not None:
|
|
508
|
+
success = set_project_concurrency(name, settings.default_concurrency)
|
|
509
|
+
if not success:
|
|
510
|
+
raise HTTPException(status_code=500, detail="Failed to update concurrency")
|
|
511
|
+
|
|
512
|
+
# Return updated project details
|
|
513
|
+
has_spec = _check_spec_exists(project_dir)
|
|
514
|
+
stats = get_project_stats(project_dir)
|
|
515
|
+
prompts_dir = _get_project_prompts_dir(project_dir)
|
|
516
|
+
|
|
517
|
+
return ProjectDetail(
|
|
518
|
+
name=name,
|
|
519
|
+
path=project_dir.as_posix(),
|
|
520
|
+
has_spec=has_spec,
|
|
521
|
+
stats=stats,
|
|
522
|
+
prompts_dir=str(prompts_dir),
|
|
523
|
+
default_concurrency=get_project_concurrency(name),
|
|
524
|
+
)
|