draft-board 0.1.0-beta.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 (250) hide show
  1. package/app/backend/.env.example +9 -0
  2. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
  3. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
  4. package/app/backend/CURL_EXAMPLES.md +335 -0
  5. package/app/backend/ENV_SETUP.md +65 -0
  6. package/app/backend/alembic/env.py +71 -0
  7. package/app/backend/alembic/script.py.mako +28 -0
  8. package/app/backend/alembic/versions/001_initial_schema.py +104 -0
  9. package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
  10. package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
  11. package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
  12. package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
  13. package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
  14. package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
  15. package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
  16. package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
  17. package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
  18. package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
  19. package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
  20. package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
  21. package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
  22. package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
  23. package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
  24. package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
  25. package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
  26. package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
  27. package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
  28. package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
  29. package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
  30. package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
  31. package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
  32. package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
  33. package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
  34. package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
  35. package/app/backend/alembic/versions/add_job_variant.py +34 -0
  36. package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
  37. package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
  38. package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
  39. package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
  40. package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
  41. package/app/backend/alembic.ini +89 -0
  42. package/app/backend/app/__init__.py +3 -0
  43. package/app/backend/app/data_dir.py +85 -0
  44. package/app/backend/app/database.py +70 -0
  45. package/app/backend/app/database_sync.py +64 -0
  46. package/app/backend/app/dependencies/__init__.py +5 -0
  47. package/app/backend/app/dependencies/auth.py +80 -0
  48. package/app/backend/app/dependencies.py +43 -0
  49. package/app/backend/app/exceptions.py +178 -0
  50. package/app/backend/app/executors/__init__.py +1 -0
  51. package/app/backend/app/executors/adapters/__init__.py +1 -0
  52. package/app/backend/app/executors/adapters/aider.py +152 -0
  53. package/app/backend/app/executors/adapters/amazon_q.py +103 -0
  54. package/app/backend/app/executors/adapters/amp.py +123 -0
  55. package/app/backend/app/executors/adapters/claude.py +177 -0
  56. package/app/backend/app/executors/adapters/cline.py +127 -0
  57. package/app/backend/app/executors/adapters/codex.py +167 -0
  58. package/app/backend/app/executors/adapters/copilot.py +202 -0
  59. package/app/backend/app/executors/adapters/cursor.py +87 -0
  60. package/app/backend/app/executors/adapters/droid.py +123 -0
  61. package/app/backend/app/executors/adapters/gemini.py +132 -0
  62. package/app/backend/app/executors/adapters/goose.py +131 -0
  63. package/app/backend/app/executors/adapters/opencode.py +123 -0
  64. package/app/backend/app/executors/adapters/qwen.py +123 -0
  65. package/app/backend/app/executors/plugins/__init__.py +1 -0
  66. package/app/backend/app/executors/registry.py +202 -0
  67. package/app/backend/app/executors/spec.py +226 -0
  68. package/app/backend/app/main.py +486 -0
  69. package/app/backend/app/middleware/__init__.py +13 -0
  70. package/app/backend/app/middleware/idempotency.py +426 -0
  71. package/app/backend/app/middleware/rate_limit.py +312 -0
  72. package/app/backend/app/middleware/security_headers.py +43 -0
  73. package/app/backend/app/middleware/timeout.py +37 -0
  74. package/app/backend/app/models/__init__.py +56 -0
  75. package/app/backend/app/models/agent_conversation_history.py +56 -0
  76. package/app/backend/app/models/agent_session.py +127 -0
  77. package/app/backend/app/models/analysis_cache.py +49 -0
  78. package/app/backend/app/models/base.py +9 -0
  79. package/app/backend/app/models/board.py +79 -0
  80. package/app/backend/app/models/board_repo.py +68 -0
  81. package/app/backend/app/models/cost_budget.py +42 -0
  82. package/app/backend/app/models/enums.py +40 -0
  83. package/app/backend/app/models/evidence.py +132 -0
  84. package/app/backend/app/models/goal.py +102 -0
  85. package/app/backend/app/models/idempotency_entry.py +30 -0
  86. package/app/backend/app/models/job.py +163 -0
  87. package/app/backend/app/models/job_queue.py +39 -0
  88. package/app/backend/app/models/kv_store.py +28 -0
  89. package/app/backend/app/models/merge_checklist.py +87 -0
  90. package/app/backend/app/models/normalized_log.py +100 -0
  91. package/app/backend/app/models/planner_lock.py +43 -0
  92. package/app/backend/app/models/rate_limit_entry.py +25 -0
  93. package/app/backend/app/models/repo.py +66 -0
  94. package/app/backend/app/models/review_comment.py +91 -0
  95. package/app/backend/app/models/review_summary.py +69 -0
  96. package/app/backend/app/models/revision.py +130 -0
  97. package/app/backend/app/models/ticket.py +223 -0
  98. package/app/backend/app/models/ticket_event.py +83 -0
  99. package/app/backend/app/models/user.py +47 -0
  100. package/app/backend/app/models/workspace.py +71 -0
  101. package/app/backend/app/redis_client.py +119 -0
  102. package/app/backend/app/routers/__init__.py +29 -0
  103. package/app/backend/app/routers/agents.py +296 -0
  104. package/app/backend/app/routers/auth.py +94 -0
  105. package/app/backend/app/routers/board.py +885 -0
  106. package/app/backend/app/routers/dashboard.py +351 -0
  107. package/app/backend/app/routers/debug.py +528 -0
  108. package/app/backend/app/routers/evidence.py +96 -0
  109. package/app/backend/app/routers/executors.py +324 -0
  110. package/app/backend/app/routers/goals.py +574 -0
  111. package/app/backend/app/routers/jobs.py +448 -0
  112. package/app/backend/app/routers/maintenance.py +172 -0
  113. package/app/backend/app/routers/merge.py +360 -0
  114. package/app/backend/app/routers/planner.py +537 -0
  115. package/app/backend/app/routers/pull_requests.py +382 -0
  116. package/app/backend/app/routers/repos.py +263 -0
  117. package/app/backend/app/routers/revisions.py +939 -0
  118. package/app/backend/app/routers/settings.py +267 -0
  119. package/app/backend/app/routers/tickets.py +2003 -0
  120. package/app/backend/app/routers/webhooks.py +143 -0
  121. package/app/backend/app/routers/websocket.py +249 -0
  122. package/app/backend/app/schemas/__init__.py +109 -0
  123. package/app/backend/app/schemas/board.py +87 -0
  124. package/app/backend/app/schemas/common.py +33 -0
  125. package/app/backend/app/schemas/evidence.py +87 -0
  126. package/app/backend/app/schemas/goal.py +90 -0
  127. package/app/backend/app/schemas/job.py +97 -0
  128. package/app/backend/app/schemas/merge.py +139 -0
  129. package/app/backend/app/schemas/planner.py +500 -0
  130. package/app/backend/app/schemas/repo.py +187 -0
  131. package/app/backend/app/schemas/review.py +137 -0
  132. package/app/backend/app/schemas/revision.py +114 -0
  133. package/app/backend/app/schemas/ticket.py +238 -0
  134. package/app/backend/app/schemas/ticket_event.py +72 -0
  135. package/app/backend/app/schemas/workspace.py +19 -0
  136. package/app/backend/app/services/__init__.py +31 -0
  137. package/app/backend/app/services/agent_memory_service.py +223 -0
  138. package/app/backend/app/services/agent_registry.py +346 -0
  139. package/app/backend/app/services/agent_session_manager.py +318 -0
  140. package/app/backend/app/services/agent_session_service.py +219 -0
  141. package/app/backend/app/services/agent_tools.py +379 -0
  142. package/app/backend/app/services/auth_service.py +98 -0
  143. package/app/backend/app/services/autonomy_service.py +380 -0
  144. package/app/backend/app/services/board_repo_service.py +201 -0
  145. package/app/backend/app/services/board_service.py +326 -0
  146. package/app/backend/app/services/cleanup_service.py +1085 -0
  147. package/app/backend/app/services/config_service.py +908 -0
  148. package/app/backend/app/services/context_gatherer.py +557 -0
  149. package/app/backend/app/services/cost_tracking_service.py +293 -0
  150. package/app/backend/app/services/cursor_log_normalizer.py +536 -0
  151. package/app/backend/app/services/delivery_pipeline.py +440 -0
  152. package/app/backend/app/services/executor_service.py +634 -0
  153. package/app/backend/app/services/git_host/__init__.py +11 -0
  154. package/app/backend/app/services/git_host/factory.py +87 -0
  155. package/app/backend/app/services/git_host/github.py +270 -0
  156. package/app/backend/app/services/git_host/gitlab.py +194 -0
  157. package/app/backend/app/services/git_host/protocol.py +75 -0
  158. package/app/backend/app/services/git_merge_simple.py +346 -0
  159. package/app/backend/app/services/git_ops.py +384 -0
  160. package/app/backend/app/services/github_service.py +233 -0
  161. package/app/backend/app/services/goal_service.py +113 -0
  162. package/app/backend/app/services/job_service.py +423 -0
  163. package/app/backend/app/services/job_watchdog_service.py +424 -0
  164. package/app/backend/app/services/langchain_adapter.py +122 -0
  165. package/app/backend/app/services/llm_provider_clients.py +351 -0
  166. package/app/backend/app/services/llm_service.py +285 -0
  167. package/app/backend/app/services/log_normalizer.py +342 -0
  168. package/app/backend/app/services/log_stream_service.py +276 -0
  169. package/app/backend/app/services/merge_checklist_service.py +264 -0
  170. package/app/backend/app/services/merge_service.py +784 -0
  171. package/app/backend/app/services/orchestrator_log.py +84 -0
  172. package/app/backend/app/services/planner_service.py +1662 -0
  173. package/app/backend/app/services/planner_tick_sync.py +1040 -0
  174. package/app/backend/app/services/queued_message_service.py +156 -0
  175. package/app/backend/app/services/reliability_wrapper.py +389 -0
  176. package/app/backend/app/services/repo_discovery_service.py +318 -0
  177. package/app/backend/app/services/review_service.py +334 -0
  178. package/app/backend/app/services/revision_service.py +389 -0
  179. package/app/backend/app/services/safe_autopilot.py +510 -0
  180. package/app/backend/app/services/sqlite_worker.py +372 -0
  181. package/app/backend/app/services/task_dispatch.py +135 -0
  182. package/app/backend/app/services/ticket_generation_service.py +1781 -0
  183. package/app/backend/app/services/ticket_service.py +486 -0
  184. package/app/backend/app/services/udar_planner_service.py +1007 -0
  185. package/app/backend/app/services/webhook_service.py +126 -0
  186. package/app/backend/app/services/workspace_service.py +465 -0
  187. package/app/backend/app/services/worktree_file_service.py +92 -0
  188. package/app/backend/app/services/worktree_validator.py +213 -0
  189. package/app/backend/app/sqlite_kv.py +278 -0
  190. package/app/backend/app/state_machine.py +128 -0
  191. package/app/backend/app/templates/__init__.py +5 -0
  192. package/app/backend/app/templates/registry.py +243 -0
  193. package/app/backend/app/utils/__init__.py +5 -0
  194. package/app/backend/app/utils/artifact_reader.py +87 -0
  195. package/app/backend/app/utils/circuit_breaker.py +229 -0
  196. package/app/backend/app/utils/db_retry.py +136 -0
  197. package/app/backend/app/utils/ignored_fields.py +123 -0
  198. package/app/backend/app/utils/validators.py +54 -0
  199. package/app/backend/app/websocket/__init__.py +5 -0
  200. package/app/backend/app/websocket/manager.py +179 -0
  201. package/app/backend/app/websocket/state_tracker.py +113 -0
  202. package/app/backend/app/worker.py +3190 -0
  203. package/app/backend/calculator_tickets.json +40 -0
  204. package/app/backend/canary_tests.sh +591 -0
  205. package/app/backend/celerybeat-schedule +0 -0
  206. package/app/backend/celerybeat-schedule-shm +0 -0
  207. package/app/backend/celerybeat-schedule-wal +0 -0
  208. package/app/backend/logs/.gitkeep +3 -0
  209. package/app/backend/multiplication_division_implementation_tickets.json +55 -0
  210. package/app/backend/multiplication_division_tickets.json +42 -0
  211. package/app/backend/pyproject.toml +45 -0
  212. package/app/backend/requirements-dev.txt +8 -0
  213. package/app/backend/requirements.txt +20 -0
  214. package/app/backend/run.sh +30 -0
  215. package/app/backend/run_with_logs.sh +10 -0
  216. package/app/backend/scientific_calculator_tickets.json +40 -0
  217. package/app/backend/scripts/extract_openapi.py +21 -0
  218. package/app/backend/scripts/seed_demo.py +187 -0
  219. package/app/backend/setup_demo_review.py +302 -0
  220. package/app/backend/test_actual_parse.py +41 -0
  221. package/app/backend/test_agent_streaming.py +61 -0
  222. package/app/backend/test_parse.py +51 -0
  223. package/app/backend/test_streaming.py +51 -0
  224. package/app/backend/test_subprocess_streaming.py +50 -0
  225. package/app/backend/tests/__init__.py +1 -0
  226. package/app/backend/tests/conftest.py +46 -0
  227. package/app/backend/tests/test_auth.py +341 -0
  228. package/app/backend/tests/test_autonomy_service.py +391 -0
  229. package/app/backend/tests/test_cleanup_service_safety.py +417 -0
  230. package/app/backend/tests/test_middleware.py +279 -0
  231. package/app/backend/tests/test_planner_providers.py +290 -0
  232. package/app/backend/tests/test_planner_unblock.py +183 -0
  233. package/app/backend/tests/test_revision_invariants.py +618 -0
  234. package/app/backend/tests/test_sqlite_kv.py +290 -0
  235. package/app/backend/tests/test_sqlite_worker.py +353 -0
  236. package/app/backend/tests/test_task_dispatch.py +100 -0
  237. package/app/backend/tests/test_ticket_validation.py +304 -0
  238. package/app/backend/tests/test_udar_agent.py +693 -0
  239. package/app/backend/tests/test_webhook_service.py +184 -0
  240. package/app/backend/tickets_output.json +59 -0
  241. package/app/backend/user_management_tickets.json +50 -0
  242. package/app/backend/uvicorn.log +0 -0
  243. package/app/draft.yaml +313 -0
  244. package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
  245. package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
  246. package/app/frontend/dist/index.html +14 -0
  247. package/app/frontend/dist/vite.svg +1 -0
  248. package/app/frontend/package.json +101 -0
  249. package/bin/cli.js +527 -0
  250. package/package.json +37 -0
@@ -0,0 +1,80 @@
1
+ """Auth dependency: extract current user from JWT Bearer token.
2
+
3
+ When AUTH_ENABLED=false (the default for existing single-user setups),
4
+ all auth dependencies return None so existing routes keep working unchanged.
5
+ """
6
+
7
+ import os
8
+
9
+ from fastapi import Depends, HTTPException, status
10
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from app.database import get_db
14
+ from app.models.user import User
15
+ from app.services.auth_service import decode_access_token, get_user_by_id
16
+
17
+ AUTH_ENABLED = os.getenv("AUTH_ENABLED", "false").lower() in ("true", "1", "yes")
18
+
19
+ # auto_error=False so the dependency doesn't 403 when no header is present
20
+ _bearer_scheme = HTTPBearer(auto_error=False)
21
+
22
+
23
+ async def get_current_user(
24
+ credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
25
+ db: AsyncSession = Depends(get_db),
26
+ ) -> User | None:
27
+ """Return the authenticated User, or None when auth is disabled.
28
+
29
+ When AUTH_ENABLED=true:
30
+ - Missing/invalid token -> 401
31
+ - Valid token -> User object
32
+
33
+ When AUTH_ENABLED=false:
34
+ - Always returns None (backward compatible, no login needed)
35
+ """
36
+ if not AUTH_ENABLED:
37
+ return None
38
+
39
+ if credentials is None:
40
+ raise HTTPException(
41
+ status_code=status.HTTP_401_UNAUTHORIZED,
42
+ detail="Authentication required",
43
+ headers={"WWW-Authenticate": "Bearer"},
44
+ )
45
+
46
+ payload = decode_access_token(credentials.credentials)
47
+ if payload is None:
48
+ raise HTTPException(
49
+ status_code=status.HTTP_401_UNAUTHORIZED,
50
+ detail="Invalid or expired token",
51
+ headers={"WWW-Authenticate": "Bearer"},
52
+ )
53
+
54
+ user = await get_user_by_id(db, payload["sub"])
55
+ if user is None or not user.is_active:
56
+ raise HTTPException(
57
+ status_code=status.HTTP_401_UNAUTHORIZED,
58
+ detail="User not found or inactive",
59
+ headers={"WWW-Authenticate": "Bearer"},
60
+ )
61
+
62
+ return user
63
+
64
+
65
+ async def get_optional_user(
66
+ credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
67
+ db: AsyncSession = Depends(get_db),
68
+ ) -> User | None:
69
+ """Like get_current_user but never raises 401 — always returns User or None."""
70
+ if credentials is None:
71
+ return None
72
+
73
+ payload = decode_access_token(credentials.credentials)
74
+ if payload is None:
75
+ return None
76
+
77
+ user = await get_user_by_id(db, payload["sub"])
78
+ if user is None or not user.is_active:
79
+ return None
80
+ return user
@@ -0,0 +1,43 @@
1
+ """Reusable FastAPI dependencies for validation and DI."""
2
+
3
+ from typing import Annotated
4
+
5
+ from fastapi import Path
6
+
7
+ from app.utils.validators import validate_uuid
8
+
9
+
10
+ def ValidatedUUID(field_name: str):
11
+ """Create an annotated type for UUID path parameter validation.
12
+
13
+ Creates a type annotation that validates UUID format and returns the normalized UUID string.
14
+
15
+ Usage:
16
+ @router.get("/{ticket_id}")
17
+ async def get_ticket(
18
+ ticket_id: Annotated[str, ValidatedUUID("ticket_id")],
19
+ db: AsyncSession = Depends(get_db),
20
+ ):
21
+ # ticket_id is guaranteed to be a valid UUID
22
+
23
+ Args:
24
+ field_name: Name of the field for error messages (e.g., "ticket_id")
25
+
26
+ Returns:
27
+ A Path dependency that validates and returns the UUID
28
+ """
29
+
30
+ def validator(value: str) -> str:
31
+ return validate_uuid(value, field_name)
32
+
33
+ return Path(..., description=f"Valid UUID for {field_name}")
34
+
35
+
36
+ # Type aliases for common ID types - use with Annotated
37
+ # Example: ticket_id: TicketID
38
+ TicketID = Annotated[str, Path(..., description="Valid UUID for ticket_id")]
39
+ JobID = Annotated[str, Path(..., description="Valid UUID for job_id")]
40
+ GoalID = Annotated[str, Path(..., description="Valid UUID for goal_id")]
41
+ BoardID = Annotated[str, Path(..., description="Valid UUID for board_id")]
42
+ RevisionID = Annotated[str, Path(..., description="Valid UUID for revision_id")]
43
+ EvidenceID = Annotated[str, Path(..., description="Valid UUID for evidence_id")]
@@ -0,0 +1,178 @@
1
+ """Custom exceptions for Draft."""
2
+
3
+
4
+ class DraftError(Exception):
5
+ """Base exception for Draft."""
6
+
7
+ pass
8
+
9
+
10
+ class InvalidStateTransitionError(DraftError):
11
+ """Raised when an invalid state transition is attempted."""
12
+
13
+ def __init__(self, from_state: str, to_state: str, message: str | None = None):
14
+ self.from_state = from_state
15
+ self.to_state = to_state
16
+ self.message = (
17
+ message or f"Invalid transition from '{from_state}' to '{to_state}'"
18
+ )
19
+ super().__init__(self.message)
20
+
21
+
22
+ class ResourceNotFoundError(DraftError):
23
+ """Raised when a requested resource is not found."""
24
+
25
+ def __init__(self, resource_type: str, resource_id: str):
26
+ self.resource_type = resource_type
27
+ self.resource_id = resource_id
28
+ self.message = f"{resource_type} with id '{resource_id}' not found"
29
+ super().__init__(self.message)
30
+
31
+
32
+ class ValidationError(DraftError):
33
+ """Raised when validation fails."""
34
+
35
+ def __init__(self, message: str):
36
+ self.message = message
37
+ super().__init__(self.message)
38
+
39
+
40
+ class ConflictError(DraftError):
41
+ """Raised when an operation conflicts with current resource state.
42
+
43
+ Typically maps to HTTP 409 Conflict.
44
+ Example: Attempting to comment on a superseded revision.
45
+ """
46
+
47
+ def __init__(self, message: str):
48
+ self.message = message
49
+ super().__init__(self.message)
50
+
51
+
52
+ class WorkspaceError(DraftError):
53
+ """Base exception for workspace-related errors."""
54
+
55
+ def __init__(self, message: str):
56
+ self.message = message
57
+ super().__init__(self.message)
58
+
59
+
60
+ class NotAGitRepositoryError(WorkspaceError):
61
+ """Raised when the configured path is not a git repository."""
62
+
63
+ def __init__(self, path: str):
64
+ self.path = path
65
+ super().__init__(f"Repository at '{path}' is not a git repository")
66
+
67
+
68
+ class WorktreeCreationError(WorkspaceError):
69
+ """Raised when worktree creation fails."""
70
+
71
+ def __init__(self, message: str, git_error: str | None = None):
72
+ self.git_error = git_error
73
+ full_message = message
74
+ if git_error:
75
+ full_message = f"{message}: {git_error}"
76
+ super().__init__(full_message)
77
+
78
+
79
+ class BranchNotFoundError(WorkspaceError):
80
+ """Raised when the base branch is not found."""
81
+
82
+ def __init__(self, branch: str):
83
+ self.branch = branch
84
+ super().__init__(f"Base branch '{branch}' not found in repository")
85
+
86
+
87
+ class ExecutorError(DraftError):
88
+ """Base exception for executor-related errors."""
89
+
90
+ def __init__(self, message: str):
91
+ self.message = message
92
+ super().__init__(self.message)
93
+
94
+
95
+ class ExecutorNotFoundError(ExecutorError):
96
+ """Raised when no supported code executor CLI is found."""
97
+
98
+ def __init__(self, message: str | None = None):
99
+ default_message = (
100
+ "No supported code executor CLI found. "
101
+ "Please install Cursor CLI or Claude Code CLI."
102
+ )
103
+ super().__init__(message or default_message)
104
+
105
+
106
+ class ExecutorInvocationError(ExecutorError):
107
+ """Raised when the executor CLI invocation fails."""
108
+
109
+ def __init__(
110
+ self,
111
+ message: str,
112
+ exit_code: int | None = None,
113
+ stderr: str | None = None,
114
+ ):
115
+ self.exit_code = exit_code
116
+ self.stderr = stderr
117
+ full_message = message
118
+ if exit_code is not None:
119
+ full_message = f"{message} (exit code: {exit_code})"
120
+ if stderr:
121
+ full_message = f"{full_message}\nError output: {stderr}"
122
+ super().__init__(full_message)
123
+
124
+
125
+ class ConfigurationError(DraftError):
126
+ """Raised when required configuration is missing or invalid."""
127
+
128
+ def __init__(self, message: str):
129
+ self.message = message
130
+ super().__init__(self.message)
131
+
132
+
133
+ class PlannerError(DraftError):
134
+ """Base exception for planner-related errors."""
135
+
136
+ def __init__(self, message: str):
137
+ self.message = message
138
+ super().__init__(self.message)
139
+
140
+
141
+ class LLMAPIError(PlannerError):
142
+ """Raised when an LLM API call fails."""
143
+
144
+ def __init__(self, message: str, provider: str, status_code: int | None = None):
145
+ self.provider = provider
146
+ self.status_code = status_code
147
+ full_message = f"[{provider}] {message}"
148
+ if status_code:
149
+ full_message = f"{full_message} (status: {status_code})"
150
+ super().__init__(full_message)
151
+
152
+
153
+ class LLMTimeoutError(LLMAPIError):
154
+ """Raised when an LLM API call times out."""
155
+
156
+ def __init__(self, provider: str, timeout_seconds: int):
157
+ self.timeout_seconds = timeout_seconds
158
+ message = f"LLM API call timed out after {timeout_seconds} seconds"
159
+ super().__init__(message, provider)
160
+
161
+
162
+ class UDARAgentError(PlannerError):
163
+ """Base exception for UDAR agent errors."""
164
+
165
+ def __init__(self, message: str, phase: str | None = None):
166
+ self.phase = phase
167
+ full_message = message
168
+ if phase:
169
+ full_message = f"[{phase} phase] {message}"
170
+ super().__init__(full_message)
171
+
172
+
173
+ class ToolExecutionError(UDARAgentError):
174
+ """Raised when a UDAR tool execution fails."""
175
+
176
+ def __init__(self, tool_name: str, message: str, phase: str | None = None):
177
+ self.tool_name = tool_name
178
+ super().__init__(f"Tool '{tool_name}' failed: {message}", phase)
@@ -0,0 +1 @@
1
+ """Executor plugin system for Draft."""
@@ -0,0 +1 @@
1
+ """Built-in executor adapters."""
@@ -0,0 +1,152 @@
1
+ """Aider AI coding assistant adapter."""
2
+
3
+ import asyncio
4
+ import os
5
+ import shutil
6
+ from collections.abc import AsyncIterator
7
+
8
+ from app.executors.registry import ExecutorRegistry
9
+ from app.executors.spec import (
10
+ ExecutionRequest,
11
+ ExecutionResult,
12
+ ExecutorAdapter,
13
+ ExecutorCapability,
14
+ ExecutorInvocationError,
15
+ ExecutorMetadata,
16
+ ExecutorNotFoundError,
17
+ ExecutorTimeoutError,
18
+ )
19
+
20
+
21
+ @ExecutorRegistry.register("aider")
22
+ class AiderAdapter(ExecutorAdapter):
23
+ """Aider AI coding assistant adapter."""
24
+
25
+ def get_metadata(self) -> ExecutorMetadata:
26
+ return ExecutorMetadata(
27
+ name="aider",
28
+ display_name="Aider",
29
+ version="1.0.0",
30
+ capabilities=[
31
+ ExecutorCapability.STREAMING_OUTPUT,
32
+ ExecutorCapability.SESSION_RESUME,
33
+ ExecutorCapability.COST_TRACKING,
34
+ ],
35
+ config_schema={
36
+ "type": "object",
37
+ "properties": {
38
+ "model": {
39
+ "type": "string",
40
+ "default": "gpt-4",
41
+ "description": "LLM model to use",
42
+ },
43
+ "edit_format": {
44
+ "type": "string",
45
+ "enum": ["diff", "whole"],
46
+ "default": "diff",
47
+ "description": "Edit format (diff or whole file)",
48
+ },
49
+ "auto_commits": {
50
+ "type": "boolean",
51
+ "default": True,
52
+ "description": "Auto-commit changes",
53
+ },
54
+ },
55
+ },
56
+ documentation_url="https://aider.chat/docs/",
57
+ author="Aider Project",
58
+ license="Apache-2.0",
59
+ )
60
+
61
+ async def is_available(self) -> bool:
62
+ """Check if aider is installed."""
63
+ return shutil.which("aider") is not None
64
+
65
+ async def execute(self, request: ExecutionRequest) -> ExecutionResult:
66
+ """Execute using Aider."""
67
+ if not await self.is_available():
68
+ raise ExecutorNotFoundError(
69
+ "Aider not found. Install: pip install aider-chat"
70
+ )
71
+
72
+ # Build command
73
+ cmd = [
74
+ "aider",
75
+ "--yes", # Auto-confirm
76
+ "--no-git", # We handle git ourselves
77
+ "--message",
78
+ request.prompt,
79
+ ]
80
+
81
+ # Add session resume if provided
82
+ if request.session_id:
83
+ cmd.extend(["--restore-chat-history", request.session_id])
84
+
85
+ # Add model config
86
+ model = request.config.get("model", "gpt-4")
87
+ cmd.extend(["--model", model])
88
+
89
+ # Execute
90
+ try:
91
+ process = await asyncio.create_subprocess_exec(
92
+ *cmd,
93
+ cwd=request.working_directory,
94
+ stdout=asyncio.subprocess.PIPE,
95
+ stderr=asyncio.subprocess.PIPE,
96
+ env={**os.environ, **request.environment},
97
+ )
98
+
99
+ stdout, stderr = await asyncio.wait_for(
100
+ process.communicate(), timeout=request.timeout_seconds
101
+ )
102
+
103
+ return ExecutionResult(
104
+ exit_code=process.returncode,
105
+ stdout=stdout.decode("utf-8", errors="replace"),
106
+ stderr=stderr.decode("utf-8", errors="replace"),
107
+ files_changed=self._parse_changed_files(stdout.decode()),
108
+ duration_seconds=0.0,
109
+ )
110
+
111
+ except TimeoutError:
112
+ process.kill()
113
+ raise ExecutorTimeoutError(
114
+ f"Aider execution timed out after {request.timeout_seconds}s"
115
+ )
116
+ except Exception as e:
117
+ raise ExecutorInvocationError(f"Aider execution failed: {str(e)}")
118
+
119
+ async def stream_output(self, request: ExecutionRequest) -> AsyncIterator[str]:
120
+ """Stream output in real-time."""
121
+ if not await self.is_available():
122
+ raise ExecutorNotFoundError("Aider not found")
123
+
124
+ cmd = ["aider", "--yes", "--no-git", "--message", request.prompt]
125
+
126
+ process = await asyncio.create_subprocess_exec(
127
+ *cmd,
128
+ cwd=request.working_directory,
129
+ stdout=asyncio.subprocess.PIPE,
130
+ stderr=asyncio.subprocess.STDOUT,
131
+ env={**os.environ, **request.environment},
132
+ )
133
+
134
+ while True:
135
+ line = await process.stdout.readline()
136
+ if not line:
137
+ break
138
+ yield line.decode("utf-8", errors="replace")
139
+
140
+ await process.wait()
141
+
142
+ def _parse_changed_files(self, output: str) -> list[str]:
143
+ """Parse changed files from Aider output.
144
+
145
+ Aider logs lines like: "Modified path/to/file.py"
146
+ """
147
+ files = []
148
+ for line in output.split("\n"):
149
+ if line.strip().startswith("Modified "):
150
+ file_path = line.strip()[9:].strip() # Remove "Modified "
151
+ files.append(file_path)
152
+ return files
@@ -0,0 +1,103 @@
1
+ """Amazon Q Developer adapter."""
2
+
3
+ import asyncio
4
+ import os
5
+ import shutil
6
+
7
+ from app.executors.registry import ExecutorRegistry
8
+ from app.executors.spec import (
9
+ ExecutionRequest,
10
+ ExecutionResult,
11
+ ExecutorAdapter,
12
+ ExecutorCapability,
13
+ ExecutorInvocationError,
14
+ ExecutorMetadata,
15
+ ExecutorNotFoundError,
16
+ ExecutorTimeoutError,
17
+ )
18
+
19
+
20
+ @ExecutorRegistry.register("amazon-q")
21
+ class AmazonQAdapter(ExecutorAdapter):
22
+ """Amazon Q Developer adapter."""
23
+
24
+ def get_metadata(self) -> ExecutorMetadata:
25
+ return ExecutorMetadata(
26
+ name="amazon-q",
27
+ display_name="Amazon Q Developer",
28
+ version="1.0.0",
29
+ capabilities=[
30
+ ExecutorCapability.STREAMING_OUTPUT,
31
+ ],
32
+ config_schema={
33
+ "type": "object",
34
+ "properties": {
35
+ "profile": {"type": "string", "description": "AWS profile to use"},
36
+ "model": {
37
+ "type": "string",
38
+ "default": "q-developer",
39
+ "description": "Model variant to use",
40
+ },
41
+ },
42
+ },
43
+ documentation_url="https://aws.amazon.com/q/developer/",
44
+ author="AWS",
45
+ license="Proprietary",
46
+ )
47
+
48
+ async def is_available(self) -> bool:
49
+ """Check if Q CLI is installed."""
50
+ # Amazon Q can be accessed via `q` or `amazon-q` command
51
+ return shutil.which("q") is not None or shutil.which("amazon-q") is not None
52
+
53
+ async def execute(self, request: ExecutionRequest) -> ExecutionResult:
54
+ """Execute using Amazon Q Developer."""
55
+ if not await self.is_available():
56
+ raise ExecutorNotFoundError(
57
+ "Amazon Q not found. Install AWS CLI and Q extension."
58
+ )
59
+
60
+ # Determine which command is available
61
+ cmd_name = "q" if shutil.which("q") else "amazon-q"
62
+
63
+ # Build command
64
+ cmd = [cmd_name, "chat"]
65
+
66
+ if request.yolo_mode:
67
+ cmd.append("--trust-all-tools")
68
+
69
+ # Add AWS profile if specified
70
+ profile = request.config.get("profile")
71
+ if profile:
72
+ cmd.extend(["--profile", profile])
73
+
74
+ try:
75
+ process = await asyncio.create_subprocess_exec(
76
+ *cmd,
77
+ cwd=request.working_directory,
78
+ stdin=asyncio.subprocess.PIPE,
79
+ stdout=asyncio.subprocess.PIPE,
80
+ stderr=asyncio.subprocess.PIPE,
81
+ env={**os.environ, **request.environment},
82
+ )
83
+
84
+ # Amazon Q uses stdin for the prompt
85
+ stdout, stderr = await asyncio.wait_for(
86
+ process.communicate(input=request.prompt.encode()),
87
+ timeout=request.timeout_seconds,
88
+ )
89
+
90
+ return ExecutionResult(
91
+ exit_code=process.returncode,
92
+ stdout=stdout.decode("utf-8", errors="replace"),
93
+ stderr=stderr.decode("utf-8", errors="replace"),
94
+ duration_seconds=0.0,
95
+ )
96
+
97
+ except TimeoutError:
98
+ process.kill()
99
+ raise ExecutorTimeoutError(
100
+ f"Amazon Q execution timed out after {request.timeout_seconds}s"
101
+ )
102
+ except Exception as e:
103
+ raise ExecutorInvocationError(f"Amazon Q execution failed: {str(e)}")
@@ -0,0 +1,123 @@
1
+ """Amp (Sourcegraph) CLI adapter."""
2
+
3
+ import asyncio
4
+ import os
5
+ import shutil
6
+ from collections.abc import AsyncIterator
7
+
8
+ from app.executors.registry import ExecutorRegistry
9
+ from app.executors.spec import (
10
+ ExecutionRequest,
11
+ ExecutionResult,
12
+ ExecutorAdapter,
13
+ ExecutorCapability,
14
+ ExecutorInvocationError,
15
+ ExecutorMetadata,
16
+ ExecutorNotFoundError,
17
+ ExecutorTimeoutError,
18
+ )
19
+
20
+
21
+ @ExecutorRegistry.register("amp")
22
+ class AmpAdapter(ExecutorAdapter):
23
+ """Amp (Sourcegraph) CLI adapter for automated code changes."""
24
+
25
+ def get_metadata(self) -> ExecutorMetadata:
26
+ return ExecutorMetadata(
27
+ name="amp",
28
+ display_name="Amp CLI (Sourcegraph)",
29
+ version="1.0.0",
30
+ capabilities=[
31
+ ExecutorCapability.STREAMING_OUTPUT,
32
+ ExecutorCapability.YOLO_MODE,
33
+ ],
34
+ config_schema={
35
+ "type": "object",
36
+ "properties": {
37
+ "model": {
38
+ "type": "string",
39
+ "default": "default",
40
+ "description": "Model to use for Amp",
41
+ }
42
+ },
43
+ },
44
+ documentation_url="https://github.com/sourcegraph/amp",
45
+ author="Sourcegraph",
46
+ license="Apache-2.0",
47
+ )
48
+
49
+ async def is_available(self) -> bool:
50
+ """Check if amp CLI is installed."""
51
+ return shutil.which("amp") is not None
52
+
53
+ async def execute(self, request: ExecutionRequest) -> ExecutionResult:
54
+ """Execute using Amp CLI."""
55
+ if not await self.is_available():
56
+ raise ExecutorNotFoundError(
57
+ "Amp CLI not found. Install from https://github.com/sourcegraph/amp"
58
+ )
59
+
60
+ cmd = ["amp", "--print"]
61
+
62
+ if request.yolo_mode:
63
+ cmd.append("--yolo")
64
+
65
+ try:
66
+ process = await asyncio.create_subprocess_exec(
67
+ *cmd,
68
+ cwd=request.working_directory,
69
+ stdin=asyncio.subprocess.PIPE,
70
+ stdout=asyncio.subprocess.PIPE,
71
+ stderr=asyncio.subprocess.PIPE,
72
+ env={**os.environ, **request.environment},
73
+ )
74
+
75
+ stdout, stderr = await asyncio.wait_for(
76
+ process.communicate(input=request.prompt.encode("utf-8")),
77
+ timeout=request.timeout_seconds,
78
+ )
79
+
80
+ return ExecutionResult(
81
+ exit_code=process.returncode,
82
+ stdout=stdout.decode("utf-8", errors="replace"),
83
+ stderr=stderr.decode("utf-8", errors="replace"),
84
+ duration_seconds=0.0,
85
+ )
86
+
87
+ except TimeoutError:
88
+ process.kill()
89
+ raise ExecutorTimeoutError(
90
+ f"Amp execution timed out after {request.timeout_seconds}s"
91
+ ) from None
92
+ except Exception as e:
93
+ raise ExecutorInvocationError(f"Amp execution failed: {e!s}") from e
94
+
95
+ async def stream_output(self, request: ExecutionRequest) -> AsyncIterator[str]:
96
+ """Stream output in real-time."""
97
+ if not await self.is_available():
98
+ raise ExecutorNotFoundError("Amp CLI not found")
99
+
100
+ cmd = ["amp", "--print"]
101
+ if request.yolo_mode:
102
+ cmd.append("--yolo")
103
+
104
+ process = await asyncio.create_subprocess_exec(
105
+ *cmd,
106
+ cwd=request.working_directory,
107
+ stdin=asyncio.subprocess.PIPE,
108
+ stdout=asyncio.subprocess.PIPE,
109
+ stderr=asyncio.subprocess.STDOUT,
110
+ env={**os.environ, **request.environment},
111
+ )
112
+
113
+ process.stdin.write(request.prompt.encode("utf-8"))
114
+ await process.stdin.drain()
115
+ process.stdin.close()
116
+
117
+ while True:
118
+ line = await process.stdout.readline()
119
+ if not line:
120
+ break
121
+ yield line.decode("utf-8", errors="replace")
122
+
123
+ await process.wait()