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,746 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Features Router
|
|
3
|
+
===============
|
|
4
|
+
|
|
5
|
+
API endpoints for feature/test case management.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, HTTPException
|
|
14
|
+
|
|
15
|
+
from ..schemas import (
|
|
16
|
+
DependencyGraphEdge,
|
|
17
|
+
DependencyGraphNode,
|
|
18
|
+
DependencyGraphResponse,
|
|
19
|
+
DependencyUpdate,
|
|
20
|
+
FeatureBulkCreate,
|
|
21
|
+
FeatureBulkCreateResponse,
|
|
22
|
+
FeatureCreate,
|
|
23
|
+
FeatureListResponse,
|
|
24
|
+
FeatureResponse,
|
|
25
|
+
FeatureUpdate,
|
|
26
|
+
)
|
|
27
|
+
from ..utils.project_helpers import get_project_path as _get_project_path
|
|
28
|
+
from ..utils.validation import validate_project_name
|
|
29
|
+
|
|
30
|
+
# Lazy imports to avoid circular dependencies
|
|
31
|
+
_create_database = None
|
|
32
|
+
_Feature = None
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_db_classes():
|
|
38
|
+
"""Lazy import of database classes."""
|
|
39
|
+
global _create_database, _Feature
|
|
40
|
+
if _create_database is None:
|
|
41
|
+
import sys
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
root = Path(__file__).parent.parent.parent
|
|
44
|
+
if str(root) not in sys.path:
|
|
45
|
+
sys.path.insert(0, str(root))
|
|
46
|
+
from api.database import Feature, create_database
|
|
47
|
+
_create_database = create_database
|
|
48
|
+
_Feature = Feature
|
|
49
|
+
return _create_database, _Feature
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
router = APIRouter(prefix="/api/projects/{project_name}/features", tags=["features"])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@contextmanager
|
|
56
|
+
def get_db_session(project_dir: Path):
|
|
57
|
+
"""
|
|
58
|
+
Context manager for database sessions.
|
|
59
|
+
Ensures session is always closed, even on exceptions.
|
|
60
|
+
"""
|
|
61
|
+
create_database, _ = _get_db_classes()
|
|
62
|
+
_, SessionLocal = create_database(project_dir)
|
|
63
|
+
session = SessionLocal()
|
|
64
|
+
try:
|
|
65
|
+
yield session
|
|
66
|
+
except Exception:
|
|
67
|
+
session.rollback()
|
|
68
|
+
raise
|
|
69
|
+
finally:
|
|
70
|
+
session.close()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def feature_to_response(f, passing_ids: set[int] | None = None) -> FeatureResponse:
|
|
74
|
+
"""Convert a Feature model to a FeatureResponse.
|
|
75
|
+
|
|
76
|
+
Handles legacy NULL values in boolean fields by treating them as False.
|
|
77
|
+
Computes blocked status if passing_ids is provided.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
f: Feature model instance
|
|
81
|
+
passing_ids: Optional set of feature IDs that are passing (for computing blocked status)
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
FeatureResponse with computed blocked status
|
|
85
|
+
"""
|
|
86
|
+
deps = f.dependencies or []
|
|
87
|
+
if passing_ids is None:
|
|
88
|
+
blocking = []
|
|
89
|
+
blocked = False
|
|
90
|
+
else:
|
|
91
|
+
blocking = [d for d in deps if d not in passing_ids]
|
|
92
|
+
blocked = len(blocking) > 0
|
|
93
|
+
|
|
94
|
+
return FeatureResponse(
|
|
95
|
+
id=f.id,
|
|
96
|
+
priority=f.priority,
|
|
97
|
+
category=f.category,
|
|
98
|
+
name=f.name,
|
|
99
|
+
description=f.description,
|
|
100
|
+
steps=f.steps if isinstance(f.steps, list) else [],
|
|
101
|
+
dependencies=deps,
|
|
102
|
+
# Handle legacy NULL values gracefully - treat as False
|
|
103
|
+
passes=f.passes if f.passes is not None else False,
|
|
104
|
+
in_progress=f.in_progress if f.in_progress is not None else False,
|
|
105
|
+
blocked=blocked,
|
|
106
|
+
blocking_dependencies=blocking,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.get("", response_model=FeatureListResponse)
|
|
111
|
+
async def list_features(project_name: str):
|
|
112
|
+
"""
|
|
113
|
+
List all features for a project organized by status.
|
|
114
|
+
|
|
115
|
+
Returns features in three lists:
|
|
116
|
+
- pending: passes=False, not currently being worked on
|
|
117
|
+
- in_progress: features currently being worked on (tracked via agent output)
|
|
118
|
+
- done: passes=True
|
|
119
|
+
"""
|
|
120
|
+
project_name = validate_project_name(project_name)
|
|
121
|
+
project_dir = _get_project_path(project_name)
|
|
122
|
+
|
|
123
|
+
if not project_dir:
|
|
124
|
+
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
|
125
|
+
|
|
126
|
+
if not project_dir.exists():
|
|
127
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
128
|
+
|
|
129
|
+
from autoforge_paths import get_features_db_path
|
|
130
|
+
db_file = get_features_db_path(project_dir)
|
|
131
|
+
if not db_file.exists():
|
|
132
|
+
return FeatureListResponse(pending=[], in_progress=[], done=[])
|
|
133
|
+
|
|
134
|
+
_, Feature = _get_db_classes()
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
with get_db_session(project_dir) as session:
|
|
138
|
+
all_features = session.query(Feature).order_by(Feature.priority).all()
|
|
139
|
+
|
|
140
|
+
# Compute passing IDs for blocked status calculation
|
|
141
|
+
passing_ids = {f.id for f in all_features if f.passes}
|
|
142
|
+
|
|
143
|
+
pending = []
|
|
144
|
+
in_progress = []
|
|
145
|
+
done = []
|
|
146
|
+
|
|
147
|
+
for f in all_features:
|
|
148
|
+
feature_response = feature_to_response(f, passing_ids)
|
|
149
|
+
if f.passes:
|
|
150
|
+
done.append(feature_response)
|
|
151
|
+
elif f.in_progress:
|
|
152
|
+
in_progress.append(feature_response)
|
|
153
|
+
else:
|
|
154
|
+
pending.append(feature_response)
|
|
155
|
+
|
|
156
|
+
return FeatureListResponse(
|
|
157
|
+
pending=pending,
|
|
158
|
+
in_progress=in_progress,
|
|
159
|
+
done=done,
|
|
160
|
+
)
|
|
161
|
+
except HTTPException:
|
|
162
|
+
raise
|
|
163
|
+
except Exception:
|
|
164
|
+
logger.exception("Database error in list_features")
|
|
165
|
+
raise HTTPException(status_code=500, detail="Database error occurred")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@router.post("", response_model=FeatureResponse)
|
|
169
|
+
async def create_feature(project_name: str, feature: FeatureCreate):
|
|
170
|
+
"""Create a new feature/test case manually."""
|
|
171
|
+
project_name = validate_project_name(project_name)
|
|
172
|
+
project_dir = _get_project_path(project_name)
|
|
173
|
+
|
|
174
|
+
if not project_dir:
|
|
175
|
+
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
|
176
|
+
|
|
177
|
+
if not project_dir.exists():
|
|
178
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
179
|
+
|
|
180
|
+
_, Feature = _get_db_classes()
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
with get_db_session(project_dir) as session:
|
|
184
|
+
# Get next priority if not specified
|
|
185
|
+
if feature.priority is None:
|
|
186
|
+
max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
|
|
187
|
+
priority = (max_priority.priority + 1) if max_priority else 1
|
|
188
|
+
else:
|
|
189
|
+
priority = feature.priority
|
|
190
|
+
|
|
191
|
+
# Create new feature
|
|
192
|
+
db_feature = Feature(
|
|
193
|
+
priority=priority,
|
|
194
|
+
category=feature.category,
|
|
195
|
+
name=feature.name,
|
|
196
|
+
description=feature.description,
|
|
197
|
+
steps=feature.steps,
|
|
198
|
+
dependencies=feature.dependencies if feature.dependencies else None,
|
|
199
|
+
passes=False,
|
|
200
|
+
in_progress=False,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
session.add(db_feature)
|
|
204
|
+
session.commit()
|
|
205
|
+
session.refresh(db_feature)
|
|
206
|
+
|
|
207
|
+
return feature_to_response(db_feature)
|
|
208
|
+
except HTTPException:
|
|
209
|
+
raise
|
|
210
|
+
except Exception:
|
|
211
|
+
logger.exception("Failed to create feature")
|
|
212
|
+
raise HTTPException(status_code=500, detail="Failed to create feature")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ============================================================================
|
|
216
|
+
# Static path endpoints - MUST be declared before /{feature_id} routes
|
|
217
|
+
# ============================================================================
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@router.post("/bulk", response_model=FeatureBulkCreateResponse)
|
|
221
|
+
async def create_features_bulk(project_name: str, bulk: FeatureBulkCreate):
|
|
222
|
+
"""
|
|
223
|
+
Create multiple features at once.
|
|
224
|
+
|
|
225
|
+
Features are assigned sequential priorities starting from:
|
|
226
|
+
- starting_priority if specified (must be >= 1)
|
|
227
|
+
- max(existing priorities) + 1 if not specified
|
|
228
|
+
|
|
229
|
+
This is useful for:
|
|
230
|
+
- Expanding a project with new features via AI
|
|
231
|
+
- Importing features from external sources
|
|
232
|
+
- Batch operations
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
{"created": N, "features": [...]}
|
|
236
|
+
"""
|
|
237
|
+
project_name = validate_project_name(project_name)
|
|
238
|
+
project_dir = _get_project_path(project_name)
|
|
239
|
+
|
|
240
|
+
if not project_dir:
|
|
241
|
+
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
|
242
|
+
|
|
243
|
+
if not project_dir.exists():
|
|
244
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
245
|
+
|
|
246
|
+
if not bulk.features:
|
|
247
|
+
return FeatureBulkCreateResponse(created=0, features=[])
|
|
248
|
+
|
|
249
|
+
# Validate starting_priority if provided
|
|
250
|
+
if bulk.starting_priority is not None and bulk.starting_priority < 1:
|
|
251
|
+
raise HTTPException(status_code=400, detail="starting_priority must be >= 1")
|
|
252
|
+
|
|
253
|
+
_, Feature = _get_db_classes()
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
with get_db_session(project_dir) as session:
|
|
257
|
+
# Determine starting priority
|
|
258
|
+
# Note: SQLite uses file-level locking, not row-level locking, so we rely on
|
|
259
|
+
# SQLite's transaction isolation. Concurrent bulk creates may get overlapping
|
|
260
|
+
# priorities, but this is acceptable since priorities can be reordered.
|
|
261
|
+
if bulk.starting_priority is not None:
|
|
262
|
+
current_priority = bulk.starting_priority
|
|
263
|
+
else:
|
|
264
|
+
max_priority_feature = (
|
|
265
|
+
session.query(Feature)
|
|
266
|
+
.order_by(Feature.priority.desc())
|
|
267
|
+
.first()
|
|
268
|
+
)
|
|
269
|
+
current_priority = (max_priority_feature.priority + 1) if max_priority_feature else 1
|
|
270
|
+
|
|
271
|
+
created_ids = []
|
|
272
|
+
|
|
273
|
+
for feature_data in bulk.features:
|
|
274
|
+
db_feature = Feature(
|
|
275
|
+
priority=current_priority,
|
|
276
|
+
category=feature_data.category,
|
|
277
|
+
name=feature_data.name,
|
|
278
|
+
description=feature_data.description,
|
|
279
|
+
steps=feature_data.steps,
|
|
280
|
+
dependencies=feature_data.dependencies if feature_data.dependencies else None,
|
|
281
|
+
passes=False,
|
|
282
|
+
in_progress=False,
|
|
283
|
+
)
|
|
284
|
+
session.add(db_feature)
|
|
285
|
+
session.flush() # Flush to get the ID immediately
|
|
286
|
+
created_ids.append(db_feature.id)
|
|
287
|
+
current_priority += 1
|
|
288
|
+
|
|
289
|
+
session.commit()
|
|
290
|
+
|
|
291
|
+
# Query created features by their IDs (avoids relying on priority range)
|
|
292
|
+
created_features = []
|
|
293
|
+
for db_feature in session.query(Feature).filter(
|
|
294
|
+
Feature.id.in_(created_ids)
|
|
295
|
+
).order_by(Feature.priority).all():
|
|
296
|
+
created_features.append(feature_to_response(db_feature))
|
|
297
|
+
|
|
298
|
+
return FeatureBulkCreateResponse(
|
|
299
|
+
created=len(created_features),
|
|
300
|
+
features=created_features
|
|
301
|
+
)
|
|
302
|
+
except HTTPException:
|
|
303
|
+
raise
|
|
304
|
+
except Exception:
|
|
305
|
+
logger.exception("Failed to bulk create features")
|
|
306
|
+
raise HTTPException(status_code=500, detail="Failed to bulk create features")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@router.get("/graph", response_model=DependencyGraphResponse)
|
|
310
|
+
async def get_dependency_graph(project_name: str):
|
|
311
|
+
"""Return dependency graph data for visualization.
|
|
312
|
+
|
|
313
|
+
Returns nodes (features) and edges (dependencies) suitable for
|
|
314
|
+
rendering with React Flow or similar graph libraries.
|
|
315
|
+
"""
|
|
316
|
+
project_name = validate_project_name(project_name)
|
|
317
|
+
project_dir = _get_project_path(project_name)
|
|
318
|
+
|
|
319
|
+
if not project_dir:
|
|
320
|
+
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
|
321
|
+
|
|
322
|
+
if not project_dir.exists():
|
|
323
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
324
|
+
|
|
325
|
+
from autoforge_paths import get_features_db_path
|
|
326
|
+
db_file = get_features_db_path(project_dir)
|
|
327
|
+
if not db_file.exists():
|
|
328
|
+
return DependencyGraphResponse(nodes=[], edges=[])
|
|
329
|
+
|
|
330
|
+
_, Feature = _get_db_classes()
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
with get_db_session(project_dir) as session:
|
|
334
|
+
all_features = session.query(Feature).all()
|
|
335
|
+
passing_ids = {f.id for f in all_features if f.passes}
|
|
336
|
+
|
|
337
|
+
nodes = []
|
|
338
|
+
edges = []
|
|
339
|
+
|
|
340
|
+
for f in all_features:
|
|
341
|
+
deps = f.dependencies or []
|
|
342
|
+
blocking = [d for d in deps if d not in passing_ids]
|
|
343
|
+
|
|
344
|
+
status: Literal["pending", "in_progress", "done", "blocked"]
|
|
345
|
+
if f.passes:
|
|
346
|
+
status = "done"
|
|
347
|
+
elif blocking:
|
|
348
|
+
status = "blocked"
|
|
349
|
+
elif f.in_progress:
|
|
350
|
+
status = "in_progress"
|
|
351
|
+
else:
|
|
352
|
+
status = "pending"
|
|
353
|
+
|
|
354
|
+
nodes.append(DependencyGraphNode(
|
|
355
|
+
id=f.id,
|
|
356
|
+
name=f.name,
|
|
357
|
+
category=f.category,
|
|
358
|
+
status=status,
|
|
359
|
+
priority=f.priority,
|
|
360
|
+
dependencies=deps
|
|
361
|
+
))
|
|
362
|
+
|
|
363
|
+
for dep_id in deps:
|
|
364
|
+
edges.append(DependencyGraphEdge(source=dep_id, target=f.id))
|
|
365
|
+
|
|
366
|
+
return DependencyGraphResponse(nodes=nodes, edges=edges)
|
|
367
|
+
except HTTPException:
|
|
368
|
+
raise
|
|
369
|
+
except Exception:
|
|
370
|
+
logger.exception("Failed to get dependency graph")
|
|
371
|
+
raise HTTPException(status_code=500, detail="Failed to get dependency graph")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ============================================================================
|
|
375
|
+
# Parameterized path endpoints - /{feature_id} routes
|
|
376
|
+
# ============================================================================
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@router.get("/{feature_id}", response_model=FeatureResponse)
|
|
380
|
+
async def get_feature(project_name: str, feature_id: int):
|
|
381
|
+
"""Get details of a specific feature."""
|
|
382
|
+
project_name = validate_project_name(project_name)
|
|
383
|
+
project_dir = _get_project_path(project_name)
|
|
384
|
+
|
|
385
|
+
if not project_dir:
|
|
386
|
+
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
|
387
|
+
|
|
388
|
+
if not project_dir.exists():
|
|
389
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
390
|
+
|
|
391
|
+
from autoforge_paths import get_features_db_path
|
|
392
|
+
db_file = get_features_db_path(project_dir)
|
|
393
|
+
if not db_file.exists():
|
|
394
|
+
raise HTTPException(status_code=404, detail="No features database found")
|
|
395
|
+
|
|
396
|
+
_, Feature = _get_db_classes()
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
with get_db_session(project_dir) as session:
|
|
400
|
+
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
|
401
|
+
|
|
402
|
+
if not feature:
|
|
403
|
+
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
|
404
|
+
|
|
405
|
+
return feature_to_response(feature)
|
|
406
|
+
except HTTPException:
|
|
407
|
+
raise
|
|
408
|
+
except Exception:
|
|
409
|
+
logger.exception("Database error in get_feature")
|
|
410
|
+
raise HTTPException(status_code=500, detail="Database error occurred")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@router.patch("/{feature_id}", response_model=FeatureResponse)
|
|
414
|
+
async def update_feature(project_name: str, feature_id: int, update: FeatureUpdate):
|
|
415
|
+
"""
|
|
416
|
+
Update a feature's details.
|
|
417
|
+
|
|
418
|
+
Only features that are not yet completed (passes=False) can be edited.
|
|
419
|
+
This allows users to provide corrections or additional instructions
|
|
420
|
+
when the agent is stuck or implementing a feature incorrectly.
|
|
421
|
+
"""
|
|
422
|
+
project_name = validate_project_name(project_name)
|
|
423
|
+
project_dir = _get_project_path(project_name)
|
|
424
|
+
|
|
425
|
+
if not project_dir:
|
|
426
|
+
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
|
427
|
+
|
|
428
|
+
if not project_dir.exists():
|
|
429
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
430
|
+
|
|
431
|
+
_, Feature = _get_db_classes()
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
with get_db_session(project_dir) as session:
|
|
435
|
+
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
|
436
|
+
|
|
437
|
+
if not feature:
|
|
438
|
+
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
|
439
|
+
|
|
440
|
+
# Prevent editing completed features
|
|
441
|
+
if feature.passes:
|
|
442
|
+
raise HTTPException(
|
|
443
|
+
status_code=400,
|
|
444
|
+
detail="Cannot edit a completed feature. Features marked as done are immutable."
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# Apply updates for non-None fields
|
|
448
|
+
if update.category is not None:
|
|
449
|
+
feature.category = update.category
|
|
450
|
+
if update.name is not None:
|
|
451
|
+
feature.name = update.name
|
|
452
|
+
if update.description is not None:
|
|
453
|
+
feature.description = update.description
|
|
454
|
+
if update.steps is not None:
|
|
455
|
+
feature.steps = update.steps
|
|
456
|
+
if update.priority is not None:
|
|
457
|
+
feature.priority = update.priority
|
|
458
|
+
if update.dependencies is not None:
|
|
459
|
+
feature.dependencies = update.dependencies if update.dependencies else None
|
|
460
|
+
|
|
461
|
+
session.commit()
|
|
462
|
+
session.refresh(feature)
|
|
463
|
+
|
|
464
|
+
# Compute passing IDs for response
|
|
465
|
+
all_features = session.query(Feature).all()
|
|
466
|
+
passing_ids = {f.id for f in all_features if f.passes}
|
|
467
|
+
|
|
468
|
+
return feature_to_response(feature, passing_ids)
|
|
469
|
+
except HTTPException:
|
|
470
|
+
raise
|
|
471
|
+
except Exception:
|
|
472
|
+
logger.exception("Failed to update feature")
|
|
473
|
+
raise HTTPException(status_code=500, detail="Failed to update feature")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@router.delete("/{feature_id}")
|
|
477
|
+
async def delete_feature(project_name: str, feature_id: int):
|
|
478
|
+
"""Delete a feature and clean up references in other features' dependencies.
|
|
479
|
+
|
|
480
|
+
When a feature is deleted, any other features that depend on it will have
|
|
481
|
+
that dependency removed from their dependencies list. This prevents orphaned
|
|
482
|
+
dependencies that would permanently block features.
|
|
483
|
+
"""
|
|
484
|
+
project_name = validate_project_name(project_name)
|
|
485
|
+
project_dir = _get_project_path(project_name)
|
|
486
|
+
|
|
487
|
+
if not project_dir:
|
|
488
|
+
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
|
489
|
+
|
|
490
|
+
if not project_dir.exists():
|
|
491
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
492
|
+
|
|
493
|
+
_, Feature = _get_db_classes()
|
|
494
|
+
|
|
495
|
+
try:
|
|
496
|
+
with get_db_session(project_dir) as session:
|
|
497
|
+
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
|
498
|
+
|
|
499
|
+
if not feature:
|
|
500
|
+
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
|
501
|
+
|
|
502
|
+
# Clean up dependency references in other features
|
|
503
|
+
# This prevents orphaned dependencies that would block features forever
|
|
504
|
+
affected_features = []
|
|
505
|
+
for f in session.query(Feature).all():
|
|
506
|
+
if f.dependencies and feature_id in f.dependencies:
|
|
507
|
+
# Remove the deleted feature from this feature's dependencies
|
|
508
|
+
deps = [d for d in f.dependencies if d != feature_id]
|
|
509
|
+
f.dependencies = deps if deps else None
|
|
510
|
+
affected_features.append(f.id)
|
|
511
|
+
|
|
512
|
+
session.delete(feature)
|
|
513
|
+
session.commit()
|
|
514
|
+
|
|
515
|
+
message = f"Feature {feature_id} deleted"
|
|
516
|
+
if affected_features:
|
|
517
|
+
message += f". Removed from dependencies of features: {affected_features}"
|
|
518
|
+
|
|
519
|
+
return {"success": True, "message": message, "affected_features": affected_features}
|
|
520
|
+
except HTTPException:
|
|
521
|
+
raise
|
|
522
|
+
except Exception:
|
|
523
|
+
logger.exception("Failed to delete feature")
|
|
524
|
+
raise HTTPException(status_code=500, detail="Failed to delete feature")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@router.patch("/{feature_id}/skip")
|
|
528
|
+
async def skip_feature(project_name: str, feature_id: int):
|
|
529
|
+
"""
|
|
530
|
+
Mark a feature as skipped by moving it to the end of the priority queue.
|
|
531
|
+
|
|
532
|
+
This doesn't delete the feature but gives it a very high priority number
|
|
533
|
+
so it will be processed last.
|
|
534
|
+
"""
|
|
535
|
+
project_name = validate_project_name(project_name)
|
|
536
|
+
project_dir = _get_project_path(project_name)
|
|
537
|
+
|
|
538
|
+
if not project_dir:
|
|
539
|
+
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
|
540
|
+
|
|
541
|
+
if not project_dir.exists():
|
|
542
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
543
|
+
|
|
544
|
+
_, Feature = _get_db_classes()
|
|
545
|
+
|
|
546
|
+
try:
|
|
547
|
+
with get_db_session(project_dir) as session:
|
|
548
|
+
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
|
549
|
+
|
|
550
|
+
if not feature:
|
|
551
|
+
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
|
552
|
+
|
|
553
|
+
# Set priority to max + 1 to push to end (consistent with MCP server)
|
|
554
|
+
max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
|
|
555
|
+
feature.priority = (max_priority.priority + 1) if max_priority else 1
|
|
556
|
+
|
|
557
|
+
session.commit()
|
|
558
|
+
|
|
559
|
+
return {"success": True, "message": f"Feature {feature_id} moved to end of queue"}
|
|
560
|
+
except HTTPException:
|
|
561
|
+
raise
|
|
562
|
+
except Exception:
|
|
563
|
+
logger.exception("Failed to skip feature")
|
|
564
|
+
raise HTTPException(status_code=500, detail="Failed to skip feature")
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
# ============================================================================
|
|
568
|
+
# Dependency Management Endpoints
|
|
569
|
+
# ============================================================================
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _get_dependency_resolver():
|
|
573
|
+
"""Lazy import of dependency resolver."""
|
|
574
|
+
import sys
|
|
575
|
+
root = Path(__file__).parent.parent.parent
|
|
576
|
+
if str(root) not in sys.path:
|
|
577
|
+
sys.path.insert(0, str(root))
|
|
578
|
+
from api.dependency_resolver import MAX_DEPENDENCIES_PER_FEATURE, would_create_circular_dependency
|
|
579
|
+
return would_create_circular_dependency, MAX_DEPENDENCIES_PER_FEATURE
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
@router.post("/{feature_id}/dependencies/{dep_id}")
|
|
583
|
+
async def add_dependency(project_name: str, feature_id: int, dep_id: int):
|
|
584
|
+
"""Add a dependency relationship between features.
|
|
585
|
+
|
|
586
|
+
The dep_id feature must be completed before feature_id can be started.
|
|
587
|
+
Validates: self-reference, existence, circular dependencies, max limit.
|
|
588
|
+
"""
|
|
589
|
+
project_name = validate_project_name(project_name)
|
|
590
|
+
|
|
591
|
+
# Security: Self-reference check
|
|
592
|
+
if feature_id == dep_id:
|
|
593
|
+
raise HTTPException(status_code=400, detail="A feature cannot depend on itself")
|
|
594
|
+
|
|
595
|
+
project_dir = _get_project_path(project_name)
|
|
596
|
+
|
|
597
|
+
if not project_dir:
|
|
598
|
+
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
|
599
|
+
|
|
600
|
+
if not project_dir.exists():
|
|
601
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
602
|
+
|
|
603
|
+
would_create_circular_dependency, MAX_DEPENDENCIES_PER_FEATURE = _get_dependency_resolver()
|
|
604
|
+
_, Feature = _get_db_classes()
|
|
605
|
+
|
|
606
|
+
try:
|
|
607
|
+
with get_db_session(project_dir) as session:
|
|
608
|
+
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
|
609
|
+
dependency = session.query(Feature).filter(Feature.id == dep_id).first()
|
|
610
|
+
|
|
611
|
+
if not feature:
|
|
612
|
+
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
|
613
|
+
if not dependency:
|
|
614
|
+
raise HTTPException(status_code=404, detail=f"Dependency {dep_id} not found")
|
|
615
|
+
|
|
616
|
+
current_deps = feature.dependencies or []
|
|
617
|
+
|
|
618
|
+
# Security: Limit check
|
|
619
|
+
if len(current_deps) >= MAX_DEPENDENCIES_PER_FEATURE:
|
|
620
|
+
raise HTTPException(status_code=400, detail=f"Maximum {MAX_DEPENDENCIES_PER_FEATURE} dependencies allowed")
|
|
621
|
+
|
|
622
|
+
if dep_id in current_deps:
|
|
623
|
+
raise HTTPException(status_code=400, detail="Dependency already exists")
|
|
624
|
+
|
|
625
|
+
# Security: Circular dependency check
|
|
626
|
+
# source_id = feature_id (gaining dep), target_id = dep_id (being depended upon)
|
|
627
|
+
all_features = [f.to_dict() for f in session.query(Feature).all()]
|
|
628
|
+
if would_create_circular_dependency(all_features, feature_id, dep_id):
|
|
629
|
+
raise HTTPException(status_code=400, detail="Would create circular dependency")
|
|
630
|
+
|
|
631
|
+
current_deps.append(dep_id)
|
|
632
|
+
feature.dependencies = sorted(current_deps)
|
|
633
|
+
session.commit()
|
|
634
|
+
|
|
635
|
+
return {"success": True, "feature_id": feature_id, "dependencies": feature.dependencies}
|
|
636
|
+
except HTTPException:
|
|
637
|
+
raise
|
|
638
|
+
except Exception:
|
|
639
|
+
logger.exception("Failed to add dependency")
|
|
640
|
+
raise HTTPException(status_code=500, detail="Failed to add dependency")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
@router.delete("/{feature_id}/dependencies/{dep_id}")
|
|
644
|
+
async def remove_dependency(project_name: str, feature_id: int, dep_id: int):
|
|
645
|
+
"""Remove a dependency from a feature."""
|
|
646
|
+
project_name = validate_project_name(project_name)
|
|
647
|
+
project_dir = _get_project_path(project_name)
|
|
648
|
+
|
|
649
|
+
if not project_dir:
|
|
650
|
+
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
|
651
|
+
|
|
652
|
+
if not project_dir.exists():
|
|
653
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
654
|
+
|
|
655
|
+
_, Feature = _get_db_classes()
|
|
656
|
+
|
|
657
|
+
try:
|
|
658
|
+
with get_db_session(project_dir) as session:
|
|
659
|
+
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
|
660
|
+
if not feature:
|
|
661
|
+
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
|
662
|
+
|
|
663
|
+
current_deps = feature.dependencies or []
|
|
664
|
+
if dep_id not in current_deps:
|
|
665
|
+
raise HTTPException(status_code=400, detail="Dependency does not exist")
|
|
666
|
+
|
|
667
|
+
current_deps.remove(dep_id)
|
|
668
|
+
feature.dependencies = current_deps if current_deps else None
|
|
669
|
+
session.commit()
|
|
670
|
+
|
|
671
|
+
return {"success": True, "feature_id": feature_id, "dependencies": feature.dependencies or []}
|
|
672
|
+
except HTTPException:
|
|
673
|
+
raise
|
|
674
|
+
except Exception:
|
|
675
|
+
logger.exception("Failed to remove dependency")
|
|
676
|
+
raise HTTPException(status_code=500, detail="Failed to remove dependency")
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
@router.put("/{feature_id}/dependencies")
|
|
680
|
+
async def set_dependencies(project_name: str, feature_id: int, update: DependencyUpdate):
|
|
681
|
+
"""Set all dependencies for a feature at once, replacing any existing.
|
|
682
|
+
|
|
683
|
+
Validates: self-reference, existence of all dependencies, circular dependencies, max limit.
|
|
684
|
+
"""
|
|
685
|
+
project_name = validate_project_name(project_name)
|
|
686
|
+
project_dir = _get_project_path(project_name)
|
|
687
|
+
|
|
688
|
+
if not project_dir:
|
|
689
|
+
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
|
690
|
+
|
|
691
|
+
if not project_dir.exists():
|
|
692
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
693
|
+
|
|
694
|
+
dependency_ids = update.dependency_ids
|
|
695
|
+
|
|
696
|
+
# Security: Self-reference check
|
|
697
|
+
if feature_id in dependency_ids:
|
|
698
|
+
raise HTTPException(status_code=400, detail="A feature cannot depend on itself")
|
|
699
|
+
|
|
700
|
+
# Check for duplicates
|
|
701
|
+
if len(dependency_ids) != len(set(dependency_ids)):
|
|
702
|
+
raise HTTPException(status_code=400, detail="Duplicate dependencies not allowed")
|
|
703
|
+
|
|
704
|
+
would_create_circular_dependency, _ = _get_dependency_resolver()
|
|
705
|
+
_, Feature = _get_db_classes()
|
|
706
|
+
|
|
707
|
+
try:
|
|
708
|
+
with get_db_session(project_dir) as session:
|
|
709
|
+
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
|
710
|
+
if not feature:
|
|
711
|
+
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
|
712
|
+
|
|
713
|
+
# Validate all dependencies exist
|
|
714
|
+
all_feature_ids = {f.id for f in session.query(Feature).all()}
|
|
715
|
+
missing = [d for d in dependency_ids if d not in all_feature_ids]
|
|
716
|
+
if missing:
|
|
717
|
+
raise HTTPException(status_code=400, detail=f"Dependencies not found: {missing}")
|
|
718
|
+
|
|
719
|
+
# Check for circular dependencies
|
|
720
|
+
all_features = [f.to_dict() for f in session.query(Feature).all()]
|
|
721
|
+
# Temporarily update the feature's dependencies for cycle check
|
|
722
|
+
test_features = []
|
|
723
|
+
for f in all_features:
|
|
724
|
+
if f["id"] == feature_id:
|
|
725
|
+
test_features.append({**f, "dependencies": dependency_ids})
|
|
726
|
+
else:
|
|
727
|
+
test_features.append(f)
|
|
728
|
+
|
|
729
|
+
for dep_id in dependency_ids:
|
|
730
|
+
# source_id = feature_id (gaining dep), target_id = dep_id (being depended upon)
|
|
731
|
+
if would_create_circular_dependency(test_features, feature_id, dep_id):
|
|
732
|
+
raise HTTPException(
|
|
733
|
+
status_code=400,
|
|
734
|
+
detail=f"Cannot add dependency {dep_id}: would create circular dependency"
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
# Set dependencies
|
|
738
|
+
feature.dependencies = sorted(dependency_ids) if dependency_ids else None
|
|
739
|
+
session.commit()
|
|
740
|
+
|
|
741
|
+
return {"success": True, "feature_id": feature_id, "dependencies": feature.dependencies or []}
|
|
742
|
+
except HTTPException:
|
|
743
|
+
raise
|
|
744
|
+
except Exception:
|
|
745
|
+
logger.exception("Failed to set dependencies")
|
|
746
|
+
raise HTTPException(status_code=500, detail="Failed to set dependencies")
|