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,213 @@
1
+ """Worktree validation service - enforces safety checks for execution.
2
+
3
+ This module provides hard validation that execution only happens in
4
+ properly isolated worktrees, not the main repository.
5
+ """
6
+
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from enum import StrEnum
10
+ from pathlib import Path
11
+
12
+ from app.data_dir import LEGACY_WORKTREES_DIR, get_worktrees_root
13
+
14
+
15
+ class WorktreeValidationError(StrEnum):
16
+ """Types of worktree validation failures."""
17
+
18
+ NOT_IN_DRAFT_DIR = "not_in_draft_dir"
19
+ ON_PROTECTED_BRANCH = "on_protected_branch"
20
+ IS_MAIN_REPO = "is_main_repo"
21
+ NOT_A_GIT_REPO = "not_a_git_repo"
22
+ PATH_MISMATCH = "path_mismatch"
23
+
24
+
25
+ @dataclass
26
+ class WorktreeValidationResult:
27
+ """Result of worktree validation."""
28
+
29
+ valid: bool
30
+ error: WorktreeValidationError | None = None
31
+ message: str | None = None
32
+ worktree_path: str | None = None
33
+ branch: str | None = None
34
+ main_repo_path: str | None = None
35
+
36
+ @classmethod
37
+ def success(cls, worktree_path: str, branch: str) -> "WorktreeValidationResult":
38
+ """Create a successful validation result."""
39
+ return cls(valid=True, worktree_path=worktree_path, branch=branch)
40
+
41
+ @classmethod
42
+ def failure(
43
+ cls,
44
+ error: WorktreeValidationError,
45
+ message: str,
46
+ worktree_path: str | None = None,
47
+ branch: str | None = None,
48
+ main_repo_path: str | None = None,
49
+ ) -> "WorktreeValidationResult":
50
+ """Create a failed validation result."""
51
+ return cls(
52
+ valid=False,
53
+ error=error,
54
+ message=message,
55
+ worktree_path=worktree_path,
56
+ branch=branch,
57
+ main_repo_path=main_repo_path,
58
+ )
59
+
60
+
61
+ class WorktreeValidator:
62
+ """Validates that a path is a safe, isolated worktree for execution.
63
+
64
+ Safety Checks:
65
+ 1. Path must be under .draft/worktrees/
66
+ 2. Branch must NOT be main/master/develop (protected branches)
67
+ 3. git rev-parse --show-toplevel must match the worktree path
68
+ 4. Worktree path must be different from the main repo path
69
+
70
+ These checks prevent accidental execution in the main repository.
71
+ """
72
+
73
+ # Protected branches that should never be modified by automated execution
74
+ PROTECTED_BRANCHES = {"main", "master", "develop", "production", "staging"}
75
+
76
+ # Required path component for worktrees (central dir or legacy)
77
+ WORKTREE_PATH_MARKER = str(get_worktrees_root())
78
+ LEGACY_WORKTREE_PATH_MARKER = LEGACY_WORKTREES_DIR
79
+
80
+ def __init__(self, main_repo_path: Path | str):
81
+ """
82
+ Initialize the validator.
83
+
84
+ Args:
85
+ main_repo_path: Path to the main repository (not a worktree).
86
+ """
87
+ self.main_repo_path = Path(main_repo_path).resolve()
88
+
89
+ def validate(self, worktree_path: Path | str) -> WorktreeValidationResult:
90
+ """
91
+ Validate that a path is a safe worktree for execution.
92
+
93
+ Args:
94
+ worktree_path: Path to validate as a worktree.
95
+
96
+ Returns:
97
+ WorktreeValidationResult with validation status and details.
98
+ """
99
+ worktree = Path(worktree_path).resolve()
100
+ worktree_str = str(worktree)
101
+
102
+ # Check 1: Path must be under central data dir or legacy .draft/worktrees/
103
+ in_central = worktree_str.startswith(str(get_worktrees_root()))
104
+ in_legacy = self.LEGACY_WORKTREE_PATH_MARKER in worktree_str
105
+ if not in_central and not in_legacy:
106
+ return WorktreeValidationResult.failure(
107
+ error=WorktreeValidationError.NOT_IN_DRAFT_DIR,
108
+ message=(
109
+ f"Worktree path must be under {self.WORKTREE_PATH_MARKER}/ "
110
+ f"or legacy {self.LEGACY_WORKTREE_PATH_MARKER}/. "
111
+ f"Got: {worktree_str}"
112
+ ),
113
+ worktree_path=worktree_str,
114
+ )
115
+
116
+ # Check 2: Must be a git repository
117
+ try:
118
+ result = subprocess.run(
119
+ ["git", "rev-parse", "--show-toplevel"],
120
+ cwd=worktree,
121
+ capture_output=True,
122
+ text=True,
123
+ timeout=10,
124
+ )
125
+ if result.returncode != 0:
126
+ return WorktreeValidationResult.failure(
127
+ error=WorktreeValidationError.NOT_A_GIT_REPO,
128
+ message=f"Not a git repository: {worktree_str}",
129
+ worktree_path=worktree_str,
130
+ )
131
+ git_toplevel = Path(result.stdout.strip()).resolve()
132
+ except subprocess.TimeoutExpired:
133
+ return WorktreeValidationResult.failure(
134
+ error=WorktreeValidationError.NOT_A_GIT_REPO,
135
+ message="git rev-parse timed out",
136
+ worktree_path=worktree_str,
137
+ )
138
+ except Exception as e:
139
+ return WorktreeValidationResult.failure(
140
+ error=WorktreeValidationError.NOT_A_GIT_REPO,
141
+ message=f"Failed to check git status: {e}",
142
+ worktree_path=worktree_str,
143
+ )
144
+
145
+ # Check 3: git toplevel must match worktree path (or be under it)
146
+ # This catches cases where someone symlinks or mounts a worktree elsewhere
147
+ if git_toplevel != worktree and not str(git_toplevel).startswith(str(worktree)):
148
+ return WorktreeValidationResult.failure(
149
+ error=WorktreeValidationError.PATH_MISMATCH,
150
+ message=(
151
+ f"Git toplevel doesn't match worktree path. "
152
+ f"Toplevel: {git_toplevel}, Worktree: {worktree}"
153
+ ),
154
+ worktree_path=worktree_str,
155
+ )
156
+
157
+ # Check 4: Worktree must NOT be the main repo
158
+ if git_toplevel == self.main_repo_path:
159
+ return WorktreeValidationResult.failure(
160
+ error=WorktreeValidationError.IS_MAIN_REPO,
161
+ message=(
162
+ f"Cannot execute in main repository. "
163
+ f"Use a worktree under {self.WORKTREE_PATH_MARKER}/. "
164
+ f"Main repo: {self.main_repo_path}"
165
+ ),
166
+ worktree_path=worktree_str,
167
+ main_repo_path=str(self.main_repo_path),
168
+ )
169
+
170
+ # Check 5: Get current branch and verify it's not protected
171
+ try:
172
+ branch_result = subprocess.run(
173
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
174
+ cwd=worktree,
175
+ capture_output=True,
176
+ text=True,
177
+ timeout=10,
178
+ )
179
+ if branch_result.returncode != 0:
180
+ branch = "unknown"
181
+ else:
182
+ branch = branch_result.stdout.strip()
183
+ except Exception:
184
+ branch = "unknown"
185
+
186
+ if branch.lower() in self.PROTECTED_BRANCHES:
187
+ return WorktreeValidationResult.failure(
188
+ error=WorktreeValidationError.ON_PROTECTED_BRANCH,
189
+ message=(
190
+ f"Cannot execute on protected branch '{branch}'. "
191
+ f"Protected branches: {', '.join(sorted(self.PROTECTED_BRANCHES))}"
192
+ ),
193
+ worktree_path=worktree_str,
194
+ branch=branch,
195
+ )
196
+
197
+ # All checks passed
198
+ return WorktreeValidationResult.success(
199
+ worktree_path=worktree_str,
200
+ branch=branch,
201
+ )
202
+
203
+ def is_safe_for_execution(self, worktree_path: Path | str) -> bool:
204
+ """
205
+ Quick check if a path is safe for execution.
206
+
207
+ Args:
208
+ worktree_path: Path to check.
209
+
210
+ Returns:
211
+ True if the path passes all safety checks.
212
+ """
213
+ return self.validate(worktree_path).valid
@@ -0,0 +1,278 @@
1
+ """Low-level SQLite operations for middleware (sync, used via asyncio.to_thread).
2
+
3
+ These functions use raw SQLite connections (not SQLAlchemy sessions) for
4
+ atomicity and to avoid interfering with the async session lifecycle.
5
+ The middleware runs outside of route handlers, so it cannot use get_db().
6
+ """
7
+
8
+ import os
9
+ import sqlite3
10
+ import time
11
+ from pathlib import Path
12
+
13
+ _BACKEND_DIR = Path(__file__).parent.parent.resolve()
14
+ _DB_PATH = os.getenv("SQLITE_BACKEND_DB", str(_BACKEND_DIR / "kanban.db"))
15
+
16
+ # Parse async DATABASE_URL to extract path if set
17
+ _DATABASE_URL = os.getenv("DATABASE_URL", "")
18
+ if _DATABASE_URL:
19
+ # Handle both sqlite:///path and sqlite+aiosqlite:///path
20
+ for prefix in ("sqlite+aiosqlite:///", "sqlite:///"):
21
+ if _DATABASE_URL.startswith(prefix):
22
+ _extracted = _DATABASE_URL[len(prefix) :]
23
+ if _extracted:
24
+ _DB_PATH = _extracted
25
+ break
26
+
27
+
28
+ def _get_conn() -> sqlite3.Connection:
29
+ """Get a SQLite connection with WAL mode."""
30
+ conn = sqlite3.connect(_DB_PATH, timeout=30)
31
+ conn.execute("PRAGMA journal_mode=WAL")
32
+ conn.execute("PRAGMA busy_timeout=30000")
33
+ return conn
34
+
35
+
36
+ # ─── Idempotency operations ───
37
+
38
+
39
+ def idempotency_try_acquire(cache_key: str, lock_value: str, ttl_seconds: int) -> bool:
40
+ """Try to acquire an idempotency lock. Returns True if acquired."""
41
+ now = time.time()
42
+ now + ttl_seconds
43
+ conn = _get_conn()
44
+ try:
45
+ # Clean expired locks first
46
+ conn.execute(
47
+ "DELETE FROM idempotency_cache WHERE lock_expires_at IS NOT NULL "
48
+ "AND lock_expires_at < datetime('now')"
49
+ )
50
+ # Try atomic insert
51
+ cursor = conn.execute(
52
+ "INSERT OR IGNORE INTO idempotency_cache "
53
+ "(cache_key, lock_value, lock_expires_at, created_at) "
54
+ "VALUES (?, ?, datetime('now', ?), datetime('now'))",
55
+ (cache_key, lock_value, f"+{ttl_seconds} seconds"),
56
+ )
57
+ conn.commit()
58
+ return cursor.rowcount == 1
59
+ finally:
60
+ conn.close()
61
+
62
+
63
+ def idempotency_get_lock(cache_key: str) -> str | None:
64
+ """Get the lock value for a key (None if not locked or expired)."""
65
+ conn = _get_conn()
66
+ try:
67
+ row = conn.execute(
68
+ "SELECT lock_value FROM idempotency_cache "
69
+ "WHERE cache_key = ? AND (lock_expires_at IS NULL OR lock_expires_at >= datetime('now'))",
70
+ (cache_key,),
71
+ ).fetchone()
72
+ return row[0] if row else None
73
+ finally:
74
+ conn.close()
75
+
76
+
77
+ def idempotency_store_result(
78
+ cache_key: str, result_value: str, ttl_seconds: int
79
+ ) -> None:
80
+ """Store the result and clear the lock."""
81
+ conn = _get_conn()
82
+ try:
83
+ conn.execute(
84
+ "UPDATE idempotency_cache SET result_value = ?, "
85
+ "result_expires_at = datetime('now', ?), "
86
+ "lock_value = NULL, lock_expires_at = NULL "
87
+ "WHERE cache_key = ?",
88
+ (result_value, f"+{ttl_seconds} seconds", cache_key),
89
+ )
90
+ conn.commit()
91
+ finally:
92
+ conn.close()
93
+
94
+
95
+ def idempotency_get_result(cache_key: str) -> str | None:
96
+ """Get cached result (None if not found or expired)."""
97
+ conn = _get_conn()
98
+ try:
99
+ row = conn.execute(
100
+ "SELECT result_value FROM idempotency_cache "
101
+ "WHERE cache_key = ? AND result_value IS NOT NULL "
102
+ "AND (result_expires_at IS NULL OR result_expires_at >= datetime('now'))",
103
+ (cache_key,),
104
+ ).fetchone()
105
+ return row[0] if row else None
106
+ finally:
107
+ conn.close()
108
+
109
+
110
+ def idempotency_release_lock(cache_key: str) -> None:
111
+ """Release lock (delete entry if no result stored)."""
112
+ conn = _get_conn()
113
+ try:
114
+ conn.execute(
115
+ "DELETE FROM idempotency_cache WHERE cache_key = ? AND result_value IS NULL",
116
+ (cache_key,),
117
+ )
118
+ conn.commit()
119
+ finally:
120
+ conn.close()
121
+
122
+
123
+ # ─── Rate limit operations ───
124
+
125
+
126
+ def rate_limit_check_and_record(
127
+ client_key: str, cost: int, window_seconds: int
128
+ ) -> tuple[int, float]:
129
+ """Check current usage and record new cost entry.
130
+
131
+ Returns (current_cost_before_recording, oldest_entry_time).
132
+ """
133
+ now = time.time()
134
+ window_start = now - window_seconds
135
+ expires_at = now + window_seconds
136
+
137
+ conn = _get_conn()
138
+ try:
139
+ # Cleanup expired
140
+ conn.execute("DELETE FROM rate_limit_entries WHERE expires_at < ?", (now,))
141
+
142
+ # Sum current cost in window (also filter by expires_at for consistency)
143
+ row = conn.execute(
144
+ "SELECT COALESCE(SUM(cost), 0) FROM rate_limit_entries "
145
+ "WHERE client_key = ? AND recorded_at > ? AND expires_at >= ?",
146
+ (client_key, window_start, now),
147
+ ).fetchone()
148
+ current_cost = row[0] if row else 0
149
+
150
+ # Get oldest entry time for retry-after calculation
151
+ oldest_row = conn.execute(
152
+ "SELECT MIN(recorded_at) FROM rate_limit_entries "
153
+ "WHERE client_key = ? AND recorded_at > ? AND expires_at >= ?",
154
+ (client_key, window_start, now),
155
+ ).fetchone()
156
+ oldest_time = oldest_row[0] if oldest_row and oldest_row[0] else now
157
+
158
+ # Record new entry
159
+ conn.execute(
160
+ "INSERT INTO rate_limit_entries (client_key, cost, recorded_at, expires_at) "
161
+ "VALUES (?, ?, ?, ?)",
162
+ (client_key, cost, now, expires_at),
163
+ )
164
+ conn.commit()
165
+
166
+ return current_cost, oldest_time
167
+ finally:
168
+ conn.close()
169
+
170
+
171
+ def rate_limit_check_only(client_key: str, window_seconds: int) -> tuple[int, float]:
172
+ """Check current usage without recording. Returns (current_cost, oldest_time)."""
173
+ now = time.time()
174
+ window_start = now - window_seconds
175
+
176
+ conn = _get_conn()
177
+ try:
178
+ # Cleanup expired
179
+ conn.execute("DELETE FROM rate_limit_entries WHERE expires_at < ?", (now,))
180
+
181
+ row = conn.execute(
182
+ "SELECT COALESCE(SUM(cost), 0) FROM rate_limit_entries "
183
+ "WHERE client_key = ? AND recorded_at > ? AND expires_at >= ?",
184
+ (client_key, window_start, now),
185
+ ).fetchone()
186
+ current_cost = row[0] if row else 0
187
+
188
+ oldest_row = conn.execute(
189
+ "SELECT MIN(recorded_at) FROM rate_limit_entries "
190
+ "WHERE client_key = ? AND recorded_at > ? AND expires_at >= ?",
191
+ (client_key, window_start, now),
192
+ ).fetchone()
193
+ oldest_time = oldest_row[0] if oldest_row and oldest_row[0] else now
194
+
195
+ conn.commit()
196
+ return current_cost, oldest_time
197
+ finally:
198
+ conn.close()
199
+
200
+
201
+ # ─── KV store operations (for queued messages) ───
202
+
203
+
204
+ def kv_set(key: str, value: str, ttl_seconds: int | None = None) -> None:
205
+ """Set a key-value pair with optional TTL."""
206
+ conn = _get_conn()
207
+ try:
208
+ if ttl_seconds:
209
+ conn.execute(
210
+ "INSERT OR REPLACE INTO kv_store (key, value, expires_at, created_at) "
211
+ "VALUES (?, ?, datetime('now', ?), datetime('now'))",
212
+ (key, value, f"+{ttl_seconds} seconds"),
213
+ )
214
+ else:
215
+ conn.execute(
216
+ "INSERT OR REPLACE INTO kv_store (key, value, created_at) "
217
+ "VALUES (?, ?, datetime('now'))",
218
+ (key, value),
219
+ )
220
+ conn.commit()
221
+ finally:
222
+ conn.close()
223
+
224
+
225
+ def kv_get(key: str) -> str | None:
226
+ """Get a value by key (None if not found or expired)."""
227
+ conn = _get_conn()
228
+ try:
229
+ row = conn.execute(
230
+ "SELECT value FROM kv_store WHERE key = ? "
231
+ "AND (expires_at IS NULL OR expires_at >= datetime('now'))",
232
+ (key,),
233
+ ).fetchone()
234
+ return row[0] if row else None
235
+ finally:
236
+ conn.close()
237
+
238
+
239
+ def kv_take(key: str) -> str | None:
240
+ """Get and delete a value atomically (None if not found or expired)."""
241
+ conn = _get_conn()
242
+ try:
243
+ # Atomic get-and-delete using DELETE...RETURNING (SQLite 3.35+)
244
+ row = conn.execute(
245
+ "DELETE FROM kv_store WHERE key = ? "
246
+ "AND (expires_at IS NULL OR expires_at >= datetime('now')) "
247
+ "RETURNING value",
248
+ (key,),
249
+ ).fetchone()
250
+ conn.commit()
251
+ return row[0] if row else None
252
+ finally:
253
+ conn.close()
254
+
255
+
256
+ def kv_delete(key: str) -> bool:
257
+ """Delete a key. Returns True if deleted."""
258
+ conn = _get_conn()
259
+ try:
260
+ cursor = conn.execute("DELETE FROM kv_store WHERE key = ?", (key,))
261
+ conn.commit()
262
+ return cursor.rowcount > 0
263
+ finally:
264
+ conn.close()
265
+
266
+
267
+ def kv_exists(key: str) -> bool:
268
+ """Check if a key exists (and is not expired)."""
269
+ conn = _get_conn()
270
+ try:
271
+ row = conn.execute(
272
+ "SELECT 1 FROM kv_store WHERE key = ? "
273
+ "AND (expires_at IS NULL OR expires_at >= datetime('now'))",
274
+ (key,),
275
+ ).fetchone()
276
+ return row is not None
277
+ finally:
278
+ conn.close()
@@ -0,0 +1,128 @@
1
+ """State machine implementation for ticket workflow.
2
+
3
+ This module contains the TicketState enum and state transition rules.
4
+ Event types and actor types are defined in models/enums.py but re-exported
5
+ here for backwards compatibility.
6
+ """
7
+
8
+ from enum import StrEnum
9
+
10
+ # Re-export event types and actor types for backwards compatibility
11
+ # The canonical definitions are in models/enums.py
12
+ from app.models.enums import ActorType, EventType
13
+
14
+ __all__ = [
15
+ "TicketState",
16
+ "ActorType",
17
+ "EventType",
18
+ "ALLOWED_TRANSITIONS",
19
+ "TERMINAL_STATES",
20
+ "validate_transition",
21
+ "get_allowed_transitions",
22
+ "is_terminal_state",
23
+ ]
24
+
25
+
26
+ class TicketState(StrEnum):
27
+ """Enum representing valid ticket states."""
28
+
29
+ PROPOSED = "proposed"
30
+ PLANNED = "planned"
31
+ EXECUTING = "executing"
32
+ VERIFYING = "verifying"
33
+ NEEDS_HUMAN = "needs_human"
34
+ BLOCKED = "blocked"
35
+ DONE = "done"
36
+ ABANDONED = "abandoned"
37
+
38
+
39
+ # Allowed state transitions map
40
+ # Key: current state, Value: list of valid next states
41
+ ALLOWED_TRANSITIONS: dict[TicketState, list[TicketState]] = {
42
+ TicketState.PROPOSED: [
43
+ TicketState.PLANNED,
44
+ TicketState.ABANDONED,
45
+ ],
46
+ TicketState.PLANNED: [
47
+ TicketState.PROPOSED,
48
+ TicketState.EXECUTING,
49
+ TicketState.BLOCKED,
50
+ TicketState.ABANDONED,
51
+ ],
52
+ TicketState.EXECUTING: [
53
+ TicketState.VERIFYING,
54
+ TicketState.NEEDS_HUMAN,
55
+ TicketState.BLOCKED,
56
+ ],
57
+ TicketState.VERIFYING: [
58
+ TicketState.EXECUTING, # Rework needed
59
+ TicketState.NEEDS_HUMAN, # Verification passed, awaiting human review
60
+ TicketState.BLOCKED, # Verification failed
61
+ TicketState.DONE, # Auto-approved (autonomy mode, skips NEEDS_HUMAN)
62
+ ],
63
+ TicketState.NEEDS_HUMAN: [
64
+ TicketState.EXECUTING, # Human resolved, back to executing
65
+ TicketState.PLANNED, # Human replanned
66
+ TicketState.DONE, # Human approved revision
67
+ TicketState.ABANDONED,
68
+ ],
69
+ TicketState.BLOCKED: [
70
+ TicketState.PLANNED, # Unblocked, back to planning
71
+ TicketState.EXECUTING, # Retry execution (e.g., after fixing blocker or retrying failed execution)
72
+ TicketState.ABANDONED,
73
+ ],
74
+ TicketState.DONE: [
75
+ TicketState.EXECUTING, # Human requested changes on revision
76
+ ],
77
+ TicketState.ABANDONED: [
78
+ TicketState.PLANNED, # Reactivate accidentally abandoned tickets
79
+ ],
80
+ }
81
+
82
+
83
+ def validate_transition(from_state: TicketState, to_state: TicketState) -> bool:
84
+ """
85
+ Validate if a state transition is allowed.
86
+
87
+ Args:
88
+ from_state: The current state of the ticket
89
+ to_state: The desired new state
90
+
91
+ Returns:
92
+ True if the transition is valid, False otherwise
93
+ """
94
+ if from_state not in ALLOWED_TRANSITIONS:
95
+ return False
96
+ return to_state in ALLOWED_TRANSITIONS[from_state]
97
+
98
+
99
+ def get_allowed_transitions(current_state: TicketState) -> list[TicketState]:
100
+ """
101
+ Get list of valid next states from the current state.
102
+
103
+ Args:
104
+ current_state: The current state of the ticket
105
+
106
+ Returns:
107
+ List of valid next states
108
+ """
109
+ return ALLOWED_TRANSITIONS.get(current_state, [])
110
+
111
+
112
+ # Terminal states for workspace cleanup and watchdog purposes.
113
+ # Note: DONE can transition back to EXECUTING if human requests changes on revision,
114
+ # but is still considered "terminal" for cleanup purposes (workspace recreated if needed).
115
+ TERMINAL_STATES: set[TicketState] = {TicketState.DONE, TicketState.ABANDONED}
116
+
117
+
118
+ def is_terminal_state(state: TicketState) -> bool:
119
+ """
120
+ Check if a state is a terminal state.
121
+
122
+ Args:
123
+ state: The ticket state to check
124
+
125
+ Returns:
126
+ True if the state is terminal (DONE or ABANDONED)
127
+ """
128
+ return state in TERMINAL_STATES
@@ -0,0 +1,5 @@
1
+ """Project templates for Draft boards."""
2
+
3
+ from .registry import TEMPLATES, get_template, list_templates
4
+
5
+ __all__ = ["TEMPLATES", "get_template", "list_templates"]