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,113 @@
|
|
|
1
|
+
"""Service layer for Goal operations."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import select
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from app.exceptions import ResourceNotFoundError
|
|
7
|
+
from app.models.goal import Goal
|
|
8
|
+
from app.schemas.goal import GoalCreate, GoalUpdate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GoalService:
|
|
12
|
+
"""Service class for Goal business logic."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, db: AsyncSession):
|
|
15
|
+
self.db = db
|
|
16
|
+
|
|
17
|
+
async def create_goal(self, data: GoalCreate) -> Goal:
|
|
18
|
+
"""
|
|
19
|
+
Create a new goal.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
data: Goal creation data
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The created Goal instance
|
|
26
|
+
"""
|
|
27
|
+
# If board_id provided, verify the board exists
|
|
28
|
+
if data.board_id:
|
|
29
|
+
from app.models.board import Board
|
|
30
|
+
|
|
31
|
+
result = await self.db.execute(
|
|
32
|
+
select(Board).where(Board.id == data.board_id)
|
|
33
|
+
)
|
|
34
|
+
if not result.scalar_one_or_none():
|
|
35
|
+
raise ValueError(f"Board not found: {data.board_id}")
|
|
36
|
+
|
|
37
|
+
goal = Goal(
|
|
38
|
+
title=data.title,
|
|
39
|
+
description=data.description,
|
|
40
|
+
board_id=data.board_id,
|
|
41
|
+
autonomy_enabled=data.autonomy_enabled,
|
|
42
|
+
auto_approve_tickets=data.auto_approve_tickets,
|
|
43
|
+
auto_approve_revisions=data.auto_approve_revisions,
|
|
44
|
+
auto_merge=data.auto_merge,
|
|
45
|
+
auto_approve_followups=data.auto_approve_followups,
|
|
46
|
+
max_auto_approvals=data.max_auto_approvals,
|
|
47
|
+
)
|
|
48
|
+
self.db.add(goal)
|
|
49
|
+
await self.db.flush()
|
|
50
|
+
await self.db.refresh(goal)
|
|
51
|
+
return goal
|
|
52
|
+
|
|
53
|
+
async def get_goals(self, board_id: str | None = None) -> list[Goal]:
|
|
54
|
+
"""
|
|
55
|
+
Get all goals, optionally filtered by board.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
board_id: If provided, only return goals for this board
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
List of Goal instances
|
|
62
|
+
"""
|
|
63
|
+
stmt = select(Goal)
|
|
64
|
+
if board_id:
|
|
65
|
+
stmt = stmt.where(Goal.board_id == board_id)
|
|
66
|
+
result = await self.db.execute(stmt.order_by(Goal.created_at.desc()))
|
|
67
|
+
return list(result.scalars().all())
|
|
68
|
+
|
|
69
|
+
async def update_goal(self, goal_id: str, data: GoalUpdate) -> Goal:
|
|
70
|
+
"""Update a goal with partial data.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
goal_id: The UUID of the goal
|
|
74
|
+
data: Fields to update (None fields are skipped)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The updated Goal instance
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ResourceNotFoundError: If the goal is not found
|
|
81
|
+
"""
|
|
82
|
+
result = await self.db.execute(select(Goal).where(Goal.id == goal_id))
|
|
83
|
+
goal = result.scalar_one_or_none()
|
|
84
|
+
if goal is None:
|
|
85
|
+
raise ResourceNotFoundError("Goal", goal_id)
|
|
86
|
+
|
|
87
|
+
update_data = data.model_dump(exclude_unset=True)
|
|
88
|
+
for field, value in update_data.items():
|
|
89
|
+
if value is not None:
|
|
90
|
+
setattr(goal, field, value)
|
|
91
|
+
|
|
92
|
+
await self.db.flush()
|
|
93
|
+
await self.db.refresh(goal)
|
|
94
|
+
return goal
|
|
95
|
+
|
|
96
|
+
async def get_goal_by_id(self, goal_id: str) -> Goal:
|
|
97
|
+
"""
|
|
98
|
+
Get a goal by its ID.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
goal_id: The UUID of the goal
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
The Goal instance
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
ResourceNotFoundError: If the goal is not found
|
|
108
|
+
"""
|
|
109
|
+
result = await self.db.execute(select(Goal).where(Goal.id == goal_id))
|
|
110
|
+
goal = result.scalar_one_or_none()
|
|
111
|
+
if goal is None:
|
|
112
|
+
raise ResourceNotFoundError("Goal", goal_id)
|
|
113
|
+
return goal
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Service layer for Job operations."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import func, select
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
from sqlalchemy.orm import selectinload
|
|
10
|
+
|
|
11
|
+
from app.exceptions import ResourceNotFoundError, ValidationError
|
|
12
|
+
from app.models.job import Job, JobKind, JobStatus
|
|
13
|
+
from app.models.ticket import Ticket
|
|
14
|
+
from app.schemas.job import QueuedJobResponse, QueueStatusResponse
|
|
15
|
+
from app.services.workspace_service import WorkspaceService
|
|
16
|
+
|
|
17
|
+
# Base directory for fallback logs (relative to backend directory)
|
|
18
|
+
FALLBACK_LOGS_DIR = Path(__file__).parent.parent.parent / "logs"
|
|
19
|
+
|
|
20
|
+
# Maximum log file size to read (2MB)
|
|
21
|
+
MAX_LOG_BYTES = 2_000_000
|
|
22
|
+
|
|
23
|
+
# Job rate limiting (prevent spam/runaway tickets)
|
|
24
|
+
MAX_JOBS_PER_TICKET_PER_HOUR = 10
|
|
25
|
+
MAX_EXECUTE_JOBS_PER_TICKET_PER_DAY = 50
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _safe_read_file(base_path: Path, allowed_root: Path, relpath: str) -> str | None:
|
|
29
|
+
"""Safely read a file, enforcing it is under allowed_root.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
base_path: Base path to prepend to relpath
|
|
33
|
+
allowed_root: Root directory that file must be under
|
|
34
|
+
relpath: Relative path to the file
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
File content if safe and exists, None otherwise
|
|
38
|
+
"""
|
|
39
|
+
rel = Path(relpath)
|
|
40
|
+
|
|
41
|
+
# Reject absolute paths
|
|
42
|
+
if rel.is_absolute():
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
# Resolve paths to canonical form
|
|
46
|
+
allowed_canonical = allowed_root.resolve(strict=False)
|
|
47
|
+
target = (base_path / rel).resolve(strict=False)
|
|
48
|
+
|
|
49
|
+
# Enforce target is under allowed_root
|
|
50
|
+
try:
|
|
51
|
+
common = os.path.commonpath([str(target), str(allowed_canonical)])
|
|
52
|
+
except ValueError:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
if common != str(allowed_canonical):
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
if not target.is_file():
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
size = target.stat().st_size
|
|
63
|
+
if size > MAX_LOG_BYTES:
|
|
64
|
+
with target.open("rb") as f:
|
|
65
|
+
data = f.read(MAX_LOG_BYTES)
|
|
66
|
+
return data.decode("utf-8", errors="replace") + "\n\n[truncated]"
|
|
67
|
+
return target.read_text(encoding="utf-8", errors="replace")
|
|
68
|
+
except OSError:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _safe_read_absolute(target: Path) -> str | None:
|
|
73
|
+
"""Safely read an absolute file path with size cap."""
|
|
74
|
+
if not target.is_file():
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
size = target.stat().st_size
|
|
78
|
+
if size > MAX_LOG_BYTES:
|
|
79
|
+
with target.open("rb") as f:
|
|
80
|
+
data = f.read(MAX_LOG_BYTES)
|
|
81
|
+
return data.decode("utf-8", errors="replace") + "\n\n[truncated]"
|
|
82
|
+
return target.read_text(encoding="utf-8", errors="replace")
|
|
83
|
+
except OSError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class JobService:
|
|
88
|
+
"""Service class for Job business logic."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, db: AsyncSession):
|
|
91
|
+
self.db = db
|
|
92
|
+
|
|
93
|
+
async def create_job(
|
|
94
|
+
self, ticket_id: str, kind: JobKind, variant: str | None = None
|
|
95
|
+
) -> Job:
|
|
96
|
+
"""
|
|
97
|
+
Create a new job and enqueue the corresponding Celery task.
|
|
98
|
+
|
|
99
|
+
Includes rate limiting to prevent runaway tickets.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
ticket_id: The UUID of the ticket
|
|
103
|
+
kind: The kind of job (execute or verify)
|
|
104
|
+
variant: Optional execution variant (default, plan, qa, review)
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The created Job instance with celery_task_id set
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
ResourceNotFoundError: If the ticket is not found
|
|
111
|
+
ValidationError: If rate limit exceeded
|
|
112
|
+
"""
|
|
113
|
+
# Verify the ticket exists and get its board_id
|
|
114
|
+
# CRITICAL: Use SELECT FOR UPDATE to prevent race conditions in rate limiting
|
|
115
|
+
# This locks the ticket row until transaction commits, serializing job creation
|
|
116
|
+
result = await self.db.execute(
|
|
117
|
+
select(Ticket).where(Ticket.id == ticket_id).with_for_update()
|
|
118
|
+
)
|
|
119
|
+
ticket = result.scalar_one_or_none()
|
|
120
|
+
if ticket is None:
|
|
121
|
+
raise ResourceNotFoundError("Ticket", ticket_id)
|
|
122
|
+
|
|
123
|
+
# RATE LIMITING: Check hourly limit (protected by row lock above)
|
|
124
|
+
one_hour_ago = datetime.now(UTC) - timedelta(hours=1)
|
|
125
|
+
hourly_result = await self.db.execute(
|
|
126
|
+
select(func.count(Job.id))
|
|
127
|
+
.where(Job.ticket_id == ticket_id)
|
|
128
|
+
.where(Job.kind == kind.value)
|
|
129
|
+
.where(Job.created_at >= one_hour_ago)
|
|
130
|
+
)
|
|
131
|
+
recent_jobs_hour = hourly_result.scalar()
|
|
132
|
+
|
|
133
|
+
if recent_jobs_hour >= MAX_JOBS_PER_TICKET_PER_HOUR:
|
|
134
|
+
raise ValidationError(
|
|
135
|
+
f"Rate limit exceeded: {recent_jobs_hour} {kind.value} jobs in past hour. "
|
|
136
|
+
f"Max {MAX_JOBS_PER_TICKET_PER_HOUR} per hour per ticket."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# RATE LIMITING: Check daily limit for EXECUTE jobs (expensive)
|
|
140
|
+
if kind == JobKind.EXECUTE:
|
|
141
|
+
one_day_ago = datetime.now(UTC) - timedelta(days=1)
|
|
142
|
+
daily_result = await self.db.execute(
|
|
143
|
+
select(func.count(Job.id))
|
|
144
|
+
.where(Job.ticket_id == ticket_id)
|
|
145
|
+
.where(Job.kind == JobKind.EXECUTE.value)
|
|
146
|
+
.where(Job.created_at >= one_day_ago)
|
|
147
|
+
)
|
|
148
|
+
recent_jobs_day = daily_result.scalar()
|
|
149
|
+
|
|
150
|
+
if recent_jobs_day >= MAX_EXECUTE_JOBS_PER_TICKET_PER_DAY:
|
|
151
|
+
raise ValidationError(
|
|
152
|
+
f"Daily execute limit exceeded: {recent_jobs_day} execute jobs in past 24h. "
|
|
153
|
+
f"Max {MAX_EXECUTE_JOBS_PER_TICKET_PER_DAY} per day per ticket."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Create the job record with board_id from ticket for permission scoping
|
|
157
|
+
job = Job(
|
|
158
|
+
ticket_id=ticket_id,
|
|
159
|
+
board_id=ticket.board_id, # Inherit board_id from ticket
|
|
160
|
+
kind=kind.value,
|
|
161
|
+
status=JobStatus.QUEUED.value,
|
|
162
|
+
)
|
|
163
|
+
self.db.add(job)
|
|
164
|
+
await self.db.flush()
|
|
165
|
+
await self.db.refresh(job)
|
|
166
|
+
|
|
167
|
+
# CRITICAL: Commit the job BEFORE enqueuing Celery task
|
|
168
|
+
# This ensures the Celery worker (sync session) can see the job
|
|
169
|
+
# Without this, async/sync session isolation causes "Job not found" errors
|
|
170
|
+
await self.db.commit()
|
|
171
|
+
|
|
172
|
+
# Enqueue the task via unified dispatch (supports SQLite and Celery backends)
|
|
173
|
+
from app.services.task_dispatch import enqueue_task
|
|
174
|
+
|
|
175
|
+
task_names = {
|
|
176
|
+
JobKind.EXECUTE: "execute_ticket",
|
|
177
|
+
JobKind.VERIFY: "verify_ticket",
|
|
178
|
+
JobKind.RESUME: "resume_ticket",
|
|
179
|
+
}
|
|
180
|
+
task_name = task_names.get(kind)
|
|
181
|
+
if not task_name:
|
|
182
|
+
raise ValueError(f"Unknown job kind: {kind}")
|
|
183
|
+
task = enqueue_task(task_name, args=[job.id])
|
|
184
|
+
|
|
185
|
+
# Store the task ID for later reference (e.g., cancellation)
|
|
186
|
+
job.celery_task_id = task.id
|
|
187
|
+
|
|
188
|
+
# Commit again to save the celery_task_id
|
|
189
|
+
await self.db.commit()
|
|
190
|
+
await self.db.refresh(job)
|
|
191
|
+
|
|
192
|
+
return job
|
|
193
|
+
|
|
194
|
+
async def get_job_by_id(self, job_id: str) -> Job:
|
|
195
|
+
"""
|
|
196
|
+
Get a job by its ID.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
job_id: The UUID of the job
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
The Job instance
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
ResourceNotFoundError: If the job is not found
|
|
206
|
+
"""
|
|
207
|
+
result = await self.db.execute(
|
|
208
|
+
select(Job).where(Job.id == job_id).options(selectinload(Job.ticket))
|
|
209
|
+
)
|
|
210
|
+
job = result.scalar_one_or_none()
|
|
211
|
+
if job is None:
|
|
212
|
+
raise ResourceNotFoundError("Job", job_id)
|
|
213
|
+
return job
|
|
214
|
+
|
|
215
|
+
async def get_jobs_for_ticket(self, ticket_id: str) -> list[Job]:
|
|
216
|
+
"""
|
|
217
|
+
Get all jobs for a ticket.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
ticket_id: The UUID of the ticket
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
List of Job instances ordered by created_at descending
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
ResourceNotFoundError: If the ticket is not found
|
|
227
|
+
"""
|
|
228
|
+
# Verify the ticket exists
|
|
229
|
+
result = await self.db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
|
230
|
+
ticket = result.scalar_one_or_none()
|
|
231
|
+
if ticket is None:
|
|
232
|
+
raise ResourceNotFoundError("Ticket", ticket_id)
|
|
233
|
+
|
|
234
|
+
# Get all jobs for the ticket
|
|
235
|
+
result = await self.db.execute(
|
|
236
|
+
select(Job)
|
|
237
|
+
.where(Job.ticket_id == ticket_id)
|
|
238
|
+
.order_by(Job.created_at.desc())
|
|
239
|
+
)
|
|
240
|
+
return list(result.scalars().all())
|
|
241
|
+
|
|
242
|
+
async def cancel_job(self, job_id: str) -> Job:
|
|
243
|
+
"""
|
|
244
|
+
Cancel a job (actively kills running subprocesses).
|
|
245
|
+
|
|
246
|
+
This will:
|
|
247
|
+
1. Mark the job as canceled in the database
|
|
248
|
+
2. Kill any running subprocess for this job
|
|
249
|
+
3. Attempt to revoke the Celery task
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
job_id: The UUID of the job
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
The updated Job instance
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
ResourceNotFoundError: If the job is not found
|
|
259
|
+
"""
|
|
260
|
+
import asyncio
|
|
261
|
+
import logging
|
|
262
|
+
|
|
263
|
+
logger = logging.getLogger(__name__)
|
|
264
|
+
job = await self.get_job_by_id(job_id)
|
|
265
|
+
|
|
266
|
+
# Only cancel if not already in a terminal state
|
|
267
|
+
if job.status in [
|
|
268
|
+
JobStatus.SUCCEEDED.value,
|
|
269
|
+
JobStatus.FAILED.value,
|
|
270
|
+
JobStatus.CANCELED.value,
|
|
271
|
+
]:
|
|
272
|
+
return job
|
|
273
|
+
|
|
274
|
+
# Mark as canceled in database FIRST (so worker polls see it)
|
|
275
|
+
job.status = JobStatus.CANCELED.value
|
|
276
|
+
await self.db.flush()
|
|
277
|
+
|
|
278
|
+
# Kill any running subprocess
|
|
279
|
+
try:
|
|
280
|
+
from app.worker import kill_job_process
|
|
281
|
+
|
|
282
|
+
killed = await asyncio.to_thread(kill_job_process, job_id)
|
|
283
|
+
if killed:
|
|
284
|
+
logger.info(f"Successfully killed subprocess for job {job_id}")
|
|
285
|
+
else:
|
|
286
|
+
logger.warning(f"No active subprocess found for job {job_id}")
|
|
287
|
+
except Exception as e:
|
|
288
|
+
logger.error(f"Failed to kill subprocess for job {job_id}: {e}")
|
|
289
|
+
|
|
290
|
+
await self.db.refresh(job)
|
|
291
|
+
return job
|
|
292
|
+
|
|
293
|
+
def read_job_logs(self, log_path: str | None) -> str | None:
|
|
294
|
+
"""
|
|
295
|
+
Read the log content for a job (synchronous version).
|
|
296
|
+
|
|
297
|
+
Security:
|
|
298
|
+
- Reads from central data dir, legacy .draft/, or backend/logs/
|
|
299
|
+
- Validates canonical path is under allowed directory
|
|
300
|
+
- Caps file size to prevent memory exhaustion
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
log_path: The path to the log file (absolute or relative)
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
The log content as a string, or None if no logs available
|
|
307
|
+
"""
|
|
308
|
+
if not log_path:
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
from app.data_dir import get_data_dir
|
|
312
|
+
|
|
313
|
+
# If it's an absolute path under the central data dir, read directly
|
|
314
|
+
log_p = Path(log_path)
|
|
315
|
+
if log_p.is_absolute():
|
|
316
|
+
data_dir = get_data_dir()
|
|
317
|
+
try:
|
|
318
|
+
log_p.resolve().relative_to(data_dir.resolve())
|
|
319
|
+
if log_p.is_file():
|
|
320
|
+
return _safe_read_absolute(log_p)
|
|
321
|
+
except ValueError:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
# Try central data dir (for new logs)
|
|
325
|
+
data_dir = get_data_dir()
|
|
326
|
+
content = _safe_read_file(data_dir, data_dir / "logs", log_path)
|
|
327
|
+
if content is not None:
|
|
328
|
+
return content
|
|
329
|
+
|
|
330
|
+
# Try repo root (legacy .draft/ logs)
|
|
331
|
+
repo_path = WorkspaceService.get_repo_path()
|
|
332
|
+
draft_root = repo_path / ".draft"
|
|
333
|
+
content = _safe_read_file(repo_path, draft_root, log_path)
|
|
334
|
+
if content is not None:
|
|
335
|
+
return content
|
|
336
|
+
|
|
337
|
+
# Fall back to backend/logs/ directory (for legacy fallback logs)
|
|
338
|
+
backend_root = Path(__file__).parent.parent.parent
|
|
339
|
+
content = _safe_read_file(backend_root, FALLBACK_LOGS_DIR, log_path)
|
|
340
|
+
return content
|
|
341
|
+
|
|
342
|
+
async def read_job_logs_async(self, log_path: str | None) -> str | None:
|
|
343
|
+
"""
|
|
344
|
+
Read the log content for a job (async version - non-blocking).
|
|
345
|
+
|
|
346
|
+
Wraps file I/O in asyncio.to_thread() to avoid blocking the event loop.
|
|
347
|
+
|
|
348
|
+
Security:
|
|
349
|
+
- Only reads files under <repo_root>/.draft/ or backend/logs/
|
|
350
|
+
- Rejects absolute paths
|
|
351
|
+
- Validates canonical path is under allowed directory
|
|
352
|
+
- Caps file size to prevent memory exhaustion
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
log_path: The relative path to the log file
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
The log content as a string, or None if no logs available
|
|
359
|
+
"""
|
|
360
|
+
import asyncio
|
|
361
|
+
|
|
362
|
+
return await asyncio.to_thread(self.read_job_logs, log_path)
|
|
363
|
+
|
|
364
|
+
async def get_queue_status(self) -> QueueStatusResponse:
|
|
365
|
+
"""
|
|
366
|
+
Get the current queue status showing running and queued jobs.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
QueueStatusResponse with running and queued jobs including ticket info
|
|
370
|
+
"""
|
|
371
|
+
# Get running jobs (ordered by started_at)
|
|
372
|
+
running_result = await self.db.execute(
|
|
373
|
+
select(Job)
|
|
374
|
+
.where(Job.status == JobStatus.RUNNING.value)
|
|
375
|
+
.options(selectinload(Job.ticket))
|
|
376
|
+
.order_by(Job.started_at.asc())
|
|
377
|
+
)
|
|
378
|
+
running_jobs = list(running_result.scalars().all())
|
|
379
|
+
|
|
380
|
+
# Get queued jobs (ordered by created_at - FIFO)
|
|
381
|
+
queued_result = await self.db.execute(
|
|
382
|
+
select(Job)
|
|
383
|
+
.where(Job.status == JobStatus.QUEUED.value)
|
|
384
|
+
.options(selectinload(Job.ticket))
|
|
385
|
+
.order_by(Job.created_at.asc())
|
|
386
|
+
)
|
|
387
|
+
queued_jobs = list(queued_result.scalars().all())
|
|
388
|
+
|
|
389
|
+
# Build response
|
|
390
|
+
running_responses = [
|
|
391
|
+
QueuedJobResponse(
|
|
392
|
+
id=job.id,
|
|
393
|
+
ticket_id=job.ticket_id,
|
|
394
|
+
ticket_title=job.ticket.title if job.ticket else "Unknown",
|
|
395
|
+
kind=JobKind(job.kind),
|
|
396
|
+
status=JobStatus(job.status),
|
|
397
|
+
created_at=job.created_at,
|
|
398
|
+
started_at=job.started_at,
|
|
399
|
+
queue_position=None, # Running jobs have no queue position
|
|
400
|
+
)
|
|
401
|
+
for job in running_jobs
|
|
402
|
+
]
|
|
403
|
+
|
|
404
|
+
queued_responses = [
|
|
405
|
+
QueuedJobResponse(
|
|
406
|
+
id=job.id,
|
|
407
|
+
ticket_id=job.ticket_id,
|
|
408
|
+
ticket_title=job.ticket.title if job.ticket else "Unknown",
|
|
409
|
+
kind=JobKind(job.kind),
|
|
410
|
+
status=JobStatus(job.status),
|
|
411
|
+
created_at=job.created_at,
|
|
412
|
+
started_at=job.started_at,
|
|
413
|
+
queue_position=idx + 1, # 1-based position
|
|
414
|
+
)
|
|
415
|
+
for idx, job in enumerate(queued_jobs)
|
|
416
|
+
]
|
|
417
|
+
|
|
418
|
+
return QueueStatusResponse(
|
|
419
|
+
running=running_responses,
|
|
420
|
+
queued=queued_responses,
|
|
421
|
+
total_running=len(running_responses),
|
|
422
|
+
total_queued=len(queued_responses),
|
|
423
|
+
)
|