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.
Files changed (250) hide show
  1. package/app/backend/.env.example +9 -0
  2. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
  3. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
  4. package/app/backend/CURL_EXAMPLES.md +335 -0
  5. package/app/backend/ENV_SETUP.md +65 -0
  6. package/app/backend/alembic/env.py +71 -0
  7. package/app/backend/alembic/script.py.mako +28 -0
  8. package/app/backend/alembic/versions/001_initial_schema.py +104 -0
  9. package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
  10. package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
  11. package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
  12. package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
  13. package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
  14. package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
  15. package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
  16. package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
  17. package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
  18. package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
  19. package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
  20. package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
  21. package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
  22. package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
  23. package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
  24. package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
  25. package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
  26. package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
  27. package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
  28. package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
  29. package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
  30. package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
  31. package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
  32. package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
  33. package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
  34. package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
  35. package/app/backend/alembic/versions/add_job_variant.py +34 -0
  36. package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
  37. package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
  38. package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
  39. package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
  40. package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
  41. package/app/backend/alembic.ini +89 -0
  42. package/app/backend/app/__init__.py +3 -0
  43. package/app/backend/app/data_dir.py +85 -0
  44. package/app/backend/app/database.py +70 -0
  45. package/app/backend/app/database_sync.py +64 -0
  46. package/app/backend/app/dependencies/__init__.py +5 -0
  47. package/app/backend/app/dependencies/auth.py +80 -0
  48. package/app/backend/app/dependencies.py +43 -0
  49. package/app/backend/app/exceptions.py +178 -0
  50. package/app/backend/app/executors/__init__.py +1 -0
  51. package/app/backend/app/executors/adapters/__init__.py +1 -0
  52. package/app/backend/app/executors/adapters/aider.py +152 -0
  53. package/app/backend/app/executors/adapters/amazon_q.py +103 -0
  54. package/app/backend/app/executors/adapters/amp.py +123 -0
  55. package/app/backend/app/executors/adapters/claude.py +177 -0
  56. package/app/backend/app/executors/adapters/cline.py +127 -0
  57. package/app/backend/app/executors/adapters/codex.py +167 -0
  58. package/app/backend/app/executors/adapters/copilot.py +202 -0
  59. package/app/backend/app/executors/adapters/cursor.py +87 -0
  60. package/app/backend/app/executors/adapters/droid.py +123 -0
  61. package/app/backend/app/executors/adapters/gemini.py +132 -0
  62. package/app/backend/app/executors/adapters/goose.py +131 -0
  63. package/app/backend/app/executors/adapters/opencode.py +123 -0
  64. package/app/backend/app/executors/adapters/qwen.py +123 -0
  65. package/app/backend/app/executors/plugins/__init__.py +1 -0
  66. package/app/backend/app/executors/registry.py +202 -0
  67. package/app/backend/app/executors/spec.py +226 -0
  68. package/app/backend/app/main.py +486 -0
  69. package/app/backend/app/middleware/__init__.py +13 -0
  70. package/app/backend/app/middleware/idempotency.py +426 -0
  71. package/app/backend/app/middleware/rate_limit.py +312 -0
  72. package/app/backend/app/middleware/security_headers.py +43 -0
  73. package/app/backend/app/middleware/timeout.py +37 -0
  74. package/app/backend/app/models/__init__.py +56 -0
  75. package/app/backend/app/models/agent_conversation_history.py +56 -0
  76. package/app/backend/app/models/agent_session.py +127 -0
  77. package/app/backend/app/models/analysis_cache.py +49 -0
  78. package/app/backend/app/models/base.py +9 -0
  79. package/app/backend/app/models/board.py +79 -0
  80. package/app/backend/app/models/board_repo.py +68 -0
  81. package/app/backend/app/models/cost_budget.py +42 -0
  82. package/app/backend/app/models/enums.py +40 -0
  83. package/app/backend/app/models/evidence.py +132 -0
  84. package/app/backend/app/models/goal.py +102 -0
  85. package/app/backend/app/models/idempotency_entry.py +30 -0
  86. package/app/backend/app/models/job.py +163 -0
  87. package/app/backend/app/models/job_queue.py +39 -0
  88. package/app/backend/app/models/kv_store.py +28 -0
  89. package/app/backend/app/models/merge_checklist.py +87 -0
  90. package/app/backend/app/models/normalized_log.py +100 -0
  91. package/app/backend/app/models/planner_lock.py +43 -0
  92. package/app/backend/app/models/rate_limit_entry.py +25 -0
  93. package/app/backend/app/models/repo.py +66 -0
  94. package/app/backend/app/models/review_comment.py +91 -0
  95. package/app/backend/app/models/review_summary.py +69 -0
  96. package/app/backend/app/models/revision.py +130 -0
  97. package/app/backend/app/models/ticket.py +223 -0
  98. package/app/backend/app/models/ticket_event.py +83 -0
  99. package/app/backend/app/models/user.py +47 -0
  100. package/app/backend/app/models/workspace.py +71 -0
  101. package/app/backend/app/redis_client.py +119 -0
  102. package/app/backend/app/routers/__init__.py +29 -0
  103. package/app/backend/app/routers/agents.py +296 -0
  104. package/app/backend/app/routers/auth.py +94 -0
  105. package/app/backend/app/routers/board.py +885 -0
  106. package/app/backend/app/routers/dashboard.py +351 -0
  107. package/app/backend/app/routers/debug.py +528 -0
  108. package/app/backend/app/routers/evidence.py +96 -0
  109. package/app/backend/app/routers/executors.py +324 -0
  110. package/app/backend/app/routers/goals.py +574 -0
  111. package/app/backend/app/routers/jobs.py +448 -0
  112. package/app/backend/app/routers/maintenance.py +172 -0
  113. package/app/backend/app/routers/merge.py +360 -0
  114. package/app/backend/app/routers/planner.py +537 -0
  115. package/app/backend/app/routers/pull_requests.py +382 -0
  116. package/app/backend/app/routers/repos.py +263 -0
  117. package/app/backend/app/routers/revisions.py +939 -0
  118. package/app/backend/app/routers/settings.py +267 -0
  119. package/app/backend/app/routers/tickets.py +2003 -0
  120. package/app/backend/app/routers/webhooks.py +143 -0
  121. package/app/backend/app/routers/websocket.py +249 -0
  122. package/app/backend/app/schemas/__init__.py +109 -0
  123. package/app/backend/app/schemas/board.py +87 -0
  124. package/app/backend/app/schemas/common.py +33 -0
  125. package/app/backend/app/schemas/evidence.py +87 -0
  126. package/app/backend/app/schemas/goal.py +90 -0
  127. package/app/backend/app/schemas/job.py +97 -0
  128. package/app/backend/app/schemas/merge.py +139 -0
  129. package/app/backend/app/schemas/planner.py +500 -0
  130. package/app/backend/app/schemas/repo.py +187 -0
  131. package/app/backend/app/schemas/review.py +137 -0
  132. package/app/backend/app/schemas/revision.py +114 -0
  133. package/app/backend/app/schemas/ticket.py +238 -0
  134. package/app/backend/app/schemas/ticket_event.py +72 -0
  135. package/app/backend/app/schemas/workspace.py +19 -0
  136. package/app/backend/app/services/__init__.py +31 -0
  137. package/app/backend/app/services/agent_memory_service.py +223 -0
  138. package/app/backend/app/services/agent_registry.py +346 -0
  139. package/app/backend/app/services/agent_session_manager.py +318 -0
  140. package/app/backend/app/services/agent_session_service.py +219 -0
  141. package/app/backend/app/services/agent_tools.py +379 -0
  142. package/app/backend/app/services/auth_service.py +98 -0
  143. package/app/backend/app/services/autonomy_service.py +380 -0
  144. package/app/backend/app/services/board_repo_service.py +201 -0
  145. package/app/backend/app/services/board_service.py +326 -0
  146. package/app/backend/app/services/cleanup_service.py +1085 -0
  147. package/app/backend/app/services/config_service.py +908 -0
  148. package/app/backend/app/services/context_gatherer.py +557 -0
  149. package/app/backend/app/services/cost_tracking_service.py +293 -0
  150. package/app/backend/app/services/cursor_log_normalizer.py +536 -0
  151. package/app/backend/app/services/delivery_pipeline.py +440 -0
  152. package/app/backend/app/services/executor_service.py +634 -0
  153. package/app/backend/app/services/git_host/__init__.py +11 -0
  154. package/app/backend/app/services/git_host/factory.py +87 -0
  155. package/app/backend/app/services/git_host/github.py +270 -0
  156. package/app/backend/app/services/git_host/gitlab.py +194 -0
  157. package/app/backend/app/services/git_host/protocol.py +75 -0
  158. package/app/backend/app/services/git_merge_simple.py +346 -0
  159. package/app/backend/app/services/git_ops.py +384 -0
  160. package/app/backend/app/services/github_service.py +233 -0
  161. package/app/backend/app/services/goal_service.py +113 -0
  162. package/app/backend/app/services/job_service.py +423 -0
  163. package/app/backend/app/services/job_watchdog_service.py +424 -0
  164. package/app/backend/app/services/langchain_adapter.py +122 -0
  165. package/app/backend/app/services/llm_provider_clients.py +351 -0
  166. package/app/backend/app/services/llm_service.py +285 -0
  167. package/app/backend/app/services/log_normalizer.py +342 -0
  168. package/app/backend/app/services/log_stream_service.py +276 -0
  169. package/app/backend/app/services/merge_checklist_service.py +264 -0
  170. package/app/backend/app/services/merge_service.py +784 -0
  171. package/app/backend/app/services/orchestrator_log.py +84 -0
  172. package/app/backend/app/services/planner_service.py +1662 -0
  173. package/app/backend/app/services/planner_tick_sync.py +1040 -0
  174. package/app/backend/app/services/queued_message_service.py +156 -0
  175. package/app/backend/app/services/reliability_wrapper.py +389 -0
  176. package/app/backend/app/services/repo_discovery_service.py +318 -0
  177. package/app/backend/app/services/review_service.py +334 -0
  178. package/app/backend/app/services/revision_service.py +389 -0
  179. package/app/backend/app/services/safe_autopilot.py +510 -0
  180. package/app/backend/app/services/sqlite_worker.py +372 -0
  181. package/app/backend/app/services/task_dispatch.py +135 -0
  182. package/app/backend/app/services/ticket_generation_service.py +1781 -0
  183. package/app/backend/app/services/ticket_service.py +486 -0
  184. package/app/backend/app/services/udar_planner_service.py +1007 -0
  185. package/app/backend/app/services/webhook_service.py +126 -0
  186. package/app/backend/app/services/workspace_service.py +465 -0
  187. package/app/backend/app/services/worktree_file_service.py +92 -0
  188. package/app/backend/app/services/worktree_validator.py +213 -0
  189. package/app/backend/app/sqlite_kv.py +278 -0
  190. package/app/backend/app/state_machine.py +128 -0
  191. package/app/backend/app/templates/__init__.py +5 -0
  192. package/app/backend/app/templates/registry.py +243 -0
  193. package/app/backend/app/utils/__init__.py +5 -0
  194. package/app/backend/app/utils/artifact_reader.py +87 -0
  195. package/app/backend/app/utils/circuit_breaker.py +229 -0
  196. package/app/backend/app/utils/db_retry.py +136 -0
  197. package/app/backend/app/utils/ignored_fields.py +123 -0
  198. package/app/backend/app/utils/validators.py +54 -0
  199. package/app/backend/app/websocket/__init__.py +5 -0
  200. package/app/backend/app/websocket/manager.py +179 -0
  201. package/app/backend/app/websocket/state_tracker.py +113 -0
  202. package/app/backend/app/worker.py +3190 -0
  203. package/app/backend/calculator_tickets.json +40 -0
  204. package/app/backend/canary_tests.sh +591 -0
  205. package/app/backend/celerybeat-schedule +0 -0
  206. package/app/backend/celerybeat-schedule-shm +0 -0
  207. package/app/backend/celerybeat-schedule-wal +0 -0
  208. package/app/backend/logs/.gitkeep +3 -0
  209. package/app/backend/multiplication_division_implementation_tickets.json +55 -0
  210. package/app/backend/multiplication_division_tickets.json +42 -0
  211. package/app/backend/pyproject.toml +45 -0
  212. package/app/backend/requirements-dev.txt +8 -0
  213. package/app/backend/requirements.txt +20 -0
  214. package/app/backend/run.sh +30 -0
  215. package/app/backend/run_with_logs.sh +10 -0
  216. package/app/backend/scientific_calculator_tickets.json +40 -0
  217. package/app/backend/scripts/extract_openapi.py +21 -0
  218. package/app/backend/scripts/seed_demo.py +187 -0
  219. package/app/backend/setup_demo_review.py +302 -0
  220. package/app/backend/test_actual_parse.py +41 -0
  221. package/app/backend/test_agent_streaming.py +61 -0
  222. package/app/backend/test_parse.py +51 -0
  223. package/app/backend/test_streaming.py +51 -0
  224. package/app/backend/test_subprocess_streaming.py +50 -0
  225. package/app/backend/tests/__init__.py +1 -0
  226. package/app/backend/tests/conftest.py +46 -0
  227. package/app/backend/tests/test_auth.py +341 -0
  228. package/app/backend/tests/test_autonomy_service.py +391 -0
  229. package/app/backend/tests/test_cleanup_service_safety.py +417 -0
  230. package/app/backend/tests/test_middleware.py +279 -0
  231. package/app/backend/tests/test_planner_providers.py +290 -0
  232. package/app/backend/tests/test_planner_unblock.py +183 -0
  233. package/app/backend/tests/test_revision_invariants.py +618 -0
  234. package/app/backend/tests/test_sqlite_kv.py +290 -0
  235. package/app/backend/tests/test_sqlite_worker.py +353 -0
  236. package/app/backend/tests/test_task_dispatch.py +100 -0
  237. package/app/backend/tests/test_ticket_validation.py +304 -0
  238. package/app/backend/tests/test_udar_agent.py +693 -0
  239. package/app/backend/tests/test_webhook_service.py +184 -0
  240. package/app/backend/tickets_output.json +59 -0
  241. package/app/backend/user_management_tickets.json +50 -0
  242. package/app/backend/uvicorn.log +0 -0
  243. package/app/draft.yaml +313 -0
  244. package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
  245. package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
  246. package/app/frontend/dist/index.html +14 -0
  247. package/app/frontend/dist/vite.svg +1 -0
  248. package/app/frontend/package.json +101 -0
  249. package/bin/cli.js +527 -0
  250. package/package.json +37 -0
@@ -0,0 +1,391 @@
1
+ """Tests for AutonomyService safety checks and auto-actions."""
2
+
3
+ import uuid
4
+
5
+ from sqlalchemy import select
6
+
7
+ from app.models.enums import ActorType
8
+ from app.models.evidence import Evidence, EvidenceKind
9
+ from app.models.goal import Goal
10
+ from app.models.ticket import Ticket
11
+ from app.models.ticket_event import TicketEvent
12
+ from app.services.autonomy_service import AutonomyService
13
+ from app.services.config_service import AutonomyConfig
14
+ from app.state_machine import TicketState
15
+
16
+ # ── Fixtures ──
17
+
18
+
19
+ def make_goal(
20
+ autonomy_enabled: bool = True,
21
+ auto_approve_tickets: bool = True,
22
+ auto_approve_revisions: bool = True,
23
+ auto_merge: bool = True,
24
+ auto_approve_followups: bool = True,
25
+ max_auto_approvals: int | None = None,
26
+ auto_approval_count: int = 0,
27
+ ) -> Goal:
28
+ """Create a Goal with autonomy settings."""
29
+ return Goal(
30
+ id=str(uuid.uuid4()),
31
+ title="Test Goal",
32
+ description="Test goal for autonomy",
33
+ autonomy_enabled=autonomy_enabled,
34
+ auto_approve_tickets=auto_approve_tickets,
35
+ auto_approve_revisions=auto_approve_revisions,
36
+ auto_merge=auto_merge,
37
+ auto_approve_followups=auto_approve_followups,
38
+ max_auto_approvals=max_auto_approvals,
39
+ auto_approval_count=auto_approval_count,
40
+ )
41
+
42
+
43
+ def make_ticket(goal: Goal, state: str = "verifying") -> Ticket:
44
+ """Create a Ticket linked to a goal."""
45
+ return Ticket(
46
+ id=str(uuid.uuid4()),
47
+ goal_id=goal.id,
48
+ title="Test Ticket",
49
+ description="Test ticket",
50
+ state=state,
51
+ )
52
+
53
+
54
+ def make_evidence(
55
+ ticket_id: str,
56
+ kind: str = EvidenceKind.VERIFY_META.value,
57
+ exit_code: int = 0,
58
+ stdout_path: str = "test.txt",
59
+ ) -> Evidence:
60
+ """Create an Evidence record."""
61
+ return Evidence(
62
+ id=str(uuid.uuid4()),
63
+ ticket_id=ticket_id,
64
+ job_id=str(uuid.uuid4()),
65
+ kind=kind,
66
+ command="test",
67
+ exit_code=exit_code,
68
+ stdout_path=stdout_path,
69
+ )
70
+
71
+
72
+ # ── can_auto_approve_ticket tests ──
73
+
74
+
75
+ async def test_auto_approve_ticket_all_checks_pass(db):
76
+ """Auto-approve ticket when all goal settings are enabled."""
77
+ goal = make_goal()
78
+ db.add(goal)
79
+ await db.flush()
80
+
81
+ ticket = make_ticket(goal, state="proposed")
82
+ db.add(ticket)
83
+ await db.flush()
84
+
85
+ service = AutonomyService(config=AutonomyConfig())
86
+ result = await service.can_auto_approve_ticket(db, ticket)
87
+
88
+ assert result.approved is True
89
+ assert "allowed" in result.reason
90
+
91
+
92
+ async def test_auto_approve_ticket_autonomy_disabled(db):
93
+ """Reject when autonomy_enabled is False."""
94
+ goal = make_goal(autonomy_enabled=False)
95
+ db.add(goal)
96
+ await db.flush()
97
+
98
+ ticket = make_ticket(goal, state="proposed")
99
+ db.add(ticket)
100
+ await db.flush()
101
+
102
+ service = AutonomyService(config=AutonomyConfig())
103
+ result = await service.can_auto_approve_ticket(db, ticket)
104
+
105
+ assert result.approved is False
106
+ assert "not enabled" in result.reason
107
+
108
+
109
+ async def test_auto_approve_ticket_feature_disabled(db):
110
+ """Reject when auto_approve_tickets is False."""
111
+ goal = make_goal(auto_approve_tickets=False)
112
+ db.add(goal)
113
+ await db.flush()
114
+
115
+ ticket = make_ticket(goal, state="proposed")
116
+ db.add(ticket)
117
+ await db.flush()
118
+
119
+ service = AutonomyService(config=AutonomyConfig())
120
+ result = await service.can_auto_approve_ticket(db, ticket)
121
+
122
+ assert result.approved is False
123
+ assert "not enabled" in result.reason
124
+
125
+
126
+ async def test_auto_approve_ticket_max_approvals_reached(db):
127
+ """Reject when max_auto_approvals is reached."""
128
+ goal = make_goal(max_auto_approvals=2, auto_approval_count=2)
129
+ db.add(goal)
130
+ await db.flush()
131
+
132
+ ticket = make_ticket(goal, state="proposed")
133
+ db.add(ticket)
134
+ await db.flush()
135
+
136
+ service = AutonomyService(config=AutonomyConfig())
137
+ result = await service.can_auto_approve_ticket(db, ticket)
138
+
139
+ assert result.approved is False
140
+ assert "Max auto-approvals" in result.reason
141
+
142
+
143
+ async def test_auto_approve_ticket_under_max_approvals(db):
144
+ """Allow when auto_approval_count is under max."""
145
+ goal = make_goal(max_auto_approvals=5, auto_approval_count=3)
146
+ db.add(goal)
147
+ await db.flush()
148
+
149
+ ticket = make_ticket(goal, state="proposed")
150
+ db.add(ticket)
151
+ await db.flush()
152
+
153
+ service = AutonomyService(config=AutonomyConfig())
154
+ result = await service.can_auto_approve_ticket(db, ticket)
155
+
156
+ assert result.approved is True
157
+
158
+
159
+ # ── can_auto_approve_revision tests ──
160
+
161
+
162
+ async def test_auto_approve_revision_all_checks_pass(db):
163
+ """Auto-approve revision when all checks pass."""
164
+ goal = make_goal()
165
+ db.add(goal)
166
+ await db.flush()
167
+
168
+ ticket = make_ticket(goal)
169
+ db.add(ticket)
170
+ await db.flush()
171
+
172
+ # Add passing verification evidence
173
+ evidence = make_evidence(
174
+ ticket.id, kind=EvidenceKind.VERIFY_META.value, exit_code=0
175
+ )
176
+ db.add(evidence)
177
+ await db.flush()
178
+
179
+ service = AutonomyService(config=AutonomyConfig())
180
+ result = await service.can_auto_approve_revision(db, ticket)
181
+
182
+ assert result.approved is True
183
+
184
+
185
+ async def test_auto_approve_revision_verification_failed(db):
186
+ """Reject when verification evidence has non-zero exit code."""
187
+ goal = make_goal()
188
+ db.add(goal)
189
+ await db.flush()
190
+
191
+ ticket = make_ticket(goal)
192
+ db.add(ticket)
193
+ await db.flush()
194
+
195
+ # Add failing verification evidence
196
+ evidence = make_evidence(
197
+ ticket.id, kind=EvidenceKind.VERIFY_META.value, exit_code=1
198
+ )
199
+ db.add(evidence)
200
+ await db.flush()
201
+
202
+ service = AutonomyService(config=AutonomyConfig())
203
+ result = await service.can_auto_approve_revision(db, ticket)
204
+
205
+ assert result.approved is False
206
+ assert "Verification failed" in result.reason
207
+
208
+
209
+ async def test_auto_approve_revision_autonomy_disabled(db):
210
+ """Reject when autonomy is not enabled on goal."""
211
+ goal = make_goal(autonomy_enabled=False)
212
+ db.add(goal)
213
+ await db.flush()
214
+
215
+ ticket = make_ticket(goal)
216
+ db.add(ticket)
217
+ await db.flush()
218
+
219
+ service = AutonomyService(config=AutonomyConfig())
220
+ result = await service.can_auto_approve_revision(db, ticket)
221
+
222
+ assert result.approved is False
223
+
224
+
225
+ async def test_auto_approve_revision_feature_disabled(db):
226
+ """Reject when auto_approve_revisions is False."""
227
+ goal = make_goal(auto_approve_revisions=False)
228
+ db.add(goal)
229
+ await db.flush()
230
+
231
+ ticket = make_ticket(goal)
232
+ db.add(ticket)
233
+ await db.flush()
234
+
235
+ service = AutonomyService(config=AutonomyConfig())
236
+ result = await service.can_auto_approve_revision(db, ticket)
237
+
238
+ assert result.approved is False
239
+ assert "not enabled" in result.reason
240
+
241
+
242
+ async def test_auto_approve_revision_max_approvals_reached(db):
243
+ """Reject when max auto-approvals has been reached."""
244
+ goal = make_goal(max_auto_approvals=1, auto_approval_count=1)
245
+ db.add(goal)
246
+ await db.flush()
247
+
248
+ ticket = make_ticket(goal)
249
+ db.add(ticket)
250
+ await db.flush()
251
+
252
+ service = AutonomyService(config=AutonomyConfig())
253
+ result = await service.can_auto_approve_revision(db, ticket)
254
+
255
+ assert result.approved is False
256
+ assert "Max auto-approvals" in result.reason
257
+
258
+
259
+ # ── Diff size and sensitive file tests (unit-level, no file I/O) ──
260
+
261
+
262
+ def test_check_revision_approval_passes_basic():
263
+ """Pure logic check: revision approval with passing goal and no evidence."""
264
+ goal = make_goal()
265
+ config = AutonomyConfig()
266
+ service = AutonomyService(config=config)
267
+
268
+ result = service._check_revision_approval(goal, [])
269
+ assert result.approved is True
270
+
271
+
272
+ def test_check_revision_approval_verification_fail():
273
+ """Pure logic check: verify evidence with bad exit code blocks approval."""
274
+ goal = make_goal()
275
+ ticket = make_ticket(goal)
276
+ evidence = make_evidence(
277
+ ticket.id, kind=EvidenceKind.VERIFY_META.value, exit_code=1
278
+ )
279
+
280
+ config = AutonomyConfig(require_verification_pass=True)
281
+ service = AutonomyService(config=config)
282
+
283
+ result = service._check_revision_approval(goal, [evidence])
284
+ assert result.approved is False
285
+ assert "Verification failed" in result.reason
286
+
287
+
288
+ def test_check_revision_approval_verification_pass_not_required():
289
+ """When require_verification_pass is False, failing verify does not block."""
290
+ goal = make_goal()
291
+ ticket = make_ticket(goal)
292
+ evidence = make_evidence(
293
+ ticket.id, kind=EvidenceKind.VERIFY_META.value, exit_code=1
294
+ )
295
+
296
+ config = AutonomyConfig(require_verification_pass=False)
297
+ service = AutonomyService(config=config)
298
+
299
+ result = service._check_revision_approval(goal, [evidence])
300
+ assert result.approved is True
301
+
302
+
303
+ def test_check_ticket_approval_all_enabled():
304
+ """Pure logic: ticket approval passes with all flags on."""
305
+ goal = make_goal()
306
+ service = AutonomyService(config=AutonomyConfig())
307
+ result = service._check_ticket_approval(goal)
308
+ assert result.approved is True
309
+
310
+
311
+ def test_check_ticket_approval_max_exceeded():
312
+ """Pure logic: ticket approval fails when count >= max."""
313
+ goal = make_goal(max_auto_approvals=3, auto_approval_count=3)
314
+ service = AutonomyService(config=AutonomyConfig())
315
+ result = service._check_ticket_approval(goal)
316
+ assert result.approved is False
317
+
318
+
319
+ def test_check_ticket_approval_no_max():
320
+ """Pure logic: no max means unlimited approvals."""
321
+ goal = make_goal(max_auto_approvals=None, auto_approval_count=100)
322
+ service = AutonomyService(config=AutonomyConfig())
323
+ result = service._check_ticket_approval(goal)
324
+ assert result.approved is True
325
+
326
+
327
+ # ── record_auto_action tests ──
328
+
329
+
330
+ async def test_record_auto_action_creates_event(db):
331
+ """Record auto-action creates a TicketEvent and increments counter."""
332
+ goal = make_goal(auto_approval_count=0)
333
+ db.add(goal)
334
+ await db.flush()
335
+
336
+ ticket = make_ticket(goal, state="verifying")
337
+ db.add(ticket)
338
+ await db.flush()
339
+
340
+ service = AutonomyService(config=AutonomyConfig())
341
+ await service.record_auto_action(
342
+ db,
343
+ ticket,
344
+ action_type="approve_revision",
345
+ details={"reason": "All checks passed"},
346
+ from_state="verifying",
347
+ to_state="done",
348
+ )
349
+ await db.flush()
350
+
351
+ # Check event was created
352
+ result = await db.execute(
353
+ select(TicketEvent).where(TicketEvent.ticket_id == ticket.id)
354
+ )
355
+ events = list(result.scalars().all())
356
+ assert len(events) == 1
357
+ assert events[0].actor_type == ActorType.SYSTEM.value
358
+ assert events[0].actor_id == "autonomy_service"
359
+ assert "approve_revision" in events[0].reason
360
+
361
+ # Check counter incremented
362
+ await db.refresh(goal)
363
+ assert goal.auto_approval_count == 1
364
+
365
+
366
+ async def test_record_auto_action_increments_counter(db):
367
+ """Multiple auto-actions increment the counter correctly."""
368
+ goal = make_goal(auto_approval_count=5)
369
+ db.add(goal)
370
+ await db.flush()
371
+
372
+ ticket = make_ticket(goal, state="proposed")
373
+ db.add(ticket)
374
+ await db.flush()
375
+
376
+ service = AutonomyService(config=AutonomyConfig())
377
+ await service.record_auto_action(db, ticket, "approve_ticket", {"reason": "test"})
378
+ await db.flush()
379
+ await db.refresh(goal)
380
+
381
+ assert goal.auto_approval_count == 6
382
+
383
+
384
+ # ── State machine transition test ──
385
+
386
+
387
+ def test_verifying_to_done_transition_allowed():
388
+ """VERIFYING -> DONE should be allowed in the state machine."""
389
+ from app.state_machine import validate_transition
390
+
391
+ assert validate_transition(TicketState.VERIFYING, TicketState.DONE) is True