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,574 @@
|
|
|
1
|
+
"""API router for Goal endpoints."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
|
|
9
|
+
from app.database import get_db
|
|
10
|
+
from app.schemas.common import PaginatedResponse
|
|
11
|
+
from app.schemas.goal import (
|
|
12
|
+
AutonomySettings,
|
|
13
|
+
AutonomyStatusResponse,
|
|
14
|
+
GoalCreate,
|
|
15
|
+
GoalListResponse,
|
|
16
|
+
GoalResponse,
|
|
17
|
+
GoalUpdate,
|
|
18
|
+
)
|
|
19
|
+
from app.schemas.planner import (
|
|
20
|
+
GenerateTicketsRequest,
|
|
21
|
+
GenerateTicketsResponse,
|
|
22
|
+
ReflectionResult,
|
|
23
|
+
)
|
|
24
|
+
from app.services.goal_service import GoalService
|
|
25
|
+
from app.services.ticket_generation_service import TicketGenerationService
|
|
26
|
+
from app.services.udar_planner_service import UDARPlannerService
|
|
27
|
+
from app.utils.ignored_fields import add_ignored_fields_header, check_ignored_fields
|
|
28
|
+
|
|
29
|
+
router = APIRouter(prefix="/goals", tags=["goals"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post(
|
|
33
|
+
"",
|
|
34
|
+
response_model=GoalResponse,
|
|
35
|
+
status_code=status.HTTP_201_CREATED,
|
|
36
|
+
summary="Create a new goal",
|
|
37
|
+
)
|
|
38
|
+
async def create_goal(
|
|
39
|
+
data: GoalCreate,
|
|
40
|
+
db: AsyncSession = Depends(get_db),
|
|
41
|
+
) -> GoalResponse:
|
|
42
|
+
"""Create a new goal."""
|
|
43
|
+
service = GoalService(db)
|
|
44
|
+
goal = await service.create_goal(data)
|
|
45
|
+
return GoalResponse.model_validate(goal)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.get(
|
|
49
|
+
"",
|
|
50
|
+
summary="List all goals",
|
|
51
|
+
)
|
|
52
|
+
async def list_goals(
|
|
53
|
+
board_id: str | None = None,
|
|
54
|
+
page: int | None = Query(
|
|
55
|
+
None, ge=1, description="Page number (1-based). Omit for all results."
|
|
56
|
+
),
|
|
57
|
+
limit: int | None = Query(
|
|
58
|
+
None, ge=1, le=200, description="Items per page. Omit for all results."
|
|
59
|
+
),
|
|
60
|
+
db: AsyncSession = Depends(get_db),
|
|
61
|
+
) -> GoalListResponse | PaginatedResponse[GoalResponse]:
|
|
62
|
+
"""Get all goals, optionally filtered by board_id.
|
|
63
|
+
|
|
64
|
+
**Pagination (optional):**
|
|
65
|
+
- If `page` and `limit` are provided, returns paginated response.
|
|
66
|
+
- If omitted, returns all goals (backward compatible).
|
|
67
|
+
"""
|
|
68
|
+
service = GoalService(db)
|
|
69
|
+
goals = await service.get_goals(board_id=board_id)
|
|
70
|
+
all_responses = [GoalResponse.model_validate(g) for g in goals]
|
|
71
|
+
|
|
72
|
+
# If pagination params are provided, return paginated response
|
|
73
|
+
if page is not None and limit is not None:
|
|
74
|
+
total = len(all_responses)
|
|
75
|
+
offset = (page - 1) * limit
|
|
76
|
+
page_items = all_responses[offset : offset + limit]
|
|
77
|
+
return PaginatedResponse[GoalResponse](
|
|
78
|
+
items=page_items,
|
|
79
|
+
total=total,
|
|
80
|
+
page=page,
|
|
81
|
+
limit=limit,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Backward compatible: return all
|
|
85
|
+
return GoalListResponse(
|
|
86
|
+
goals=all_responses,
|
|
87
|
+
total=len(all_responses),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@router.get(
|
|
92
|
+
"/{goal_id}",
|
|
93
|
+
response_model=GoalResponse,
|
|
94
|
+
summary="Get a goal by ID",
|
|
95
|
+
)
|
|
96
|
+
async def get_goal(
|
|
97
|
+
goal_id: str,
|
|
98
|
+
db: AsyncSession = Depends(get_db),
|
|
99
|
+
) -> GoalResponse:
|
|
100
|
+
"""Get a goal by its ID."""
|
|
101
|
+
service = GoalService(db)
|
|
102
|
+
goal = await service.get_goal_by_id(goal_id)
|
|
103
|
+
return GoalResponse.model_validate(goal)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@router.patch(
|
|
107
|
+
"/{goal_id}",
|
|
108
|
+
response_model=GoalResponse,
|
|
109
|
+
summary="Update a goal",
|
|
110
|
+
)
|
|
111
|
+
async def update_goal(
|
|
112
|
+
goal_id: str,
|
|
113
|
+
data: GoalUpdate,
|
|
114
|
+
db: AsyncSession = Depends(get_db),
|
|
115
|
+
) -> GoalResponse:
|
|
116
|
+
"""Update a goal with partial data. Supports updating autonomy settings."""
|
|
117
|
+
service = GoalService(db)
|
|
118
|
+
goal = await service.update_goal(goal_id, data)
|
|
119
|
+
await db.commit()
|
|
120
|
+
return GoalResponse.model_validate(goal)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@router.patch(
|
|
124
|
+
"/{goal_id}/autonomy",
|
|
125
|
+
response_model=GoalResponse,
|
|
126
|
+
summary="Update autonomy settings for a goal",
|
|
127
|
+
)
|
|
128
|
+
async def update_autonomy(
|
|
129
|
+
goal_id: str,
|
|
130
|
+
data: AutonomySettings,
|
|
131
|
+
db: AsyncSession = Depends(get_db),
|
|
132
|
+
) -> GoalResponse:
|
|
133
|
+
"""Update autonomy settings for a goal. Accepts partial updates."""
|
|
134
|
+
service = GoalService(db)
|
|
135
|
+
update_data = GoalUpdate(**data.model_dump())
|
|
136
|
+
goal = await service.update_goal(goal_id, update_data)
|
|
137
|
+
await db.commit()
|
|
138
|
+
return GoalResponse.model_validate(goal)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@router.delete(
|
|
142
|
+
"/{goal_id}",
|
|
143
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
144
|
+
summary="Delete a goal and all its tickets",
|
|
145
|
+
)
|
|
146
|
+
async def delete_goal(
|
|
147
|
+
goal_id: str,
|
|
148
|
+
db: AsyncSession = Depends(get_db),
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Delete a goal and cascade delete all associated tickets, jobs, evidence, etc."""
|
|
151
|
+
from sqlalchemy import delete as sql_delete
|
|
152
|
+
|
|
153
|
+
from app.models.goal import Goal
|
|
154
|
+
|
|
155
|
+
service = GoalService(db)
|
|
156
|
+
await service.get_goal_by_id(goal_id) # Verify exists
|
|
157
|
+
await db.execute(sql_delete(Goal).where(Goal.id == goal_id))
|
|
158
|
+
await db.commit()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@router.get(
|
|
162
|
+
"/{goal_id}/autonomy/status",
|
|
163
|
+
response_model=AutonomyStatusResponse,
|
|
164
|
+
summary="Get autonomy status for a goal",
|
|
165
|
+
)
|
|
166
|
+
async def get_autonomy_status(
|
|
167
|
+
goal_id: str,
|
|
168
|
+
db: AsyncSession = Depends(get_db),
|
|
169
|
+
) -> AutonomyStatusResponse:
|
|
170
|
+
"""Get autonomy status including settings, approval count, and budget info."""
|
|
171
|
+
service = GoalService(db)
|
|
172
|
+
goal = await service.get_goal_by_id(goal_id)
|
|
173
|
+
|
|
174
|
+
# Check budget remaining using CostTrackingService
|
|
175
|
+
budget_remaining = None
|
|
176
|
+
if goal.budget:
|
|
177
|
+
if goal.budget.total_budget is not None:
|
|
178
|
+
from app.services.cost_tracking_service import CostTrackingService
|
|
179
|
+
|
|
180
|
+
cost_service = CostTrackingService(db)
|
|
181
|
+
spent = await cost_service.get_goal_cost(goal_id)
|
|
182
|
+
budget_remaining = max(0.0, goal.budget.total_budget - spent)
|
|
183
|
+
|
|
184
|
+
return AutonomyStatusResponse(
|
|
185
|
+
goal_id=goal.id,
|
|
186
|
+
autonomy_enabled=goal.autonomy_enabled,
|
|
187
|
+
auto_approve_tickets=goal.auto_approve_tickets,
|
|
188
|
+
auto_approve_revisions=goal.auto_approve_revisions,
|
|
189
|
+
auto_merge=goal.auto_merge,
|
|
190
|
+
auto_approve_followups=goal.auto_approve_followups,
|
|
191
|
+
max_auto_approvals=goal.max_auto_approvals,
|
|
192
|
+
auto_approval_count=goal.auto_approval_count,
|
|
193
|
+
budget_remaining=budget_remaining,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@router.get(
|
|
198
|
+
"/{goal_id}/generate-tickets/stream",
|
|
199
|
+
summary="Generate tickets with streaming progress (SSE)",
|
|
200
|
+
)
|
|
201
|
+
async def generate_tickets_stream(
|
|
202
|
+
goal_id: str,
|
|
203
|
+
):
|
|
204
|
+
"""
|
|
205
|
+
Generate tickets with real-time streaming feedback using Server-Sent Events (SSE).
|
|
206
|
+
|
|
207
|
+
Uses its own DB session to survive client disconnects (EventSource reconnects).
|
|
208
|
+
|
|
209
|
+
The stream sends JSON events with the following types:
|
|
210
|
+
- status: Progress updates like "Analyzing codebase...", "Generating tickets..."
|
|
211
|
+
- agent_output: Real-time output from the agent CLI
|
|
212
|
+
- ticket: Each ticket as it's created
|
|
213
|
+
- complete: Final summary when done
|
|
214
|
+
- error: If something goes wrong
|
|
215
|
+
"""
|
|
216
|
+
import asyncio
|
|
217
|
+
import json as json_lib
|
|
218
|
+
import logging
|
|
219
|
+
|
|
220
|
+
from fastapi.responses import StreamingResponse
|
|
221
|
+
|
|
222
|
+
from app.database import async_session_maker
|
|
223
|
+
|
|
224
|
+
logger = logging.getLogger(__name__)
|
|
225
|
+
|
|
226
|
+
async def event_generator():
|
|
227
|
+
try:
|
|
228
|
+
from app.services.cursor_log_normalizer import CursorLogNormalizer
|
|
229
|
+
|
|
230
|
+
# Send initial status
|
|
231
|
+
yield f"data: {json_lib.dumps({'type': 'status', 'message': 'Starting ticket generation...'})}\n\n"
|
|
232
|
+
await asyncio.sleep(0.05)
|
|
233
|
+
|
|
234
|
+
# Use our own DB session (not request-scoped) so it survives SSE disconnects
|
|
235
|
+
async with async_session_maker() as db:
|
|
236
|
+
# Load config from goal's board (DB is source of truth)
|
|
237
|
+
from sqlalchemy import select as sa_select
|
|
238
|
+
|
|
239
|
+
from app.models.board import Board
|
|
240
|
+
from app.models.goal import Goal
|
|
241
|
+
from app.services.config_service import DraftConfig
|
|
242
|
+
|
|
243
|
+
yield f"data: {json_lib.dumps({'type': 'status', 'message': 'Loading goal and board configuration...'})}\n\n"
|
|
244
|
+
|
|
245
|
+
goal_result = await db.execute(
|
|
246
|
+
sa_select(Goal).where(Goal.id == goal_id)
|
|
247
|
+
)
|
|
248
|
+
goal_obj = goal_result.scalar_one_or_none()
|
|
249
|
+
if not goal_obj:
|
|
250
|
+
yield f"data: {json_lib.dumps({'type': 'error', 'message': f'Goal not found: {goal_id}'})}\n\n"
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
board_config_dict = None
|
|
254
|
+
if goal_obj and goal_obj.board_id:
|
|
255
|
+
board_result = await db.execute(
|
|
256
|
+
sa_select(Board).where(Board.id == goal_obj.board_id)
|
|
257
|
+
)
|
|
258
|
+
board_obj = board_result.scalar_one_or_none()
|
|
259
|
+
if board_obj and board_obj.config:
|
|
260
|
+
board_config_dict = board_obj.config
|
|
261
|
+
|
|
262
|
+
config = DraftConfig.from_board_config(board_config_dict)
|
|
263
|
+
|
|
264
|
+
yield f"data: {json_lib.dumps({'type': 'status', 'message': f'Using model: {config.planner_config.model}'})}\n\n"
|
|
265
|
+
|
|
266
|
+
service = TicketGenerationService(db, config=config.planner_config)
|
|
267
|
+
|
|
268
|
+
# Create a queue for streaming agent output
|
|
269
|
+
output_queue: asyncio.Queue = asyncio.Queue()
|
|
270
|
+
loop = asyncio.get_running_loop()
|
|
271
|
+
|
|
272
|
+
# Normalizer to parse CLI JSON output into structured entries
|
|
273
|
+
normalizer = CursorLogNormalizer()
|
|
274
|
+
|
|
275
|
+
def stream_callback(line: str):
|
|
276
|
+
"""Called from subprocess thread when agent outputs a line."""
|
|
277
|
+
try:
|
|
278
|
+
loop.call_soon_threadsafe(
|
|
279
|
+
output_queue.put_nowait, ("agent_output", line)
|
|
280
|
+
)
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
yield f"data: {json_lib.dumps({'type': 'status', 'message': 'Launching agent subprocess...'})}\n\n"
|
|
285
|
+
|
|
286
|
+
# Start generation task
|
|
287
|
+
generation_task = asyncio.create_task(
|
|
288
|
+
service.generate_from_goal(
|
|
289
|
+
goal_id=goal_id,
|
|
290
|
+
include_readme=False,
|
|
291
|
+
validate_tickets=config.planner_config.features.validate_tickets,
|
|
292
|
+
stream_callback=stream_callback,
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def _normalize_and_yield(line: str):
|
|
297
|
+
"""Parse a raw CLI line into normalized entries."""
|
|
298
|
+
entries = normalizer.process_line(line)
|
|
299
|
+
results = []
|
|
300
|
+
for entry in entries:
|
|
301
|
+
entry_data = {
|
|
302
|
+
"entry_type": entry.entry_type.value,
|
|
303
|
+
"content": entry.content,
|
|
304
|
+
"sequence": entry.sequence,
|
|
305
|
+
"tool_name": entry.tool_name,
|
|
306
|
+
"action_type": entry.action_type.value
|
|
307
|
+
if entry.action_type
|
|
308
|
+
else None,
|
|
309
|
+
"tool_status": entry.tool_status.value
|
|
310
|
+
if entry.tool_status
|
|
311
|
+
else None,
|
|
312
|
+
"metadata": entry.metadata or {},
|
|
313
|
+
"timestamp": None,
|
|
314
|
+
}
|
|
315
|
+
results.append(
|
|
316
|
+
f"data: {json_lib.dumps({'type': 'agent_normalized', 'entry': entry_data})}\n\n"
|
|
317
|
+
)
|
|
318
|
+
return results
|
|
319
|
+
|
|
320
|
+
# Stream agent output as it comes in
|
|
321
|
+
while not generation_task.done():
|
|
322
|
+
try:
|
|
323
|
+
msg_type, data = await asyncio.wait_for(
|
|
324
|
+
output_queue.get(), timeout=0.1
|
|
325
|
+
)
|
|
326
|
+
if msg_type == "agent_output":
|
|
327
|
+
normalized_chunks = _normalize_and_yield(data)
|
|
328
|
+
if normalized_chunks:
|
|
329
|
+
for chunk in normalized_chunks:
|
|
330
|
+
yield chunk
|
|
331
|
+
else:
|
|
332
|
+
yield f"data: {json_lib.dumps({'type': 'agent_output', 'message': data})}\n\n"
|
|
333
|
+
except TimeoutError:
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
# Get final result
|
|
337
|
+
try:
|
|
338
|
+
result = await generation_task
|
|
339
|
+
except Exception as e:
|
|
340
|
+
logger.error(f"Ticket generation failed: {e}", exc_info=True)
|
|
341
|
+
yield f"data: {json_lib.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
# Drain any remaining messages
|
|
345
|
+
while not output_queue.empty():
|
|
346
|
+
msg_type, data = await output_queue.get()
|
|
347
|
+
if msg_type == "agent_output":
|
|
348
|
+
normalized_chunks = _normalize_and_yield(data)
|
|
349
|
+
if normalized_chunks:
|
|
350
|
+
for chunk in normalized_chunks:
|
|
351
|
+
yield chunk
|
|
352
|
+
else:
|
|
353
|
+
yield f"data: {json_lib.dumps({'type': 'agent_output', 'message': data})}\n\n"
|
|
354
|
+
|
|
355
|
+
# Flush any remaining buffered entries from normalizer
|
|
356
|
+
for entry in normalizer.finalize():
|
|
357
|
+
entry_data = {
|
|
358
|
+
"entry_type": entry.entry_type.value,
|
|
359
|
+
"content": entry.content,
|
|
360
|
+
"sequence": entry.sequence,
|
|
361
|
+
"tool_name": entry.tool_name,
|
|
362
|
+
"action_type": entry.action_type.value
|
|
363
|
+
if entry.action_type
|
|
364
|
+
else None,
|
|
365
|
+
"tool_status": entry.tool_status.value
|
|
366
|
+
if entry.tool_status
|
|
367
|
+
else None,
|
|
368
|
+
"metadata": entry.metadata or {},
|
|
369
|
+
"timestamp": None,
|
|
370
|
+
}
|
|
371
|
+
yield f"data: {json_lib.dumps({'type': 'agent_normalized', 'entry': entry_data})}\n\n"
|
|
372
|
+
|
|
373
|
+
# Stream each created ticket
|
|
374
|
+
if result.tickets:
|
|
375
|
+
yield f"data: {json_lib.dumps({'type': 'status', 'message': f'Created {len(result.tickets)} ticket(s)'})}\n\n"
|
|
376
|
+
for ticket in result.tickets:
|
|
377
|
+
desc = ticket.description or ""
|
|
378
|
+
desc_short = desc[:150] + "..." if len(desc) > 150 else desc
|
|
379
|
+
ticket_data = {
|
|
380
|
+
"id": ticket.id,
|
|
381
|
+
"title": ticket.title,
|
|
382
|
+
"priority": ticket.priority,
|
|
383
|
+
"description": desc_short,
|
|
384
|
+
"blocked_by_title": getattr(
|
|
385
|
+
ticket, "blocked_by_title", None
|
|
386
|
+
),
|
|
387
|
+
}
|
|
388
|
+
yield f"data: {json_lib.dumps({'type': 'ticket', 'ticket': ticket_data})}\n\n"
|
|
389
|
+
await asyncio.sleep(0.05)
|
|
390
|
+
else:
|
|
391
|
+
yield f"data: {json_lib.dumps({'type': 'status', 'message': 'Agent finished but generated no tickets.'})}\n\n"
|
|
392
|
+
|
|
393
|
+
# Send completion (always — even for 0 tickets so frontend gets onComplete)
|
|
394
|
+
yield f"data: {json_lib.dumps({'type': 'complete', 'count': len(result.tickets)})}\n\n"
|
|
395
|
+
|
|
396
|
+
except ValueError as e:
|
|
397
|
+
yield f"data: {json_lib.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
|
398
|
+
except Exception as e:
|
|
399
|
+
logger.error(f"Ticket generation stream error: {e}", exc_info=True)
|
|
400
|
+
yield f"data: {json_lib.dumps({'type': 'error', 'message': f'Generation failed: {str(e)}'})}\n\n"
|
|
401
|
+
|
|
402
|
+
return StreamingResponse(
|
|
403
|
+
event_generator(),
|
|
404
|
+
media_type="text/event-stream",
|
|
405
|
+
headers={
|
|
406
|
+
"Cache-Control": "no-cache",
|
|
407
|
+
"Connection": "keep-alive",
|
|
408
|
+
"X-Accel-Buffering": "no",
|
|
409
|
+
},
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@router.post(
|
|
414
|
+
"/{goal_id}/generate-tickets",
|
|
415
|
+
summary="Generate proposed tickets using LLM planner",
|
|
416
|
+
)
|
|
417
|
+
async def generate_tickets(
|
|
418
|
+
goal_id: str,
|
|
419
|
+
raw_request: Request,
|
|
420
|
+
db: AsyncSession = Depends(get_db),
|
|
421
|
+
):
|
|
422
|
+
"""
|
|
423
|
+
Generate proposed tickets for a goal using AI planner.
|
|
424
|
+
|
|
425
|
+
The planner analyzes the goal and repository context to generate
|
|
426
|
+
2-5 specific, actionable tickets with verification commands.
|
|
427
|
+
|
|
428
|
+
**Security:** Repository path is inferred from server config (draft.yaml),
|
|
429
|
+
NOT from client request. The `workspace_path` field is deprecated and ignored.
|
|
430
|
+
If sent, it will appear in X-Ignored-Fields response header.
|
|
431
|
+
|
|
432
|
+
**New in v2:** Tickets now include priority buckets (P0-P3) which are
|
|
433
|
+
normalized to numeric priorities (P0=90, P1=70, P2=50, P3=30).
|
|
434
|
+
|
|
435
|
+
Requires LLM API key environment variables (OPENAI_API_KEY, etc.).
|
|
436
|
+
"""
|
|
437
|
+
import json
|
|
438
|
+
|
|
439
|
+
# Parse raw body to check for ignored fields
|
|
440
|
+
body = await raw_request.body()
|
|
441
|
+
try:
|
|
442
|
+
raw_body = json.loads(body) if body else {}
|
|
443
|
+
except json.JSONDecodeError:
|
|
444
|
+
raw_body = {}
|
|
445
|
+
|
|
446
|
+
# Check for ignored/deprecated fields
|
|
447
|
+
allowed_fields = {"include_readme"}
|
|
448
|
+
ignored_fields = check_ignored_fields(raw_request, raw_body, allowed_fields)
|
|
449
|
+
|
|
450
|
+
# Parse into Pydantic model
|
|
451
|
+
request = GenerateTicketsRequest(
|
|
452
|
+
**{k: v for k, v in raw_body.items() if k in allowed_fields}
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
# Get config from goal's board (DB is source of truth)
|
|
456
|
+
from sqlalchemy import select as sa_select
|
|
457
|
+
|
|
458
|
+
from app.models.board import Board
|
|
459
|
+
from app.models.goal import Goal
|
|
460
|
+
from app.services.config_service import DraftConfig
|
|
461
|
+
|
|
462
|
+
goal_result = await db.execute(sa_select(Goal).where(Goal.id == goal_id))
|
|
463
|
+
goal_obj = goal_result.scalar_one_or_none()
|
|
464
|
+
if not goal_obj:
|
|
465
|
+
raise HTTPException(status_code=404, detail=f"Goal not found: {goal_id}")
|
|
466
|
+
|
|
467
|
+
board_config_dict = None
|
|
468
|
+
repo_root = None
|
|
469
|
+
if goal_obj.board_id:
|
|
470
|
+
board_result = await db.execute(
|
|
471
|
+
sa_select(Board).where(Board.id == goal_obj.board_id)
|
|
472
|
+
)
|
|
473
|
+
board_obj = board_result.scalar_one_or_none()
|
|
474
|
+
if board_obj:
|
|
475
|
+
if board_obj.config:
|
|
476
|
+
board_config_dict = board_obj.config
|
|
477
|
+
repo_root = Path(board_obj.repo_root).resolve()
|
|
478
|
+
|
|
479
|
+
config = DraftConfig.from_board_config(board_config_dict)
|
|
480
|
+
|
|
481
|
+
if not repo_root or not repo_root.exists():
|
|
482
|
+
raise HTTPException(
|
|
483
|
+
status_code=500,
|
|
484
|
+
detail=f"Board repo_root does not exist: {repo_root}",
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
# Check if UDAR agent is enabled (Phase 2 feature flag)
|
|
488
|
+
if config.planner_config.udar.enabled:
|
|
489
|
+
# Use UDAR agent for adaptive ticket generation
|
|
490
|
+
udar_service = UDARPlannerService(db)
|
|
491
|
+
try:
|
|
492
|
+
udar_result = await udar_service.generate_from_goal(goal_id=goal_id)
|
|
493
|
+
# Convert UDAR result to expected format
|
|
494
|
+
result_tickets = [
|
|
495
|
+
{
|
|
496
|
+
"id": t.get("id"),
|
|
497
|
+
"title": t.get("title"),
|
|
498
|
+
"description": t.get("description"),
|
|
499
|
+
"priority": t.get("priority", 50),
|
|
500
|
+
"state": "proposed",
|
|
501
|
+
}
|
|
502
|
+
for t in udar_result.get("tickets", [])
|
|
503
|
+
]
|
|
504
|
+
result = type("obj", (object,), {"tickets": result_tickets})()
|
|
505
|
+
except ValueError as e:
|
|
506
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
507
|
+
else:
|
|
508
|
+
# Use ticket generation service with board config
|
|
509
|
+
service = TicketGenerationService(db, config=config.planner_config)
|
|
510
|
+
try:
|
|
511
|
+
result = await service.generate_from_goal(
|
|
512
|
+
goal_id=goal_id,
|
|
513
|
+
repo_root=repo_root,
|
|
514
|
+
include_readme=request.include_readme,
|
|
515
|
+
validate_tickets=config.planner_config.features.validate_tickets,
|
|
516
|
+
)
|
|
517
|
+
except ValueError as e:
|
|
518
|
+
error_msg = str(e)
|
|
519
|
+
if (
|
|
520
|
+
"API key" in error_msg
|
|
521
|
+
or "credentials" in error_msg
|
|
522
|
+
or "unavailable" in error_msg
|
|
523
|
+
):
|
|
524
|
+
raise HTTPException(status_code=503, detail=error_msg)
|
|
525
|
+
raise HTTPException(status_code=404, detail=error_msg)
|
|
526
|
+
|
|
527
|
+
# Build response
|
|
528
|
+
if len(result.tickets) == 0:
|
|
529
|
+
response_data = GenerateTicketsResponse(
|
|
530
|
+
tickets=[],
|
|
531
|
+
goal_id=goal_id,
|
|
532
|
+
)
|
|
533
|
+
else:
|
|
534
|
+
response_data = GenerateTicketsResponse(
|
|
535
|
+
tickets=result.tickets,
|
|
536
|
+
goal_id=goal_id,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
# Build response with X-Ignored-Fields header if applicable
|
|
540
|
+
response = JSONResponse(content=response_data.model_dump())
|
|
541
|
+
add_ignored_fields_header(response, ignored_fields)
|
|
542
|
+
|
|
543
|
+
return response
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@router.post(
|
|
547
|
+
"/{goal_id}/reflect-on-tickets",
|
|
548
|
+
response_model=ReflectionResult,
|
|
549
|
+
summary="Reflect on proposed tickets for quality and coverage",
|
|
550
|
+
)
|
|
551
|
+
async def reflect_on_tickets(
|
|
552
|
+
goal_id: str,
|
|
553
|
+
db: AsyncSession = Depends(get_db),
|
|
554
|
+
) -> ReflectionResult:
|
|
555
|
+
"""
|
|
556
|
+
Evaluate proposed tickets for a goal using AI reflection.
|
|
557
|
+
|
|
558
|
+
This endpoint analyzes the PROPOSED tickets for a goal and returns:
|
|
559
|
+
- **overall_quality**: "good", "needs_work", or "insufficient"
|
|
560
|
+
- **quality_notes**: Detailed assessment of ticket quality
|
|
561
|
+
- **coverage_gaps**: Areas not covered by current tickets
|
|
562
|
+
- **suggested_changes**: Recommended priority adjustments
|
|
563
|
+
|
|
564
|
+
**Important:** This endpoint does NOT apply changes. To apply suggested
|
|
565
|
+
priority changes, use `POST /tickets/bulk-update-priority` with the
|
|
566
|
+
suggested ticket IDs and new priority buckets.
|
|
567
|
+
|
|
568
|
+
This allows humans to review suggestions before applying them.
|
|
569
|
+
"""
|
|
570
|
+
service = TicketGenerationService(db)
|
|
571
|
+
try:
|
|
572
|
+
return await service.reflect_on_proposals(goal_id)
|
|
573
|
+
except ValueError as e:
|
|
574
|
+
raise HTTPException(status_code=404, detail=str(e))
|