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,123 @@
1
+ """Qwen 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("qwen")
22
+ class QwenAdapter(ExecutorAdapter):
23
+ """Qwen Code CLI adapter for automated code changes."""
24
+
25
+ def get_metadata(self) -> ExecutorMetadata:
26
+ return ExecutorMetadata(
27
+ name="qwen",
28
+ display_name="Qwen Code CLI",
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": "qwen-coder",
40
+ "description": "Qwen model to use",
41
+ }
42
+ },
43
+ },
44
+ documentation_url="https://github.com/QwenLM/qwen-agent",
45
+ author="Alibaba Cloud",
46
+ license="Apache-2.0",
47
+ )
48
+
49
+ async def is_available(self) -> bool:
50
+ """Check if qwen CLI is installed."""
51
+ return shutil.which("qwen") is not None
52
+
53
+ async def execute(self, request: ExecutionRequest) -> ExecutionResult:
54
+ """Execute using Qwen CLI."""
55
+ if not await self.is_available():
56
+ raise ExecutorNotFoundError(
57
+ "Qwen CLI not found. Install from https://github.com/QwenLM/qwen-agent"
58
+ )
59
+
60
+ cmd = ["qwen", "--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"Qwen execution timed out after {request.timeout_seconds}s"
91
+ ) from None
92
+ except Exception as e:
93
+ raise ExecutorInvocationError(f"Qwen 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("Qwen CLI not found")
99
+
100
+ cmd = ["qwen", "--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()
@@ -0,0 +1 @@
1
+ """Community/third-party executor plugins."""
@@ -0,0 +1,202 @@
1
+ """Executor plugin registry with dynamic loading."""
2
+
3
+ import importlib
4
+ import logging
5
+ import pkgutil
6
+ from pathlib import Path
7
+
8
+ from app.executors.spec import ExecutorAdapter, ExecutorMetadata, ExecutorNotFoundError
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class ExecutorRegistry:
14
+ """Registry for executor plugins with dynamic loading."""
15
+
16
+ _adapters: dict[str, type[ExecutorAdapter]] = {}
17
+ _instances: dict[str, ExecutorAdapter] = {}
18
+ _loaded: bool = False
19
+
20
+ @classmethod
21
+ def register(cls, name: str):
22
+ """Decorator to register an executor adapter.
23
+
24
+ Usage:
25
+ @ExecutorRegistry.register("claude")
26
+ class ClaudeAdapter(ExecutorAdapter):
27
+ ...
28
+ """
29
+
30
+ def decorator(adapter_class: type[ExecutorAdapter]):
31
+ if name in cls._adapters:
32
+ logger.warning(f"Overwriting existing executor adapter: {name}")
33
+ cls._adapters[name] = adapter_class
34
+ logger.info(f"Registered executor adapter: {name}")
35
+ return adapter_class
36
+
37
+ return decorator
38
+
39
+ @classmethod
40
+ def get(cls, name: str) -> ExecutorAdapter:
41
+ """Get an executor adapter instance by name.
42
+
43
+ Args:
44
+ name: Executor name (e.g., "claude", "cursor")
45
+
46
+ Returns:
47
+ ExecutorAdapter instance
48
+
49
+ Raises:
50
+ ExecutorNotFoundError: If executor not registered
51
+ """
52
+ # Ensure plugins are loaded
53
+ if not cls._loaded:
54
+ cls.load_all_plugins()
55
+
56
+ if name not in cls._adapters:
57
+ raise ExecutorNotFoundError(
58
+ f"Unknown executor: {name}. Available: {list(cls._adapters.keys())}"
59
+ )
60
+
61
+ # Return cached instance or create new
62
+ if name not in cls._instances:
63
+ cls._instances[name] = cls._adapters[name]()
64
+
65
+ return cls._instances[name]
66
+
67
+ @classmethod
68
+ async def get_available(cls) -> list[ExecutorMetadata]:
69
+ """List all available executors (installed and accessible).
70
+
71
+ Returns:
72
+ List of ExecutorMetadata for available executors
73
+ """
74
+ if not cls._loaded:
75
+ cls.load_all_plugins()
76
+
77
+ available = []
78
+ for name, _adapter_class in cls._adapters.items():
79
+ try:
80
+ adapter = cls.get(name)
81
+ if await adapter.is_available():
82
+ available.append(adapter.get_metadata())
83
+ except Exception as e:
84
+ logger.warning(f"Failed to check availability of {name}: {e}")
85
+
86
+ return available
87
+
88
+ @classmethod
89
+ def list_all(cls) -> list[ExecutorMetadata]:
90
+ """List all registered executors (may not be installed).
91
+
92
+ Returns:
93
+ List of ExecutorMetadata for all registered executors
94
+ """
95
+ if not cls._loaded:
96
+ cls.load_all_plugins()
97
+
98
+ all_executors = []
99
+ for name, _adapter_class in cls._adapters.items():
100
+ try:
101
+ adapter = cls.get(name)
102
+ all_executors.append(adapter.get_metadata())
103
+ except Exception as e:
104
+ logger.warning(f"Failed to get metadata for {name}: {e}")
105
+
106
+ return all_executors
107
+
108
+ @classmethod
109
+ def load_all_plugins(cls):
110
+ """Load all executor plugins from adapters and plugins directories."""
111
+ if cls._loaded:
112
+ return
113
+
114
+ logger.info("Loading executor plugins...")
115
+
116
+ # Load built-in adapters
117
+ cls._load_plugins_from_package("app.executors.adapters")
118
+
119
+ # Load community plugins
120
+ cls._load_plugins_from_package("app.executors.plugins")
121
+
122
+ cls._loaded = True
123
+ logger.info(f"Loaded {len(cls._adapters)} executor plugins")
124
+
125
+ @classmethod
126
+ def _load_plugins_from_package(cls, package_name: str):
127
+ """Load all Python modules from a package.
128
+
129
+ Args:
130
+ package_name: Package name (e.g., "app.executors.adapters")
131
+ """
132
+ try:
133
+ package = importlib.import_module(package_name)
134
+ package_path = Path(package.__file__).parent
135
+
136
+ for _finder, name, _ispkg in pkgutil.iter_modules([str(package_path)]):
137
+ if name.startswith("_"):
138
+ continue
139
+
140
+ module_name = f"{package_name}.{name}"
141
+ try:
142
+ importlib.import_module(module_name)
143
+ logger.debug(f"Loaded plugin module: {module_name}")
144
+ except Exception as e:
145
+ logger.error(f"Failed to load plugin {module_name}: {e}")
146
+
147
+ except ImportError as e:
148
+ logger.warning(f"Package {package_name} not found: {e}")
149
+
150
+ @classmethod
151
+ def reload_plugins(cls):
152
+ """Reload all plugins (useful for development)."""
153
+ cls._adapters.clear()
154
+ cls._instances.clear()
155
+ cls._loaded = False
156
+ cls.load_all_plugins()
157
+
158
+ @classmethod
159
+ def unregister(cls, name: str):
160
+ """Unregister an executor adapter.
161
+
162
+ Args:
163
+ name: Executor name to unregister
164
+ """
165
+ if name in cls._adapters:
166
+ del cls._adapters[name]
167
+ if name in cls._instances:
168
+ del cls._instances[name]
169
+ logger.info(f"Unregistered executor adapter: {name}")
170
+
171
+
172
+ # Convenience functions
173
+
174
+
175
+ def get_executor(name: str) -> ExecutorAdapter:
176
+ """Get an executor adapter by name.
177
+
178
+ Args:
179
+ name: Executor name
180
+
181
+ Returns:
182
+ ExecutorAdapter instance
183
+ """
184
+ return ExecutorRegistry.get(name)
185
+
186
+
187
+ async def list_available_executors() -> list[ExecutorMetadata]:
188
+ """List all available executors.
189
+
190
+ Returns:
191
+ List of ExecutorMetadata
192
+ """
193
+ return await ExecutorRegistry.get_available()
194
+
195
+
196
+ def list_all_executors() -> list[ExecutorMetadata]:
197
+ """List all registered executors.
198
+
199
+ Returns:
200
+ List of ExecutorMetadata
201
+ """
202
+ return ExecutorRegistry.list_all()
@@ -0,0 +1,226 @@
1
+ """
2
+ Draft Executor Adapter Specification v1.0
3
+
4
+ This defines the interface for adding new AI coding agents to Draft.
5
+ Implement this interface to create a new executor plugin.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from collections.abc import AsyncIterator
10
+ from dataclasses import dataclass, field
11
+ from enum import StrEnum
12
+
13
+
14
+ class ExecutorCapability(StrEnum):
15
+ """Capabilities an executor may support."""
16
+
17
+ STREAMING_OUTPUT = "streaming_output" # Real-time stdout
18
+ SESSION_RESUME = "session_resume" # Continue previous sessions
19
+ YOLO_MODE = "yolo_mode" # Auto-approve all actions
20
+ MCP_SERVERS = "mcp_servers" # Model Context Protocol
21
+ COST_TRACKING = "cost_tracking" # Token/cost reporting
22
+ INTERACTIVE = "interactive" # Requires human interaction
23
+
24
+
25
+ @dataclass
26
+ class ExecutorMetadata:
27
+ """Metadata about an executor."""
28
+
29
+ name: str # e.g., "claude", "codex"
30
+ display_name: str # e.g., "Claude Code", "OpenAI Codex"
31
+ version: str # Executor adapter version
32
+ agent_version: str | None = None # Underlying agent version if detectable
33
+ capabilities: list[ExecutorCapability] = field(default_factory=list)
34
+ config_schema: dict = field(default_factory=dict) # JSON Schema for configuration
35
+ documentation_url: str | None = None
36
+ author: str | None = None # Plugin author
37
+ license: str | None = None # Plugin license
38
+
39
+
40
+ @dataclass
41
+ class ExecutionRequest:
42
+ """Request to execute a task."""
43
+
44
+ prompt: str
45
+ working_directory: str
46
+ timeout_seconds: int = 600
47
+ yolo_mode: bool = False
48
+ session_id: str | None = None # For session resume
49
+ environment: dict[str, str] = field(default_factory=dict) # Additional env vars
50
+ mcp_servers: list[dict] = field(default_factory=list) # MCP server configs
51
+ config: dict[str, any] = field(default_factory=dict) # Executor-specific config
52
+
53
+
54
+ @dataclass
55
+ class ExecutionResult:
56
+ """Result of an execution."""
57
+
58
+ exit_code: int
59
+ stdout: str
60
+ stderr: str
61
+ session_id: str | None = None # For future resume
62
+ files_changed: list[str] = field(default_factory=list)
63
+ cost_usd: float | None = None
64
+ tokens_used: dict[str, int] | None = None # {"input": X, "output": Y}
65
+ duration_seconds: float = 0.0
66
+ metadata: dict[str, any] = field(default_factory=dict) # Executor-specific data
67
+
68
+
69
+ class ExecutorAdapter(ABC):
70
+ """
71
+ Abstract base class for executor adapters.
72
+
73
+ Implement this to add a new AI coding agent to Draft.
74
+ """
75
+
76
+ @abstractmethod
77
+ def get_metadata(self) -> ExecutorMetadata:
78
+ """Return metadata about this executor.
79
+
80
+ Returns:
81
+ ExecutorMetadata with name, capabilities, etc.
82
+ """
83
+ pass
84
+
85
+ @abstractmethod
86
+ async def is_available(self) -> bool:
87
+ """Check if the underlying agent is installed and accessible.
88
+
89
+ Returns:
90
+ True if executor can be used, False otherwise
91
+ """
92
+ pass
93
+
94
+ @abstractmethod
95
+ async def execute(self, request: ExecutionRequest) -> ExecutionResult:
96
+ """Execute a task and return the result.
97
+
98
+ Args:
99
+ request: ExecutionRequest with prompt, working directory, etc.
100
+
101
+ Returns:
102
+ ExecutionResult with exit code, stdout, stderr, etc.
103
+
104
+ Raises:
105
+ ExecutorError: If execution fails
106
+ """
107
+ pass
108
+
109
+ async def stream_output(self, request: ExecutionRequest) -> AsyncIterator[str]:
110
+ """
111
+ Stream output in real-time. Optional - implement if your agent supports it.
112
+
113
+ Default implementation runs execute() and yields all output at once.
114
+
115
+ Args:
116
+ request: ExecutionRequest
117
+
118
+ Yields:
119
+ str: Output lines as they're produced
120
+ """
121
+ result = await self.execute(request)
122
+ yield result.stdout
123
+ if result.stderr:
124
+ yield result.stderr
125
+
126
+ def get_mcp_config_path(self) -> str | None:
127
+ """Return the path to this agent's MCP config file, if applicable.
128
+
129
+ Returns:
130
+ Path to MCP config or None
131
+ """
132
+ return None
133
+
134
+ def supports_capability(self, capability: ExecutorCapability) -> bool:
135
+ """Check if this executor supports a capability.
136
+
137
+ Args:
138
+ capability: ExecutorCapability to check
139
+
140
+ Returns:
141
+ True if supported
142
+ """
143
+ metadata = self.get_metadata()
144
+ return capability in metadata.capabilities
145
+
146
+ async def check_availability(self) -> dict[str, any]:
147
+ """Return detailed availability diagnostics.
148
+
149
+ Returns:
150
+ Dict with keys:
151
+ - available (bool): Whether executor is ready
152
+ - cli_found (bool): Whether CLI binary was found
153
+ - version (str|None): Detected CLI version
154
+ - issues (list[str]): Problems preventing use
155
+ - setup_instructions (str): How to install/configure
156
+
157
+ Default implementation delegates to is_available().
158
+ Override for richer diagnostics.
159
+ """
160
+ available = await self.is_available()
161
+ metadata = self.get_metadata()
162
+ return {
163
+ "available": available,
164
+ "cli_found": available,
165
+ "version": metadata.agent_version,
166
+ "issues": [] if available else ["CLI not found in PATH"],
167
+ "setup_instructions": self.get_setup_instructions(),
168
+ }
169
+
170
+ def get_setup_instructions(self) -> str:
171
+ """Return human-readable setup instructions for this executor.
172
+
173
+ Returns:
174
+ Markdown-formatted setup guide.
175
+ """
176
+ metadata = self.get_metadata()
177
+ url = metadata.documentation_url or ""
178
+ return (
179
+ f"Install {metadata.display_name}. See {url}"
180
+ if url
181
+ else f"Install {metadata.display_name}."
182
+ )
183
+
184
+ async def validate_config(self, config: dict) -> bool:
185
+ """Validate executor-specific configuration.
186
+
187
+ Args:
188
+ config: Configuration dict to validate
189
+
190
+ Returns:
191
+ True if valid
192
+
193
+ Raises:
194
+ ValueError: If config is invalid
195
+ """
196
+ # Default: no validation
197
+ return True
198
+
199
+
200
+ class ExecutorError(Exception):
201
+ """Base exception for executor errors."""
202
+
203
+ pass
204
+
205
+
206
+ class ExecutorNotFoundError(ExecutorError):
207
+ """Raised when executor CLI is not found."""
208
+
209
+ pass
210
+
211
+
212
+ class ExecutorInvocationError(ExecutorError):
213
+ """Raised when executor CLI invocation fails."""
214
+
215
+ def __init__(
216
+ self, message: str, exit_code: int | None = None, stderr: str | None = None
217
+ ):
218
+ super().__init__(message)
219
+ self.exit_code = exit_code
220
+ self.stderr = stderr
221
+
222
+
223
+ class ExecutorTimeoutError(ExecutorError):
224
+ """Raised when executor execution times out."""
225
+
226
+ pass