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.
Files changed (84) hide show
  1. package/.claude/commands/check-code.md +32 -0
  2. package/.claude/commands/checkpoint.md +40 -0
  3. package/.claude/commands/create-spec.md +613 -0
  4. package/.claude/commands/expand-project.md +234 -0
  5. package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
  6. package/.claude/commands/review-pr.md +75 -0
  7. package/.claude/templates/app_spec.template.txt +331 -0
  8. package/.claude/templates/coding_prompt.template.md +265 -0
  9. package/.claude/templates/initializer_prompt.template.md +354 -0
  10. package/.claude/templates/testing_prompt.template.md +146 -0
  11. package/.env.example +64 -0
  12. package/LICENSE.md +676 -0
  13. package/README.md +423 -0
  14. package/agent.py +444 -0
  15. package/api/__init__.py +10 -0
  16. package/api/database.py +536 -0
  17. package/api/dependency_resolver.py +449 -0
  18. package/api/migration.py +156 -0
  19. package/auth.py +83 -0
  20. package/autoforge_paths.py +315 -0
  21. package/autonomous_agent_demo.py +293 -0
  22. package/bin/autoforge.js +3 -0
  23. package/client.py +607 -0
  24. package/env_constants.py +27 -0
  25. package/examples/OPTIMIZE_CONFIG.md +230 -0
  26. package/examples/README.md +531 -0
  27. package/examples/org_config.yaml +172 -0
  28. package/examples/project_allowed_commands.yaml +139 -0
  29. package/lib/cli.js +791 -0
  30. package/mcp_server/__init__.py +1 -0
  31. package/mcp_server/feature_mcp.py +988 -0
  32. package/package.json +53 -0
  33. package/parallel_orchestrator.py +1800 -0
  34. package/progress.py +247 -0
  35. package/prompts.py +427 -0
  36. package/pyproject.toml +17 -0
  37. package/rate_limit_utils.py +132 -0
  38. package/registry.py +614 -0
  39. package/requirements-prod.txt +14 -0
  40. package/security.py +959 -0
  41. package/server/__init__.py +17 -0
  42. package/server/main.py +261 -0
  43. package/server/routers/__init__.py +32 -0
  44. package/server/routers/agent.py +177 -0
  45. package/server/routers/assistant_chat.py +327 -0
  46. package/server/routers/devserver.py +309 -0
  47. package/server/routers/expand_project.py +239 -0
  48. package/server/routers/features.py +746 -0
  49. package/server/routers/filesystem.py +514 -0
  50. package/server/routers/projects.py +524 -0
  51. package/server/routers/schedules.py +356 -0
  52. package/server/routers/settings.py +127 -0
  53. package/server/routers/spec_creation.py +357 -0
  54. package/server/routers/terminal.py +453 -0
  55. package/server/schemas.py +593 -0
  56. package/server/services/__init__.py +36 -0
  57. package/server/services/assistant_chat_session.py +496 -0
  58. package/server/services/assistant_database.py +304 -0
  59. package/server/services/chat_constants.py +57 -0
  60. package/server/services/dev_server_manager.py +557 -0
  61. package/server/services/expand_chat_session.py +399 -0
  62. package/server/services/process_manager.py +657 -0
  63. package/server/services/project_config.py +475 -0
  64. package/server/services/scheduler_service.py +683 -0
  65. package/server/services/spec_chat_session.py +502 -0
  66. package/server/services/terminal_manager.py +756 -0
  67. package/server/utils/__init__.py +1 -0
  68. package/server/utils/process_utils.py +134 -0
  69. package/server/utils/project_helpers.py +32 -0
  70. package/server/utils/validation.py +54 -0
  71. package/server/websocket.py +903 -0
  72. package/start.py +456 -0
  73. package/ui/dist/assets/index-8W_wmZzz.js +168 -0
  74. package/ui/dist/assets/index-B47Ubhox.css +1 -0
  75. package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
  76. package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
  77. package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
  78. package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
  79. package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
  80. package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
  81. package/ui/dist/index.html +23 -0
  82. package/ui/dist/ollama.png +0 -0
  83. package/ui/dist/vite.svg +6 -0
  84. 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")