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,143 @@
1
+ """Router for webhook configuration (CRUD on Board.config.webhooks)."""
2
+
3
+ import logging
4
+ import uuid
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException, Query
7
+ from pydantic import BaseModel, Field
8
+ from sqlalchemy import select
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from app.database import get_db
12
+ from app.models.board import Board
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ router = APIRouter(prefix="/webhooks", tags=["webhooks"])
17
+
18
+
19
+ # --- Schemas ---
20
+
21
+
22
+ class WebhookCreate(BaseModel):
23
+ url: str = Field(..., description="URL to POST webhook payloads to")
24
+ events: list[str] = Field(
25
+ default=["*"],
26
+ description='Event filter list. Use ["*"] for all events.',
27
+ )
28
+ secret: str | None = Field(
29
+ None, description="Optional HMAC-SHA256 secret for payload signing"
30
+ )
31
+
32
+
33
+ class WebhookResponse(BaseModel):
34
+ id: str
35
+ url: str
36
+ events: list[str]
37
+ has_secret: bool
38
+
39
+
40
+ class WebhookListResponse(BaseModel):
41
+ webhooks: list[WebhookResponse]
42
+ board_id: str
43
+
44
+
45
+ # --- Helpers ---
46
+
47
+
48
+ async def _resolve_board(db: AsyncSession, board_id: str | None) -> Board:
49
+ if board_id:
50
+ result = await db.execute(select(Board).where(Board.id == board_id))
51
+ board = result.scalar_one_or_none()
52
+ if not board:
53
+ raise HTTPException(status_code=404, detail=f"Board not found: {board_id}")
54
+ return board
55
+ result = await db.execute(select(Board).limit(1))
56
+ board = result.scalar_one_or_none()
57
+ if not board:
58
+ raise HTTPException(status_code=400, detail="No boards exist.")
59
+ return board
60
+
61
+
62
+ def _get_webhooks(board: Board) -> list[dict]:
63
+ config = board.config or {}
64
+ return config.get("webhooks", [])
65
+
66
+
67
+ def _set_webhooks(board: Board, webhooks: list[dict]) -> None:
68
+ config = board.config or {}
69
+ config["webhooks"] = webhooks
70
+ board.config = config
71
+
72
+
73
+ def _to_response(wh: dict) -> WebhookResponse:
74
+ return WebhookResponse(
75
+ id=wh["id"],
76
+ url=wh["url"],
77
+ events=wh.get("events", ["*"]),
78
+ has_secret=bool(wh.get("secret")),
79
+ )
80
+
81
+
82
+ # --- Endpoints ---
83
+
84
+
85
+ @router.get("", response_model=WebhookListResponse)
86
+ async def list_webhooks(
87
+ board_id: str | None = Query(None),
88
+ db: AsyncSession = Depends(get_db),
89
+ ):
90
+ """List all webhooks configured for a board."""
91
+ board = await _resolve_board(db, board_id)
92
+ webhooks = _get_webhooks(board)
93
+ return WebhookListResponse(
94
+ webhooks=[_to_response(wh) for wh in webhooks],
95
+ board_id=board.id,
96
+ )
97
+
98
+
99
+ @router.post("", response_model=WebhookResponse, status_code=201)
100
+ async def create_webhook(
101
+ data: WebhookCreate,
102
+ board_id: str | None = Query(None),
103
+ db: AsyncSession = Depends(get_db),
104
+ ):
105
+ """Add a new webhook to a board."""
106
+ board = await _resolve_board(db, board_id)
107
+ webhooks = _get_webhooks(board)
108
+
109
+ wh = {
110
+ "id": str(uuid.uuid4()),
111
+ "url": data.url,
112
+ "events": data.events,
113
+ }
114
+ if data.secret:
115
+ wh["secret"] = data.secret
116
+
117
+ webhooks.append(wh)
118
+ _set_webhooks(board, webhooks)
119
+ await db.commit()
120
+ await db.refresh(board)
121
+
122
+ logger.info("Webhook created: id=%s url=%s board=%s", wh["id"], wh["url"], board.id)
123
+ return _to_response(wh)
124
+
125
+
126
+ @router.delete("/{webhook_id}", status_code=204)
127
+ async def delete_webhook(
128
+ webhook_id: str,
129
+ board_id: str | None = Query(None),
130
+ db: AsyncSession = Depends(get_db),
131
+ ):
132
+ """Remove a webhook from a board."""
133
+ board = await _resolve_board(db, board_id)
134
+ webhooks = _get_webhooks(board)
135
+ original_len = len(webhooks)
136
+ webhooks = [wh for wh in webhooks if wh.get("id") != webhook_id]
137
+
138
+ if len(webhooks) == original_len:
139
+ raise HTTPException(status_code=404, detail=f"Webhook not found: {webhook_id}")
140
+
141
+ _set_webhooks(board, webhooks)
142
+ await db.commit()
143
+ logger.info("Webhook deleted: id=%s board=%s", webhook_id, board.id)
@@ -0,0 +1,249 @@
1
+ """WebSocket endpoints for real-time updates.
2
+
3
+ This module provides WebSocket endpoints for streaming live updates to clients:
4
+ - Job output streaming (live execution logs)
5
+ - Board updates (ticket status changes, new jobs, etc.)
6
+ - Board JSON patches (incremental state updates via RFC 6902)
7
+ """
8
+
9
+ import json
10
+ import logging
11
+
12
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
13
+
14
+ from app.websocket.manager import manager
15
+ from app.websocket.state_tracker import get_tracker, remove_tracker
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ router = APIRouter(prefix="/ws", tags=["websocket"])
20
+
21
+
22
+ @router.websocket("/jobs/{job_id}")
23
+ async def job_output_stream(websocket: WebSocket, job_id: str):
24
+ """Stream live output from a running job.
25
+
26
+ Clients subscribe to this endpoint to receive real-time updates about
27
+ job execution, including stdout/stderr output and status changes.
28
+
29
+ Subscribes to the in-memory log broadcaster so terminal output from
30
+ the worker is forwarded to the WebSocket in real-time.
31
+
32
+ Args:
33
+ websocket: The WebSocket connection
34
+ job_id: The job ID to stream updates for
35
+
36
+ Message format:
37
+ {
38
+ "type": "output" | "status" | "complete" | "error",
39
+ "content": str, # For output messages
40
+ "status": str, # For status messages
41
+ "timestamp": str # ISO format timestamp
42
+ }
43
+ """
44
+ import asyncio
45
+
46
+ from app.services.log_stream_service import LogLevel, log_stream_service
47
+
48
+ channel = f"job:{job_id}"
49
+ await manager.connect(websocket, channel)
50
+
51
+ # Background task to forward log stream messages to WebSocket
52
+ async def _forward_logs():
53
+ try:
54
+ async for msg in log_stream_service.subscribe(job_id):
55
+ try:
56
+ if msg.level == LogLevel.FINISHED:
57
+ await websocket.send_json(
58
+ {
59
+ "type": "complete",
60
+ "content": "",
61
+ "timestamp": msg.timestamp.isoformat(),
62
+ }
63
+ )
64
+ return
65
+ elif msg.level in (LogLevel.STDOUT, LogLevel.STDERR):
66
+ await websocket.send_json(
67
+ {
68
+ "type": "output",
69
+ "content": msg.content,
70
+ "timestamp": msg.timestamp.isoformat(),
71
+ }
72
+ )
73
+ elif msg.level == LogLevel.ERROR:
74
+ await websocket.send_json(
75
+ {
76
+ "type": "error",
77
+ "content": msg.content,
78
+ "timestamp": msg.timestamp.isoformat(),
79
+ }
80
+ )
81
+ elif msg.level == LogLevel.PROGRESS:
82
+ await websocket.send_json(
83
+ {
84
+ "type": "status",
85
+ "content": msg.content,
86
+ "status": msg.stage or "running",
87
+ "progress_pct": msg.progress_pct,
88
+ "timestamp": msg.timestamp.isoformat(),
89
+ }
90
+ )
91
+ else:
92
+ await websocket.send_json(
93
+ {
94
+ "type": "output",
95
+ "content": msg.content,
96
+ "timestamp": msg.timestamp.isoformat(),
97
+ }
98
+ )
99
+ except Exception:
100
+ return # WebSocket closed
101
+ except asyncio.CancelledError:
102
+ pass
103
+
104
+ forward_task = asyncio.create_task(_forward_logs())
105
+
106
+ try:
107
+ # Keep connection alive and handle client messages
108
+ while True:
109
+ data = await websocket.receive_text()
110
+
111
+ # Handle ping/pong for keep-alive
112
+ if data == "ping":
113
+ await websocket.send_text("pong")
114
+ elif data == "subscribe":
115
+ # Already subscribed, acknowledge
116
+ await websocket.send_json(
117
+ {"type": "subscribed", "channel": channel, "job_id": job_id}
118
+ )
119
+ elif data == "unsubscribe":
120
+ # Client wants to unsubscribe
121
+ break
122
+
123
+ except WebSocketDisconnect:
124
+ logger.info(f"WebSocket disconnected from job stream: {job_id}")
125
+ except Exception as e:
126
+ logger.error(f"WebSocket error on job stream {job_id}: {e}", exc_info=True)
127
+ finally:
128
+ forward_task.cancel()
129
+ try:
130
+ await forward_task
131
+ except asyncio.CancelledError:
132
+ pass
133
+ await manager.disconnect(websocket, channel)
134
+
135
+
136
+ @router.websocket("/board/{board_id}")
137
+ async def board_updates(websocket: WebSocket, board_id: str):
138
+ """Stream board updates (ticket status changes, new jobs, etc.).
139
+
140
+ Supports two protocols:
141
+ 1. Legacy: broadcasts raw event messages
142
+ 2. JSON Patch: sends snapshot on connect, then RFC 6902 patches
143
+
144
+ Client can request a resync by sending: {"type": "resync"}
145
+
146
+ Args:
147
+ websocket: The WebSocket connection
148
+ board_id: The board ID to stream updates for
149
+
150
+ Message format (legacy):
151
+ {
152
+ "type": "ticket_update" | "job_created" | "job_completed",
153
+ "ticket_id": str,
154
+ "job_id": str,
155
+ "data": dict,
156
+ "timestamp": str
157
+ }
158
+
159
+ Message format (JSON Patch):
160
+ Connect: {"type": "snapshot", "data": {...}, "seq": 0}
161
+ Update: {"type": "patch", "ops": [...], "seq": N}
162
+ Resync: client sends {"type": "resync"} → server sends snapshot
163
+ """
164
+ channel = f"board:{board_id}"
165
+ await manager.connect(websocket, channel)
166
+
167
+ try:
168
+ # Keep connection alive and handle client messages
169
+ while True:
170
+ data = await websocket.receive_text()
171
+
172
+ if data == "ping":
173
+ await websocket.send_text("pong")
174
+ elif data == "subscribe":
175
+ await websocket.send_json(
176
+ {"type": "subscribed", "channel": channel, "board_id": board_id}
177
+ )
178
+ elif data == "unsubscribe":
179
+ break
180
+ else:
181
+ # Try to parse JSON messages
182
+ try:
183
+ msg = json.loads(data)
184
+ if msg.get("type") == "resync":
185
+ # Client requested a full resync - tracker will send
186
+ # snapshot on next broadcast
187
+ tracker = get_tracker(board_id)
188
+ if tracker.has_state:
189
+ # Re-send current snapshot
190
+ snapshot = tracker.get_snapshot_message(
191
+ tracker._state # type: ignore[arg-type]
192
+ )
193
+ await websocket.send_json(snapshot)
194
+ except (json.JSONDecodeError, TypeError):
195
+ pass
196
+
197
+ except WebSocketDisconnect:
198
+ logger.info(f"WebSocket disconnected from board: {board_id}")
199
+ except Exception as e:
200
+ logger.error(f"WebSocket error on board {board_id}: {e}", exc_info=True)
201
+ finally:
202
+ await manager.disconnect(websocket, channel)
203
+ # Clean up tracker if no more connections
204
+ if manager.get_connection_count(channel) == 0:
205
+ remove_tracker(board_id)
206
+
207
+
208
+ @router.websocket("/goals/{goal_id}")
209
+ async def goal_updates(websocket: WebSocket, goal_id: str):
210
+ """Stream goal updates (ticket generation, pipeline progress, etc.).
211
+
212
+ Clients subscribe to this endpoint to receive real-time updates about
213
+ goal-level changes, including ticket generation progress, pipeline
214
+ execution status, and goal completion.
215
+
216
+ Args:
217
+ websocket: The WebSocket connection
218
+ goal_id: The goal ID to stream updates for
219
+
220
+ Message format:
221
+ {
222
+ "type": "ticket_generated" | "pipeline_progress" | "goal_completed",
223
+ "goal_id": str,
224
+ "data": dict,
225
+ "timestamp": str
226
+ }
227
+ """
228
+ channel = f"goal:{goal_id}"
229
+ await manager.connect(websocket, channel)
230
+
231
+ try:
232
+ while True:
233
+ data = await websocket.receive_text()
234
+
235
+ if data == "ping":
236
+ await websocket.send_text("pong")
237
+ elif data == "subscribe":
238
+ await websocket.send_json(
239
+ {"type": "subscribed", "channel": channel, "goal_id": goal_id}
240
+ )
241
+ elif data == "unsubscribe":
242
+ break
243
+
244
+ except WebSocketDisconnect:
245
+ logger.info(f"WebSocket disconnected from goal: {goal_id}")
246
+ except Exception as e:
247
+ logger.error(f"WebSocket error on goal {goal_id}: {e}", exc_info=True)
248
+ finally:
249
+ await manager.disconnect(websocket, channel)
@@ -0,0 +1,109 @@
1
+ """Pydantic schemas for Draft API."""
2
+
3
+ from app.schemas.board import (
4
+ BoardCreate,
5
+ BoardListResponse,
6
+ BoardUpdate,
7
+ )
8
+ from app.schemas.board import (
9
+ BoardResponse as BoardEntityResponse,
10
+ )
11
+ from app.schemas.common import ErrorResponse, SuccessResponse
12
+ from app.schemas.evidence import (
13
+ EvidenceDetailResponse,
14
+ EvidenceKind,
15
+ EvidenceListResponse,
16
+ EvidenceResponse,
17
+ )
18
+ from app.schemas.goal import GoalCreate, GoalListResponse, GoalResponse
19
+ from app.schemas.job import (
20
+ CancelJobResponse,
21
+ JobCreateResponse,
22
+ JobDetailResponse,
23
+ JobKind,
24
+ JobListResponse,
25
+ JobResponse,
26
+ JobStatus,
27
+ )
28
+ from app.schemas.review import (
29
+ AuthorType,
30
+ FeedbackBundle,
31
+ FeedbackComment,
32
+ ReviewCommentCreate,
33
+ ReviewCommentListResponse,
34
+ ReviewCommentResponse,
35
+ ReviewDecision,
36
+ ReviewSubmit,
37
+ ReviewSummaryResponse,
38
+ )
39
+ from app.schemas.revision import (
40
+ DiffFile,
41
+ RevisionDetailResponse,
42
+ RevisionDiffResponse,
43
+ RevisionListResponse,
44
+ RevisionResponse,
45
+ RevisionStatus,
46
+ )
47
+ from app.schemas.ticket import (
48
+ BoardResponse,
49
+ TicketCreate,
50
+ TicketDetailResponse,
51
+ TicketResponse,
52
+ TicketsByState,
53
+ TicketTransition,
54
+ TicketWithGoal,
55
+ )
56
+ from app.schemas.ticket_event import TicketEventListResponse, TicketEventResponse
57
+ from app.schemas.workspace import WorkspaceResponse
58
+
59
+ __all__ = [
60
+ # Board schemas
61
+ "BoardCreate",
62
+ "BoardEntityResponse",
63
+ "BoardListResponse",
64
+ "BoardUpdate",
65
+ # Goal schemas
66
+ "GoalCreate",
67
+ "GoalResponse",
68
+ "GoalListResponse",
69
+ "TicketCreate",
70
+ "TicketResponse",
71
+ "TicketDetailResponse",
72
+ "TicketTransition",
73
+ "TicketWithGoal",
74
+ "TicketsByState",
75
+ "BoardResponse",
76
+ "TicketEventResponse",
77
+ "TicketEventListResponse",
78
+ "ErrorResponse",
79
+ "SuccessResponse",
80
+ "EvidenceKind",
81
+ "EvidenceResponse",
82
+ "EvidenceDetailResponse",
83
+ "EvidenceListResponse",
84
+ "JobKind",
85
+ "JobStatus",
86
+ "JobResponse",
87
+ "JobDetailResponse",
88
+ "JobListResponse",
89
+ "JobCreateResponse",
90
+ "CancelJobResponse",
91
+ "WorkspaceResponse",
92
+ # Revision schemas
93
+ "RevisionStatus",
94
+ "RevisionResponse",
95
+ "RevisionDetailResponse",
96
+ "RevisionListResponse",
97
+ "RevisionDiffResponse",
98
+ "DiffFile",
99
+ # Review schemas
100
+ "AuthorType",
101
+ "ReviewDecision",
102
+ "ReviewCommentCreate",
103
+ "ReviewCommentResponse",
104
+ "ReviewCommentListResponse",
105
+ "ReviewSubmit",
106
+ "ReviewSummaryResponse",
107
+ "FeedbackComment",
108
+ "FeedbackBundle",
109
+ ]
@@ -0,0 +1,87 @@
1
+ """Board schemas for API request/response validation."""
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+
8
+ class BoardCreate(BaseModel):
9
+ """Schema for creating a new board."""
10
+
11
+ name: str = Field(..., min_length=1, max_length=255, description="Board name")
12
+ description: str | None = Field(None, description="Optional description")
13
+ repo_root: str = Field(
14
+ ...,
15
+ min_length=1,
16
+ max_length=1024,
17
+ description="Absolute path to repository root",
18
+ )
19
+ default_branch: str | None = Field(
20
+ None,
21
+ max_length=255,
22
+ description="Default branch (e.g., main, master)",
23
+ )
24
+ template_id: str | None = Field(
25
+ None,
26
+ description="Optional template ID to apply project template configuration and starter goals",
27
+ )
28
+ create_starter_goals: bool = Field(
29
+ True,
30
+ description="Whether to create starter goals from the template (default: true)",
31
+ )
32
+
33
+
34
+ class BoardUpdate(BaseModel):
35
+ """Schema for updating a board."""
36
+
37
+ model_config = ConfigDict(extra="ignore")
38
+
39
+ name: str | None = Field(None, min_length=1, max_length=255)
40
+ description: str | None = None
41
+ default_branch: str | None = None
42
+ config: dict | None = Field(None, description="Board-level configuration overrides")
43
+
44
+
45
+ class BoardResponse(BaseModel):
46
+ """Schema for board API response."""
47
+
48
+ model_config = ConfigDict(from_attributes=True)
49
+
50
+ id: str
51
+ name: str
52
+ description: str | None
53
+ repo_root: str
54
+ default_branch: str | None
55
+ config: dict | None
56
+ owner_id: str | None = None
57
+ created_at: datetime
58
+ updated_at: datetime
59
+
60
+
61
+ class BoardListResponse(BaseModel):
62
+ """Schema for list of boards."""
63
+
64
+ boards: list[BoardResponse]
65
+ total: int
66
+
67
+
68
+ class BoardConfigUpdate(BaseModel):
69
+ """Schema for updating board-level configuration overrides."""
70
+
71
+ model_config = ConfigDict(extra="ignore")
72
+
73
+ config: dict | None = Field(
74
+ None,
75
+ description="Board-level configuration that overrides draft.yaml settings",
76
+ )
77
+
78
+
79
+ class BoardConfigResponse(BaseModel):
80
+ """Schema for board configuration response."""
81
+
82
+ board_id: str
83
+ config: dict | None = Field(None, description="Board-level configuration JSON")
84
+ has_overrides: bool = Field(
85
+ default=False,
86
+ description="Whether the board has custom configuration overrides",
87
+ )
@@ -0,0 +1,33 @@
1
+ """Common Pydantic schemas for API responses."""
2
+
3
+ from typing import Generic, TypeVar
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ class ErrorResponse(BaseModel):
11
+ """Schema for error responses."""
12
+
13
+ detail: str
14
+ error_type: str | None = None
15
+
16
+
17
+ class SuccessResponse(BaseModel):
18
+ """Schema for simple success responses."""
19
+
20
+ message: str
21
+ success: bool = True
22
+
23
+
24
+ class PaginatedResponse(BaseModel, Generic[T]):
25
+ """Generic paginated response wrapper.
26
+
27
+ Used for list endpoints that support pagination via page/limit params.
28
+ """
29
+
30
+ items: list[T]
31
+ total: int = Field(description="Total number of items matching the query")
32
+ page: int = Field(description="Current page number (1-based)")
33
+ limit: int = Field(description="Number of items per page")