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,290 @@
1
+ """Tests for sqlite_kv module - low-level SQLite operations for middleware.
2
+
3
+ Tests cover: KV store, idempotency locking, rate limiting, TTL expiry.
4
+ """
5
+
6
+ import sqlite3
7
+ import time
8
+ from unittest.mock import patch
9
+
10
+ import pytest
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Fixture: temp SQLite DB with all required tables
14
+ # ---------------------------------------------------------------------------
15
+
16
+
17
+ @pytest.fixture
18
+ def sqlite_db(tmp_path):
19
+ """Create a temporary SQLite DB with required tables and patch _DB_PATH."""
20
+ db_path = str(tmp_path / "test_kv.db")
21
+ conn = sqlite3.connect(db_path)
22
+ conn.execute("PRAGMA journal_mode=WAL")
23
+ conn.executescript("""
24
+ CREATE TABLE idempotency_cache (
25
+ cache_key TEXT PRIMARY KEY,
26
+ lock_value TEXT,
27
+ result_value TEXT,
28
+ lock_expires_at TIMESTAMP,
29
+ result_expires_at TIMESTAMP,
30
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
31
+ );
32
+ CREATE TABLE rate_limit_entries (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
34
+ client_key TEXT NOT NULL,
35
+ cost INTEGER NOT NULL DEFAULT 1,
36
+ recorded_at REAL NOT NULL,
37
+ expires_at REAL NOT NULL
38
+ );
39
+ CREATE INDEX ix_rate_limit_entries_client_key
40
+ ON rate_limit_entries(client_key);
41
+ CREATE INDEX ix_rate_limit_entries_expires_at
42
+ ON rate_limit_entries(expires_at);
43
+ CREATE TABLE kv_store (
44
+ key TEXT PRIMARY KEY,
45
+ value TEXT NOT NULL,
46
+ expires_at TIMESTAMP,
47
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
48
+ );
49
+ """)
50
+ conn.close()
51
+
52
+ with patch("app.sqlite_kv._DB_PATH", db_path):
53
+ yield db_path
54
+
55
+
56
+ # ===========================================================================
57
+ # KV store tests
58
+ # ===========================================================================
59
+
60
+
61
+ class TestKVStore:
62
+ """Tests for kv_set / kv_get / kv_take / kv_delete / kv_exists."""
63
+
64
+ def test_set_and_get(self, sqlite_db):
65
+ from app.sqlite_kv import kv_get, kv_set
66
+
67
+ kv_set("hello", "world")
68
+ assert kv_get("hello") == "world"
69
+
70
+ def test_get_missing_key_returns_none(self, sqlite_db):
71
+ from app.sqlite_kv import kv_get
72
+
73
+ assert kv_get("nonexistent") is None
74
+
75
+ def test_set_overwrites_existing(self, sqlite_db):
76
+ from app.sqlite_kv import kv_get, kv_set
77
+
78
+ kv_set("key", "v1")
79
+ kv_set("key", "v2")
80
+ assert kv_get("key") == "v2"
81
+
82
+ def test_delete_existing_key(self, sqlite_db):
83
+ from app.sqlite_kv import kv_delete, kv_get, kv_set
84
+
85
+ kv_set("key", "val")
86
+ assert kv_delete("key") is True
87
+ assert kv_get("key") is None
88
+
89
+ def test_delete_missing_key_returns_false(self, sqlite_db):
90
+ from app.sqlite_kv import kv_delete
91
+
92
+ assert kv_delete("nope") is False
93
+
94
+ def test_exists(self, sqlite_db):
95
+ from app.sqlite_kv import kv_exists, kv_set
96
+
97
+ assert kv_exists("key") is False
98
+ kv_set("key", "val")
99
+ assert kv_exists("key") is True
100
+
101
+ def test_take_returns_value_and_removes(self, sqlite_db):
102
+ from app.sqlite_kv import kv_exists, kv_set, kv_take
103
+
104
+ kv_set("take-me", "payload")
105
+ result = kv_take("take-me")
106
+ assert result == "payload"
107
+ assert kv_exists("take-me") is False
108
+
109
+ def test_take_missing_key_returns_none(self, sqlite_db):
110
+ from app.sqlite_kv import kv_take
111
+
112
+ assert kv_take("gone") is None
113
+
114
+ def test_take_is_atomic(self, sqlite_db):
115
+ """Two consecutive takes: only the first should get the value."""
116
+ from app.sqlite_kv import kv_set, kv_take
117
+
118
+ kv_set("once", "value")
119
+ first = kv_take("once")
120
+ second = kv_take("once")
121
+ assert first == "value"
122
+ assert second is None
123
+
124
+ def test_ttl_expiry(self, sqlite_db):
125
+ """Expired keys should not be returned by kv_get."""
126
+ from app.sqlite_kv import kv_get, kv_set
127
+
128
+ # Set with 1-second TTL
129
+ kv_set("ephemeral", "data", ttl_seconds=1)
130
+ assert kv_get("ephemeral") == "data"
131
+
132
+ # Wait for expiry (2s to account for SQLite datetime second resolution)
133
+ time.sleep(2.0)
134
+ assert kv_get("ephemeral") is None
135
+
136
+ def test_take_expired_key_returns_none(self, sqlite_db):
137
+ from app.sqlite_kv import kv_set, kv_take
138
+
139
+ kv_set("exp", "data", ttl_seconds=1)
140
+ time.sleep(2.0)
141
+ assert kv_take("exp") is None
142
+
143
+
144
+ # ===========================================================================
145
+ # Idempotency lock tests
146
+ # ===========================================================================
147
+
148
+
149
+ class TestIdempotencyLock:
150
+ """Tests for idempotency_try_acquire / get_lock / store_result / get_result / release_lock."""
151
+
152
+ def test_acquire_lock(self, sqlite_db):
153
+ from app.sqlite_kv import idempotency_try_acquire
154
+
155
+ assert idempotency_try_acquire("key1", "lock-a", 60) is True
156
+
157
+ def test_double_acquire_fails(self, sqlite_db):
158
+ from app.sqlite_kv import idempotency_try_acquire
159
+
160
+ assert idempotency_try_acquire("key1", "lock-a", 60) is True
161
+ assert idempotency_try_acquire("key1", "lock-b", 60) is False
162
+
163
+ def test_get_lock_value(self, sqlite_db):
164
+ from app.sqlite_kv import idempotency_get_lock, idempotency_try_acquire
165
+
166
+ idempotency_try_acquire("key1", "my-lock", 60)
167
+ assert idempotency_get_lock("key1") == "my-lock"
168
+
169
+ def test_get_lock_missing_returns_none(self, sqlite_db):
170
+ from app.sqlite_kv import idempotency_get_lock
171
+
172
+ assert idempotency_get_lock("missing") is None
173
+
174
+ def test_store_and_get_result(self, sqlite_db):
175
+ from app.sqlite_kv import (
176
+ idempotency_get_result,
177
+ idempotency_store_result,
178
+ idempotency_try_acquire,
179
+ )
180
+
181
+ idempotency_try_acquire("key1", "lock", 60)
182
+ idempotency_store_result("key1", '{"status": "ok"}', 300)
183
+ assert idempotency_get_result("key1") == '{"status": "ok"}'
184
+
185
+ def test_store_result_clears_lock(self, sqlite_db):
186
+ from app.sqlite_kv import (
187
+ idempotency_get_lock,
188
+ idempotency_store_result,
189
+ idempotency_try_acquire,
190
+ )
191
+
192
+ idempotency_try_acquire("key1", "lock", 60)
193
+ idempotency_store_result("key1", "result", 300)
194
+ assert idempotency_get_lock("key1") is None
195
+
196
+ def test_release_lock_deletes_entry(self, sqlite_db):
197
+ from app.sqlite_kv import (
198
+ idempotency_get_lock,
199
+ idempotency_release_lock,
200
+ idempotency_try_acquire,
201
+ )
202
+
203
+ idempotency_try_acquire("key1", "lock", 60)
204
+ idempotency_release_lock("key1")
205
+ assert idempotency_get_lock("key1") is None
206
+
207
+ def test_release_lock_preserves_result(self, sqlite_db):
208
+ """Release should not delete entries that have a stored result."""
209
+ from app.sqlite_kv import (
210
+ idempotency_get_result,
211
+ idempotency_release_lock,
212
+ idempotency_store_result,
213
+ idempotency_try_acquire,
214
+ )
215
+
216
+ idempotency_try_acquire("key1", "lock", 60)
217
+ idempotency_store_result("key1", "cached", 300)
218
+ idempotency_release_lock("key1")
219
+ assert idempotency_get_result("key1") == "cached"
220
+
221
+ def test_get_result_missing_returns_none(self, sqlite_db):
222
+ from app.sqlite_kv import idempotency_get_result
223
+
224
+ assert idempotency_get_result("nope") is None
225
+
226
+
227
+ # ===========================================================================
228
+ # Rate limit tests
229
+ # ===========================================================================
230
+
231
+
232
+ class TestRateLimit:
233
+ """Tests for rate_limit_check_and_record / rate_limit_check_only."""
234
+
235
+ def test_first_request_returns_zero_cost(self, sqlite_db):
236
+ from app.sqlite_kv import rate_limit_check_and_record
237
+
238
+ current_cost, _ = rate_limit_check_and_record(
239
+ "client-1", cost=5, window_seconds=60
240
+ )
241
+ assert current_cost == 0
242
+
243
+ def test_cost_accumulates(self, sqlite_db):
244
+ from app.sqlite_kv import rate_limit_check_and_record
245
+
246
+ rate_limit_check_and_record("client-1", cost=5, window_seconds=60)
247
+ current_cost, _ = rate_limit_check_and_record(
248
+ "client-1", cost=3, window_seconds=60
249
+ )
250
+ assert current_cost == 5
251
+
252
+ def test_different_clients_isolated(self, sqlite_db):
253
+ from app.sqlite_kv import rate_limit_check_and_record
254
+
255
+ rate_limit_check_and_record("client-a", cost=10, window_seconds=60)
256
+ current_cost, _ = rate_limit_check_and_record(
257
+ "client-b", cost=1, window_seconds=60
258
+ )
259
+ assert current_cost == 0
260
+
261
+ def test_check_only_does_not_record(self, sqlite_db):
262
+ from app.sqlite_kv import rate_limit_check_and_record, rate_limit_check_only
263
+
264
+ rate_limit_check_and_record("client-1", cost=5, window_seconds=60)
265
+ cost1, _ = rate_limit_check_only("client-1", window_seconds=60)
266
+ cost2, _ = rate_limit_check_only("client-1", window_seconds=60)
267
+ assert cost1 == cost2 == 5
268
+
269
+ def test_expired_entries_cleaned(self, sqlite_db):
270
+ from app.sqlite_kv import rate_limit_check_and_record
271
+
272
+ # Record with 1-second window (expires quickly)
273
+ rate_limit_check_and_record("client-1", cost=10, window_seconds=1)
274
+ time.sleep(1.5)
275
+
276
+ # After expiry, cost should be zero
277
+ current_cost, _ = rate_limit_check_and_record(
278
+ "client-1", cost=1, window_seconds=60
279
+ )
280
+ assert current_cost == 0
281
+
282
+ def test_oldest_time_returned(self, sqlite_db):
283
+ from app.sqlite_kv import rate_limit_check_and_record
284
+
285
+ before = time.time()
286
+ rate_limit_check_and_record("client-1", cost=1, window_seconds=60)
287
+ _, oldest_time = rate_limit_check_and_record(
288
+ "client-1", cost=1, window_seconds=60
289
+ )
290
+ assert oldest_time >= before
@@ -0,0 +1,353 @@
1
+ """Tests for sqlite_worker module - in-process job runner backed by SQLite."""
2
+
3
+ import json
4
+ import sqlite3
5
+ import threading
6
+ import time
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Fixture: temp SQLite DB with job_queue table + patched _DB_PATH
13
+ # ---------------------------------------------------------------------------
14
+
15
+
16
+ @pytest.fixture
17
+ def sqlite_db(tmp_path):
18
+ """Create a temporary SQLite DB with job_queue table."""
19
+ db_path = str(tmp_path / "test_worker.db")
20
+ conn = sqlite3.connect(db_path)
21
+ conn.execute("PRAGMA journal_mode=WAL")
22
+ conn.execute("""
23
+ CREATE TABLE job_queue (
24
+ id TEXT PRIMARY KEY,
25
+ task_name TEXT NOT NULL,
26
+ args_json TEXT NOT NULL DEFAULT '[]',
27
+ status TEXT NOT NULL DEFAULT 'pending',
28
+ claimed_by TEXT,
29
+ claimed_at TIMESTAMP,
30
+ completed_at TIMESTAMP,
31
+ result_json TEXT,
32
+ priority INTEGER NOT NULL DEFAULT 0,
33
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
34
+ )
35
+ """)
36
+ conn.execute("""
37
+ CREATE INDEX ix_job_queue_status ON job_queue(status)
38
+ """)
39
+ conn.execute("""
40
+ CREATE INDEX ix_job_queue_claim_order
41
+ ON job_queue(status, priority, created_at)
42
+ """)
43
+ conn.commit()
44
+ conn.close()
45
+
46
+ with (
47
+ patch("app.sqlite_kv._DB_PATH", db_path),
48
+ patch("app.services.sqlite_worker._DB_PATH", db_path),
49
+ ):
50
+ yield db_path
51
+
52
+
53
+ def _insert_task(
54
+ db_path: str, task_id: str, task_name: str, args: list, priority: int = 0
55
+ ):
56
+ """Helper to insert a pending task."""
57
+ conn = sqlite3.connect(db_path)
58
+ conn.execute(
59
+ "INSERT INTO job_queue (id, task_name, args_json, status, priority, created_at) "
60
+ "VALUES (?, ?, ?, 'pending', ?, datetime('now'))",
61
+ (task_id, task_name, json.dumps(args), priority),
62
+ )
63
+ conn.commit()
64
+ conn.close()
65
+
66
+
67
+ def _get_task_status(db_path: str, task_id: str) -> str | None:
68
+ """Helper to read task status."""
69
+ conn = sqlite3.connect(db_path)
70
+ row = conn.execute(
71
+ "SELECT status FROM job_queue WHERE id = ?", (task_id,)
72
+ ).fetchone()
73
+ conn.close()
74
+ return row[0] if row else None
75
+
76
+
77
+ # ===========================================================================
78
+ # SQLiteWorker unit tests (no background threads)
79
+ # ===========================================================================
80
+
81
+
82
+ class TestWorkerClaimTask:
83
+ """Test atomic task claiming without starting the poll loop."""
84
+
85
+ def test_claim_pending_task(self, sqlite_db):
86
+ from app.services.sqlite_worker import SQLiteWorker
87
+
88
+ _insert_task(sqlite_db, "t1", "execute_ticket", ["job-1"])
89
+
90
+ worker = SQLiteWorker()
91
+ claimed = worker._claim_next_task()
92
+ assert claimed is not None
93
+ task_id, task_name, args_json = claimed
94
+ assert task_id == "t1"
95
+ assert task_name == "execute_ticket"
96
+ assert json.loads(args_json) == ["job-1"]
97
+
98
+ def test_claim_returns_none_when_empty(self, sqlite_db):
99
+ from app.services.sqlite_worker import SQLiteWorker
100
+
101
+ worker = SQLiteWorker()
102
+ assert worker._claim_next_task() is None
103
+
104
+ def test_claim_skips_already_claimed(self, sqlite_db):
105
+ from app.services.sqlite_worker import SQLiteWorker
106
+
107
+ _insert_task(sqlite_db, "t1", "execute_ticket", ["j1"])
108
+
109
+ worker = SQLiteWorker()
110
+ first = worker._claim_next_task()
111
+ assert first is not None
112
+
113
+ second = worker._claim_next_task()
114
+ assert second is None
115
+
116
+ def test_claim_respects_priority(self, sqlite_db):
117
+ from app.services.sqlite_worker import SQLiteWorker
118
+
119
+ _insert_task(sqlite_db, "low", "execute_ticket", ["j-low"], priority=0)
120
+ _insert_task(sqlite_db, "high", "execute_ticket", ["j-high"], priority=10)
121
+
122
+ worker = SQLiteWorker()
123
+ claimed = worker._claim_next_task()
124
+ assert claimed[0] == "high"
125
+
126
+ def test_claim_marks_status_as_claimed(self, sqlite_db):
127
+ from app.services.sqlite_worker import SQLiteWorker
128
+
129
+ _insert_task(sqlite_db, "t1", "execute_ticket", ["j1"])
130
+ worker = SQLiteWorker()
131
+ worker._claim_next_task()
132
+
133
+ assert _get_task_status(sqlite_db, "t1") == "claimed"
134
+
135
+
136
+ class TestWorkerMarkResults:
137
+ """Test _mark_completed and _mark_failed."""
138
+
139
+ def test_mark_completed(self, sqlite_db):
140
+ from app.services.sqlite_worker import SQLiteWorker
141
+
142
+ _insert_task(sqlite_db, "t1", "execute_ticket", ["j1"])
143
+
144
+ worker = SQLiteWorker()
145
+ worker._mark_completed("t1", {"status": "ok"})
146
+
147
+ conn = sqlite3.connect(sqlite_db)
148
+ row = conn.execute(
149
+ "SELECT status, result_json, completed_at FROM job_queue WHERE id = 't1'"
150
+ ).fetchone()
151
+ conn.close()
152
+
153
+ assert row[0] == "completed"
154
+ assert json.loads(row[1]) == {"status": "ok"}
155
+ assert row[2] is not None
156
+
157
+ def test_mark_completed_none_result(self, sqlite_db):
158
+ from app.services.sqlite_worker import SQLiteWorker
159
+
160
+ _insert_task(sqlite_db, "t1", "execute_ticket", ["j1"])
161
+ worker = SQLiteWorker()
162
+ worker._mark_completed("t1", None)
163
+
164
+ conn = sqlite3.connect(sqlite_db)
165
+ row = conn.execute(
166
+ "SELECT status, result_json FROM job_queue WHERE id = 't1'"
167
+ ).fetchone()
168
+ conn.close()
169
+
170
+ assert row[0] == "completed"
171
+ assert row[1] is None
172
+
173
+ def test_mark_failed(self, sqlite_db):
174
+ from app.services.sqlite_worker import SQLiteWorker
175
+
176
+ _insert_task(sqlite_db, "t1", "execute_ticket", ["j1"])
177
+ worker = SQLiteWorker()
178
+ worker._mark_failed("t1", "Something went wrong")
179
+
180
+ conn = sqlite3.connect(sqlite_db)
181
+ row = conn.execute(
182
+ "SELECT status, result_json FROM job_queue WHERE id = 't1'"
183
+ ).fetchone()
184
+ conn.close()
185
+
186
+ assert row[0] == "failed"
187
+ result = json.loads(row[1])
188
+ assert "Something went wrong" in result["error"]
189
+
190
+
191
+ class TestWorkerTaskRegistration:
192
+ """Test register_task and register_periodic."""
193
+
194
+ def test_register_task(self, sqlite_db):
195
+ from app.services.sqlite_worker import SQLiteWorker
196
+
197
+ worker = SQLiteWorker()
198
+ worker.register_task("my_task", lambda x: x)
199
+ assert "my_task" in worker._tasks
200
+
201
+ def test_register_periodic(self, sqlite_db):
202
+ from app.services.sqlite_worker import SQLiteWorker
203
+
204
+ worker = SQLiteWorker()
205
+ worker.register_periodic("heartbeat", 10.0, lambda: None)
206
+ assert len(worker._periodic_tasks) == 1
207
+ assert worker._periodic_tasks[0][0] == "heartbeat"
208
+
209
+
210
+ # ===========================================================================
211
+ # SQLiteWorker integration tests (with background threads)
212
+ # ===========================================================================
213
+
214
+
215
+ class TestWorkerExecution:
216
+ """Test full task execution with worker start/stop."""
217
+
218
+ def test_worker_executes_task(self, sqlite_db):
219
+ from app.services.sqlite_worker import SQLiteWorker
220
+
221
+ results = []
222
+
223
+ def my_task(job_id):
224
+ results.append(job_id)
225
+ return {"done": True}
226
+
227
+ worker = SQLiteWorker(poll_interval=0.1)
228
+ worker.register_task("test_task", my_task)
229
+
230
+ _insert_task(sqlite_db, "t1", "test_task", ["job-abc"])
231
+
232
+ worker.start()
233
+ try:
234
+ # Wait for task execution (up to 3 seconds)
235
+ for _ in range(30):
236
+ if _get_task_status(sqlite_db, "t1") == "completed":
237
+ break
238
+ time.sleep(0.1)
239
+
240
+ assert _get_task_status(sqlite_db, "t1") == "completed"
241
+ assert results == ["job-abc"]
242
+ finally:
243
+ worker.stop()
244
+
245
+ def test_worker_handles_task_failure(self, sqlite_db):
246
+ from app.services.sqlite_worker import SQLiteWorker
247
+
248
+ def failing_task(job_id):
249
+ raise ValueError("boom")
250
+
251
+ worker = SQLiteWorker(poll_interval=0.1)
252
+ worker.register_task("bad_task", failing_task)
253
+
254
+ _insert_task(sqlite_db, "t1", "bad_task", ["j1"])
255
+
256
+ worker.start()
257
+ try:
258
+ for _ in range(30):
259
+ status = _get_task_status(sqlite_db, "t1")
260
+ if status == "failed":
261
+ break
262
+ time.sleep(0.1)
263
+
264
+ assert _get_task_status(sqlite_db, "t1") == "failed"
265
+ finally:
266
+ worker.stop()
267
+
268
+ def test_worker_handles_unknown_task(self, sqlite_db):
269
+ from app.services.sqlite_worker import SQLiteWorker
270
+
271
+ worker = SQLiteWorker(poll_interval=0.1)
272
+ # Don't register any tasks
273
+
274
+ _insert_task(sqlite_db, "t1", "unknown_task", ["j1"])
275
+
276
+ worker.start()
277
+ try:
278
+ for _ in range(30):
279
+ status = _get_task_status(sqlite_db, "t1")
280
+ if status == "failed":
281
+ break
282
+ time.sleep(0.1)
283
+
284
+ assert _get_task_status(sqlite_db, "t1") == "failed"
285
+ finally:
286
+ worker.stop()
287
+
288
+ def test_worker_executes_multiple_tasks_in_order(self, sqlite_db):
289
+ from app.services.sqlite_worker import SQLiteWorker
290
+
291
+ order = []
292
+
293
+ def tracking_task(job_id):
294
+ order.append(job_id)
295
+ return {"ok": True}
296
+
297
+ worker = SQLiteWorker(poll_interval=0.1)
298
+ worker.register_task("track", tracking_task)
299
+
300
+ # Insert tasks with increasing time
301
+ for i in range(3):
302
+ _insert_task(sqlite_db, f"t{i}", "track", [f"job-{i}"])
303
+
304
+ worker.start()
305
+ try:
306
+ for _ in range(60):
307
+ conn = sqlite3.connect(sqlite_db)
308
+ completed = conn.execute(
309
+ "SELECT COUNT(*) FROM job_queue WHERE status = 'completed'"
310
+ ).fetchone()[0]
311
+ conn.close()
312
+ if completed == 3:
313
+ break
314
+ time.sleep(0.1)
315
+
316
+ assert len(order) == 3
317
+ finally:
318
+ worker.stop()
319
+
320
+ def test_worker_start_stop_idempotent(self, sqlite_db):
321
+ from app.services.sqlite_worker import SQLiteWorker
322
+
323
+ worker = SQLiteWorker(poll_interval=0.1)
324
+ worker.register_task("noop", lambda: None)
325
+
326
+ # Double start
327
+ worker.start()
328
+ worker.start()
329
+
330
+ # Double stop
331
+ worker.stop()
332
+ worker.stop()
333
+
334
+ def test_periodic_task_runs(self, sqlite_db):
335
+ from app.services.sqlite_worker import SQLiteWorker
336
+
337
+ call_count = {"n": 0}
338
+ lock = threading.Lock()
339
+
340
+ def periodic_fn():
341
+ with lock:
342
+ call_count["n"] += 1
343
+
344
+ worker = SQLiteWorker(poll_interval=0.1)
345
+ worker.register_periodic("ticker", 0.2, periodic_fn)
346
+
347
+ worker.start()
348
+ try:
349
+ time.sleep(1.0)
350
+ with lock:
351
+ assert call_count["n"] >= 2, f"Periodic ran {call_count['n']} times"
352
+ finally:
353
+ worker.stop()