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,448 @@
|
|
|
1
|
+
"""API router for Job endpoints."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
4
|
+
from fastapi.responses import PlainTextResponse
|
|
5
|
+
from sqlalchemy import func, select
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
from sse_starlette.sse import EventSourceResponse
|
|
8
|
+
|
|
9
|
+
from app.database import get_db
|
|
10
|
+
from app.exceptions import ResourceNotFoundError
|
|
11
|
+
from app.models.job import Job, JobKind
|
|
12
|
+
from app.schemas.common import PaginatedResponse
|
|
13
|
+
from app.schemas.job import (
|
|
14
|
+
CancelJobResponse,
|
|
15
|
+
JobCreateResponse,
|
|
16
|
+
JobDetailResponse,
|
|
17
|
+
JobResponse,
|
|
18
|
+
JobStatus,
|
|
19
|
+
QueueStatusResponse,
|
|
20
|
+
)
|
|
21
|
+
from app.services.job_service import JobService
|
|
22
|
+
from app.services.log_normalizer import LogNormalizerService
|
|
23
|
+
from app.services.log_stream_service import LogLevel, log_stream_service
|
|
24
|
+
|
|
25
|
+
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.get(
|
|
29
|
+
"",
|
|
30
|
+
response_model=PaginatedResponse[JobResponse],
|
|
31
|
+
summary="List historical jobs with filters",
|
|
32
|
+
)
|
|
33
|
+
async def list_jobs(
|
|
34
|
+
ticket_id: str | None = Query(None, description="Filter by ticket ID"),
|
|
35
|
+
job_status: JobStatus | None = Query(
|
|
36
|
+
None, alias="status", description="Filter by job status"
|
|
37
|
+
),
|
|
38
|
+
kind: str | None = Query(None, description="Filter by job kind"),
|
|
39
|
+
board_id: str | None = Query(None, description="Filter by board ID"),
|
|
40
|
+
page: int = Query(1, ge=1, description="Page number (1-based)"),
|
|
41
|
+
limit: int = Query(50, ge=1, le=200, description="Items per page"),
|
|
42
|
+
db: AsyncSession = Depends(get_db),
|
|
43
|
+
) -> PaginatedResponse[JobResponse]:
|
|
44
|
+
"""
|
|
45
|
+
List historical jobs with optional filters and pagination.
|
|
46
|
+
|
|
47
|
+
**Filters:**
|
|
48
|
+
- `ticket_id`: Filter by ticket
|
|
49
|
+
- `status`: Filter by status (queued, running, succeeded, failed, canceled)
|
|
50
|
+
- `kind`: Filter by kind (execute, verify, resume)
|
|
51
|
+
- `board_id`: Filter by board
|
|
52
|
+
|
|
53
|
+
**Pagination:**
|
|
54
|
+
- `page`: Page number (1-based, default 1)
|
|
55
|
+
- `limit`: Items per page (default 50, max 200)
|
|
56
|
+
|
|
57
|
+
Results are ordered by creation time descending (newest first).
|
|
58
|
+
"""
|
|
59
|
+
query = select(Job)
|
|
60
|
+
count_query = select(func.count(Job.id))
|
|
61
|
+
|
|
62
|
+
# Apply filters
|
|
63
|
+
if ticket_id is not None:
|
|
64
|
+
query = query.where(Job.ticket_id == ticket_id)
|
|
65
|
+
count_query = count_query.where(Job.ticket_id == ticket_id)
|
|
66
|
+
if job_status is not None:
|
|
67
|
+
query = query.where(Job.status == job_status.value)
|
|
68
|
+
count_query = count_query.where(Job.status == job_status.value)
|
|
69
|
+
if kind is not None:
|
|
70
|
+
query = query.where(Job.kind == kind)
|
|
71
|
+
count_query = count_query.where(Job.kind == kind)
|
|
72
|
+
if board_id is not None:
|
|
73
|
+
query = query.where(Job.board_id == board_id)
|
|
74
|
+
count_query = count_query.where(Job.board_id == board_id)
|
|
75
|
+
|
|
76
|
+
# Get total count
|
|
77
|
+
total_result = await db.execute(count_query)
|
|
78
|
+
total = total_result.scalar() or 0
|
|
79
|
+
|
|
80
|
+
# Apply ordering and pagination
|
|
81
|
+
offset = (page - 1) * limit
|
|
82
|
+
query = query.order_by(Job.created_at.desc()).offset(offset).limit(limit)
|
|
83
|
+
|
|
84
|
+
result = await db.execute(query)
|
|
85
|
+
jobs = result.scalars().all()
|
|
86
|
+
|
|
87
|
+
items = [
|
|
88
|
+
JobResponse(
|
|
89
|
+
id=job.id,
|
|
90
|
+
ticket_id=job.ticket_id,
|
|
91
|
+
kind=job.kind_enum,
|
|
92
|
+
status=job.status_enum,
|
|
93
|
+
created_at=job.created_at,
|
|
94
|
+
started_at=job.started_at,
|
|
95
|
+
finished_at=job.finished_at,
|
|
96
|
+
exit_code=job.exit_code,
|
|
97
|
+
log_path=job.log_path,
|
|
98
|
+
)
|
|
99
|
+
for job in jobs
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
return PaginatedResponse[JobResponse](
|
|
103
|
+
items=items,
|
|
104
|
+
total=total,
|
|
105
|
+
page=page,
|
|
106
|
+
limit=limit,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.get(
|
|
111
|
+
"/queue",
|
|
112
|
+
response_model=QueueStatusResponse,
|
|
113
|
+
summary="Get queue status with running and queued jobs",
|
|
114
|
+
)
|
|
115
|
+
async def get_queue_status(
|
|
116
|
+
db: AsyncSession = Depends(get_db),
|
|
117
|
+
) -> QueueStatusResponse:
|
|
118
|
+
"""
|
|
119
|
+
Get the current queue status showing which agents/jobs are running
|
|
120
|
+
and which jobs are waiting in the queue.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
- running: List of currently running jobs with ticket info
|
|
124
|
+
- queued: List of queued jobs in order (first = next to run)
|
|
125
|
+
"""
|
|
126
|
+
service = JobService(db)
|
|
127
|
+
return await service.get_queue_status()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@router.get(
|
|
131
|
+
"/{job_id}",
|
|
132
|
+
response_model=JobDetailResponse,
|
|
133
|
+
summary="Get a job by ID",
|
|
134
|
+
)
|
|
135
|
+
async def get_job(
|
|
136
|
+
job_id: str,
|
|
137
|
+
db: AsyncSession = Depends(get_db),
|
|
138
|
+
) -> JobDetailResponse:
|
|
139
|
+
"""
|
|
140
|
+
Get a job by its ID, including log content if available.
|
|
141
|
+
"""
|
|
142
|
+
service = JobService(db)
|
|
143
|
+
job = await service.get_job_by_id(job_id)
|
|
144
|
+
# Use async version to avoid blocking event loop during file I/O
|
|
145
|
+
logs = await service.read_job_logs_async(job.log_path)
|
|
146
|
+
|
|
147
|
+
return JobDetailResponse(
|
|
148
|
+
id=job.id,
|
|
149
|
+
ticket_id=job.ticket_id,
|
|
150
|
+
kind=job.kind_enum,
|
|
151
|
+
status=job.status_enum,
|
|
152
|
+
created_at=job.created_at,
|
|
153
|
+
started_at=job.started_at,
|
|
154
|
+
finished_at=job.finished_at,
|
|
155
|
+
exit_code=job.exit_code,
|
|
156
|
+
log_path=job.log_path,
|
|
157
|
+
logs=logs,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@router.get(
|
|
162
|
+
"/{job_id}/logs",
|
|
163
|
+
response_class=PlainTextResponse,
|
|
164
|
+
summary="Get raw logs for a job",
|
|
165
|
+
)
|
|
166
|
+
async def get_job_logs(
|
|
167
|
+
job_id: str,
|
|
168
|
+
db: AsyncSession = Depends(get_db),
|
|
169
|
+
) -> PlainTextResponse:
|
|
170
|
+
"""
|
|
171
|
+
Get the raw log content for a job as plain text.
|
|
172
|
+
"""
|
|
173
|
+
service = JobService(db)
|
|
174
|
+
job = await service.get_job_by_id(job_id)
|
|
175
|
+
# Use async version to avoid blocking event loop during file I/O
|
|
176
|
+
logs = await service.read_job_logs_async(job.log_path)
|
|
177
|
+
|
|
178
|
+
if logs is None:
|
|
179
|
+
return PlainTextResponse(content="No logs available yet.", status_code=200)
|
|
180
|
+
|
|
181
|
+
return PlainTextResponse(content=logs)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@router.get(
|
|
185
|
+
"/{job_id}/logs/stream",
|
|
186
|
+
summary="Stream logs in real-time via SSE",
|
|
187
|
+
)
|
|
188
|
+
async def stream_job_logs(
|
|
189
|
+
job_id: str,
|
|
190
|
+
db: AsyncSession = Depends(get_db),
|
|
191
|
+
) -> EventSourceResponse:
|
|
192
|
+
"""
|
|
193
|
+
Stream job logs in real-time using Server-Sent Events (SSE).
|
|
194
|
+
|
|
195
|
+
This provides instant feedback during job execution - similar to
|
|
196
|
+
vibe-kanban's WebSocket streaming but using SSE for simplicity.
|
|
197
|
+
|
|
198
|
+
Events:
|
|
199
|
+
- stdout: Standard output from executor
|
|
200
|
+
- stderr: Standard error from executor
|
|
201
|
+
- info: Informational messages
|
|
202
|
+
- error: Error messages
|
|
203
|
+
- finished: Job has completed
|
|
204
|
+
|
|
205
|
+
The stream will:
|
|
206
|
+
1. First send all historical messages (catch-up)
|
|
207
|
+
2. Then stream live updates as they happen
|
|
208
|
+
3. Close when job finishes or client disconnects
|
|
209
|
+
|
|
210
|
+
Example client usage (JavaScript):
|
|
211
|
+
const es = new EventSource('/api/jobs/{job_id}/logs/stream');
|
|
212
|
+
es.addEventListener('stdout', (e) => console.log(e.data));
|
|
213
|
+
es.addEventListener('finished', () => es.close());
|
|
214
|
+
"""
|
|
215
|
+
# Verify job exists
|
|
216
|
+
service = JobService(db)
|
|
217
|
+
await service.get_job_by_id(job_id)
|
|
218
|
+
|
|
219
|
+
async def event_generator():
|
|
220
|
+
"""Generate SSE events from log stream."""
|
|
221
|
+
import json
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
async for msg in log_stream_service.subscribe(job_id):
|
|
225
|
+
# For progress events, include metadata as JSON
|
|
226
|
+
if msg.level == LogLevel.PROGRESS:
|
|
227
|
+
data = json.dumps(
|
|
228
|
+
{
|
|
229
|
+
"content": msg.content,
|
|
230
|
+
"progress_pct": msg.progress_pct,
|
|
231
|
+
"stage": msg.stage,
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
else:
|
|
235
|
+
data = msg.content
|
|
236
|
+
|
|
237
|
+
yield {
|
|
238
|
+
"event": msg.level.value,
|
|
239
|
+
"data": data,
|
|
240
|
+
}
|
|
241
|
+
except Exception as e:
|
|
242
|
+
yield {
|
|
243
|
+
"event": "error",
|
|
244
|
+
"data": f"Stream error: {str(e)}",
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return EventSourceResponse(
|
|
248
|
+
event_generator(),
|
|
249
|
+
ping=15, # Send keepalive every 15 seconds
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@router.post(
|
|
254
|
+
"/{job_id}/cancel",
|
|
255
|
+
response_model=CancelJobResponse,
|
|
256
|
+
summary="Cancel a job (best-effort)",
|
|
257
|
+
)
|
|
258
|
+
async def cancel_job(
|
|
259
|
+
job_id: str,
|
|
260
|
+
db: AsyncSession = Depends(get_db),
|
|
261
|
+
) -> CancelJobResponse:
|
|
262
|
+
"""
|
|
263
|
+
Cancel a job (best-effort).
|
|
264
|
+
|
|
265
|
+
This will mark the job as canceled in the database and attempt to
|
|
266
|
+
revoke the Celery task. If the task is already running, it may
|
|
267
|
+
complete before the cancellation takes effect.
|
|
268
|
+
"""
|
|
269
|
+
service = JobService(db)
|
|
270
|
+
job = await service.cancel_job(job_id)
|
|
271
|
+
|
|
272
|
+
# Determine message based on original vs new status
|
|
273
|
+
if job.status == JobStatus.CANCELED.value:
|
|
274
|
+
message = "Job cancellation requested"
|
|
275
|
+
else:
|
|
276
|
+
message = f"Job already in terminal state: {job.status}"
|
|
277
|
+
|
|
278
|
+
return CancelJobResponse(
|
|
279
|
+
id=job.id,
|
|
280
|
+
status=job.status_enum,
|
|
281
|
+
message=message,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@router.post(
|
|
286
|
+
"/{job_id}/retry",
|
|
287
|
+
response_model=JobCreateResponse,
|
|
288
|
+
status_code=status.HTTP_201_CREATED,
|
|
289
|
+
summary="Retry a failed job",
|
|
290
|
+
)
|
|
291
|
+
async def retry_job(
|
|
292
|
+
job_id: str,
|
|
293
|
+
db: AsyncSession = Depends(get_db),
|
|
294
|
+
) -> JobCreateResponse:
|
|
295
|
+
"""
|
|
296
|
+
Retry a failed job by creating a new job with the same kind.
|
|
297
|
+
|
|
298
|
+
This will:
|
|
299
|
+
1. Verify the original job exists and is in a terminal state (FAILED/CANCELED)
|
|
300
|
+
2. Create a new job with the same kind for the same ticket
|
|
301
|
+
3. Enqueue the new job to Celery
|
|
302
|
+
|
|
303
|
+
Note: This creates a NEW job (new ID), it does not reuse the old job.
|
|
304
|
+
"""
|
|
305
|
+
service = JobService(db)
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
original_job = await service.get_job_by_id(job_id)
|
|
309
|
+
except ResourceNotFoundError:
|
|
310
|
+
raise HTTPException(
|
|
311
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
312
|
+
detail=f"Job {job_id} not found",
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Verify job is in terminal state
|
|
316
|
+
if original_job.status not in [JobStatus.FAILED.value, JobStatus.CANCELED.value]:
|
|
317
|
+
raise HTTPException(
|
|
318
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
319
|
+
detail=f"Can only retry FAILED or CANCELED jobs. Current status: {original_job.status}",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Create new job with same kind
|
|
323
|
+
try:
|
|
324
|
+
new_job = await service.create_job(
|
|
325
|
+
ticket_id=original_job.ticket_id,
|
|
326
|
+
kind=JobKind(original_job.kind),
|
|
327
|
+
)
|
|
328
|
+
await db.commit()
|
|
329
|
+
except Exception as e:
|
|
330
|
+
raise HTTPException(
|
|
331
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
332
|
+
detail=f"Failed to create retry job: {str(e)}",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
return JobCreateResponse(
|
|
336
|
+
id=new_job.id,
|
|
337
|
+
ticket_id=new_job.ticket_id,
|
|
338
|
+
kind=new_job.kind_enum,
|
|
339
|
+
status=new_job.status_enum,
|
|
340
|
+
created_at=new_job.created_at,
|
|
341
|
+
started_at=new_job.started_at,
|
|
342
|
+
finished_at=new_job.finished_at,
|
|
343
|
+
exit_code=new_job.exit_code,
|
|
344
|
+
log_path=new_job.log_path,
|
|
345
|
+
celery_task_id=new_job.celery_task_id,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@router.get(
|
|
350
|
+
"/{job_id}/normalized-logs",
|
|
351
|
+
summary="Get normalized, structured logs for a job",
|
|
352
|
+
)
|
|
353
|
+
async def get_normalized_logs(
|
|
354
|
+
job_id: str,
|
|
355
|
+
db: AsyncSession = Depends(get_db),
|
|
356
|
+
) -> list[dict]:
|
|
357
|
+
"""
|
|
358
|
+
Get normalized, structured logs for a job.
|
|
359
|
+
|
|
360
|
+
Returns a list of structured log entries parsed from raw agent output.
|
|
361
|
+
Each entry has a semantic type (thinking, file_edit, command_run, etc.)
|
|
362
|
+
and structured metadata for rich UI rendering.
|
|
363
|
+
|
|
364
|
+
If normalized logs don't exist yet, returns an empty list.
|
|
365
|
+
"""
|
|
366
|
+
service = JobService(db)
|
|
367
|
+
|
|
368
|
+
# Verify job exists
|
|
369
|
+
try:
|
|
370
|
+
await service.get_job_by_id(job_id)
|
|
371
|
+
except ResourceNotFoundError:
|
|
372
|
+
raise HTTPException(
|
|
373
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
374
|
+
detail=f"Job {job_id} not found",
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Get normalized logs
|
|
378
|
+
normalizer = LogNormalizerService()
|
|
379
|
+
logs = await normalizer.get_normalized_logs(db, job_id)
|
|
380
|
+
|
|
381
|
+
return [log.to_dict() for log in logs]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@router.post(
|
|
385
|
+
"/{job_id}/normalize-logs",
|
|
386
|
+
summary="Parse and normalize logs for a job",
|
|
387
|
+
)
|
|
388
|
+
async def normalize_logs(
|
|
389
|
+
job_id: str,
|
|
390
|
+
agent_type: str = "claude",
|
|
391
|
+
db: AsyncSession = Depends(get_db),
|
|
392
|
+
) -> dict:
|
|
393
|
+
"""
|
|
394
|
+
Parse raw logs and store normalized entries.
|
|
395
|
+
|
|
396
|
+
This endpoint manually triggers log normalization. Normally this happens
|
|
397
|
+
automatically after job completion, but this can be used to:
|
|
398
|
+
- Re-parse logs with updated parser logic
|
|
399
|
+
- Parse logs for old jobs that weren't normalized
|
|
400
|
+
- Test parser changes
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
agent_type: Type of agent (claude, cursor, etc.) - determines parser
|
|
404
|
+
"""
|
|
405
|
+
service = JobService(db)
|
|
406
|
+
|
|
407
|
+
# Get job
|
|
408
|
+
try:
|
|
409
|
+
job = await service.get_job_by_id(job_id)
|
|
410
|
+
except ResourceNotFoundError:
|
|
411
|
+
raise HTTPException(
|
|
412
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
413
|
+
detail=f"Job {job_id} not found",
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# Get raw logs (use async version to avoid blocking event loop)
|
|
417
|
+
raw_logs = await service.read_job_logs_async(job.log_path)
|
|
418
|
+
if not raw_logs:
|
|
419
|
+
raise HTTPException(
|
|
420
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
421
|
+
detail="No logs available to normalize",
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
# Delete existing normalized logs (if any)
|
|
425
|
+
from sqlalchemy import delete
|
|
426
|
+
|
|
427
|
+
from app.models.normalized_log import NormalizedLogEntry
|
|
428
|
+
|
|
429
|
+
await db.execute(
|
|
430
|
+
delete(NormalizedLogEntry).where(NormalizedLogEntry.job_id == job_id)
|
|
431
|
+
)
|
|
432
|
+
await db.commit()
|
|
433
|
+
|
|
434
|
+
# Normalize and store
|
|
435
|
+
normalizer = LogNormalizerService()
|
|
436
|
+
try:
|
|
437
|
+
entries = await normalizer.normalize_and_store(db, job_id, raw_logs, agent_type)
|
|
438
|
+
return {
|
|
439
|
+
"success": True,
|
|
440
|
+
"job_id": job_id,
|
|
441
|
+
"entries_created": len(entries),
|
|
442
|
+
"message": f"Successfully normalized {len(entries)} log entries",
|
|
443
|
+
}
|
|
444
|
+
except Exception as e:
|
|
445
|
+
raise HTTPException(
|
|
446
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
447
|
+
detail=f"Failed to normalize logs: {str(e)}",
|
|
448
|
+
)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""API router for maintenance operations."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from app.database import get_db
|
|
8
|
+
from app.schemas.merge import CleanupRequest, CleanupResponse
|
|
9
|
+
from app.services.cleanup_service import CleanupService
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/maintenance", tags=["maintenance"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WatchdogResponse(BaseModel):
|
|
15
|
+
"""Response from watchdog run."""
|
|
16
|
+
|
|
17
|
+
stale_jobs_recovered: int
|
|
18
|
+
timed_out_jobs_recovered: int
|
|
19
|
+
stuck_queued_jobs_failed: int
|
|
20
|
+
lost_tasks_reenqueued: int = 0 # Jobs re-enqueued due to lost Celery tasks
|
|
21
|
+
tickets_blocked: int
|
|
22
|
+
details: list[str]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.post(
|
|
26
|
+
"/cleanup",
|
|
27
|
+
response_model=CleanupResponse,
|
|
28
|
+
summary="Run cleanup of stale worktrees and old evidence",
|
|
29
|
+
)
|
|
30
|
+
async def run_cleanup(
|
|
31
|
+
data: CleanupRequest,
|
|
32
|
+
db: AsyncSession = Depends(get_db),
|
|
33
|
+
) -> CleanupResponse:
|
|
34
|
+
"""
|
|
35
|
+
Run cleanup of stale worktrees and old evidence files.
|
|
36
|
+
|
|
37
|
+
By default runs in dry_run mode, which only reports what would be deleted.
|
|
38
|
+
Set dry_run=false to actually perform deletions.
|
|
39
|
+
|
|
40
|
+
Cleanup rules (from draft.yaml cleanup_config):
|
|
41
|
+
- worktree_ttl_days: Delete worktrees older than this
|
|
42
|
+
- evidence_ttl_days: Delete evidence files older than this
|
|
43
|
+
- max_worktrees: Maximum number of active worktrees (not enforced yet)
|
|
44
|
+
|
|
45
|
+
Safety:
|
|
46
|
+
- Only deletes files under .draft/
|
|
47
|
+
- Uses `git worktree remove` + `git worktree prune`
|
|
48
|
+
- Never deletes worktrees for tickets in executing/verifying/needs_human
|
|
49
|
+
- Creates audit events for deletions
|
|
50
|
+
- Orphaned directories (not in DB) are also cleaned up
|
|
51
|
+
"""
|
|
52
|
+
service = CleanupService(db)
|
|
53
|
+
|
|
54
|
+
result = await service.run_full_cleanup(
|
|
55
|
+
dry_run=data.dry_run,
|
|
56
|
+
delete_worktrees=data.delete_worktrees,
|
|
57
|
+
delete_evidence=data.delete_evidence,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return CleanupResponse(
|
|
61
|
+
dry_run=data.dry_run,
|
|
62
|
+
worktrees_deleted=result.worktrees_deleted,
|
|
63
|
+
worktrees_failed=result.worktrees_failed,
|
|
64
|
+
worktrees_skipped=result.worktrees_skipped,
|
|
65
|
+
evidence_files_deleted=result.evidence_files_deleted,
|
|
66
|
+
evidence_files_failed=result.evidence_files_failed,
|
|
67
|
+
bytes_freed=result.bytes_freed,
|
|
68
|
+
details=result.details,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ReenqueueResponse(BaseModel):
|
|
73
|
+
"""Response from re-enqueue operation."""
|
|
74
|
+
|
|
75
|
+
jobs_reenqueued: int
|
|
76
|
+
details: list[str]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.post(
|
|
80
|
+
"/reenqueue-lost-jobs",
|
|
81
|
+
response_model=ReenqueueResponse,
|
|
82
|
+
summary="[DEV ONLY] Re-enqueue jobs that are queued in DB but missing from Celery",
|
|
83
|
+
)
|
|
84
|
+
async def reenqueue_lost_jobs(
|
|
85
|
+
db: AsyncSession = Depends(get_db),
|
|
86
|
+
) -> ReenqueueResponse:
|
|
87
|
+
"""
|
|
88
|
+
Re-enqueue jobs that are stuck in QUEUED status but missing from the Celery queue.
|
|
89
|
+
|
|
90
|
+
This can happen if:
|
|
91
|
+
- Redis was restarted/flushed
|
|
92
|
+
- The Celery worker was down when jobs were created
|
|
93
|
+
- Task messages were lost
|
|
94
|
+
|
|
95
|
+
For each QUEUED job, this re-sends the Celery task and updates the celery_task_id.
|
|
96
|
+
"""
|
|
97
|
+
from sqlalchemy import select
|
|
98
|
+
|
|
99
|
+
from app.models.job import Job, JobKind, JobStatus
|
|
100
|
+
from app.services.task_dispatch import enqueue_task
|
|
101
|
+
|
|
102
|
+
result = await db.execute(select(Job).where(Job.status == JobStatus.QUEUED.value))
|
|
103
|
+
queued_jobs = result.scalars().all()
|
|
104
|
+
|
|
105
|
+
details = []
|
|
106
|
+
count = 0
|
|
107
|
+
|
|
108
|
+
task_names = {
|
|
109
|
+
JobKind.EXECUTE.value: "execute_ticket",
|
|
110
|
+
JobKind.VERIFY.value: "verify_ticket",
|
|
111
|
+
JobKind.RESUME.value: "resume_ticket",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for job in queued_jobs:
|
|
115
|
+
try:
|
|
116
|
+
# Re-enqueue based on job kind using send_task
|
|
117
|
+
task_name = task_names.get(job.kind)
|
|
118
|
+
if not task_name:
|
|
119
|
+
details.append(f"Job {job.id}: Unknown kind {job.kind}")
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
task = enqueue_task(task_name, args=[job.id])
|
|
123
|
+
|
|
124
|
+
# Update task ID
|
|
125
|
+
job.celery_task_id = task.id
|
|
126
|
+
details.append(f"Job {job.id} ({job.kind}): Re-enqueued as {task.id}")
|
|
127
|
+
count += 1
|
|
128
|
+
except Exception as e:
|
|
129
|
+
details.append(f"Job {job.id}: Error re-enqueueing: {e}")
|
|
130
|
+
|
|
131
|
+
await db.commit()
|
|
132
|
+
|
|
133
|
+
return ReenqueueResponse(
|
|
134
|
+
jobs_reenqueued=count,
|
|
135
|
+
details=details,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@router.post(
|
|
140
|
+
"/watchdog/run",
|
|
141
|
+
response_model=WatchdogResponse,
|
|
142
|
+
summary="[DEV ONLY] Manually run job watchdog",
|
|
143
|
+
)
|
|
144
|
+
async def run_watchdog() -> WatchdogResponse:
|
|
145
|
+
"""
|
|
146
|
+
Manually trigger the job watchdog task.
|
|
147
|
+
|
|
148
|
+
This is a DEV/DEBUG endpoint for testing watchdog behavior.
|
|
149
|
+
In production, the watchdog runs automatically via Celery beat every 15s.
|
|
150
|
+
|
|
151
|
+
The watchdog checks for:
|
|
152
|
+
1. RUNNING jobs with stale heartbeat (no update in 2 minutes)
|
|
153
|
+
2. RUNNING jobs that exceeded their timeout_seconds
|
|
154
|
+
3. QUEUED jobs for 30+ seconds - re-enqueues lost Celery tasks
|
|
155
|
+
4. QUEUED jobs stuck for 2+ minutes - fails them as worker may be down
|
|
156
|
+
|
|
157
|
+
For stuck jobs, it either:
|
|
158
|
+
- Re-enqueues the Celery task (if task was lost from Redis)
|
|
159
|
+
- Marks the job as FAILED and transitions ticket to BLOCKED
|
|
160
|
+
"""
|
|
161
|
+
from app.services.job_watchdog_service import run_job_watchdog
|
|
162
|
+
|
|
163
|
+
result = run_job_watchdog()
|
|
164
|
+
|
|
165
|
+
return WatchdogResponse(
|
|
166
|
+
stale_jobs_recovered=result.stale_jobs_recovered,
|
|
167
|
+
timed_out_jobs_recovered=result.timed_out_jobs_recovered,
|
|
168
|
+
stuck_queued_jobs_failed=result.stuck_queued_jobs_failed,
|
|
169
|
+
lost_tasks_reenqueued=result.lost_tasks_reenqueued,
|
|
170
|
+
tickets_blocked=result.tickets_blocked,
|
|
171
|
+
details=result.details,
|
|
172
|
+
)
|