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,380 @@
|
|
|
1
|
+
"""Service for full autonomy mode safety checks and auto-actions.
|
|
2
|
+
|
|
3
|
+
Autonomy mode allows goals to bypass manual gates (ticket approval,
|
|
4
|
+
revision approval, merge, follow-up approval) with configurable safety rails.
|
|
5
|
+
|
|
6
|
+
All auto-actions are recorded as TicketEvents with actor_id="autonomy_service"
|
|
7
|
+
for a complete audit trail.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from sqlalchemy import select
|
|
18
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
19
|
+
from sqlalchemy.orm import Session, selectinload
|
|
20
|
+
|
|
21
|
+
from app.models.enums import ActorType, EventType
|
|
22
|
+
from app.models.evidence import Evidence, EvidenceKind
|
|
23
|
+
from app.models.goal import Goal
|
|
24
|
+
from app.models.revision import Revision
|
|
25
|
+
from app.models.ticket import Ticket
|
|
26
|
+
from app.models.ticket_event import TicketEvent
|
|
27
|
+
from app.services.config_service import AutonomyConfig, DraftConfig
|
|
28
|
+
from app.services.workspace_service import WorkspaceService
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class AutonomyCheckResult:
|
|
35
|
+
"""Result of an autonomy safety check."""
|
|
36
|
+
|
|
37
|
+
approved: bool
|
|
38
|
+
reason: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AutonomyService:
|
|
42
|
+
"""Core safety logic for full autonomy mode.
|
|
43
|
+
|
|
44
|
+
Provides both async (FastAPI) and sync (Celery worker) methods.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, config: AutonomyConfig | None = None):
|
|
48
|
+
if config is None:
|
|
49
|
+
config = DraftConfig().autonomy_config
|
|
50
|
+
self.config = config
|
|
51
|
+
|
|
52
|
+
# ── Async methods (for FastAPI routes and planner) ──
|
|
53
|
+
|
|
54
|
+
async def can_auto_approve_ticket(
|
|
55
|
+
self, db: AsyncSession, ticket: Ticket
|
|
56
|
+
) -> AutonomyCheckResult:
|
|
57
|
+
"""Check if a ticket can be auto-approved (PROPOSED -> PLANNED).
|
|
58
|
+
|
|
59
|
+
Checks:
|
|
60
|
+
- goal.autonomy_enabled
|
|
61
|
+
- goal.auto_approve_tickets
|
|
62
|
+
- max_auto_approvals not exceeded
|
|
63
|
+
"""
|
|
64
|
+
result = await db.execute(select(Goal).where(Goal.id == ticket.goal_id))
|
|
65
|
+
goal = result.scalar_one_or_none()
|
|
66
|
+
if goal is None:
|
|
67
|
+
return AutonomyCheckResult(False, "Goal not found")
|
|
68
|
+
|
|
69
|
+
return self._check_ticket_approval(goal)
|
|
70
|
+
|
|
71
|
+
async def can_auto_approve_revision(
|
|
72
|
+
self, db: AsyncSession, ticket: Ticket, revision: Revision | None = None
|
|
73
|
+
) -> AutonomyCheckResult:
|
|
74
|
+
"""Check if a revision can be auto-approved (VERIFYING -> DONE).
|
|
75
|
+
|
|
76
|
+
Checks:
|
|
77
|
+
- goal.autonomy_enabled + goal.auto_approve_revisions
|
|
78
|
+
- All verification evidence has exit_code == 0
|
|
79
|
+
- Diff size < max_diff_lines
|
|
80
|
+
- No sensitive files in diff
|
|
81
|
+
- max_auto_approvals not reached
|
|
82
|
+
"""
|
|
83
|
+
result = await db.execute(select(Goal).where(Goal.id == ticket.goal_id))
|
|
84
|
+
goal = result.scalar_one_or_none()
|
|
85
|
+
if goal is None:
|
|
86
|
+
return AutonomyCheckResult(False, "Goal not found")
|
|
87
|
+
|
|
88
|
+
# Load evidence for this ticket
|
|
89
|
+
evidence_result = await db.execute(
|
|
90
|
+
select(Evidence).where(Evidence.ticket_id == ticket.id)
|
|
91
|
+
)
|
|
92
|
+
evidence_list = list(evidence_result.scalars().all())
|
|
93
|
+
|
|
94
|
+
return self._check_revision_approval(goal, evidence_list)
|
|
95
|
+
|
|
96
|
+
async def can_auto_merge(
|
|
97
|
+
self, db: AsyncSession, ticket: Ticket, repo_path: Path
|
|
98
|
+
) -> AutonomyCheckResult:
|
|
99
|
+
"""Check if a ticket can be auto-merged after DONE.
|
|
100
|
+
|
|
101
|
+
Checks:
|
|
102
|
+
- goal.auto_merge
|
|
103
|
+
- Pre-check merge conflicts
|
|
104
|
+
"""
|
|
105
|
+
result = await db.execute(select(Goal).where(Goal.id == ticket.goal_id))
|
|
106
|
+
goal = result.scalar_one_or_none()
|
|
107
|
+
if goal is None:
|
|
108
|
+
return AutonomyCheckResult(False, "Goal not found")
|
|
109
|
+
|
|
110
|
+
if not goal.autonomy_enabled or not goal.auto_merge:
|
|
111
|
+
return AutonomyCheckResult(False, "Auto-merge not enabled for this goal")
|
|
112
|
+
|
|
113
|
+
# Get workspace to find branch name
|
|
114
|
+
ticket_result = await db.execute(
|
|
115
|
+
select(Ticket)
|
|
116
|
+
.where(Ticket.id == ticket.id)
|
|
117
|
+
.options(selectinload(Ticket.workspace))
|
|
118
|
+
)
|
|
119
|
+
ticket_with_ws = ticket_result.scalar_one_or_none()
|
|
120
|
+
if not ticket_with_ws or not ticket_with_ws.workspace:
|
|
121
|
+
return AutonomyCheckResult(False, "No active workspace")
|
|
122
|
+
|
|
123
|
+
branch_name = ticket_with_ws.workspace.branch_name
|
|
124
|
+
return self._check_merge_conflicts(repo_path, branch_name)
|
|
125
|
+
|
|
126
|
+
async def record_auto_action(
|
|
127
|
+
self,
|
|
128
|
+
db: AsyncSession,
|
|
129
|
+
ticket: Ticket,
|
|
130
|
+
action_type: str,
|
|
131
|
+
details: dict,
|
|
132
|
+
from_state: str | None = None,
|
|
133
|
+
to_state: str | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Record an autonomy action as a TicketEvent and increment counter."""
|
|
136
|
+
event = TicketEvent(
|
|
137
|
+
ticket_id=ticket.id,
|
|
138
|
+
event_type=EventType.TRANSITIONED.value
|
|
139
|
+
if from_state != to_state
|
|
140
|
+
else EventType.COMMENT.value,
|
|
141
|
+
from_state=from_state or ticket.state,
|
|
142
|
+
to_state=to_state or ticket.state,
|
|
143
|
+
actor_type=ActorType.SYSTEM.value,
|
|
144
|
+
actor_id="autonomy_service",
|
|
145
|
+
reason=f"Auto-{action_type}: {details.get('reason', 'autonomy mode')}",
|
|
146
|
+
payload_json=json.dumps({"autonomy_action": action_type, **details}),
|
|
147
|
+
)
|
|
148
|
+
db.add(event)
|
|
149
|
+
|
|
150
|
+
# Increment auto_approval_count on goal
|
|
151
|
+
result = await db.execute(select(Goal).where(Goal.id == ticket.goal_id))
|
|
152
|
+
goal = result.scalar_one_or_none()
|
|
153
|
+
if goal:
|
|
154
|
+
goal.auto_approval_count += 1
|
|
155
|
+
|
|
156
|
+
# ── Sync methods (for Celery worker) ──
|
|
157
|
+
|
|
158
|
+
def can_auto_approve_ticket_sync(
|
|
159
|
+
self, db: Session, ticket: Ticket
|
|
160
|
+
) -> AutonomyCheckResult:
|
|
161
|
+
"""Sync version of can_auto_approve_ticket for Celery worker."""
|
|
162
|
+
goal = db.query(Goal).filter(Goal.id == ticket.goal_id).first()
|
|
163
|
+
if goal is None:
|
|
164
|
+
return AutonomyCheckResult(False, "Goal not found")
|
|
165
|
+
return self._check_ticket_approval(goal)
|
|
166
|
+
|
|
167
|
+
def can_auto_approve_revision_sync(
|
|
168
|
+
self, db: Session, ticket: Ticket
|
|
169
|
+
) -> AutonomyCheckResult:
|
|
170
|
+
"""Sync version of can_auto_approve_revision for Celery worker."""
|
|
171
|
+
goal = db.query(Goal).filter(Goal.id == ticket.goal_id).first()
|
|
172
|
+
if goal is None:
|
|
173
|
+
return AutonomyCheckResult(False, "Goal not found")
|
|
174
|
+
|
|
175
|
+
evidence_list = db.query(Evidence).filter(Evidence.ticket_id == ticket.id).all()
|
|
176
|
+
return self._check_revision_approval(goal, evidence_list)
|
|
177
|
+
|
|
178
|
+
def record_auto_action_sync(
|
|
179
|
+
self,
|
|
180
|
+
db: Session,
|
|
181
|
+
ticket: Ticket,
|
|
182
|
+
action_type: str,
|
|
183
|
+
details: dict,
|
|
184
|
+
from_state: str | None = None,
|
|
185
|
+
to_state: str | None = None,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Sync version of record_auto_action for Celery worker."""
|
|
188
|
+
event = TicketEvent(
|
|
189
|
+
ticket_id=ticket.id,
|
|
190
|
+
event_type=EventType.TRANSITIONED.value
|
|
191
|
+
if from_state != to_state
|
|
192
|
+
else EventType.COMMENT.value,
|
|
193
|
+
from_state=from_state or ticket.state,
|
|
194
|
+
to_state=to_state or ticket.state,
|
|
195
|
+
actor_type=ActorType.SYSTEM.value,
|
|
196
|
+
actor_id="autonomy_service",
|
|
197
|
+
reason=f"Auto-{action_type}: {details.get('reason', 'autonomy mode')}",
|
|
198
|
+
payload_json=json.dumps({"autonomy_action": action_type, **details}),
|
|
199
|
+
)
|
|
200
|
+
db.add(event)
|
|
201
|
+
|
|
202
|
+
goal = db.query(Goal).filter(Goal.id == ticket.goal_id).first()
|
|
203
|
+
if goal:
|
|
204
|
+
goal.auto_approval_count += 1
|
|
205
|
+
|
|
206
|
+
# ── Shared pure logic (no DB) ──
|
|
207
|
+
|
|
208
|
+
def _check_ticket_approval(self, goal: Goal) -> AutonomyCheckResult:
|
|
209
|
+
"""Check if goal settings allow auto-approving a ticket."""
|
|
210
|
+
if not goal.autonomy_enabled:
|
|
211
|
+
return AutonomyCheckResult(False, "Autonomy not enabled for this goal")
|
|
212
|
+
if not goal.auto_approve_tickets:
|
|
213
|
+
return AutonomyCheckResult(False, "Auto-approve tickets not enabled")
|
|
214
|
+
if (
|
|
215
|
+
goal.max_auto_approvals is not None
|
|
216
|
+
and goal.auto_approval_count >= goal.max_auto_approvals
|
|
217
|
+
):
|
|
218
|
+
return AutonomyCheckResult(
|
|
219
|
+
False, f"Max auto-approvals reached ({goal.max_auto_approvals})"
|
|
220
|
+
)
|
|
221
|
+
return AutonomyCheckResult(True, "Ticket auto-approval allowed")
|
|
222
|
+
|
|
223
|
+
def _check_revision_approval(
|
|
224
|
+
self, goal: Goal, evidence_list: list[Evidence]
|
|
225
|
+
) -> AutonomyCheckResult:
|
|
226
|
+
"""Check if goal settings and evidence allow auto-approving a revision."""
|
|
227
|
+
if not goal.autonomy_enabled:
|
|
228
|
+
return AutonomyCheckResult(False, "Autonomy not enabled for this goal")
|
|
229
|
+
if not goal.auto_approve_revisions:
|
|
230
|
+
return AutonomyCheckResult(False, "Auto-approve revisions not enabled")
|
|
231
|
+
if (
|
|
232
|
+
goal.max_auto_approvals is not None
|
|
233
|
+
and goal.auto_approval_count >= goal.max_auto_approvals
|
|
234
|
+
):
|
|
235
|
+
return AutonomyCheckResult(
|
|
236
|
+
False, f"Max auto-approvals reached ({goal.max_auto_approvals})"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Check verification evidence
|
|
240
|
+
if self.config.require_verification_pass:
|
|
241
|
+
verify_evidence = [
|
|
242
|
+
e
|
|
243
|
+
for e in evidence_list
|
|
244
|
+
if e.kind in (EvidenceKind.VERIFY_META.value, EvidenceKind.VERIFY_META)
|
|
245
|
+
]
|
|
246
|
+
if verify_evidence:
|
|
247
|
+
for ve in verify_evidence:
|
|
248
|
+
if ve.exit_code != 0:
|
|
249
|
+
return AutonomyCheckResult(
|
|
250
|
+
False, f"Verification failed (exit_code={ve.exit_code})"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Check diff size
|
|
254
|
+
diff_stat_evidence = [
|
|
255
|
+
e
|
|
256
|
+
for e in evidence_list
|
|
257
|
+
if e.kind in (EvidenceKind.GIT_DIFF_STAT.value, EvidenceKind.GIT_DIFF_STAT)
|
|
258
|
+
]
|
|
259
|
+
if diff_stat_evidence:
|
|
260
|
+
total_lines = self._parse_diff_stat_lines(diff_stat_evidence[-1])
|
|
261
|
+
if total_lines > self.config.max_diff_lines:
|
|
262
|
+
return AutonomyCheckResult(
|
|
263
|
+
False,
|
|
264
|
+
f"Diff too large ({total_lines} lines > {self.config.max_diff_lines} max)",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Check for sensitive files in diff
|
|
268
|
+
diff_patch_evidence = [
|
|
269
|
+
e
|
|
270
|
+
for e in evidence_list
|
|
271
|
+
if e.kind
|
|
272
|
+
in (EvidenceKind.GIT_DIFF_PATCH.value, EvidenceKind.GIT_DIFF_PATCH)
|
|
273
|
+
]
|
|
274
|
+
if diff_patch_evidence:
|
|
275
|
+
sensitive = self._check_sensitive_files(diff_patch_evidence[-1])
|
|
276
|
+
if sensitive:
|
|
277
|
+
return AutonomyCheckResult(
|
|
278
|
+
False,
|
|
279
|
+
f"Sensitive files detected in diff: {', '.join(sensitive)}",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return AutonomyCheckResult(True, "Revision auto-approval allowed")
|
|
283
|
+
|
|
284
|
+
def _parse_diff_stat_lines(self, evidence: Evidence) -> int:
|
|
285
|
+
"""Parse total lines changed from a git diff --stat evidence record.
|
|
286
|
+
|
|
287
|
+
The last line of diff stat output looks like:
|
|
288
|
+
N files changed, X insertions(+), Y deletions(-)
|
|
289
|
+
"""
|
|
290
|
+
try:
|
|
291
|
+
content_path = Path(evidence.stdout_path)
|
|
292
|
+
if not content_path.is_absolute():
|
|
293
|
+
repo_root = WorkspaceService.get_repo_path()
|
|
294
|
+
content_path = repo_root / evidence.stdout_path
|
|
295
|
+
if not content_path.exists():
|
|
296
|
+
return 0
|
|
297
|
+
content = content_path.read_text()
|
|
298
|
+
# Parse last line for total
|
|
299
|
+
lines = content.strip().split("\n")
|
|
300
|
+
if not lines:
|
|
301
|
+
return 0
|
|
302
|
+
last_line = lines[-1]
|
|
303
|
+
total = 0
|
|
304
|
+
# Parse "X insertions(+)" and "Y deletions(-)"
|
|
305
|
+
for part in last_line.split(","):
|
|
306
|
+
part = part.strip()
|
|
307
|
+
if "insertion" in part or "deletion" in part:
|
|
308
|
+
try:
|
|
309
|
+
total += int(part.split()[0])
|
|
310
|
+
except (ValueError, IndexError):
|
|
311
|
+
pass
|
|
312
|
+
return total
|
|
313
|
+
except Exception:
|
|
314
|
+
logger.debug("Failed to parse diff stat", exc_info=True)
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
def _check_sensitive_files(self, evidence: Evidence) -> list[str]:
|
|
318
|
+
"""Check if any files in a diff patch match sensitive file patterns."""
|
|
319
|
+
try:
|
|
320
|
+
content_path = Path(evidence.stdout_path)
|
|
321
|
+
if not content_path.is_absolute():
|
|
322
|
+
repo_root = WorkspaceService.get_repo_path()
|
|
323
|
+
content_path = repo_root / evidence.stdout_path
|
|
324
|
+
if not content_path.exists():
|
|
325
|
+
return []
|
|
326
|
+
content = content_path.read_text()
|
|
327
|
+
# Extract file paths from diff headers (--- a/path and +++ b/path)
|
|
328
|
+
files_in_diff = set()
|
|
329
|
+
for line in content.split("\n"):
|
|
330
|
+
if line.startswith("+++ b/") or line.startswith("--- a/"):
|
|
331
|
+
path = line[6:] # Remove "+++ b/" or "--- a/"
|
|
332
|
+
if path != "/dev/null":
|
|
333
|
+
files_in_diff.add(path)
|
|
334
|
+
|
|
335
|
+
# Match against sensitive patterns
|
|
336
|
+
matches = []
|
|
337
|
+
for file_path in files_in_diff:
|
|
338
|
+
for pattern in self.config.sensitive_file_patterns:
|
|
339
|
+
if fnmatch.fnmatch(file_path, pattern):
|
|
340
|
+
matches.append(file_path)
|
|
341
|
+
break
|
|
342
|
+
return matches
|
|
343
|
+
except Exception:
|
|
344
|
+
logger.debug("Failed to check sensitive files", exc_info=True)
|
|
345
|
+
return []
|
|
346
|
+
|
|
347
|
+
def _check_merge_conflicts(
|
|
348
|
+
self, repo_path: Path, branch_name: str
|
|
349
|
+
) -> AutonomyCheckResult:
|
|
350
|
+
"""Pre-check for merge conflicts using git merge --no-commit --no-ff."""
|
|
351
|
+
try:
|
|
352
|
+
# Try merge dry-run
|
|
353
|
+
result = subprocess.run(
|
|
354
|
+
["git", "merge", "--no-commit", "--no-ff", branch_name],
|
|
355
|
+
cwd=repo_path,
|
|
356
|
+
capture_output=True,
|
|
357
|
+
text=True,
|
|
358
|
+
timeout=30,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Always abort the test merge
|
|
362
|
+
subprocess.run(
|
|
363
|
+
["git", "merge", "--abort"],
|
|
364
|
+
cwd=repo_path,
|
|
365
|
+
capture_output=True,
|
|
366
|
+
timeout=10,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
if result.returncode != 0:
|
|
370
|
+
return AutonomyCheckResult(
|
|
371
|
+
False,
|
|
372
|
+
f"Merge conflicts detected: {result.stderr.strip()[:200]}",
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
return AutonomyCheckResult(True, "No merge conflicts detected")
|
|
376
|
+
|
|
377
|
+
except subprocess.TimeoutExpired:
|
|
378
|
+
return AutonomyCheckResult(False, "Merge conflict check timed out")
|
|
379
|
+
except Exception as e:
|
|
380
|
+
return AutonomyCheckResult(False, f"Merge conflict check failed: {e}")
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Service for managing Board-Repo associations."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
from sqlalchemy.orm import joinedload
|
|
8
|
+
|
|
9
|
+
from app.models.board import Board
|
|
10
|
+
from app.models.board_repo import BoardRepo
|
|
11
|
+
from app.models.repo import Repo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BoardRepoService:
|
|
15
|
+
"""Service for managing board-repo relationships."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, db: AsyncSession):
|
|
18
|
+
self.db = db
|
|
19
|
+
|
|
20
|
+
async def add_repo_to_board(
|
|
21
|
+
self,
|
|
22
|
+
board_id: str,
|
|
23
|
+
repo_id: str,
|
|
24
|
+
is_primary: bool = False,
|
|
25
|
+
custom_setup_script: str | None = None,
|
|
26
|
+
custom_cleanup_script: str | None = None,
|
|
27
|
+
) -> BoardRepo:
|
|
28
|
+
"""
|
|
29
|
+
Add a repository to a board.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
board_id: Board UUID
|
|
33
|
+
repo_id: Repo UUID
|
|
34
|
+
is_primary: Whether this is the primary repo
|
|
35
|
+
custom_setup_script: Per-board setup script override
|
|
36
|
+
custom_cleanup_script: Per-board cleanup script override
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Created BoardRepo association
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ValueError: If board or repo not found, or association already exists
|
|
43
|
+
"""
|
|
44
|
+
# Verify board exists
|
|
45
|
+
board_result = await self.db.execute(select(Board).where(Board.id == board_id))
|
|
46
|
+
board = board_result.scalar_one_or_none()
|
|
47
|
+
if not board:
|
|
48
|
+
raise ValueError(f"Board not found: {board_id}")
|
|
49
|
+
|
|
50
|
+
# Verify repo exists
|
|
51
|
+
repo_result = await self.db.execute(select(Repo).where(Repo.id == repo_id))
|
|
52
|
+
repo = repo_result.scalar_one_or_none()
|
|
53
|
+
if not repo:
|
|
54
|
+
raise ValueError(f"Repo not found: {repo_id}")
|
|
55
|
+
|
|
56
|
+
# Check if association already exists
|
|
57
|
+
existing_result = await self.db.execute(
|
|
58
|
+
select(BoardRepo).where(
|
|
59
|
+
BoardRepo.board_id == board_id, BoardRepo.repo_id == repo_id
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
existing = existing_result.scalar_one_or_none()
|
|
63
|
+
if existing:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"Repo {repo_id} is already associated with board {board_id}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# If setting as primary, unset other primary repos
|
|
69
|
+
if is_primary:
|
|
70
|
+
await self._unset_primary_repos(board_id)
|
|
71
|
+
|
|
72
|
+
# Create association
|
|
73
|
+
board_repo = BoardRepo(
|
|
74
|
+
id=str(uuid.uuid4()),
|
|
75
|
+
board_id=board_id,
|
|
76
|
+
repo_id=repo_id,
|
|
77
|
+
is_primary=is_primary,
|
|
78
|
+
custom_setup_script=custom_setup_script,
|
|
79
|
+
custom_cleanup_script=custom_cleanup_script,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
self.db.add(board_repo)
|
|
83
|
+
await self.db.commit()
|
|
84
|
+
await self.db.refresh(board_repo)
|
|
85
|
+
|
|
86
|
+
# Eager load repo relationship
|
|
87
|
+
result = await self.db.execute(
|
|
88
|
+
select(BoardRepo)
|
|
89
|
+
.options(joinedload(BoardRepo.repo))
|
|
90
|
+
.where(BoardRepo.id == board_repo.id)
|
|
91
|
+
)
|
|
92
|
+
return result.scalar_one()
|
|
93
|
+
|
|
94
|
+
async def get_board_repos(self, board_id: str) -> list[BoardRepo]:
|
|
95
|
+
"""
|
|
96
|
+
Get all repos associated with a board.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
List of BoardRepo associations with eager-loaded Repo
|
|
100
|
+
"""
|
|
101
|
+
result = await self.db.execute(
|
|
102
|
+
select(BoardRepo)
|
|
103
|
+
.options(joinedload(BoardRepo.repo))
|
|
104
|
+
.where(BoardRepo.board_id == board_id)
|
|
105
|
+
.order_by(BoardRepo.is_primary.desc(), BoardRepo.created_at.asc())
|
|
106
|
+
)
|
|
107
|
+
return list(result.scalars().all())
|
|
108
|
+
|
|
109
|
+
async def get_board_repo(self, board_id: str, repo_id: str) -> BoardRepo | None:
|
|
110
|
+
"""Get a specific board-repo association."""
|
|
111
|
+
result = await self.db.execute(
|
|
112
|
+
select(BoardRepo)
|
|
113
|
+
.options(joinedload(BoardRepo.repo))
|
|
114
|
+
.where(BoardRepo.board_id == board_id, BoardRepo.repo_id == repo_id)
|
|
115
|
+
)
|
|
116
|
+
return result.scalar_one_or_none()
|
|
117
|
+
|
|
118
|
+
async def update_board_repo(
|
|
119
|
+
self,
|
|
120
|
+
board_id: str,
|
|
121
|
+
repo_id: str,
|
|
122
|
+
is_primary: bool | None = None,
|
|
123
|
+
custom_setup_script: str | None = None,
|
|
124
|
+
custom_cleanup_script: str | None = None,
|
|
125
|
+
) -> BoardRepo:
|
|
126
|
+
"""
|
|
127
|
+
Update a board-repo association.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
board_id: Board UUID
|
|
131
|
+
repo_id: Repo UUID
|
|
132
|
+
is_primary: Whether to set as primary
|
|
133
|
+
custom_setup_script: Per-board setup script override
|
|
134
|
+
custom_cleanup_script: Per-board cleanup script override
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Updated BoardRepo
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
ValueError: If association not found
|
|
141
|
+
"""
|
|
142
|
+
board_repo = await self.get_board_repo(board_id, repo_id)
|
|
143
|
+
if not board_repo:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"Board-repo association not found: board={board_id}, repo={repo_id}"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# If setting as primary, unset other primary repos
|
|
149
|
+
if is_primary is not None and is_primary and not board_repo.is_primary:
|
|
150
|
+
await self._unset_primary_repos(board_id)
|
|
151
|
+
board_repo.is_primary = True
|
|
152
|
+
|
|
153
|
+
if custom_setup_script is not None:
|
|
154
|
+
board_repo.custom_setup_script = custom_setup_script
|
|
155
|
+
if custom_cleanup_script is not None:
|
|
156
|
+
board_repo.custom_cleanup_script = custom_cleanup_script
|
|
157
|
+
|
|
158
|
+
await self.db.commit()
|
|
159
|
+
await self.db.refresh(board_repo)
|
|
160
|
+
|
|
161
|
+
# Reload with repo relationship
|
|
162
|
+
result = await self.db.execute(
|
|
163
|
+
select(BoardRepo)
|
|
164
|
+
.options(joinedload(BoardRepo.repo))
|
|
165
|
+
.where(BoardRepo.id == board_repo.id)
|
|
166
|
+
)
|
|
167
|
+
return result.scalar_one()
|
|
168
|
+
|
|
169
|
+
async def remove_repo_from_board(self, board_id: str, repo_id: str) -> None:
|
|
170
|
+
"""
|
|
171
|
+
Remove a repo from a board.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
board_id: Board UUID
|
|
175
|
+
repo_id: Repo UUID
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
ValueError: If association not found
|
|
179
|
+
"""
|
|
180
|
+
board_repo = await self.get_board_repo(board_id, repo_id)
|
|
181
|
+
if not board_repo:
|
|
182
|
+
raise ValueError(
|
|
183
|
+
f"Board-repo association not found: board={board_id}, repo={repo_id}"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
await self.db.delete(board_repo)
|
|
187
|
+
await self.db.commit()
|
|
188
|
+
|
|
189
|
+
async def _unset_primary_repos(self, board_id: str) -> None:
|
|
190
|
+
"""Unset is_primary for all repos on a board."""
|
|
191
|
+
result = await self.db.execute(
|
|
192
|
+
select(BoardRepo).where(
|
|
193
|
+
BoardRepo.board_id == board_id, BoardRepo.is_primary
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
primary_repos = list(result.scalars().all())
|
|
197
|
+
|
|
198
|
+
for board_repo in primary_repos:
|
|
199
|
+
board_repo.is_primary = False
|
|
200
|
+
|
|
201
|
+
await self.db.flush()
|