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,102 @@
1
+ """Add boards table and board_id to goals/tickets/jobs/workspaces.
2
+
3
+ Revision ID: 012
4
+ Revises: 011
5
+ Create Date: 2026-01-08
6
+
7
+ The Board is the primary permission boundary in Draft:
8
+ - All goals, tickets, jobs, workspaces belong to a board
9
+ - board_id is the authorization check for all operations
10
+ - repo_root is a property of the board (single repo per board)
11
+ """
12
+
13
+ import sqlalchemy as sa
14
+
15
+ from alembic import op
16
+
17
+ # revision identifiers, used by Alembic.
18
+ revision: str = "012"
19
+ down_revision: str | None = "011"
20
+ branch_labels: str | tuple[str, ...] | None = None
21
+ depends_on: str | tuple[str, ...] | None = None
22
+
23
+
24
+ def upgrade() -> None:
25
+ # Create boards table
26
+ op.create_table(
27
+ "boards",
28
+ sa.Column("id", sa.String(36), primary_key=True),
29
+ sa.Column("name", sa.String(255), nullable=False),
30
+ sa.Column("description", sa.Text(), nullable=True),
31
+ sa.Column("repo_root", sa.String(1024), nullable=False),
32
+ sa.Column("default_branch", sa.String(255), nullable=True),
33
+ sa.Column(
34
+ "created_at", sa.DateTime(timezone=True), server_default=sa.func.now()
35
+ ),
36
+ sa.Column(
37
+ "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()
38
+ ),
39
+ )
40
+
41
+ # Add board_id to goals (using batch mode for SQLite compatibility)
42
+ with op.batch_alter_table("goals") as batch_op:
43
+ batch_op.add_column(sa.Column("board_id", sa.String(36), nullable=True))
44
+ batch_op.create_index("ix_goals_board_id", ["board_id"])
45
+ batch_op.create_foreign_key(
46
+ "fk_goals_board_id", "boards", ["board_id"], ["id"], ondelete="CASCADE"
47
+ )
48
+
49
+ # Add board_id to tickets
50
+ with op.batch_alter_table("tickets") as batch_op:
51
+ batch_op.add_column(sa.Column("board_id", sa.String(36), nullable=True))
52
+ batch_op.create_index("ix_tickets_board_id", ["board_id"])
53
+ batch_op.create_foreign_key(
54
+ "fk_tickets_board_id", "boards", ["board_id"], ["id"], ondelete="CASCADE"
55
+ )
56
+
57
+ # Add board_id to jobs
58
+ with op.batch_alter_table("jobs") as batch_op:
59
+ batch_op.add_column(sa.Column("board_id", sa.String(36), nullable=True))
60
+ batch_op.create_index("ix_jobs_board_id", ["board_id"])
61
+ batch_op.create_foreign_key(
62
+ "fk_jobs_board_id", "boards", ["board_id"], ["id"], ondelete="CASCADE"
63
+ )
64
+
65
+ # Add board_id to workspaces
66
+ with op.batch_alter_table("workspaces") as batch_op:
67
+ batch_op.add_column(sa.Column("board_id", sa.String(36), nullable=True))
68
+ batch_op.create_index("ix_workspaces_board_id", ["board_id"])
69
+ batch_op.create_foreign_key(
70
+ "fk_workspaces_board_id", "boards", ["board_id"], ["id"], ondelete="CASCADE"
71
+ )
72
+
73
+
74
+ def downgrade() -> None:
75
+ # Drop foreign keys and columns in reverse order (using batch mode for SQLite)
76
+
77
+ # workspaces
78
+ with op.batch_alter_table("workspaces") as batch_op:
79
+ batch_op.drop_constraint("fk_workspaces_board_id", type_="foreignkey")
80
+ batch_op.drop_index("ix_workspaces_board_id")
81
+ batch_op.drop_column("board_id")
82
+
83
+ # jobs
84
+ with op.batch_alter_table("jobs") as batch_op:
85
+ batch_op.drop_constraint("fk_jobs_board_id", type_="foreignkey")
86
+ batch_op.drop_index("ix_jobs_board_id")
87
+ batch_op.drop_column("board_id")
88
+
89
+ # tickets
90
+ with op.batch_alter_table("tickets") as batch_op:
91
+ batch_op.drop_constraint("fk_tickets_board_id", type_="foreignkey")
92
+ batch_op.drop_index("ix_tickets_board_id")
93
+ batch_op.drop_column("board_id")
94
+
95
+ # goals
96
+ with op.batch_alter_table("goals") as batch_op:
97
+ batch_op.drop_constraint("fk_goals_board_id", type_="foreignkey")
98
+ batch_op.drop_index("ix_goals_board_id")
99
+ batch_op.drop_column("board_id")
100
+
101
+ # boards table
102
+ op.drop_table("boards")
@@ -0,0 +1,45 @@
1
+ """Add ticket blocking/dependency support.
2
+
3
+ Revision ID: 013
4
+ Revises: 012
5
+ Create Date: 2026-01-11
6
+
7
+ Adds blocked_by_ticket_id to tickets table to support ticket dependencies.
8
+ When a ticket is blocked by another ticket, it cannot be queued for execution
9
+ until the blocking ticket is completed.
10
+ """
11
+
12
+ import sqlalchemy as sa
13
+
14
+ from alembic import op
15
+
16
+ # revision identifiers, used by Alembic.
17
+ revision: str = "013"
18
+ down_revision: str | None = "012"
19
+ branch_labels: str | tuple[str, ...] | None = None
20
+ depends_on: str | tuple[str, ...] | None = None
21
+
22
+
23
+ def upgrade() -> None:
24
+ # Add blocked_by_ticket_id to tickets (self-referential FK)
25
+ with op.batch_alter_table("tickets") as batch_op:
26
+ batch_op.add_column(
27
+ sa.Column("blocked_by_ticket_id", sa.String(36), nullable=True)
28
+ )
29
+ batch_op.create_index(
30
+ "ix_tickets_blocked_by_ticket_id", ["blocked_by_ticket_id"]
31
+ )
32
+ batch_op.create_foreign_key(
33
+ "fk_tickets_blocked_by_ticket_id",
34
+ "tickets",
35
+ ["blocked_by_ticket_id"],
36
+ ["id"],
37
+ ondelete="SET NULL", # If blocker is deleted, unblock this ticket
38
+ )
39
+
40
+
41
+ def downgrade() -> None:
42
+ with op.batch_alter_table("tickets") as batch_op:
43
+ batch_op.drop_constraint("fk_tickets_blocked_by_ticket_id", type_="foreignkey")
44
+ batch_op.drop_index("ix_tickets_blocked_by_ticket_id")
45
+ batch_op.drop_column("blocked_by_ticket_id")
@@ -0,0 +1,220 @@
1
+ """Add agent sessions and messages tables for conversation continuity and cost tracking.
2
+
3
+ Revision ID: 014
4
+ Revises: 013
5
+ Create Date: 2026-01-12
6
+
7
+ """
8
+
9
+ from collections.abc import Sequence
10
+
11
+ import sqlalchemy as sa
12
+
13
+ from alembic import op
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = "014"
17
+ down_revision: str | None = "013"
18
+ branch_labels: str | Sequence[str] | None = None
19
+ depends_on: str | Sequence[str] | None = None
20
+
21
+
22
+ def table_exists(table_name: str) -> bool:
23
+ """Check if a table exists in SQLite."""
24
+ from alembic import op
25
+
26
+ conn = op.get_bind()
27
+ result = conn.execute(
28
+ sa.text(
29
+ f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}'"
30
+ )
31
+ )
32
+ return result.fetchone() is not None
33
+
34
+
35
+ def column_exists(table_name: str, column_name: str) -> bool:
36
+ """Check if a column exists in a SQLite table."""
37
+ from alembic import op
38
+
39
+ conn = op.get_bind()
40
+ result = conn.execute(sa.text(f"PRAGMA table_info({table_name})"))
41
+ columns = [row[1] for row in result.fetchall()]
42
+ return column_name in columns
43
+
44
+
45
+ def index_exists(index_name: str) -> bool:
46
+ """Check if an index exists in SQLite."""
47
+ from alembic import op
48
+
49
+ conn = op.get_bind()
50
+ result = conn.execute(
51
+ sa.text(
52
+ f"SELECT name FROM sqlite_master WHERE type='index' AND name='{index_name}'"
53
+ )
54
+ )
55
+ return result.fetchone() is not None
56
+
57
+
58
+ def upgrade() -> None:
59
+ # Create agent_sessions table if it doesn't exist
60
+ if not table_exists("agent_sessions"):
61
+ op.create_table(
62
+ "agent_sessions",
63
+ sa.Column("id", sa.String(36), primary_key=True),
64
+ sa.Column(
65
+ "ticket_id",
66
+ sa.String(36),
67
+ sa.ForeignKey("tickets.id", ondelete="CASCADE"),
68
+ nullable=False,
69
+ index=True,
70
+ ),
71
+ sa.Column(
72
+ "job_id",
73
+ sa.String(36),
74
+ sa.ForeignKey("jobs.id", ondelete="SET NULL"),
75
+ nullable=True,
76
+ index=True,
77
+ ),
78
+ # Agent identification
79
+ sa.Column("agent_type", sa.String(50), nullable=False),
80
+ sa.Column(
81
+ "agent_session_id", sa.String(255), nullable=True
82
+ ), # External session ID
83
+ # Session state
84
+ sa.Column("is_active", sa.Boolean, default=True, nullable=False),
85
+ sa.Column("turn_count", sa.Integer, default=0, nullable=False),
86
+ # Token tracking
87
+ sa.Column("total_input_tokens", sa.Integer, default=0, nullable=False),
88
+ sa.Column("total_output_tokens", sa.Integer, default=0, nullable=False),
89
+ sa.Column("estimated_cost_usd", sa.Float, default=0.0, nullable=False),
90
+ # Context
91
+ sa.Column("last_prompt", sa.Text, nullable=True),
92
+ sa.Column("last_response_summary", sa.Text, nullable=True),
93
+ sa.Column("metadata", sa.JSON, nullable=True),
94
+ # Timestamps
95
+ sa.Column(
96
+ "created_at", sa.DateTime, server_default=sa.func.now(), nullable=False
97
+ ),
98
+ sa.Column(
99
+ "updated_at",
100
+ sa.DateTime,
101
+ server_default=sa.func.now(),
102
+ onupdate=sa.func.now(),
103
+ nullable=False,
104
+ ),
105
+ sa.Column("ended_at", sa.DateTime, nullable=True),
106
+ )
107
+
108
+ # Create agent_messages table if it doesn't exist
109
+ if not table_exists("agent_messages"):
110
+ op.create_table(
111
+ "agent_messages",
112
+ sa.Column("id", sa.String(36), primary_key=True),
113
+ sa.Column(
114
+ "session_id",
115
+ sa.String(36),
116
+ sa.ForeignKey("agent_sessions.id", ondelete="CASCADE"),
117
+ nullable=False,
118
+ index=True,
119
+ ),
120
+ # Message content
121
+ sa.Column(
122
+ "role", sa.String(20), nullable=False
123
+ ), # user, assistant, system, tool
124
+ sa.Column("content", sa.Text, nullable=False),
125
+ # Token counts
126
+ sa.Column("input_tokens", sa.Integer, default=0, nullable=False),
127
+ sa.Column("output_tokens", sa.Integer, default=0, nullable=False),
128
+ # Tool tracking
129
+ sa.Column("tool_name", sa.String(100), nullable=True),
130
+ sa.Column("tool_input", sa.JSON, nullable=True),
131
+ sa.Column("tool_output", sa.Text, nullable=True),
132
+ # Metadata
133
+ sa.Column("metadata", sa.JSON, nullable=True),
134
+ sa.Column(
135
+ "created_at", sa.DateTime, server_default=sa.func.now(), nullable=False
136
+ ),
137
+ )
138
+
139
+ # Create cost_budgets table if it doesn't exist
140
+ if not table_exists("cost_budgets"):
141
+ op.create_table(
142
+ "cost_budgets",
143
+ sa.Column("id", sa.String(36), primary_key=True),
144
+ sa.Column(
145
+ "goal_id",
146
+ sa.String(36),
147
+ sa.ForeignKey("goals.id", ondelete="CASCADE"),
148
+ nullable=True,
149
+ index=True,
150
+ ),
151
+ # Budget limits
152
+ sa.Column("daily_budget", sa.Float, nullable=True),
153
+ sa.Column("weekly_budget", sa.Float, nullable=True),
154
+ sa.Column("monthly_budget", sa.Float, nullable=True),
155
+ sa.Column("total_budget", sa.Float, nullable=True),
156
+ # Alerts
157
+ sa.Column(
158
+ "warning_threshold", sa.Float, default=0.8, nullable=False
159
+ ), # 80%
160
+ sa.Column("pause_on_exceed", sa.Boolean, default=False, nullable=False),
161
+ # Timestamps
162
+ sa.Column(
163
+ "created_at", sa.DateTime, server_default=sa.func.now(), nullable=False
164
+ ),
165
+ sa.Column(
166
+ "updated_at",
167
+ sa.DateTime,
168
+ server_default=sa.func.now(),
169
+ onupdate=sa.func.now(),
170
+ nullable=False,
171
+ ),
172
+ )
173
+
174
+ # Add agent_type column to jobs table if it doesn't exist
175
+ if not column_exists("jobs", "agent_type"):
176
+ op.add_column("jobs", sa.Column("agent_type", sa.String(50), nullable=True))
177
+ if not column_exists("jobs", "agent_session_id"):
178
+ op.add_column(
179
+ "jobs", sa.Column("agent_session_id", sa.String(36), nullable=True)
180
+ )
181
+
182
+ # Add cost tracking to tickets if it doesn't exist
183
+ if not column_exists("tickets", "total_cost_usd"):
184
+ op.add_column(
185
+ "tickets", sa.Column("total_cost_usd", sa.Float, default=0.0, nullable=True)
186
+ )
187
+
188
+ # Create indexes for performance if they don't exist
189
+ if not index_exists("ix_agent_sessions_created_at"):
190
+ op.create_index(
191
+ "ix_agent_sessions_created_at", "agent_sessions", ["created_at"]
192
+ )
193
+ if not index_exists("ix_agent_sessions_agent_type"):
194
+ op.create_index(
195
+ "ix_agent_sessions_agent_type", "agent_sessions", ["agent_type"]
196
+ )
197
+
198
+
199
+ def downgrade() -> None:
200
+ # Drop indexes if they exist
201
+ if index_exists("ix_agent_sessions_agent_type"):
202
+ op.drop_index("ix_agent_sessions_agent_type", "agent_sessions")
203
+ if index_exists("ix_agent_sessions_created_at"):
204
+ op.drop_index("ix_agent_sessions_created_at", "agent_sessions")
205
+
206
+ # Drop columns from existing tables if they exist
207
+ if column_exists("tickets", "total_cost_usd"):
208
+ op.drop_column("tickets", "total_cost_usd")
209
+ if column_exists("jobs", "agent_session_id"):
210
+ op.drop_column("jobs", "agent_session_id")
211
+ if column_exists("jobs", "agent_type"):
212
+ op.drop_column("jobs", "agent_type")
213
+
214
+ # Drop new tables if they exist
215
+ if table_exists("cost_budgets"):
216
+ op.drop_table("cost_budgets")
217
+ if table_exists("agent_messages"):
218
+ op.drop_table("agent_messages")
219
+ if table_exists("agent_sessions"):
220
+ op.drop_table("agent_sessions")
@@ -0,0 +1,33 @@
1
+ """Add sort_order column to tickets for drag-and-drop ordering.
2
+
3
+ Revision ID: 015
4
+ Revises: 774dc335c679
5
+ Create Date: 2026-03-01
6
+
7
+ Adds a sort_order integer column to the tickets table so that users
8
+ can manually reorder tickets within a state column via drag-and-drop.
9
+ """
10
+
11
+ from collections.abc import Sequence
12
+
13
+ import sqlalchemy as sa
14
+
15
+ from alembic import op
16
+
17
+ # revision identifiers, used by Alembic.
18
+ revision: str = "015"
19
+ down_revision: str | None = "774dc335c679"
20
+ branch_labels: str | Sequence[str] | None = None
21
+ depends_on: str | Sequence[str] | None = None
22
+
23
+
24
+ def upgrade() -> None:
25
+ with op.batch_alter_table("tickets") as batch_op:
26
+ batch_op.add_column(sa.Column("sort_order", sa.Integer(), nullable=True))
27
+ batch_op.create_index("ix_tickets_sort_order", ["sort_order"])
28
+
29
+
30
+ def downgrade() -> None:
31
+ with op.batch_alter_table("tickets") as batch_op:
32
+ batch_op.drop_index("ix_tickets_sort_order")
33
+ batch_op.drop_column("sort_order")
@@ -0,0 +1,49 @@
1
+ """add_pr_fields_to_ticket
2
+
3
+ Revision ID: 03220f0b93ae
4
+ Revises: 8ef5054dc280
5
+ Create Date: 2026-01-12 11:20:58.959405
6
+
7
+ """
8
+
9
+ from collections.abc import Sequence
10
+
11
+ import sqlalchemy as sa
12
+
13
+ from alembic import op
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = "03220f0b93ae"
17
+ down_revision: str | None = "8ef5054dc280"
18
+ branch_labels: str | Sequence[str] | None = None
19
+ depends_on: str | Sequence[str] | None = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ # Add PR fields to tickets table
24
+ op.add_column("tickets", sa.Column("pr_number", sa.Integer(), nullable=True))
25
+ op.add_column("tickets", sa.Column("pr_url", sa.String(length=500), nullable=True))
26
+ op.add_column("tickets", sa.Column("pr_state", sa.String(length=20), nullable=True))
27
+ op.add_column("tickets", sa.Column("pr_created_at", sa.DateTime(), nullable=True))
28
+ op.add_column("tickets", sa.Column("pr_merged_at", sa.DateTime(), nullable=True))
29
+ op.add_column(
30
+ "tickets", sa.Column("pr_head_branch", sa.String(length=255), nullable=True)
31
+ )
32
+ op.add_column(
33
+ "tickets", sa.Column("pr_base_branch", sa.String(length=255), nullable=True)
34
+ )
35
+ op.create_index(
36
+ op.f("ix_tickets_pr_number"), "tickets", ["pr_number"], unique=False
37
+ )
38
+
39
+
40
+ def downgrade() -> None:
41
+ # Remove PR fields from tickets table
42
+ op.drop_index(op.f("ix_tickets_pr_number"), table_name="tickets")
43
+ op.drop_column("tickets", "pr_base_branch")
44
+ op.drop_column("tickets", "pr_head_branch")
45
+ op.drop_column("tickets", "pr_merged_at")
46
+ op.drop_column("tickets", "pr_created_at")
47
+ op.drop_column("tickets", "pr_state")
48
+ op.drop_column("tickets", "pr_url")
49
+ op.drop_column("tickets", "pr_number")
@@ -0,0 +1,206 @@
1
+ """seed_board_configs_from_yaml
2
+
3
+ Revision ID: 0c2d89fff3b1
4
+ Revises: 82ecd978cc70
5
+ Create Date: 2026-03-01 12:00:00.000000
6
+
7
+ Seed migration: populates Board.config for all existing boards.
8
+
9
+ For each board:
10
+ 1. Load draft.yaml from board.repo_root (if it exists)
11
+ 2. Deep-merge: DraftConfig defaults ← YAML values ← existing board.config overrides
12
+ 3. Write the full merged config back to Board.config
13
+
14
+ This ensures every board has a complete, self-contained config in the DB
15
+ so the system no longer needs to read from YAML at runtime.
16
+ """
17
+
18
+ import json
19
+ import logging
20
+ from pathlib import Path
21
+
22
+ import yaml
23
+ from sqlalchemy import text
24
+
25
+ from alembic import op
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # revision identifiers, used by Alembic.
30
+ revision: str = "0c2d89fff3b1"
31
+ down_revision: str | None = "82ecd978cc70"
32
+ branch_labels: str | None = None
33
+ depends_on: str | None = None
34
+
35
+ CONFIG_FILENAME = "draft.yaml"
36
+
37
+
38
+ def deep_merge_dicts(base: dict, override: dict) -> dict:
39
+ """Deep merge two dicts. override wins on conflicts."""
40
+ result = base.copy()
41
+ for key, value in override.items():
42
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
43
+ result[key] = deep_merge_dicts(result[key], value)
44
+ else:
45
+ result[key] = value
46
+ return result
47
+
48
+
49
+ def _load_yaml_config(repo_root: str) -> dict | None:
50
+ """Try to load draft.yaml from a repo root. Returns None on failure."""
51
+ config_path = Path(repo_root) / CONFIG_FILENAME
52
+ if not config_path.exists():
53
+ return None
54
+ try:
55
+ with open(config_path) as f:
56
+ data = yaml.safe_load(f)
57
+ if isinstance(data, dict):
58
+ return data
59
+ except Exception as e:
60
+ logger.warning("Failed to load %s: %s", config_path, e)
61
+ return None
62
+
63
+
64
+ def _get_defaults() -> dict:
65
+ """Build the full default config dict.
66
+
67
+ We inline the defaults here to make the migration self-contained and
68
+ deterministic — importing from app code could break if the dataclass
69
+ structure changes in a future version.
70
+ """
71
+ return {
72
+ "project": {
73
+ "name": "Draft Project",
74
+ "repo_root": ".",
75
+ },
76
+ "execute_config": {
77
+ "executor": "claude",
78
+ "executor_model": "sonnet",
79
+ "timeout": 600,
80
+ "max_retries": 2,
81
+ "use_yolo": False,
82
+ "yolo_allowlist": [],
83
+ },
84
+ "verify_config": {
85
+ "commands": [],
86
+ "timeout": 300,
87
+ "stop_on_first_failure": True,
88
+ },
89
+ "planner_config": {
90
+ "enabled": False,
91
+ "model": "anthropic/claude-sonnet-4-20250514",
92
+ "interval_seconds": 2,
93
+ "max_followups_per_ticket": 2,
94
+ "max_followups_per_tick": 3,
95
+ "reflection_enabled": True,
96
+ "auto_verify": True,
97
+ "max_parallel_jobs": 1,
98
+ "features": {
99
+ "auto_execute": False,
100
+ "auto_followup": True,
101
+ "auto_reflection": True,
102
+ "validate_tickets": False,
103
+ },
104
+ "udar": {
105
+ "enabled": False,
106
+ "max_self_corrections": 3,
107
+ "significance_threshold": 0.2,
108
+ },
109
+ },
110
+ "cleanup_config": {
111
+ "worktree_ttl_days": 7,
112
+ "evidence_ttl_days": 30,
113
+ "auto_cleanup_terminal": True,
114
+ },
115
+ "merge_config": {
116
+ "strategy": "merge",
117
+ "delete_branch_after_merge": True,
118
+ "pull_before_merge": True,
119
+ "require_pull_success": False,
120
+ },
121
+ "autonomy_config": {
122
+ "enabled": False,
123
+ "max_diff_lines": 500,
124
+ "sensitive_file_patterns": [
125
+ "*.env*",
126
+ "*secret*",
127
+ "*credential*",
128
+ "*password*",
129
+ "*.pem",
130
+ "*.key",
131
+ ],
132
+ },
133
+ "executor_profiles": {},
134
+ }
135
+
136
+
137
+ def upgrade() -> None:
138
+ """Seed Board.config for all existing boards."""
139
+ conn = op.get_bind()
140
+
141
+ rows = conn.execute(text("SELECT id, repo_root, config FROM boards")).fetchall()
142
+
143
+ if not rows:
144
+ logger.info("No boards found — nothing to seed.")
145
+ return
146
+
147
+ defaults = _get_defaults()
148
+ updated = 0
149
+
150
+ for row in rows:
151
+ board_id = row[0]
152
+ repo_root = row[1]
153
+ existing_config = row[2]
154
+
155
+ # Parse existing config (may be JSON string or dict depending on driver)
156
+ if existing_config is None:
157
+ existing = {}
158
+ elif isinstance(existing_config, str):
159
+ try:
160
+ existing = json.loads(existing_config)
161
+ except (json.JSONDecodeError, TypeError):
162
+ existing = {}
163
+ elif isinstance(existing_config, dict):
164
+ existing = existing_config
165
+ else:
166
+ existing = {}
167
+
168
+ # Try loading YAML from repo_root
169
+ yaml_config = _load_yaml_config(repo_root) if repo_root else None
170
+
171
+ # Deep-merge: defaults ← YAML ← existing board overrides
172
+ merged = defaults.copy()
173
+ if yaml_config:
174
+ merged = deep_merge_dicts(merged, yaml_config)
175
+ if existing:
176
+ merged = deep_merge_dicts(merged, existing)
177
+
178
+ # Write back
179
+ conn.execute(
180
+ text("UPDATE boards SET config = :config WHERE id = :id"),
181
+ {"config": json.dumps(merged), "id": board_id},
182
+ )
183
+ updated += 1
184
+
185
+ source = "defaults"
186
+ if yaml_config and existing:
187
+ source = "defaults + YAML + existing overrides"
188
+ elif yaml_config:
189
+ source = "defaults + YAML"
190
+ elif existing:
191
+ source = "defaults + existing overrides"
192
+ logger.info("Board %s: seeded config from %s", board_id, source)
193
+
194
+ logger.info("Seeded config for %d board(s).", updated)
195
+
196
+
197
+ def downgrade() -> None:
198
+ """Revert boards to their pre-seed config state.
199
+
200
+ We cannot perfectly restore the original state (we don't know what
201
+ it was), so we set config to NULL which means 'use defaults'.
202
+ This is safe because the old code path falls back to defaults anyway.
203
+ """
204
+ conn = op.get_bind()
205
+ conn.execute(text("UPDATE boards SET config = NULL"))
206
+ logger.info("Cleared Board.config for all boards (reverted to NULL/defaults).")