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,528 @@
|
|
|
1
|
+
"""API router for Debug endpoints - live logs and system status."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, Query
|
|
9
|
+
from fastapi.responses import StreamingResponse
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
from sqlalchemy import desc, func, select
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
|
+
from sqlalchemy.orm import selectinload
|
|
14
|
+
|
|
15
|
+
from app.database import get_db
|
|
16
|
+
from app.models.evidence import Evidence
|
|
17
|
+
from app.models.job import Job, JobStatus
|
|
18
|
+
from app.models.ticket import Ticket
|
|
19
|
+
from app.models.ticket_event import TicketEvent
|
|
20
|
+
from app.services.orchestrator_log import _orchestrator_logs
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
router = APIRouter(prefix="/debug", tags=["debug"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OrchestratorLogEntry(BaseModel):
|
|
28
|
+
"""A single orchestrator log entry."""
|
|
29
|
+
|
|
30
|
+
timestamp: str
|
|
31
|
+
level: str
|
|
32
|
+
message: str
|
|
33
|
+
data: dict
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OrchestratorLogsResponse(BaseModel):
|
|
37
|
+
"""Response containing orchestrator logs."""
|
|
38
|
+
|
|
39
|
+
logs: list[OrchestratorLogEntry]
|
|
40
|
+
total: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AgentLogEntry(BaseModel):
|
|
44
|
+
"""A single agent log entry with context."""
|
|
45
|
+
|
|
46
|
+
timestamp: str
|
|
47
|
+
job_id: str
|
|
48
|
+
ticket_id: str
|
|
49
|
+
ticket_title: str
|
|
50
|
+
kind: str
|
|
51
|
+
content: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AgentLogsResponse(BaseModel):
|
|
55
|
+
"""Response containing agent logs."""
|
|
56
|
+
|
|
57
|
+
logs: list[AgentLogEntry]
|
|
58
|
+
job_id: str | None
|
|
59
|
+
ticket_title: str | None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RunningJobInfo(BaseModel):
|
|
63
|
+
"""Information about a running job."""
|
|
64
|
+
|
|
65
|
+
job_id: str
|
|
66
|
+
ticket_id: str
|
|
67
|
+
ticket_title: str
|
|
68
|
+
kind: str
|
|
69
|
+
started_at: str | None
|
|
70
|
+
log_preview: str | None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SystemStatusResponse(BaseModel):
|
|
74
|
+
"""Live system status for debug panel."""
|
|
75
|
+
|
|
76
|
+
timestamp: str
|
|
77
|
+
running_jobs: list[RunningJobInfo]
|
|
78
|
+
queued_count: int
|
|
79
|
+
tickets_by_state: dict[str, int]
|
|
80
|
+
recent_events_count: int
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.get(
|
|
84
|
+
"/orchestrator/logs",
|
|
85
|
+
response_model=OrchestratorLogsResponse,
|
|
86
|
+
summary="Get orchestrator logs from in-memory buffer",
|
|
87
|
+
)
|
|
88
|
+
async def get_orchestrator_logs(
|
|
89
|
+
limit: int = Query(
|
|
90
|
+
default=100, le=500, description="Number of log entries to return"
|
|
91
|
+
),
|
|
92
|
+
since: str | None = Query(
|
|
93
|
+
default=None, description="Only return logs after this ISO timestamp"
|
|
94
|
+
),
|
|
95
|
+
) -> OrchestratorLogsResponse:
|
|
96
|
+
"""
|
|
97
|
+
Get recent orchestrator logs from the in-memory buffer.
|
|
98
|
+
|
|
99
|
+
These logs capture planner decisions, ticket state transitions,
|
|
100
|
+
and other orchestrator-level events.
|
|
101
|
+
"""
|
|
102
|
+
logs = list(_orchestrator_logs)
|
|
103
|
+
|
|
104
|
+
# Filter by timestamp if provided
|
|
105
|
+
if since:
|
|
106
|
+
logs = [l for l in logs if l["timestamp"] > since]
|
|
107
|
+
|
|
108
|
+
# Return most recent entries (buffer stores oldest to newest)
|
|
109
|
+
logs = logs[-limit:]
|
|
110
|
+
|
|
111
|
+
return OrchestratorLogsResponse(
|
|
112
|
+
logs=[OrchestratorLogEntry(**l) for l in logs],
|
|
113
|
+
total=len(_orchestrator_logs),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@router.get(
|
|
118
|
+
"/orchestrator/stream",
|
|
119
|
+
summary="Stream orchestrator logs via Server-Sent Events",
|
|
120
|
+
)
|
|
121
|
+
async def stream_orchestrator_logs() -> StreamingResponse:
|
|
122
|
+
"""
|
|
123
|
+
Stream orchestrator logs in real-time using Server-Sent Events (SSE).
|
|
124
|
+
|
|
125
|
+
Connect to this endpoint to receive live log updates as they happen.
|
|
126
|
+
Each event is a JSON object with timestamp, level, message, and data.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
async def event_generator() -> AsyncGenerator[str, None]:
|
|
130
|
+
len(_orchestrator_logs)
|
|
131
|
+
last_timestamp = ""
|
|
132
|
+
|
|
133
|
+
# Send initial logs
|
|
134
|
+
for log in list(_orchestrator_logs)[-20:]: # Last 20 entries
|
|
135
|
+
import json
|
|
136
|
+
|
|
137
|
+
yield f"data: {json.dumps(log)}\n\n"
|
|
138
|
+
last_timestamp = log["timestamp"]
|
|
139
|
+
|
|
140
|
+
# Stream new logs
|
|
141
|
+
while True:
|
|
142
|
+
await asyncio.sleep(0.5) # Poll every 500ms
|
|
143
|
+
|
|
144
|
+
current_logs = list(_orchestrator_logs)
|
|
145
|
+
if not current_logs:
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
# Find new logs since last check
|
|
149
|
+
new_logs = [l for l in current_logs if l["timestamp"] > last_timestamp]
|
|
150
|
+
|
|
151
|
+
for log in new_logs:
|
|
152
|
+
import json
|
|
153
|
+
|
|
154
|
+
yield f"data: {json.dumps(log)}\n\n"
|
|
155
|
+
last_timestamp = log["timestamp"]
|
|
156
|
+
|
|
157
|
+
return StreamingResponse(
|
|
158
|
+
event_generator(),
|
|
159
|
+
media_type="text/event-stream",
|
|
160
|
+
headers={
|
|
161
|
+
"Cache-Control": "no-cache",
|
|
162
|
+
"Connection": "keep-alive",
|
|
163
|
+
"X-Accel-Buffering": "no",
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@router.get(
|
|
169
|
+
"/agent/logs/{job_id}",
|
|
170
|
+
response_model=AgentLogsResponse,
|
|
171
|
+
summary="Get agent output for a specific job",
|
|
172
|
+
)
|
|
173
|
+
async def get_agent_logs(
|
|
174
|
+
job_id: str,
|
|
175
|
+
db: AsyncSession = Depends(get_db),
|
|
176
|
+
) -> AgentLogsResponse:
|
|
177
|
+
"""
|
|
178
|
+
Get the agent's stdout/stderr for a specific job.
|
|
179
|
+
|
|
180
|
+
This returns the actual output from the executor (cursor-agent, etc.)
|
|
181
|
+
showing what the agent did and why.
|
|
182
|
+
"""
|
|
183
|
+
# Get job with ticket
|
|
184
|
+
result = await db.execute(
|
|
185
|
+
select(Job).options(selectinload(Job.ticket)).where(Job.id == job_id)
|
|
186
|
+
)
|
|
187
|
+
job = result.scalar_one_or_none()
|
|
188
|
+
|
|
189
|
+
if not job:
|
|
190
|
+
return AgentLogsResponse(logs=[], job_id=job_id, ticket_title=None)
|
|
191
|
+
|
|
192
|
+
# Get evidence for this job (executor stdout)
|
|
193
|
+
evidence_result = await db.execute(
|
|
194
|
+
select(Evidence).where(Evidence.job_id == job_id).order_by(Evidence.created_at)
|
|
195
|
+
)
|
|
196
|
+
evidences = evidence_result.scalars().all()
|
|
197
|
+
|
|
198
|
+
logs = []
|
|
199
|
+
for ev in evidences:
|
|
200
|
+
# Try to get stdout content
|
|
201
|
+
content = ""
|
|
202
|
+
if ev.stdout_path:
|
|
203
|
+
try:
|
|
204
|
+
from pathlib import Path
|
|
205
|
+
|
|
206
|
+
stdout_path = Path(ev.stdout_path)
|
|
207
|
+
if stdout_path.exists():
|
|
208
|
+
content = stdout_path.read_text()[:10000] # Limit size
|
|
209
|
+
except Exception:
|
|
210
|
+
content = f"[Error reading stdout from {ev.stdout_path}]"
|
|
211
|
+
|
|
212
|
+
if content:
|
|
213
|
+
logs.append(
|
|
214
|
+
AgentLogEntry(
|
|
215
|
+
timestamp=ev.created_at.isoformat() if ev.created_at else "",
|
|
216
|
+
job_id=job_id,
|
|
217
|
+
ticket_id=job.ticket_id,
|
|
218
|
+
ticket_title=job.ticket.title if job.ticket else "Unknown",
|
|
219
|
+
kind=ev.kind,
|
|
220
|
+
content=content,
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return AgentLogsResponse(
|
|
225
|
+
logs=logs,
|
|
226
|
+
job_id=job_id,
|
|
227
|
+
ticket_title=job.ticket.title if job.ticket else None,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@router.get(
|
|
232
|
+
"/agent/stream/{job_id}",
|
|
233
|
+
summary="Stream agent logs for a running job",
|
|
234
|
+
)
|
|
235
|
+
async def stream_agent_logs(
|
|
236
|
+
job_id: str,
|
|
237
|
+
db: AsyncSession = Depends(get_db),
|
|
238
|
+
) -> StreamingResponse:
|
|
239
|
+
"""
|
|
240
|
+
Stream agent logs in real-time for a running job.
|
|
241
|
+
|
|
242
|
+
Tails the job's log file and streams updates as they happen.
|
|
243
|
+
"""
|
|
244
|
+
from pathlib import Path
|
|
245
|
+
|
|
246
|
+
# Get job to find log path
|
|
247
|
+
result = await db.execute(select(Job).where(Job.id == job_id))
|
|
248
|
+
job = result.scalar_one_or_none()
|
|
249
|
+
|
|
250
|
+
async def event_generator() -> AsyncGenerator[str, None]:
|
|
251
|
+
import json
|
|
252
|
+
|
|
253
|
+
if not job or not job.log_path:
|
|
254
|
+
yield f"data: {json.dumps({'error': 'No log file found'})}\n\n"
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
log_path = Path(job.log_path)
|
|
258
|
+
last_size = 0
|
|
259
|
+
|
|
260
|
+
while True:
|
|
261
|
+
try:
|
|
262
|
+
if log_path.exists():
|
|
263
|
+
current_size = log_path.stat().st_size
|
|
264
|
+
|
|
265
|
+
if current_size > last_size:
|
|
266
|
+
with open(log_path) as f:
|
|
267
|
+
f.seek(last_size)
|
|
268
|
+
new_content = f.read()
|
|
269
|
+
if new_content:
|
|
270
|
+
yield f"data: {json.dumps({'content': new_content})}\n\n"
|
|
271
|
+
last_size = current_size
|
|
272
|
+
|
|
273
|
+
# Check if job is still running
|
|
274
|
+
async with db.begin():
|
|
275
|
+
job_check = await db.execute(
|
|
276
|
+
select(Job.status).where(Job.id == job_id)
|
|
277
|
+
)
|
|
278
|
+
status = job_check.scalar_one_or_none()
|
|
279
|
+
if status and status not in [
|
|
280
|
+
JobStatus.QUEUED.value,
|
|
281
|
+
JobStatus.RUNNING.value,
|
|
282
|
+
]:
|
|
283
|
+
yield f"data: {json.dumps({'status': 'completed', 'final_status': status})}\n\n"
|
|
284
|
+
break
|
|
285
|
+
|
|
286
|
+
except Exception as e:
|
|
287
|
+
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
|
288
|
+
|
|
289
|
+
await asyncio.sleep(0.5)
|
|
290
|
+
|
|
291
|
+
return StreamingResponse(
|
|
292
|
+
event_generator(),
|
|
293
|
+
media_type="text/event-stream",
|
|
294
|
+
headers={
|
|
295
|
+
"Cache-Control": "no-cache",
|
|
296
|
+
"Connection": "keep-alive",
|
|
297
|
+
"X-Accel-Buffering": "no",
|
|
298
|
+
},
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@router.get(
|
|
303
|
+
"/status",
|
|
304
|
+
response_model=SystemStatusResponse,
|
|
305
|
+
summary="Get live system status for debug panel",
|
|
306
|
+
)
|
|
307
|
+
async def get_system_status(
|
|
308
|
+
db: AsyncSession = Depends(get_db),
|
|
309
|
+
) -> SystemStatusResponse:
|
|
310
|
+
"""
|
|
311
|
+
Get comprehensive system status for the debug panel.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
- Running jobs with log previews
|
|
315
|
+
- Queued job count
|
|
316
|
+
- Ticket counts by state
|
|
317
|
+
- Recent event count
|
|
318
|
+
"""
|
|
319
|
+
# Get running jobs with ticket info
|
|
320
|
+
running_result = await db.execute(
|
|
321
|
+
select(Job)
|
|
322
|
+
.options(selectinload(Job.ticket))
|
|
323
|
+
.where(Job.status == JobStatus.RUNNING.value)
|
|
324
|
+
.order_by(Job.started_at)
|
|
325
|
+
)
|
|
326
|
+
running_jobs = running_result.scalars().all()
|
|
327
|
+
|
|
328
|
+
running_info = []
|
|
329
|
+
for job in running_jobs:
|
|
330
|
+
# Try to get last few lines of log
|
|
331
|
+
log_preview = None
|
|
332
|
+
if job.log_path:
|
|
333
|
+
try:
|
|
334
|
+
from pathlib import Path
|
|
335
|
+
|
|
336
|
+
log_path = Path(job.log_path)
|
|
337
|
+
if log_path.exists():
|
|
338
|
+
content = log_path.read_text()
|
|
339
|
+
lines = content.strip().split("\n")
|
|
340
|
+
log_preview = "\n".join(lines[-5:]) # Last 5 lines
|
|
341
|
+
except Exception:
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
running_info.append(
|
|
345
|
+
RunningJobInfo(
|
|
346
|
+
job_id=job.id,
|
|
347
|
+
ticket_id=job.ticket_id,
|
|
348
|
+
ticket_title=job.ticket.title if job.ticket else "Unknown",
|
|
349
|
+
kind=job.kind,
|
|
350
|
+
started_at=job.started_at.isoformat() if job.started_at else None,
|
|
351
|
+
log_preview=log_preview,
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# Count queued jobs
|
|
356
|
+
queued_result = await db.execute(
|
|
357
|
+
select(Job).where(Job.status == JobStatus.QUEUED.value)
|
|
358
|
+
)
|
|
359
|
+
queued_count = len(queued_result.scalars().all())
|
|
360
|
+
|
|
361
|
+
# Count tickets by state (single GROUP BY query instead of N+1)
|
|
362
|
+
tickets_by_state = {}
|
|
363
|
+
state_counts_result = await db.execute(
|
|
364
|
+
select(Ticket.state, func.count(Ticket.id)).group_by(Ticket.state)
|
|
365
|
+
)
|
|
366
|
+
for state_value, count in state_counts_result.all():
|
|
367
|
+
if count > 0:
|
|
368
|
+
tickets_by_state[state_value] = count
|
|
369
|
+
|
|
370
|
+
# Count recent events (last hour)
|
|
371
|
+
from datetime import timedelta
|
|
372
|
+
|
|
373
|
+
one_hour_ago = datetime.now(UTC) - timedelta(hours=1)
|
|
374
|
+
events_result = await db.execute(
|
|
375
|
+
select(TicketEvent).where(TicketEvent.created_at >= one_hour_ago)
|
|
376
|
+
)
|
|
377
|
+
recent_events_count = len(events_result.scalars().all())
|
|
378
|
+
|
|
379
|
+
return SystemStatusResponse(
|
|
380
|
+
timestamp=datetime.now(UTC).isoformat(),
|
|
381
|
+
running_jobs=running_info,
|
|
382
|
+
queued_count=queued_count,
|
|
383
|
+
tickets_by_state=tickets_by_state,
|
|
384
|
+
recent_events_count=recent_events_count,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class ResetResponse(BaseModel):
|
|
389
|
+
"""Response from reset operation."""
|
|
390
|
+
|
|
391
|
+
tickets_deleted: int
|
|
392
|
+
goals_deleted: int
|
|
393
|
+
jobs_deleted: int
|
|
394
|
+
events_deleted: int
|
|
395
|
+
message: str
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@router.post(
|
|
399
|
+
"/reset",
|
|
400
|
+
response_model=ResetResponse,
|
|
401
|
+
summary="[DEV ONLY] Delete ALL data - nuclear reset",
|
|
402
|
+
)
|
|
403
|
+
async def reset_all_data(
|
|
404
|
+
confirm: str = Query(..., description="Must be 'yes-delete-everything' to confirm"),
|
|
405
|
+
db: AsyncSession = Depends(get_db),
|
|
406
|
+
) -> ResetResponse:
|
|
407
|
+
"""
|
|
408
|
+
**DANGER:** Delete ALL tickets, goals, jobs, events, evidence, etc.
|
|
409
|
+
|
|
410
|
+
This is a nuclear option for development/testing. Use with caution.
|
|
411
|
+
|
|
412
|
+
Requires `confirm=yes-delete-everything` query parameter.
|
|
413
|
+
"""
|
|
414
|
+
if confirm != "yes-delete-everything":
|
|
415
|
+
from fastapi import HTTPException
|
|
416
|
+
|
|
417
|
+
raise HTTPException(
|
|
418
|
+
status_code=400,
|
|
419
|
+
detail="Must provide confirm=yes-delete-everything to proceed",
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
from app.models.analysis_cache import AnalysisCache
|
|
423
|
+
from app.models.goal import Goal
|
|
424
|
+
from app.models.planner_lock import PlannerLock
|
|
425
|
+
from app.models.review_comment import ReviewComment
|
|
426
|
+
from app.models.review_summary import ReviewSummary
|
|
427
|
+
from app.models.revision import Revision
|
|
428
|
+
from app.models.workspace import Workspace
|
|
429
|
+
|
|
430
|
+
# Count before deletion
|
|
431
|
+
tickets_count = len((await db.execute(select(Ticket))).scalars().all())
|
|
432
|
+
goals_count = len((await db.execute(select(Goal))).scalars().all())
|
|
433
|
+
jobs_count = len((await db.execute(select(Job))).scalars().all())
|
|
434
|
+
events_count = len((await db.execute(select(TicketEvent))).scalars().all())
|
|
435
|
+
|
|
436
|
+
# Delete in correct order (respecting foreign keys)
|
|
437
|
+
# 1. Review comments and summaries
|
|
438
|
+
await db.execute(select(ReviewComment).execution_options(synchronize_session=False))
|
|
439
|
+
for rc in (await db.execute(select(ReviewComment))).scalars().all():
|
|
440
|
+
await db.delete(rc)
|
|
441
|
+
|
|
442
|
+
for rs in (await db.execute(select(ReviewSummary))).scalars().all():
|
|
443
|
+
await db.delete(rs)
|
|
444
|
+
|
|
445
|
+
# 2. Revisions
|
|
446
|
+
for rev in (await db.execute(select(Revision))).scalars().all():
|
|
447
|
+
await db.delete(rev)
|
|
448
|
+
|
|
449
|
+
# 3. Evidence
|
|
450
|
+
for ev in (await db.execute(select(Evidence))).scalars().all():
|
|
451
|
+
await db.delete(ev)
|
|
452
|
+
|
|
453
|
+
# 4. Jobs
|
|
454
|
+
for job in (await db.execute(select(Job))).scalars().all():
|
|
455
|
+
await db.delete(job)
|
|
456
|
+
|
|
457
|
+
# 5. Ticket events
|
|
458
|
+
for event in (await db.execute(select(TicketEvent))).scalars().all():
|
|
459
|
+
await db.delete(event)
|
|
460
|
+
|
|
461
|
+
# 6. Tickets
|
|
462
|
+
for ticket in (await db.execute(select(Ticket))).scalars().all():
|
|
463
|
+
await db.delete(ticket)
|
|
464
|
+
|
|
465
|
+
# 7. Workspaces
|
|
466
|
+
for ws in (await db.execute(select(Workspace))).scalars().all():
|
|
467
|
+
await db.delete(ws)
|
|
468
|
+
|
|
469
|
+
# 8. Goals
|
|
470
|
+
for goal in (await db.execute(select(Goal))).scalars().all():
|
|
471
|
+
await db.delete(goal)
|
|
472
|
+
|
|
473
|
+
# 9. Planner locks
|
|
474
|
+
for lock in (await db.execute(select(PlannerLock))).scalars().all():
|
|
475
|
+
await db.delete(lock)
|
|
476
|
+
|
|
477
|
+
# 10. Analysis cache
|
|
478
|
+
for cache in (await db.execute(select(AnalysisCache))).scalars().all():
|
|
479
|
+
await db.delete(cache)
|
|
480
|
+
|
|
481
|
+
await db.commit()
|
|
482
|
+
|
|
483
|
+
# Clear in-memory logs too
|
|
484
|
+
_orchestrator_logs.clear()
|
|
485
|
+
|
|
486
|
+
return ResetResponse(
|
|
487
|
+
tickets_deleted=tickets_count,
|
|
488
|
+
goals_deleted=goals_count,
|
|
489
|
+
jobs_deleted=jobs_count,
|
|
490
|
+
events_deleted=events_count,
|
|
491
|
+
message="All data deleted successfully",
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@router.get(
|
|
496
|
+
"/events/recent",
|
|
497
|
+
summary="Get recent ticket events for activity feed",
|
|
498
|
+
)
|
|
499
|
+
async def get_recent_events(
|
|
500
|
+
limit: int = Query(default=50, le=200),
|
|
501
|
+
db: AsyncSession = Depends(get_db),
|
|
502
|
+
) -> list[dict]:
|
|
503
|
+
"""
|
|
504
|
+
Get recent ticket events for the activity feed.
|
|
505
|
+
|
|
506
|
+
Returns events ordered by most recent first.
|
|
507
|
+
"""
|
|
508
|
+
result = await db.execute(
|
|
509
|
+
select(TicketEvent)
|
|
510
|
+
.options(selectinload(TicketEvent.ticket))
|
|
511
|
+
.order_by(desc(TicketEvent.created_at))
|
|
512
|
+
.limit(limit)
|
|
513
|
+
)
|
|
514
|
+
events = result.scalars().all()
|
|
515
|
+
|
|
516
|
+
return [
|
|
517
|
+
{
|
|
518
|
+
"id": ev.id,
|
|
519
|
+
"ticket_id": ev.ticket_id,
|
|
520
|
+
"ticket_title": ev.ticket.title if ev.ticket else None,
|
|
521
|
+
"event_type": ev.event_type,
|
|
522
|
+
"actor_type": ev.actor_type,
|
|
523
|
+
"actor_id": ev.actor_id,
|
|
524
|
+
"payload": ev.get_payload(),
|
|
525
|
+
"created_at": ev.created_at.isoformat() if ev.created_at else None,
|
|
526
|
+
}
|
|
527
|
+
for ev in events
|
|
528
|
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""API router for Evidence endpoints."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
6
|
+
from fastapi.responses import PlainTextResponse
|
|
7
|
+
from sqlalchemy import select
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from app.database import get_db
|
|
11
|
+
from app.models.board import Board
|
|
12
|
+
from app.models.evidence import Evidence
|
|
13
|
+
from app.models.job import Job
|
|
14
|
+
from app.utils.artifact_reader import read_artifact
|
|
15
|
+
|
|
16
|
+
router = APIRouter(prefix="/evidence", tags=["evidence"])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def get_evidence_by_id(evidence_id: str, db: AsyncSession) -> Evidence:
|
|
20
|
+
"""Get evidence by ID or raise 404."""
|
|
21
|
+
result = await db.execute(select(Evidence).where(Evidence.id == evidence_id))
|
|
22
|
+
evidence = result.scalar_one_or_none()
|
|
23
|
+
if evidence is None:
|
|
24
|
+
raise HTTPException(
|
|
25
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
26
|
+
detail=f"Evidence with id '{evidence_id}' not found",
|
|
27
|
+
)
|
|
28
|
+
return evidence
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _get_repo_root_for_evidence(evidence: Evidence, db: AsyncSession) -> Path:
|
|
32
|
+
"""Get repo_root from the board associated with this evidence's job.
|
|
33
|
+
|
|
34
|
+
Falls back to the global ConfigService repo_root if no board is found.
|
|
35
|
+
"""
|
|
36
|
+
if evidence.job_id:
|
|
37
|
+
job_result = await db.execute(select(Job).where(Job.id == evidence.job_id))
|
|
38
|
+
job = job_result.scalar_one_or_none()
|
|
39
|
+
if job and job.board_id:
|
|
40
|
+
board_result = await db.execute(
|
|
41
|
+
select(Board).where(Board.id == job.board_id)
|
|
42
|
+
)
|
|
43
|
+
board = board_result.scalar_one_or_none()
|
|
44
|
+
if board and board.repo_root:
|
|
45
|
+
return Path(board.repo_root)
|
|
46
|
+
|
|
47
|
+
# Fallback to WorkspaceService
|
|
48
|
+
from app.services.workspace_service import WorkspaceService
|
|
49
|
+
|
|
50
|
+
return WorkspaceService.get_repo_path()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@router.get(
|
|
54
|
+
"/{evidence_id}/stdout",
|
|
55
|
+
response_class=PlainTextResponse,
|
|
56
|
+
summary="Get stdout content for an evidence record",
|
|
57
|
+
)
|
|
58
|
+
async def get_evidence_stdout(
|
|
59
|
+
evidence_id: str,
|
|
60
|
+
db: AsyncSession = Depends(get_db),
|
|
61
|
+
) -> PlainTextResponse:
|
|
62
|
+
"""Get the stdout content for a verification command.
|
|
63
|
+
|
|
64
|
+
Security: Only reads files under <repo_root>/.draft/
|
|
65
|
+
"""
|
|
66
|
+
evidence = await get_evidence_by_id(evidence_id, db)
|
|
67
|
+
repo_root = await _get_repo_root_for_evidence(evidence, db)
|
|
68
|
+
|
|
69
|
+
content = read_artifact(repo_root, evidence.stdout_path)
|
|
70
|
+
if content is None:
|
|
71
|
+
return PlainTextResponse(content="", status_code=status.HTTP_200_OK)
|
|
72
|
+
|
|
73
|
+
return PlainTextResponse(content=content)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.get(
|
|
77
|
+
"/{evidence_id}/stderr",
|
|
78
|
+
response_class=PlainTextResponse,
|
|
79
|
+
summary="Get stderr content for an evidence record",
|
|
80
|
+
)
|
|
81
|
+
async def get_evidence_stderr(
|
|
82
|
+
evidence_id: str,
|
|
83
|
+
db: AsyncSession = Depends(get_db),
|
|
84
|
+
) -> PlainTextResponse:
|
|
85
|
+
"""Get the stderr content for a verification command.
|
|
86
|
+
|
|
87
|
+
Security: Only reads files under <repo_root>/.draft/
|
|
88
|
+
"""
|
|
89
|
+
evidence = await get_evidence_by_id(evidence_id, db)
|
|
90
|
+
repo_root = await _get_repo_root_for_evidence(evidence, db)
|
|
91
|
+
|
|
92
|
+
content = read_artifact(repo_root, evidence.stderr_path)
|
|
93
|
+
if content is None:
|
|
94
|
+
return PlainTextResponse(content="", status_code=status.HTTP_200_OK)
|
|
95
|
+
|
|
96
|
+
return PlainTextResponse(content=content)
|