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.
- package/app/backend/.env.example +9 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
- package/app/backend/CURL_EXAMPLES.md +335 -0
- package/app/backend/ENV_SETUP.md +65 -0
- package/app/backend/alembic/env.py +71 -0
- package/app/backend/alembic/script.py.mako +28 -0
- package/app/backend/alembic/versions/001_initial_schema.py +104 -0
- package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
- package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
- package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
- package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
- package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
- package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
- package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
- package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
- package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
- package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
- package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
- package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
- package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
- package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
- package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
- package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
- package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
- package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
- package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
- package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
- package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
- package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
- package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
- package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
- package/app/backend/alembic/versions/add_job_variant.py +34 -0
- package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
- package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
- package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
- package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
- package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
- package/app/backend/alembic.ini +89 -0
- package/app/backend/app/__init__.py +3 -0
- package/app/backend/app/data_dir.py +85 -0
- package/app/backend/app/database.py +70 -0
- package/app/backend/app/database_sync.py +64 -0
- package/app/backend/app/dependencies/__init__.py +5 -0
- package/app/backend/app/dependencies/auth.py +80 -0
- package/app/backend/app/dependencies.py +43 -0
- package/app/backend/app/exceptions.py +178 -0
- package/app/backend/app/executors/__init__.py +1 -0
- package/app/backend/app/executors/adapters/__init__.py +1 -0
- package/app/backend/app/executors/adapters/aider.py +152 -0
- package/app/backend/app/executors/adapters/amazon_q.py +103 -0
- package/app/backend/app/executors/adapters/amp.py +123 -0
- package/app/backend/app/executors/adapters/claude.py +177 -0
- package/app/backend/app/executors/adapters/cline.py +127 -0
- package/app/backend/app/executors/adapters/codex.py +167 -0
- package/app/backend/app/executors/adapters/copilot.py +202 -0
- package/app/backend/app/executors/adapters/cursor.py +87 -0
- package/app/backend/app/executors/adapters/droid.py +123 -0
- package/app/backend/app/executors/adapters/gemini.py +132 -0
- package/app/backend/app/executors/adapters/goose.py +131 -0
- package/app/backend/app/executors/adapters/opencode.py +123 -0
- package/app/backend/app/executors/adapters/qwen.py +123 -0
- package/app/backend/app/executors/plugins/__init__.py +1 -0
- package/app/backend/app/executors/registry.py +202 -0
- package/app/backend/app/executors/spec.py +226 -0
- package/app/backend/app/main.py +486 -0
- package/app/backend/app/middleware/__init__.py +13 -0
- package/app/backend/app/middleware/idempotency.py +426 -0
- package/app/backend/app/middleware/rate_limit.py +312 -0
- package/app/backend/app/middleware/security_headers.py +43 -0
- package/app/backend/app/middleware/timeout.py +37 -0
- package/app/backend/app/models/__init__.py +56 -0
- package/app/backend/app/models/agent_conversation_history.py +56 -0
- package/app/backend/app/models/agent_session.py +127 -0
- package/app/backend/app/models/analysis_cache.py +49 -0
- package/app/backend/app/models/base.py +9 -0
- package/app/backend/app/models/board.py +79 -0
- package/app/backend/app/models/board_repo.py +68 -0
- package/app/backend/app/models/cost_budget.py +42 -0
- package/app/backend/app/models/enums.py +40 -0
- package/app/backend/app/models/evidence.py +132 -0
- package/app/backend/app/models/goal.py +102 -0
- package/app/backend/app/models/idempotency_entry.py +30 -0
- package/app/backend/app/models/job.py +163 -0
- package/app/backend/app/models/job_queue.py +39 -0
- package/app/backend/app/models/kv_store.py +28 -0
- package/app/backend/app/models/merge_checklist.py +87 -0
- package/app/backend/app/models/normalized_log.py +100 -0
- package/app/backend/app/models/planner_lock.py +43 -0
- package/app/backend/app/models/rate_limit_entry.py +25 -0
- package/app/backend/app/models/repo.py +66 -0
- package/app/backend/app/models/review_comment.py +91 -0
- package/app/backend/app/models/review_summary.py +69 -0
- package/app/backend/app/models/revision.py +130 -0
- package/app/backend/app/models/ticket.py +223 -0
- package/app/backend/app/models/ticket_event.py +83 -0
- package/app/backend/app/models/user.py +47 -0
- package/app/backend/app/models/workspace.py +71 -0
- package/app/backend/app/redis_client.py +119 -0
- package/app/backend/app/routers/__init__.py +29 -0
- package/app/backend/app/routers/agents.py +296 -0
- package/app/backend/app/routers/auth.py +94 -0
- package/app/backend/app/routers/board.py +885 -0
- package/app/backend/app/routers/dashboard.py +351 -0
- package/app/backend/app/routers/debug.py +528 -0
- package/app/backend/app/routers/evidence.py +96 -0
- package/app/backend/app/routers/executors.py +324 -0
- package/app/backend/app/routers/goals.py +574 -0
- package/app/backend/app/routers/jobs.py +448 -0
- package/app/backend/app/routers/maintenance.py +172 -0
- package/app/backend/app/routers/merge.py +360 -0
- package/app/backend/app/routers/planner.py +537 -0
- package/app/backend/app/routers/pull_requests.py +382 -0
- package/app/backend/app/routers/repos.py +263 -0
- package/app/backend/app/routers/revisions.py +939 -0
- package/app/backend/app/routers/settings.py +267 -0
- package/app/backend/app/routers/tickets.py +2003 -0
- package/app/backend/app/routers/webhooks.py +143 -0
- package/app/backend/app/routers/websocket.py +249 -0
- package/app/backend/app/schemas/__init__.py +109 -0
- package/app/backend/app/schemas/board.py +87 -0
- package/app/backend/app/schemas/common.py +33 -0
- package/app/backend/app/schemas/evidence.py +87 -0
- package/app/backend/app/schemas/goal.py +90 -0
- package/app/backend/app/schemas/job.py +97 -0
- package/app/backend/app/schemas/merge.py +139 -0
- package/app/backend/app/schemas/planner.py +500 -0
- package/app/backend/app/schemas/repo.py +187 -0
- package/app/backend/app/schemas/review.py +137 -0
- package/app/backend/app/schemas/revision.py +114 -0
- package/app/backend/app/schemas/ticket.py +238 -0
- package/app/backend/app/schemas/ticket_event.py +72 -0
- package/app/backend/app/schemas/workspace.py +19 -0
- package/app/backend/app/services/__init__.py +31 -0
- package/app/backend/app/services/agent_memory_service.py +223 -0
- package/app/backend/app/services/agent_registry.py +346 -0
- package/app/backend/app/services/agent_session_manager.py +318 -0
- package/app/backend/app/services/agent_session_service.py +219 -0
- package/app/backend/app/services/agent_tools.py +379 -0
- package/app/backend/app/services/auth_service.py +98 -0
- package/app/backend/app/services/autonomy_service.py +380 -0
- package/app/backend/app/services/board_repo_service.py +201 -0
- package/app/backend/app/services/board_service.py +326 -0
- package/app/backend/app/services/cleanup_service.py +1085 -0
- package/app/backend/app/services/config_service.py +908 -0
- package/app/backend/app/services/context_gatherer.py +557 -0
- package/app/backend/app/services/cost_tracking_service.py +293 -0
- package/app/backend/app/services/cursor_log_normalizer.py +536 -0
- package/app/backend/app/services/delivery_pipeline.py +440 -0
- package/app/backend/app/services/executor_service.py +634 -0
- package/app/backend/app/services/git_host/__init__.py +11 -0
- package/app/backend/app/services/git_host/factory.py +87 -0
- package/app/backend/app/services/git_host/github.py +270 -0
- package/app/backend/app/services/git_host/gitlab.py +194 -0
- package/app/backend/app/services/git_host/protocol.py +75 -0
- package/app/backend/app/services/git_merge_simple.py +346 -0
- package/app/backend/app/services/git_ops.py +384 -0
- package/app/backend/app/services/github_service.py +233 -0
- package/app/backend/app/services/goal_service.py +113 -0
- package/app/backend/app/services/job_service.py +423 -0
- package/app/backend/app/services/job_watchdog_service.py +424 -0
- package/app/backend/app/services/langchain_adapter.py +122 -0
- package/app/backend/app/services/llm_provider_clients.py +351 -0
- package/app/backend/app/services/llm_service.py +285 -0
- package/app/backend/app/services/log_normalizer.py +342 -0
- package/app/backend/app/services/log_stream_service.py +276 -0
- package/app/backend/app/services/merge_checklist_service.py +264 -0
- package/app/backend/app/services/merge_service.py +784 -0
- package/app/backend/app/services/orchestrator_log.py +84 -0
- package/app/backend/app/services/planner_service.py +1662 -0
- package/app/backend/app/services/planner_tick_sync.py +1040 -0
- package/app/backend/app/services/queued_message_service.py +156 -0
- package/app/backend/app/services/reliability_wrapper.py +389 -0
- package/app/backend/app/services/repo_discovery_service.py +318 -0
- package/app/backend/app/services/review_service.py +334 -0
- package/app/backend/app/services/revision_service.py +389 -0
- package/app/backend/app/services/safe_autopilot.py +510 -0
- package/app/backend/app/services/sqlite_worker.py +372 -0
- package/app/backend/app/services/task_dispatch.py +135 -0
- package/app/backend/app/services/ticket_generation_service.py +1781 -0
- package/app/backend/app/services/ticket_service.py +486 -0
- package/app/backend/app/services/udar_planner_service.py +1007 -0
- package/app/backend/app/services/webhook_service.py +126 -0
- package/app/backend/app/services/workspace_service.py +465 -0
- package/app/backend/app/services/worktree_file_service.py +92 -0
- package/app/backend/app/services/worktree_validator.py +213 -0
- package/app/backend/app/sqlite_kv.py +278 -0
- package/app/backend/app/state_machine.py +128 -0
- package/app/backend/app/templates/__init__.py +5 -0
- package/app/backend/app/templates/registry.py +243 -0
- package/app/backend/app/utils/__init__.py +5 -0
- package/app/backend/app/utils/artifact_reader.py +87 -0
- package/app/backend/app/utils/circuit_breaker.py +229 -0
- package/app/backend/app/utils/db_retry.py +136 -0
- package/app/backend/app/utils/ignored_fields.py +123 -0
- package/app/backend/app/utils/validators.py +54 -0
- package/app/backend/app/websocket/__init__.py +5 -0
- package/app/backend/app/websocket/manager.py +179 -0
- package/app/backend/app/websocket/state_tracker.py +113 -0
- package/app/backend/app/worker.py +3190 -0
- package/app/backend/calculator_tickets.json +40 -0
- package/app/backend/canary_tests.sh +591 -0
- package/app/backend/celerybeat-schedule +0 -0
- package/app/backend/celerybeat-schedule-shm +0 -0
- package/app/backend/celerybeat-schedule-wal +0 -0
- package/app/backend/logs/.gitkeep +3 -0
- package/app/backend/multiplication_division_implementation_tickets.json +55 -0
- package/app/backend/multiplication_division_tickets.json +42 -0
- package/app/backend/pyproject.toml +45 -0
- package/app/backend/requirements-dev.txt +8 -0
- package/app/backend/requirements.txt +20 -0
- package/app/backend/run.sh +30 -0
- package/app/backend/run_with_logs.sh +10 -0
- package/app/backend/scientific_calculator_tickets.json +40 -0
- package/app/backend/scripts/extract_openapi.py +21 -0
- package/app/backend/scripts/seed_demo.py +187 -0
- package/app/backend/setup_demo_review.py +302 -0
- package/app/backend/test_actual_parse.py +41 -0
- package/app/backend/test_agent_streaming.py +61 -0
- package/app/backend/test_parse.py +51 -0
- package/app/backend/test_streaming.py +51 -0
- package/app/backend/test_subprocess_streaming.py +50 -0
- package/app/backend/tests/__init__.py +1 -0
- package/app/backend/tests/conftest.py +46 -0
- package/app/backend/tests/test_auth.py +341 -0
- package/app/backend/tests/test_autonomy_service.py +391 -0
- package/app/backend/tests/test_cleanup_service_safety.py +417 -0
- package/app/backend/tests/test_middleware.py +279 -0
- package/app/backend/tests/test_planner_providers.py +290 -0
- package/app/backend/tests/test_planner_unblock.py +183 -0
- package/app/backend/tests/test_revision_invariants.py +618 -0
- package/app/backend/tests/test_sqlite_kv.py +290 -0
- package/app/backend/tests/test_sqlite_worker.py +353 -0
- package/app/backend/tests/test_task_dispatch.py +100 -0
- package/app/backend/tests/test_ticket_validation.py +304 -0
- package/app/backend/tests/test_udar_agent.py +693 -0
- package/app/backend/tests/test_webhook_service.py +184 -0
- package/app/backend/tickets_output.json +59 -0
- package/app/backend/user_management_tickets.json +50 -0
- package/app/backend/uvicorn.log +0 -0
- package/app/draft.yaml +313 -0
- package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
- package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
- package/app/frontend/dist/index.html +14 -0
- package/app/frontend/dist/vite.svg +1 -0
- package/app/frontend/package.json +101 -0
- package/bin/cli.js +527 -0
- 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")
|