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,213 @@
|
|
|
1
|
+
"""Worktree validation service - enforces safety checks for execution.
|
|
2
|
+
|
|
3
|
+
This module provides hard validation that execution only happens in
|
|
4
|
+
properly isolated worktrees, not the main repository.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from app.data_dir import LEGACY_WORKTREES_DIR, get_worktrees_root
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WorktreeValidationError(StrEnum):
|
|
16
|
+
"""Types of worktree validation failures."""
|
|
17
|
+
|
|
18
|
+
NOT_IN_DRAFT_DIR = "not_in_draft_dir"
|
|
19
|
+
ON_PROTECTED_BRANCH = "on_protected_branch"
|
|
20
|
+
IS_MAIN_REPO = "is_main_repo"
|
|
21
|
+
NOT_A_GIT_REPO = "not_a_git_repo"
|
|
22
|
+
PATH_MISMATCH = "path_mismatch"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class WorktreeValidationResult:
|
|
27
|
+
"""Result of worktree validation."""
|
|
28
|
+
|
|
29
|
+
valid: bool
|
|
30
|
+
error: WorktreeValidationError | None = None
|
|
31
|
+
message: str | None = None
|
|
32
|
+
worktree_path: str | None = None
|
|
33
|
+
branch: str | None = None
|
|
34
|
+
main_repo_path: str | None = None
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def success(cls, worktree_path: str, branch: str) -> "WorktreeValidationResult":
|
|
38
|
+
"""Create a successful validation result."""
|
|
39
|
+
return cls(valid=True, worktree_path=worktree_path, branch=branch)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def failure(
|
|
43
|
+
cls,
|
|
44
|
+
error: WorktreeValidationError,
|
|
45
|
+
message: str,
|
|
46
|
+
worktree_path: str | None = None,
|
|
47
|
+
branch: str | None = None,
|
|
48
|
+
main_repo_path: str | None = None,
|
|
49
|
+
) -> "WorktreeValidationResult":
|
|
50
|
+
"""Create a failed validation result."""
|
|
51
|
+
return cls(
|
|
52
|
+
valid=False,
|
|
53
|
+
error=error,
|
|
54
|
+
message=message,
|
|
55
|
+
worktree_path=worktree_path,
|
|
56
|
+
branch=branch,
|
|
57
|
+
main_repo_path=main_repo_path,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class WorktreeValidator:
|
|
62
|
+
"""Validates that a path is a safe, isolated worktree for execution.
|
|
63
|
+
|
|
64
|
+
Safety Checks:
|
|
65
|
+
1. Path must be under .draft/worktrees/
|
|
66
|
+
2. Branch must NOT be main/master/develop (protected branches)
|
|
67
|
+
3. git rev-parse --show-toplevel must match the worktree path
|
|
68
|
+
4. Worktree path must be different from the main repo path
|
|
69
|
+
|
|
70
|
+
These checks prevent accidental execution in the main repository.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
# Protected branches that should never be modified by automated execution
|
|
74
|
+
PROTECTED_BRANCHES = {"main", "master", "develop", "production", "staging"}
|
|
75
|
+
|
|
76
|
+
# Required path component for worktrees (central dir or legacy)
|
|
77
|
+
WORKTREE_PATH_MARKER = str(get_worktrees_root())
|
|
78
|
+
LEGACY_WORKTREE_PATH_MARKER = LEGACY_WORKTREES_DIR
|
|
79
|
+
|
|
80
|
+
def __init__(self, main_repo_path: Path | str):
|
|
81
|
+
"""
|
|
82
|
+
Initialize the validator.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
main_repo_path: Path to the main repository (not a worktree).
|
|
86
|
+
"""
|
|
87
|
+
self.main_repo_path = Path(main_repo_path).resolve()
|
|
88
|
+
|
|
89
|
+
def validate(self, worktree_path: Path | str) -> WorktreeValidationResult:
|
|
90
|
+
"""
|
|
91
|
+
Validate that a path is a safe worktree for execution.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
worktree_path: Path to validate as a worktree.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
WorktreeValidationResult with validation status and details.
|
|
98
|
+
"""
|
|
99
|
+
worktree = Path(worktree_path).resolve()
|
|
100
|
+
worktree_str = str(worktree)
|
|
101
|
+
|
|
102
|
+
# Check 1: Path must be under central data dir or legacy .draft/worktrees/
|
|
103
|
+
in_central = worktree_str.startswith(str(get_worktrees_root()))
|
|
104
|
+
in_legacy = self.LEGACY_WORKTREE_PATH_MARKER in worktree_str
|
|
105
|
+
if not in_central and not in_legacy:
|
|
106
|
+
return WorktreeValidationResult.failure(
|
|
107
|
+
error=WorktreeValidationError.NOT_IN_DRAFT_DIR,
|
|
108
|
+
message=(
|
|
109
|
+
f"Worktree path must be under {self.WORKTREE_PATH_MARKER}/ "
|
|
110
|
+
f"or legacy {self.LEGACY_WORKTREE_PATH_MARKER}/. "
|
|
111
|
+
f"Got: {worktree_str}"
|
|
112
|
+
),
|
|
113
|
+
worktree_path=worktree_str,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Check 2: Must be a git repository
|
|
117
|
+
try:
|
|
118
|
+
result = subprocess.run(
|
|
119
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
120
|
+
cwd=worktree,
|
|
121
|
+
capture_output=True,
|
|
122
|
+
text=True,
|
|
123
|
+
timeout=10,
|
|
124
|
+
)
|
|
125
|
+
if result.returncode != 0:
|
|
126
|
+
return WorktreeValidationResult.failure(
|
|
127
|
+
error=WorktreeValidationError.NOT_A_GIT_REPO,
|
|
128
|
+
message=f"Not a git repository: {worktree_str}",
|
|
129
|
+
worktree_path=worktree_str,
|
|
130
|
+
)
|
|
131
|
+
git_toplevel = Path(result.stdout.strip()).resolve()
|
|
132
|
+
except subprocess.TimeoutExpired:
|
|
133
|
+
return WorktreeValidationResult.failure(
|
|
134
|
+
error=WorktreeValidationError.NOT_A_GIT_REPO,
|
|
135
|
+
message="git rev-parse timed out",
|
|
136
|
+
worktree_path=worktree_str,
|
|
137
|
+
)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
return WorktreeValidationResult.failure(
|
|
140
|
+
error=WorktreeValidationError.NOT_A_GIT_REPO,
|
|
141
|
+
message=f"Failed to check git status: {e}",
|
|
142
|
+
worktree_path=worktree_str,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Check 3: git toplevel must match worktree path (or be under it)
|
|
146
|
+
# This catches cases where someone symlinks or mounts a worktree elsewhere
|
|
147
|
+
if git_toplevel != worktree and not str(git_toplevel).startswith(str(worktree)):
|
|
148
|
+
return WorktreeValidationResult.failure(
|
|
149
|
+
error=WorktreeValidationError.PATH_MISMATCH,
|
|
150
|
+
message=(
|
|
151
|
+
f"Git toplevel doesn't match worktree path. "
|
|
152
|
+
f"Toplevel: {git_toplevel}, Worktree: {worktree}"
|
|
153
|
+
),
|
|
154
|
+
worktree_path=worktree_str,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Check 4: Worktree must NOT be the main repo
|
|
158
|
+
if git_toplevel == self.main_repo_path:
|
|
159
|
+
return WorktreeValidationResult.failure(
|
|
160
|
+
error=WorktreeValidationError.IS_MAIN_REPO,
|
|
161
|
+
message=(
|
|
162
|
+
f"Cannot execute in main repository. "
|
|
163
|
+
f"Use a worktree under {self.WORKTREE_PATH_MARKER}/. "
|
|
164
|
+
f"Main repo: {self.main_repo_path}"
|
|
165
|
+
),
|
|
166
|
+
worktree_path=worktree_str,
|
|
167
|
+
main_repo_path=str(self.main_repo_path),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Check 5: Get current branch and verify it's not protected
|
|
171
|
+
try:
|
|
172
|
+
branch_result = subprocess.run(
|
|
173
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
174
|
+
cwd=worktree,
|
|
175
|
+
capture_output=True,
|
|
176
|
+
text=True,
|
|
177
|
+
timeout=10,
|
|
178
|
+
)
|
|
179
|
+
if branch_result.returncode != 0:
|
|
180
|
+
branch = "unknown"
|
|
181
|
+
else:
|
|
182
|
+
branch = branch_result.stdout.strip()
|
|
183
|
+
except Exception:
|
|
184
|
+
branch = "unknown"
|
|
185
|
+
|
|
186
|
+
if branch.lower() in self.PROTECTED_BRANCHES:
|
|
187
|
+
return WorktreeValidationResult.failure(
|
|
188
|
+
error=WorktreeValidationError.ON_PROTECTED_BRANCH,
|
|
189
|
+
message=(
|
|
190
|
+
f"Cannot execute on protected branch '{branch}'. "
|
|
191
|
+
f"Protected branches: {', '.join(sorted(self.PROTECTED_BRANCHES))}"
|
|
192
|
+
),
|
|
193
|
+
worktree_path=worktree_str,
|
|
194
|
+
branch=branch,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# All checks passed
|
|
198
|
+
return WorktreeValidationResult.success(
|
|
199
|
+
worktree_path=worktree_str,
|
|
200
|
+
branch=branch,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def is_safe_for_execution(self, worktree_path: Path | str) -> bool:
|
|
204
|
+
"""
|
|
205
|
+
Quick check if a path is safe for execution.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
worktree_path: Path to check.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
True if the path passes all safety checks.
|
|
212
|
+
"""
|
|
213
|
+
return self.validate(worktree_path).valid
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Low-level SQLite operations for middleware (sync, used via asyncio.to_thread).
|
|
2
|
+
|
|
3
|
+
These functions use raw SQLite connections (not SQLAlchemy sessions) for
|
|
4
|
+
atomicity and to avoid interfering with the async session lifecycle.
|
|
5
|
+
The middleware runs outside of route handlers, so it cannot use get_db().
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sqlite3
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
_BACKEND_DIR = Path(__file__).parent.parent.resolve()
|
|
14
|
+
_DB_PATH = os.getenv("SQLITE_BACKEND_DB", str(_BACKEND_DIR / "kanban.db"))
|
|
15
|
+
|
|
16
|
+
# Parse async DATABASE_URL to extract path if set
|
|
17
|
+
_DATABASE_URL = os.getenv("DATABASE_URL", "")
|
|
18
|
+
if _DATABASE_URL:
|
|
19
|
+
# Handle both sqlite:///path and sqlite+aiosqlite:///path
|
|
20
|
+
for prefix in ("sqlite+aiosqlite:///", "sqlite:///"):
|
|
21
|
+
if _DATABASE_URL.startswith(prefix):
|
|
22
|
+
_extracted = _DATABASE_URL[len(prefix) :]
|
|
23
|
+
if _extracted:
|
|
24
|
+
_DB_PATH = _extracted
|
|
25
|
+
break
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_conn() -> sqlite3.Connection:
|
|
29
|
+
"""Get a SQLite connection with WAL mode."""
|
|
30
|
+
conn = sqlite3.connect(_DB_PATH, timeout=30)
|
|
31
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
32
|
+
conn.execute("PRAGMA busy_timeout=30000")
|
|
33
|
+
return conn
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ─── Idempotency operations ───
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def idempotency_try_acquire(cache_key: str, lock_value: str, ttl_seconds: int) -> bool:
|
|
40
|
+
"""Try to acquire an idempotency lock. Returns True if acquired."""
|
|
41
|
+
now = time.time()
|
|
42
|
+
now + ttl_seconds
|
|
43
|
+
conn = _get_conn()
|
|
44
|
+
try:
|
|
45
|
+
# Clean expired locks first
|
|
46
|
+
conn.execute(
|
|
47
|
+
"DELETE FROM idempotency_cache WHERE lock_expires_at IS NOT NULL "
|
|
48
|
+
"AND lock_expires_at < datetime('now')"
|
|
49
|
+
)
|
|
50
|
+
# Try atomic insert
|
|
51
|
+
cursor = conn.execute(
|
|
52
|
+
"INSERT OR IGNORE INTO idempotency_cache "
|
|
53
|
+
"(cache_key, lock_value, lock_expires_at, created_at) "
|
|
54
|
+
"VALUES (?, ?, datetime('now', ?), datetime('now'))",
|
|
55
|
+
(cache_key, lock_value, f"+{ttl_seconds} seconds"),
|
|
56
|
+
)
|
|
57
|
+
conn.commit()
|
|
58
|
+
return cursor.rowcount == 1
|
|
59
|
+
finally:
|
|
60
|
+
conn.close()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def idempotency_get_lock(cache_key: str) -> str | None:
|
|
64
|
+
"""Get the lock value for a key (None if not locked or expired)."""
|
|
65
|
+
conn = _get_conn()
|
|
66
|
+
try:
|
|
67
|
+
row = conn.execute(
|
|
68
|
+
"SELECT lock_value FROM idempotency_cache "
|
|
69
|
+
"WHERE cache_key = ? AND (lock_expires_at IS NULL OR lock_expires_at >= datetime('now'))",
|
|
70
|
+
(cache_key,),
|
|
71
|
+
).fetchone()
|
|
72
|
+
return row[0] if row else None
|
|
73
|
+
finally:
|
|
74
|
+
conn.close()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def idempotency_store_result(
|
|
78
|
+
cache_key: str, result_value: str, ttl_seconds: int
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Store the result and clear the lock."""
|
|
81
|
+
conn = _get_conn()
|
|
82
|
+
try:
|
|
83
|
+
conn.execute(
|
|
84
|
+
"UPDATE idempotency_cache SET result_value = ?, "
|
|
85
|
+
"result_expires_at = datetime('now', ?), "
|
|
86
|
+
"lock_value = NULL, lock_expires_at = NULL "
|
|
87
|
+
"WHERE cache_key = ?",
|
|
88
|
+
(result_value, f"+{ttl_seconds} seconds", cache_key),
|
|
89
|
+
)
|
|
90
|
+
conn.commit()
|
|
91
|
+
finally:
|
|
92
|
+
conn.close()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def idempotency_get_result(cache_key: str) -> str | None:
|
|
96
|
+
"""Get cached result (None if not found or expired)."""
|
|
97
|
+
conn = _get_conn()
|
|
98
|
+
try:
|
|
99
|
+
row = conn.execute(
|
|
100
|
+
"SELECT result_value FROM idempotency_cache "
|
|
101
|
+
"WHERE cache_key = ? AND result_value IS NOT NULL "
|
|
102
|
+
"AND (result_expires_at IS NULL OR result_expires_at >= datetime('now'))",
|
|
103
|
+
(cache_key,),
|
|
104
|
+
).fetchone()
|
|
105
|
+
return row[0] if row else None
|
|
106
|
+
finally:
|
|
107
|
+
conn.close()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def idempotency_release_lock(cache_key: str) -> None:
|
|
111
|
+
"""Release lock (delete entry if no result stored)."""
|
|
112
|
+
conn = _get_conn()
|
|
113
|
+
try:
|
|
114
|
+
conn.execute(
|
|
115
|
+
"DELETE FROM idempotency_cache WHERE cache_key = ? AND result_value IS NULL",
|
|
116
|
+
(cache_key,),
|
|
117
|
+
)
|
|
118
|
+
conn.commit()
|
|
119
|
+
finally:
|
|
120
|
+
conn.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ─── Rate limit operations ───
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def rate_limit_check_and_record(
|
|
127
|
+
client_key: str, cost: int, window_seconds: int
|
|
128
|
+
) -> tuple[int, float]:
|
|
129
|
+
"""Check current usage and record new cost entry.
|
|
130
|
+
|
|
131
|
+
Returns (current_cost_before_recording, oldest_entry_time).
|
|
132
|
+
"""
|
|
133
|
+
now = time.time()
|
|
134
|
+
window_start = now - window_seconds
|
|
135
|
+
expires_at = now + window_seconds
|
|
136
|
+
|
|
137
|
+
conn = _get_conn()
|
|
138
|
+
try:
|
|
139
|
+
# Cleanup expired
|
|
140
|
+
conn.execute("DELETE FROM rate_limit_entries WHERE expires_at < ?", (now,))
|
|
141
|
+
|
|
142
|
+
# Sum current cost in window (also filter by expires_at for consistency)
|
|
143
|
+
row = conn.execute(
|
|
144
|
+
"SELECT COALESCE(SUM(cost), 0) FROM rate_limit_entries "
|
|
145
|
+
"WHERE client_key = ? AND recorded_at > ? AND expires_at >= ?",
|
|
146
|
+
(client_key, window_start, now),
|
|
147
|
+
).fetchone()
|
|
148
|
+
current_cost = row[0] if row else 0
|
|
149
|
+
|
|
150
|
+
# Get oldest entry time for retry-after calculation
|
|
151
|
+
oldest_row = conn.execute(
|
|
152
|
+
"SELECT MIN(recorded_at) FROM rate_limit_entries "
|
|
153
|
+
"WHERE client_key = ? AND recorded_at > ? AND expires_at >= ?",
|
|
154
|
+
(client_key, window_start, now),
|
|
155
|
+
).fetchone()
|
|
156
|
+
oldest_time = oldest_row[0] if oldest_row and oldest_row[0] else now
|
|
157
|
+
|
|
158
|
+
# Record new entry
|
|
159
|
+
conn.execute(
|
|
160
|
+
"INSERT INTO rate_limit_entries (client_key, cost, recorded_at, expires_at) "
|
|
161
|
+
"VALUES (?, ?, ?, ?)",
|
|
162
|
+
(client_key, cost, now, expires_at),
|
|
163
|
+
)
|
|
164
|
+
conn.commit()
|
|
165
|
+
|
|
166
|
+
return current_cost, oldest_time
|
|
167
|
+
finally:
|
|
168
|
+
conn.close()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def rate_limit_check_only(client_key: str, window_seconds: int) -> tuple[int, float]:
|
|
172
|
+
"""Check current usage without recording. Returns (current_cost, oldest_time)."""
|
|
173
|
+
now = time.time()
|
|
174
|
+
window_start = now - window_seconds
|
|
175
|
+
|
|
176
|
+
conn = _get_conn()
|
|
177
|
+
try:
|
|
178
|
+
# Cleanup expired
|
|
179
|
+
conn.execute("DELETE FROM rate_limit_entries WHERE expires_at < ?", (now,))
|
|
180
|
+
|
|
181
|
+
row = conn.execute(
|
|
182
|
+
"SELECT COALESCE(SUM(cost), 0) FROM rate_limit_entries "
|
|
183
|
+
"WHERE client_key = ? AND recorded_at > ? AND expires_at >= ?",
|
|
184
|
+
(client_key, window_start, now),
|
|
185
|
+
).fetchone()
|
|
186
|
+
current_cost = row[0] if row else 0
|
|
187
|
+
|
|
188
|
+
oldest_row = conn.execute(
|
|
189
|
+
"SELECT MIN(recorded_at) FROM rate_limit_entries "
|
|
190
|
+
"WHERE client_key = ? AND recorded_at > ? AND expires_at >= ?",
|
|
191
|
+
(client_key, window_start, now),
|
|
192
|
+
).fetchone()
|
|
193
|
+
oldest_time = oldest_row[0] if oldest_row and oldest_row[0] else now
|
|
194
|
+
|
|
195
|
+
conn.commit()
|
|
196
|
+
return current_cost, oldest_time
|
|
197
|
+
finally:
|
|
198
|
+
conn.close()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ─── KV store operations (for queued messages) ───
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def kv_set(key: str, value: str, ttl_seconds: int | None = None) -> None:
|
|
205
|
+
"""Set a key-value pair with optional TTL."""
|
|
206
|
+
conn = _get_conn()
|
|
207
|
+
try:
|
|
208
|
+
if ttl_seconds:
|
|
209
|
+
conn.execute(
|
|
210
|
+
"INSERT OR REPLACE INTO kv_store (key, value, expires_at, created_at) "
|
|
211
|
+
"VALUES (?, ?, datetime('now', ?), datetime('now'))",
|
|
212
|
+
(key, value, f"+{ttl_seconds} seconds"),
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
conn.execute(
|
|
216
|
+
"INSERT OR REPLACE INTO kv_store (key, value, created_at) "
|
|
217
|
+
"VALUES (?, ?, datetime('now'))",
|
|
218
|
+
(key, value),
|
|
219
|
+
)
|
|
220
|
+
conn.commit()
|
|
221
|
+
finally:
|
|
222
|
+
conn.close()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def kv_get(key: str) -> str | None:
|
|
226
|
+
"""Get a value by key (None if not found or expired)."""
|
|
227
|
+
conn = _get_conn()
|
|
228
|
+
try:
|
|
229
|
+
row = conn.execute(
|
|
230
|
+
"SELECT value FROM kv_store WHERE key = ? "
|
|
231
|
+
"AND (expires_at IS NULL OR expires_at >= datetime('now'))",
|
|
232
|
+
(key,),
|
|
233
|
+
).fetchone()
|
|
234
|
+
return row[0] if row else None
|
|
235
|
+
finally:
|
|
236
|
+
conn.close()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def kv_take(key: str) -> str | None:
|
|
240
|
+
"""Get and delete a value atomically (None if not found or expired)."""
|
|
241
|
+
conn = _get_conn()
|
|
242
|
+
try:
|
|
243
|
+
# Atomic get-and-delete using DELETE...RETURNING (SQLite 3.35+)
|
|
244
|
+
row = conn.execute(
|
|
245
|
+
"DELETE FROM kv_store WHERE key = ? "
|
|
246
|
+
"AND (expires_at IS NULL OR expires_at >= datetime('now')) "
|
|
247
|
+
"RETURNING value",
|
|
248
|
+
(key,),
|
|
249
|
+
).fetchone()
|
|
250
|
+
conn.commit()
|
|
251
|
+
return row[0] if row else None
|
|
252
|
+
finally:
|
|
253
|
+
conn.close()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def kv_delete(key: str) -> bool:
|
|
257
|
+
"""Delete a key. Returns True if deleted."""
|
|
258
|
+
conn = _get_conn()
|
|
259
|
+
try:
|
|
260
|
+
cursor = conn.execute("DELETE FROM kv_store WHERE key = ?", (key,))
|
|
261
|
+
conn.commit()
|
|
262
|
+
return cursor.rowcount > 0
|
|
263
|
+
finally:
|
|
264
|
+
conn.close()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def kv_exists(key: str) -> bool:
|
|
268
|
+
"""Check if a key exists (and is not expired)."""
|
|
269
|
+
conn = _get_conn()
|
|
270
|
+
try:
|
|
271
|
+
row = conn.execute(
|
|
272
|
+
"SELECT 1 FROM kv_store WHERE key = ? "
|
|
273
|
+
"AND (expires_at IS NULL OR expires_at >= datetime('now'))",
|
|
274
|
+
(key,),
|
|
275
|
+
).fetchone()
|
|
276
|
+
return row is not None
|
|
277
|
+
finally:
|
|
278
|
+
conn.close()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""State machine implementation for ticket workflow.
|
|
2
|
+
|
|
3
|
+
This module contains the TicketState enum and state transition rules.
|
|
4
|
+
Event types and actor types are defined in models/enums.py but re-exported
|
|
5
|
+
here for backwards compatibility.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
|
|
10
|
+
# Re-export event types and actor types for backwards compatibility
|
|
11
|
+
# The canonical definitions are in models/enums.py
|
|
12
|
+
from app.models.enums import ActorType, EventType
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"TicketState",
|
|
16
|
+
"ActorType",
|
|
17
|
+
"EventType",
|
|
18
|
+
"ALLOWED_TRANSITIONS",
|
|
19
|
+
"TERMINAL_STATES",
|
|
20
|
+
"validate_transition",
|
|
21
|
+
"get_allowed_transitions",
|
|
22
|
+
"is_terminal_state",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TicketState(StrEnum):
|
|
27
|
+
"""Enum representing valid ticket states."""
|
|
28
|
+
|
|
29
|
+
PROPOSED = "proposed"
|
|
30
|
+
PLANNED = "planned"
|
|
31
|
+
EXECUTING = "executing"
|
|
32
|
+
VERIFYING = "verifying"
|
|
33
|
+
NEEDS_HUMAN = "needs_human"
|
|
34
|
+
BLOCKED = "blocked"
|
|
35
|
+
DONE = "done"
|
|
36
|
+
ABANDONED = "abandoned"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Allowed state transitions map
|
|
40
|
+
# Key: current state, Value: list of valid next states
|
|
41
|
+
ALLOWED_TRANSITIONS: dict[TicketState, list[TicketState]] = {
|
|
42
|
+
TicketState.PROPOSED: [
|
|
43
|
+
TicketState.PLANNED,
|
|
44
|
+
TicketState.ABANDONED,
|
|
45
|
+
],
|
|
46
|
+
TicketState.PLANNED: [
|
|
47
|
+
TicketState.PROPOSED,
|
|
48
|
+
TicketState.EXECUTING,
|
|
49
|
+
TicketState.BLOCKED,
|
|
50
|
+
TicketState.ABANDONED,
|
|
51
|
+
],
|
|
52
|
+
TicketState.EXECUTING: [
|
|
53
|
+
TicketState.VERIFYING,
|
|
54
|
+
TicketState.NEEDS_HUMAN,
|
|
55
|
+
TicketState.BLOCKED,
|
|
56
|
+
],
|
|
57
|
+
TicketState.VERIFYING: [
|
|
58
|
+
TicketState.EXECUTING, # Rework needed
|
|
59
|
+
TicketState.NEEDS_HUMAN, # Verification passed, awaiting human review
|
|
60
|
+
TicketState.BLOCKED, # Verification failed
|
|
61
|
+
TicketState.DONE, # Auto-approved (autonomy mode, skips NEEDS_HUMAN)
|
|
62
|
+
],
|
|
63
|
+
TicketState.NEEDS_HUMAN: [
|
|
64
|
+
TicketState.EXECUTING, # Human resolved, back to executing
|
|
65
|
+
TicketState.PLANNED, # Human replanned
|
|
66
|
+
TicketState.DONE, # Human approved revision
|
|
67
|
+
TicketState.ABANDONED,
|
|
68
|
+
],
|
|
69
|
+
TicketState.BLOCKED: [
|
|
70
|
+
TicketState.PLANNED, # Unblocked, back to planning
|
|
71
|
+
TicketState.EXECUTING, # Retry execution (e.g., after fixing blocker or retrying failed execution)
|
|
72
|
+
TicketState.ABANDONED,
|
|
73
|
+
],
|
|
74
|
+
TicketState.DONE: [
|
|
75
|
+
TicketState.EXECUTING, # Human requested changes on revision
|
|
76
|
+
],
|
|
77
|
+
TicketState.ABANDONED: [
|
|
78
|
+
TicketState.PLANNED, # Reactivate accidentally abandoned tickets
|
|
79
|
+
],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def validate_transition(from_state: TicketState, to_state: TicketState) -> bool:
|
|
84
|
+
"""
|
|
85
|
+
Validate if a state transition is allowed.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
from_state: The current state of the ticket
|
|
89
|
+
to_state: The desired new state
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
True if the transition is valid, False otherwise
|
|
93
|
+
"""
|
|
94
|
+
if from_state not in ALLOWED_TRANSITIONS:
|
|
95
|
+
return False
|
|
96
|
+
return to_state in ALLOWED_TRANSITIONS[from_state]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_allowed_transitions(current_state: TicketState) -> list[TicketState]:
|
|
100
|
+
"""
|
|
101
|
+
Get list of valid next states from the current state.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
current_state: The current state of the ticket
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
List of valid next states
|
|
108
|
+
"""
|
|
109
|
+
return ALLOWED_TRANSITIONS.get(current_state, [])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# Terminal states for workspace cleanup and watchdog purposes.
|
|
113
|
+
# Note: DONE can transition back to EXECUTING if human requests changes on revision,
|
|
114
|
+
# but is still considered "terminal" for cleanup purposes (workspace recreated if needed).
|
|
115
|
+
TERMINAL_STATES: set[TicketState] = {TicketState.DONE, TicketState.ABANDONED}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def is_terminal_state(state: TicketState) -> bool:
|
|
119
|
+
"""
|
|
120
|
+
Check if a state is a terminal state.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
state: The ticket state to check
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if the state is terminal (DONE or ABANDONED)
|
|
127
|
+
"""
|
|
128
|
+
return state in TERMINAL_STATES
|