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,223 @@
1
+ """Ticket model for Draft."""
2
+
3
+ import json
4
+ import uuid
5
+ from datetime import datetime
6
+ from typing import TYPE_CHECKING
7
+
8
+ from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
9
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
10
+
11
+ from app.models.base import Base
12
+ from app.state_machine import TicketState
13
+
14
+ if TYPE_CHECKING:
15
+ from app.models.agent_session import AgentSession
16
+ from app.models.board import Board
17
+ from app.models.evidence import Evidence
18
+ from app.models.goal import Goal
19
+ from app.models.job import Job
20
+ from app.models.revision import Revision
21
+ from app.models.ticket_event import TicketEvent
22
+ from app.models.workspace import Workspace
23
+
24
+
25
+ class Ticket(Base):
26
+ """Ticket model representing a unit of work.
27
+
28
+ IMPORTANT: Tickets are scoped by board_id for permission enforcement.
29
+ The board_id should match the goal's board_id.
30
+
31
+ BLOCKING/DEPENDENCIES:
32
+ - blocked_by_ticket_id: If set, this ticket is blocked by another ticket
33
+ - A ticket cannot be queued for execution until its blocker is DONE
34
+ - When generating tickets, the agent can specify dependencies
35
+ """
36
+
37
+ __tablename__ = "tickets"
38
+
39
+ id: Mapped[str] = mapped_column(
40
+ String(36),
41
+ primary_key=True,
42
+ default=lambda: str(uuid.uuid4()),
43
+ )
44
+ board_id: Mapped[str | None] = mapped_column(
45
+ String(36),
46
+ ForeignKey("boards.id", ondelete="CASCADE"),
47
+ nullable=True, # Nullable for migration compatibility
48
+ index=True,
49
+ )
50
+ goal_id: Mapped[str] = mapped_column(
51
+ String(36),
52
+ ForeignKey("goals.id", ondelete="CASCADE"),
53
+ nullable=False,
54
+ index=True,
55
+ )
56
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
57
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
58
+ state: Mapped[str] = mapped_column(
59
+ String(50),
60
+ default=TicketState.PROPOSED.value,
61
+ nullable=False,
62
+ index=True,
63
+ )
64
+ priority: Mapped[int | None] = mapped_column(Integer, nullable=True)
65
+ sort_order: Mapped[int | None] = mapped_column(
66
+ Integer,
67
+ nullable=True,
68
+ index=True,
69
+ doc="Manual sort order within a state column (lower = higher position)",
70
+ )
71
+ verification_commands_json: Mapped[str | None] = mapped_column(
72
+ Text, nullable=True, doc="JSON array of verification commands"
73
+ )
74
+ # Blocking/dependency: If set, this ticket cannot be executed until the blocker is DONE
75
+ blocked_by_ticket_id: Mapped[str | None] = mapped_column(
76
+ String(36),
77
+ ForeignKey("tickets.id", ondelete="SET NULL"),
78
+ nullable=True,
79
+ index=True,
80
+ )
81
+ created_at: Mapped[datetime] = mapped_column(
82
+ DateTime,
83
+ server_default=func.now(),
84
+ nullable=False,
85
+ )
86
+ updated_at: Mapped[datetime] = mapped_column(
87
+ DateTime,
88
+ server_default=func.now(),
89
+ onupdate=func.now(),
90
+ nullable=False,
91
+ )
92
+
93
+ # GitHub Pull Request fields
94
+ pr_number: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
95
+ pr_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
96
+ pr_state: Mapped[str | None] = mapped_column(
97
+ String(20), nullable=True
98
+ ) # 'OPEN', 'CLOSED', 'MERGED'
99
+ pr_created_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
100
+ pr_merged_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
101
+ pr_head_branch: Mapped[str | None] = mapped_column(String(255), nullable=True)
102
+ pr_base_branch: Mapped[str | None] = mapped_column(String(255), nullable=True)
103
+
104
+ # Relationships
105
+ board: Mapped["Board | None"] = relationship("Board", back_populates="tickets")
106
+ goal: Mapped["Goal"] = relationship("Goal", back_populates="tickets")
107
+ events: Mapped[list["TicketEvent"]] = relationship(
108
+ "TicketEvent",
109
+ back_populates="ticket",
110
+ cascade="all, delete-orphan",
111
+ order_by="TicketEvent.created_at",
112
+ )
113
+ jobs: Mapped[list["Job"]] = relationship(
114
+ "Job",
115
+ back_populates="ticket",
116
+ cascade="all, delete-orphan",
117
+ order_by="Job.created_at.desc()",
118
+ )
119
+ workspace: Mapped["Workspace | None"] = relationship(
120
+ "Workspace",
121
+ back_populates="ticket",
122
+ cascade="all, delete-orphan",
123
+ uselist=False,
124
+ )
125
+ evidence: Mapped[list["Evidence"]] = relationship(
126
+ "Evidence",
127
+ back_populates="ticket",
128
+ cascade="all, delete-orphan",
129
+ order_by="Evidence.created_at.desc()",
130
+ )
131
+ revisions: Mapped[list["Revision"]] = relationship(
132
+ "Revision",
133
+ back_populates="ticket",
134
+ cascade="all, delete-orphan",
135
+ order_by="Revision.number.desc()",
136
+ )
137
+ agent_sessions: Mapped[list["AgentSession"]] = relationship(
138
+ "AgentSession",
139
+ back_populates="ticket",
140
+ cascade="all, delete-orphan",
141
+ order_by="AgentSession.created_at.desc()",
142
+ )
143
+
144
+ # Blocking relationship (self-referential)
145
+ blocked_by: Mapped["Ticket | None"] = relationship(
146
+ "Ticket",
147
+ foreign_keys=[blocked_by_ticket_id],
148
+ remote_side="Ticket.id",
149
+ uselist=False,
150
+ back_populates="blocking",
151
+ )
152
+ # Tickets that this ticket is blocking
153
+ blocking: Mapped[list["Ticket"]] = relationship(
154
+ "Ticket",
155
+ foreign_keys="Ticket.blocked_by_ticket_id",
156
+ back_populates="blocked_by",
157
+ )
158
+
159
+ @property
160
+ def state_enum(self) -> TicketState:
161
+ """Get the state as a TicketState enum."""
162
+ return TicketState(self.state)
163
+
164
+ @property
165
+ def is_blocked_by_dependency(self) -> bool:
166
+ """Check if this ticket is blocked by an incomplete dependency.
167
+
168
+ Returns True if:
169
+ - blocked_by_ticket_id is set, AND
170
+ - The blocking ticket's state is NOT 'done'
171
+
172
+ Note: This requires the blocked_by relationship to be loaded.
173
+ Use selectinload(Ticket.blocked_by) when querying.
174
+ """
175
+ if not self.blocked_by_ticket_id:
176
+ return False
177
+ if self.blocked_by is None:
178
+ # Relationship not loaded - assume blocked for safety
179
+ return True
180
+ return self.blocked_by.state != TicketState.DONE.value
181
+
182
+ @property
183
+ def verification_commands(self) -> list[str]:
184
+ """Get verification commands as a list."""
185
+ if not self.verification_commands_json:
186
+ return []
187
+ try:
188
+ return json.loads(self.verification_commands_json)
189
+ except (json.JSONDecodeError, TypeError):
190
+ return []
191
+
192
+ @verification_commands.setter
193
+ def verification_commands(self, commands: list[str]) -> None:
194
+ """Set verification commands from a list with validation."""
195
+ if not commands:
196
+ self.verification_commands_json = None
197
+ return
198
+
199
+ # Validation constants
200
+ MAX_COMMANDS = 5
201
+ MAX_CMD_LENGTH = 500
202
+
203
+ # Validate and sanitize
204
+ validated = []
205
+ for cmd in commands[:MAX_COMMANDS]:
206
+ if not isinstance(cmd, str):
207
+ continue
208
+ # Truncate if too long
209
+ cmd = cmd[:MAX_CMD_LENGTH].strip()
210
+ # Skip empty commands
211
+ if not cmd:
212
+ continue
213
+ # Remove null bytes and control chars
214
+ cmd = "".join(c for c in cmd if ord(c) >= 32 or c in "\t\n\r")
215
+ validated.append(cmd)
216
+
217
+ if validated:
218
+ self.verification_commands_json = json.dumps(validated)
219
+ else:
220
+ self.verification_commands_json = None
221
+
222
+ def __repr__(self) -> str:
223
+ return f"<Ticket(id={self.id}, title={self.title}, state={self.state})>"
@@ -0,0 +1,83 @@
1
+ """TicketEvent model for Draft - append-only event log."""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from sqlalchemy import DateTime, ForeignKey, String, Text, func
8
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
9
+
10
+ from app.models.base import Base
11
+
12
+ if TYPE_CHECKING:
13
+ from app.models.ticket import Ticket
14
+
15
+
16
+ class TicketEvent(Base):
17
+ """
18
+ TicketEvent model for recording all ticket state changes and actions.
19
+
20
+ This is an append-only log - events should never be updated or deleted.
21
+ """
22
+
23
+ __tablename__ = "ticket_events"
24
+
25
+ id: Mapped[str] = mapped_column(
26
+ String(36),
27
+ primary_key=True,
28
+ default=lambda: str(uuid.uuid4()),
29
+ )
30
+ ticket_id: Mapped[str] = mapped_column(
31
+ String(36),
32
+ ForeignKey("tickets.id", ondelete="CASCADE"),
33
+ nullable=False,
34
+ index=True,
35
+ )
36
+ event_type: Mapped[str] = mapped_column(
37
+ String(50),
38
+ nullable=False,
39
+ )
40
+ from_state: Mapped[str | None] = mapped_column(
41
+ String(50),
42
+ nullable=True,
43
+ )
44
+ to_state: Mapped[str | None] = mapped_column(
45
+ String(50),
46
+ nullable=True,
47
+ )
48
+ actor_type: Mapped[str] = mapped_column(
49
+ String(50),
50
+ nullable=False,
51
+ )
52
+ actor_id: Mapped[str | None] = mapped_column(
53
+ String(255),
54
+ nullable=True,
55
+ )
56
+ reason: Mapped[str | None] = mapped_column(
57
+ Text,
58
+ nullable=True,
59
+ )
60
+ payload_json: Mapped[str | None] = mapped_column(
61
+ Text,
62
+ nullable=True,
63
+ )
64
+ created_at: Mapped[datetime] = mapped_column(
65
+ DateTime,
66
+ server_default=func.now(),
67
+ nullable=False,
68
+ index=True,
69
+ )
70
+
71
+ # Relationships
72
+ ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="events")
73
+
74
+ def get_payload(self) -> dict[str, Any] | None:
75
+ """Parse and return the payload as a dictionary."""
76
+ if self.payload_json is None:
77
+ return None
78
+ import json
79
+
80
+ return json.loads(self.payload_json)
81
+
82
+ def __repr__(self) -> str:
83
+ return f"<TicketEvent(id={self.id}, type={self.event_type}, ticket_id={self.ticket_id})>"
@@ -0,0 +1,47 @@
1
+ """User model for multi-user authentication."""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+
6
+ from sqlalchemy import Boolean, DateTime, String, func
7
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
8
+
9
+ from app.models.base import Base
10
+
11
+
12
+ class User(Base):
13
+ """User account for authentication and board ownership.
14
+
15
+ Each user has their own set of boards. Boards are scoped by owner_id.
16
+ """
17
+
18
+ __tablename__ = "users"
19
+
20
+ id: Mapped[str] = mapped_column(
21
+ String(36), primary_key=True, default=lambda: str(uuid.uuid4())
22
+ )
23
+ email: Mapped[str] = mapped_column(
24
+ String(255), unique=True, nullable=False, index=True
25
+ )
26
+ display_name: Mapped[str] = mapped_column(String(255), nullable=False)
27
+ hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
28
+ is_active: Mapped[bool] = mapped_column(
29
+ Boolean, default=True, server_default="1", nullable=False
30
+ )
31
+ created_at: Mapped[datetime] = mapped_column(
32
+ DateTime(timezone=True), server_default=func.now(), nullable=False
33
+ )
34
+ updated_at: Mapped[datetime] = mapped_column(
35
+ DateTime(timezone=True),
36
+ server_default=func.now(),
37
+ onupdate=func.now(),
38
+ nullable=False,
39
+ )
40
+
41
+ # Relationships
42
+ boards: Mapped[list["Board"]] = relationship( # noqa: F821
43
+ "Board", back_populates="owner", cascade="all, delete-orphan"
44
+ )
45
+
46
+ def __repr__(self) -> str:
47
+ return f"<User(id={self.id}, email={self.email})>"
@@ -0,0 +1,71 @@
1
+ """Workspace model for git worktree isolation."""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from typing import TYPE_CHECKING
6
+
7
+ from sqlalchemy import DateTime, ForeignKey, String, Text, func
8
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
9
+
10
+ from app.models.base import Base
11
+
12
+ if TYPE_CHECKING:
13
+ from app.models.board import Board
14
+ from app.models.ticket import Ticket
15
+
16
+
17
+ class Workspace(Base):
18
+ """Workspace model representing an isolated git worktree for a ticket.
19
+
20
+ IMPORTANT: Workspaces are scoped by board_id for permission enforcement.
21
+ """
22
+
23
+ __tablename__ = "workspaces"
24
+
25
+ id: Mapped[str] = mapped_column(
26
+ String(36),
27
+ primary_key=True,
28
+ default=lambda: str(uuid.uuid4()),
29
+ )
30
+ board_id: Mapped[str | None] = mapped_column(
31
+ String(36),
32
+ ForeignKey("boards.id", ondelete="CASCADE"),
33
+ nullable=True, # Nullable for migration compatibility
34
+ index=True,
35
+ )
36
+ ticket_id: Mapped[str] = mapped_column(
37
+ String(36),
38
+ ForeignKey("tickets.id", ondelete="CASCADE"),
39
+ nullable=False,
40
+ unique=True,
41
+ index=True,
42
+ )
43
+ worktree_path: Mapped[str] = mapped_column(
44
+ Text,
45
+ nullable=False,
46
+ )
47
+ branch_name: Mapped[str] = mapped_column(
48
+ Text,
49
+ nullable=False,
50
+ )
51
+ created_at: Mapped[datetime] = mapped_column(
52
+ DateTime,
53
+ server_default=func.now(),
54
+ nullable=False,
55
+ )
56
+ cleaned_up_at: Mapped[datetime | None] = mapped_column(
57
+ DateTime,
58
+ nullable=True,
59
+ )
60
+
61
+ # Relationships
62
+ board: Mapped["Board | None"] = relationship("Board", back_populates="workspaces")
63
+ ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="workspace")
64
+
65
+ @property
66
+ def is_active(self) -> bool:
67
+ """Check if the workspace is still active (not cleaned up)."""
68
+ return self.cleaned_up_at is None
69
+
70
+ def __repr__(self) -> str:
71
+ return f"<Workspace(id={self.id}, ticket_id={self.ticket_id}, branch={self.branch_name})>"
@@ -0,0 +1,119 @@
1
+ """In-memory cache for idempotency, rate limiting, and transient data.
2
+
3
+ Provides a simple dict-based cache with TTL support. Used by middleware
4
+ and services for short-lived data that doesn't need disk persistence.
5
+ """
6
+
7
+ import logging
8
+ import time
9
+ from typing import Any
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ _cache_instance: "InMemoryCache | None" = None
14
+
15
+
16
+ class InMemoryCache:
17
+ """In-memory cache with TTL support.
18
+
19
+ Provides a Redis-compatible interface for single-process operation.
20
+ """
21
+
22
+ def __init__(self):
23
+ self._data: dict[str, Any] = {}
24
+ self._expiry: dict[str, float] = {}
25
+
26
+ def _check_expired(self, key: str) -> bool:
27
+ """Check if a key has expired and delete it if so."""
28
+ if key in self._expiry:
29
+ if time.time() > self._expiry[key]:
30
+ del self._data[key]
31
+ del self._expiry[key]
32
+ return True
33
+ return False
34
+
35
+ def get(self, key: str) -> Any | None:
36
+ """Get value for key, returns None if not found or expired."""
37
+ if self._check_expired(key):
38
+ return None
39
+ return self._data.get(key)
40
+
41
+ def set(self, key: str, value: Any, ex: int | None = None) -> bool:
42
+ """Set key to value with optional expiry in seconds."""
43
+ self._data[key] = value
44
+ if ex is not None:
45
+ self._expiry[key] = time.time() + ex
46
+ elif key in self._expiry:
47
+ del self._expiry[key]
48
+ return True
49
+
50
+ def setex(self, key: str, time_seconds: int, value: Any) -> bool:
51
+ """Set key with expiry."""
52
+ return self.set(key, value, ex=time_seconds)
53
+
54
+ def delete(self, *keys: str) -> int:
55
+ """Delete one or more keys, returns count of deleted keys."""
56
+ count = 0
57
+ for key in keys:
58
+ if key in self._data:
59
+ del self._data[key]
60
+ count += 1
61
+ if key in self._expiry:
62
+ del self._expiry[key]
63
+ return count
64
+
65
+ def setnx(self, key: str, value: Any) -> bool:
66
+ """Set key only if it doesn't exist. Returns True if set, False otherwise."""
67
+ if self._check_expired(key):
68
+ pass
69
+ elif key in self._data:
70
+ return False
71
+
72
+ self._data[key] = value
73
+ return True
74
+
75
+ def exists(self, *keys: str) -> int:
76
+ """Check if keys exist, returns count of existing keys."""
77
+ count = 0
78
+ for key in keys:
79
+ if not self._check_expired(key) and key in self._data:
80
+ count += 1
81
+ return count
82
+
83
+ def expire(self, key: str, time_seconds: int) -> bool:
84
+ """Set expiry on existing key."""
85
+ if key not in self._data or self._check_expired(key):
86
+ return False
87
+ self._expiry[key] = time.time() + time_seconds
88
+ return True
89
+
90
+ def ttl(self, key: str) -> int:
91
+ """Get time to live for key. Returns -1 if no expiry, -2 if not found."""
92
+ if self._check_expired(key) or key not in self._data:
93
+ return -2
94
+ if key not in self._expiry:
95
+ return -1
96
+ remaining = int(self._expiry[key] - time.time())
97
+ return max(0, remaining)
98
+
99
+ def ping(self) -> bool:
100
+ return True
101
+
102
+ def close(self) -> None:
103
+ pass
104
+
105
+
106
+ def get_redis() -> InMemoryCache:
107
+ """Get the shared in-memory cache instance."""
108
+ global _cache_instance
109
+ if _cache_instance is None:
110
+ _cache_instance = InMemoryCache()
111
+ return _cache_instance
112
+
113
+
114
+ def close_redis() -> None:
115
+ """Clear the cache (call on shutdown)."""
116
+ global _cache_instance
117
+ if _cache_instance:
118
+ _cache_instance.close()
119
+ _cache_instance = None
@@ -0,0 +1,29 @@
1
+ """API routers for Draft."""
2
+
3
+ from app.routers.board import legacy_router as board_legacy_router
4
+ from app.routers.board import router as boards_router
5
+ from app.routers.debug import router as debug_router
6
+ from app.routers.evidence import router as evidence_router
7
+ from app.routers.goals import router as goals_router
8
+ from app.routers.jobs import router as jobs_router
9
+ from app.routers.maintenance import router as maintenance_router
10
+ from app.routers.merge import router as merge_router
11
+ from app.routers.planner import router as planner_router
12
+ from app.routers.repos import router as repos_router
13
+ from app.routers.revisions import router as revisions_router
14
+ from app.routers.tickets import router as tickets_router
15
+
16
+ __all__ = [
17
+ "goals_router",
18
+ "tickets_router",
19
+ "boards_router",
20
+ "board_legacy_router",
21
+ "repos_router",
22
+ "jobs_router",
23
+ "evidence_router",
24
+ "planner_router",
25
+ "revisions_router",
26
+ "merge_router",
27
+ "maintenance_router",
28
+ "debug_router",
29
+ ]