codeframe-ai 0.9.0__py3-none-any.whl

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 (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,887 @@
1
+ """Workspace management for CodeFRAME v2.
2
+
3
+ A workspace represents a CodeFRAME-managed repository. Each workspace has:
4
+ - A .codeframe/ directory for state storage
5
+ - A SQLite database for persistent state
6
+ - Configuration and event logs
7
+
8
+ This module is headless - no FastAPI or HTTP dependencies.
9
+ """
10
+
11
+ import sqlite3
12
+ import uuid
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+
19
+ def _utc_now() -> datetime:
20
+ """Get current UTC time as timezone-aware datetime."""
21
+ return datetime.now(timezone.utc)
22
+
23
+ # State directory name
24
+ CODEFRAME_DIR = ".codeframe"
25
+ STATE_DB_NAME = "state.db"
26
+
27
+ # Per-workspace config file written by the Settings page (issue #556).
28
+ # Owned by the UI layer today; kept here so a future core consumer can
29
+ # read it without importing from codeframe/ui/.
30
+ WORKSPACE_CONFIG_FILENAME = "workspace_config.json"
31
+
32
+
33
+ @dataclass
34
+ class Workspace:
35
+ """Represents a CodeFRAME workspace.
36
+
37
+ Attributes:
38
+ id: Unique workspace identifier (UUID)
39
+ repo_path: Absolute path to the repository
40
+ state_dir: Path to .codeframe/ directory
41
+ created_at: When the workspace was initialized
42
+ tech_stack: Natural language description of the project's technology stack
43
+ """
44
+
45
+ id: str
46
+ repo_path: Path
47
+ state_dir: Path
48
+ created_at: datetime
49
+ tech_stack: Optional[str] = None
50
+
51
+ @property
52
+ def db_path(self) -> Path:
53
+ """Path to the SQLite state database."""
54
+ return self.state_dir / STATE_DB_NAME
55
+
56
+
57
+ def _get_state_dir(repo_path: Path) -> Path:
58
+ """Get the .codeframe/ directory path for a repository."""
59
+ return repo_path / CODEFRAME_DIR
60
+
61
+
62
+ def _init_database(db_path: Path) -> None:
63
+ """Initialize the workspace SQLite database with v2 schema.
64
+
65
+ Creates tables for:
66
+ - workspaces: Workspace metadata
67
+ - prds: Product requirements documents
68
+ - tasks: Task state machine
69
+ - events: Append-only event log
70
+ - blockers: Human-in-the-loop blockers
71
+ - checkpoints: State snapshots
72
+ """
73
+ conn = sqlite3.connect(db_path)
74
+ cursor = conn.cursor()
75
+
76
+ # Workspace metadata
77
+ cursor.execute("""
78
+ CREATE TABLE IF NOT EXISTS workspace (
79
+ id TEXT PRIMARY KEY,
80
+ repo_path TEXT NOT NULL,
81
+ tech_stack TEXT,
82
+ created_at TEXT NOT NULL,
83
+ updated_at TEXT NOT NULL
84
+ )
85
+ """)
86
+
87
+ # PRD storage with versioning support
88
+ cursor.execute("""
89
+ CREATE TABLE IF NOT EXISTS prds (
90
+ id TEXT PRIMARY KEY,
91
+ workspace_id TEXT NOT NULL,
92
+ title TEXT,
93
+ content TEXT NOT NULL,
94
+ metadata TEXT,
95
+ created_at TEXT NOT NULL,
96
+ version INTEGER DEFAULT 1,
97
+ parent_id TEXT,
98
+ change_summary TEXT,
99
+ chain_id TEXT,
100
+ depends_on TEXT,
101
+ FOREIGN KEY (workspace_id) REFERENCES workspace(id),
102
+ FOREIGN KEY (parent_id) REFERENCES prds(id),
103
+ FOREIGN KEY (chain_id) REFERENCES prds(id)
104
+ )
105
+ """)
106
+
107
+ # Task state machine (Golden Path statuses)
108
+ cursor.execute("""
109
+ CREATE TABLE IF NOT EXISTS tasks (
110
+ id TEXT PRIMARY KEY,
111
+ workspace_id TEXT NOT NULL,
112
+ prd_id TEXT,
113
+ title TEXT NOT NULL,
114
+ description TEXT,
115
+ status TEXT NOT NULL DEFAULT 'BACKLOG',
116
+ priority INTEGER DEFAULT 0,
117
+ depends_on TEXT DEFAULT '[]',
118
+ estimated_hours REAL,
119
+ complexity_score INTEGER CHECK(complexity_score IS NULL OR (complexity_score BETWEEN 1 AND 5)),
120
+ uncertainty_level TEXT CHECK(uncertainty_level IS NULL OR uncertainty_level IN ('low', 'medium', 'high')),
121
+ parent_id TEXT,
122
+ lineage TEXT DEFAULT '[]',
123
+ is_leaf INTEGER DEFAULT 1,
124
+ hierarchical_id TEXT,
125
+ created_at TEXT NOT NULL,
126
+ updated_at TEXT NOT NULL,
127
+ FOREIGN KEY (workspace_id) REFERENCES workspace(id),
128
+ FOREIGN KEY (prd_id) REFERENCES prds(id),
129
+ CHECK (status IN ('BACKLOG', 'READY', 'IN_PROGRESS', 'BLOCKED', 'FAILED', 'DONE', 'MERGED'))
130
+ )
131
+ """)
132
+
133
+ # Migration: Add columns to existing tasks table
134
+ # SQLite doesn't support IF NOT EXISTS for ALTER TABLE, so we check first
135
+ cursor.execute("PRAGMA table_info(tasks)")
136
+ columns = {row[1] for row in cursor.fetchall()}
137
+ if "depends_on" not in columns:
138
+ cursor.execute("ALTER TABLE tasks ADD COLUMN depends_on TEXT DEFAULT '[]'")
139
+ if "estimated_hours" not in columns:
140
+ cursor.execute("ALTER TABLE tasks ADD COLUMN estimated_hours REAL")
141
+ if "complexity_score" not in columns:
142
+ cursor.execute("ALTER TABLE tasks ADD COLUMN complexity_score INTEGER")
143
+ if "uncertainty_level" not in columns:
144
+ cursor.execute("ALTER TABLE tasks ADD COLUMN uncertainty_level TEXT")
145
+ if "github_issue_number" not in columns:
146
+ cursor.execute("ALTER TABLE tasks ADD COLUMN github_issue_number INTEGER")
147
+ if "parent_id" not in columns:
148
+ cursor.execute("ALTER TABLE tasks ADD COLUMN parent_id TEXT")
149
+ if "lineage" not in columns:
150
+ cursor.execute("ALTER TABLE tasks ADD COLUMN lineage TEXT DEFAULT '[]'")
151
+ if "is_leaf" not in columns:
152
+ cursor.execute("ALTER TABLE tasks ADD COLUMN is_leaf INTEGER DEFAULT 1")
153
+ if "hierarchical_id" not in columns:
154
+ cursor.execute("ALTER TABLE tasks ADD COLUMN hierarchical_id TEXT")
155
+ if "requirement_ids" not in columns:
156
+ cursor.execute("ALTER TABLE tasks ADD COLUMN requirement_ids TEXT DEFAULT '[]'")
157
+ if "external_url" not in columns:
158
+ cursor.execute("ALTER TABLE tasks ADD COLUMN external_url TEXT")
159
+ if "auto_close_github_issue" not in columns:
160
+ cursor.execute("ALTER TABLE tasks ADD COLUMN auto_close_github_issue INTEGER DEFAULT 0")
161
+
162
+ # Append-only event log
163
+ cursor.execute("""
164
+ CREATE TABLE IF NOT EXISTS events (
165
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
166
+ workspace_id TEXT NOT NULL,
167
+ event_type TEXT NOT NULL,
168
+ payload TEXT,
169
+ created_at TEXT NOT NULL,
170
+ FOREIGN KEY (workspace_id) REFERENCES workspace(id)
171
+ )
172
+ """)
173
+
174
+ # Blockers (human-in-the-loop)
175
+ cursor.execute("""
176
+ CREATE TABLE IF NOT EXISTS blockers (
177
+ id TEXT PRIMARY KEY,
178
+ workspace_id TEXT NOT NULL,
179
+ task_id TEXT,
180
+ question TEXT NOT NULL,
181
+ answer TEXT,
182
+ status TEXT NOT NULL DEFAULT 'OPEN',
183
+ created_at TEXT NOT NULL,
184
+ answered_at TEXT,
185
+ created_by TEXT NOT NULL DEFAULT 'human',
186
+ FOREIGN KEY (workspace_id) REFERENCES workspace(id),
187
+ FOREIGN KEY (task_id) REFERENCES tasks(id),
188
+ CHECK (status IN ('OPEN', 'ANSWERED', 'RESOLVED')),
189
+ CHECK (created_by IN ('system', 'agent', 'human'))
190
+ )
191
+ """)
192
+
193
+ # Migration: Add created_by column to existing blockers table
194
+ cursor.execute("PRAGMA table_info(blockers)")
195
+ blocker_columns = {row[1] for row in cursor.fetchall()}
196
+ if "created_by" not in blocker_columns:
197
+ cursor.execute("ALTER TABLE blockers ADD COLUMN created_by TEXT NOT NULL DEFAULT 'human'")
198
+
199
+ # Checkpoints (state snapshots)
200
+ cursor.execute("""
201
+ CREATE TABLE IF NOT EXISTS checkpoints (
202
+ id TEXT PRIMARY KEY,
203
+ workspace_id TEXT NOT NULL,
204
+ name TEXT NOT NULL,
205
+ snapshot TEXT NOT NULL,
206
+ created_at TEXT NOT NULL,
207
+ FOREIGN KEY (workspace_id) REFERENCES workspace(id)
208
+ )
209
+ """)
210
+
211
+ # Runs (agent execution records)
212
+ cursor.execute("""
213
+ CREATE TABLE IF NOT EXISTS runs (
214
+ id TEXT PRIMARY KEY,
215
+ workspace_id TEXT NOT NULL,
216
+ task_id TEXT NOT NULL,
217
+ status TEXT NOT NULL DEFAULT 'RUNNING',
218
+ started_at TEXT NOT NULL,
219
+ completed_at TEXT,
220
+ FOREIGN KEY (workspace_id) REFERENCES workspace(id),
221
+ FOREIGN KEY (task_id) REFERENCES tasks(id),
222
+ CHECK (status IN ('RUNNING', 'COMPLETED', 'FAILED', 'BLOCKED'))
223
+ )
224
+ """)
225
+
226
+ # Batch runs (multi-task orchestration)
227
+ cursor.execute("""
228
+ CREATE TABLE IF NOT EXISTS batch_runs (
229
+ id TEXT PRIMARY KEY,
230
+ workspace_id TEXT NOT NULL,
231
+ task_ids TEXT NOT NULL,
232
+ status TEXT NOT NULL DEFAULT 'PENDING',
233
+ strategy TEXT NOT NULL DEFAULT 'serial',
234
+ max_parallel INTEGER NOT NULL DEFAULT 4,
235
+ on_failure TEXT NOT NULL DEFAULT 'continue',
236
+ started_at TEXT NOT NULL,
237
+ completed_at TEXT,
238
+ results TEXT,
239
+ engine TEXT NOT NULL DEFAULT 'plan',
240
+ FOREIGN KEY (workspace_id) REFERENCES workspace(id),
241
+ CHECK (status IN ('PENDING', 'RUNNING', 'COMPLETED', 'PARTIAL', 'FAILED', 'CANCELLED'))
242
+ )
243
+ """)
244
+
245
+ # Run logs (structured logging per run)
246
+ cursor.execute("""
247
+ CREATE TABLE IF NOT EXISTS run_logs (
248
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
249
+ run_id TEXT NOT NULL,
250
+ task_id TEXT NOT NULL,
251
+ timestamp TEXT NOT NULL,
252
+ log_level TEXT NOT NULL DEFAULT 'INFO',
253
+ category TEXT NOT NULL,
254
+ message TEXT NOT NULL,
255
+ metadata TEXT,
256
+ FOREIGN KEY (run_id) REFERENCES runs(id),
257
+ FOREIGN KEY (task_id) REFERENCES tasks(id),
258
+ CHECK (log_level IN ('DEBUG', 'INFO', 'WARNING', 'ERROR'))
259
+ )
260
+ """)
261
+
262
+ # Diagnostic reports (LLM analysis of run failures)
263
+ cursor.execute("""
264
+ CREATE TABLE IF NOT EXISTS diagnostic_reports (
265
+ id TEXT PRIMARY KEY,
266
+ task_id TEXT NOT NULL,
267
+ run_id TEXT NOT NULL,
268
+ root_cause TEXT NOT NULL,
269
+ failure_category TEXT NOT NULL,
270
+ severity TEXT NOT NULL,
271
+ recommendations TEXT NOT NULL,
272
+ log_summary TEXT,
273
+ created_at TEXT NOT NULL,
274
+ FOREIGN KEY (task_id) REFERENCES tasks(id),
275
+ FOREIGN KEY (run_id) REFERENCES runs(id),
276
+ CHECK (severity IN ('critical', 'high', 'medium', 'low'))
277
+ )
278
+ """)
279
+
280
+ # Engine performance tracking: per-run log
281
+ cursor.execute("""
282
+ CREATE TABLE IF NOT EXISTS run_engine_log (
283
+ run_id TEXT PRIMARY KEY,
284
+ engine TEXT NOT NULL,
285
+ task_id TEXT NOT NULL,
286
+ workspace_id TEXT NOT NULL,
287
+ status TEXT NOT NULL,
288
+ duration_ms INTEGER,
289
+ tokens_used INTEGER DEFAULT 0,
290
+ gates_passed INTEGER,
291
+ self_corrections INTEGER DEFAULT 0,
292
+ created_at TEXT NOT NULL
293
+ )
294
+ """)
295
+
296
+ # Engine performance tracking: aggregate stats
297
+ cursor.execute("""
298
+ CREATE TABLE IF NOT EXISTS engine_stats (
299
+ workspace_id TEXT NOT NULL,
300
+ engine TEXT NOT NULL,
301
+ metric TEXT NOT NULL,
302
+ value REAL NOT NULL DEFAULT 0.0,
303
+ updated_at TEXT NOT NULL,
304
+ PRIMARY KEY (workspace_id, engine, metric)
305
+ )
306
+ """)
307
+
308
+ # Execution trace tables (for debug/replay mode)
309
+ cursor.execute("""
310
+ CREATE TABLE IF NOT EXISTS execution_steps (
311
+ id TEXT PRIMARY KEY,
312
+ run_id TEXT NOT NULL,
313
+ step_number INTEGER NOT NULL,
314
+ step_type TEXT NOT NULL,
315
+ description TEXT NOT NULL,
316
+ started_at TEXT NOT NULL,
317
+ completed_at TEXT,
318
+ status TEXT NOT NULL DEFAULT 'started',
319
+ input_context TEXT,
320
+ output_result TEXT,
321
+ metadata TEXT,
322
+ FOREIGN KEY (run_id) REFERENCES runs(id)
323
+ )
324
+ """)
325
+
326
+ cursor.execute("""
327
+ CREATE TABLE IF NOT EXISTS llm_interactions (
328
+ id TEXT PRIMARY KEY,
329
+ run_id TEXT NOT NULL,
330
+ step_id TEXT NOT NULL,
331
+ prompt TEXT NOT NULL,
332
+ response TEXT NOT NULL,
333
+ model TEXT NOT NULL,
334
+ tokens_used INTEGER NOT NULL DEFAULT 0,
335
+ timestamp TEXT NOT NULL,
336
+ purpose TEXT NOT NULL DEFAULT 'execution',
337
+ FOREIGN KEY (run_id) REFERENCES runs(id),
338
+ FOREIGN KEY (step_id) REFERENCES execution_steps(id)
339
+ )
340
+ """)
341
+
342
+ cursor.execute("""
343
+ CREATE TABLE IF NOT EXISTS file_operations (
344
+ id TEXT PRIMARY KEY,
345
+ run_id TEXT NOT NULL,
346
+ step_id TEXT NOT NULL,
347
+ operation_type TEXT NOT NULL,
348
+ file_path TEXT NOT NULL,
349
+ content_before TEXT,
350
+ content_after TEXT,
351
+ timestamp TEXT NOT NULL,
352
+ FOREIGN KEY (run_id) REFERENCES runs(id),
353
+ FOREIGN KEY (step_id) REFERENCES execution_steps(id),
354
+ CHECK (operation_type IN ('create', 'edit', 'delete'))
355
+ )
356
+ """)
357
+
358
+ cursor.execute("""
359
+ CREATE TABLE IF NOT EXISTS cloud_run_metadata (
360
+ run_id TEXT PRIMARY KEY,
361
+ sandbox_minutes REAL NOT NULL,
362
+ cost_usd_estimate REAL NOT NULL,
363
+ files_uploaded INTEGER NOT NULL,
364
+ files_downloaded INTEGER NOT NULL,
365
+ credential_scan_blocked INTEGER NOT NULL,
366
+ created_at TEXT NOT NULL
367
+ )
368
+ """)
369
+
370
+ # Create indexes for common queries
371
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_tasks_workspace ON tasks(workspace_id)")
372
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)")
373
+ # Atomic duplicate-import protection (#565): one task per (workspace, issue
374
+ # URL). SQLite treats NULLs as distinct, so non-imported tasks (NULL
375
+ # external_url) are unaffected.
376
+ cursor.execute(
377
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_external_url "
378
+ "ON tasks(workspace_id, external_url)"
379
+ )
380
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_events_workspace ON events(workspace_id)")
381
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_blockers_workspace ON blockers(workspace_id)")
382
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_blockers_status ON blockers(status)")
383
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_batch_runs_workspace ON batch_runs(workspace_id)")
384
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_batch_runs_status ON batch_runs(status)")
385
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_prds_parent ON prds(parent_id)")
386
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_prds_chain ON prds(chain_id)")
387
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_prds_depends_on ON prds(depends_on)")
388
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_run_logs_run ON run_logs(run_id)")
389
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_run_logs_task ON run_logs(task_id)")
390
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_diagnostic_reports_task ON diagnostic_reports(task_id)")
391
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_diagnostic_reports_run ON diagnostic_reports(run_id)")
392
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_run_engine_log_ws_engine ON run_engine_log(workspace_id, engine)")
393
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_engine_stats_ws ON engine_stats(workspace_id, engine)")
394
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_execution_steps_run ON execution_steps(run_id)")
395
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_execution_steps_run_step ON execution_steps(run_id, step_number)")
396
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_llm_interactions_run ON llm_interactions(run_id)")
397
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_llm_interactions_step ON llm_interactions(step_id)")
398
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_file_operations_run ON file_operations(run_id)")
399
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_file_operations_step ON file_operations(step_id)")
400
+
401
+ conn.commit()
402
+ conn.close()
403
+
404
+
405
+ def _ensure_schema_upgrades(db_path: Path) -> None:
406
+ """Ensure schema upgrades for existing databases.
407
+
408
+ This function is idempotent and adds any new tables/columns
409
+ that were added after the initial schema creation.
410
+ """
411
+ conn = sqlite3.connect(db_path)
412
+ cursor = conn.cursor()
413
+
414
+ # Check if batch_runs table exists, if not create it
415
+ cursor.execute(
416
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='batch_runs'"
417
+ )
418
+ if not cursor.fetchone():
419
+ cursor.execute("""
420
+ CREATE TABLE IF NOT EXISTS batch_runs (
421
+ id TEXT PRIMARY KEY,
422
+ workspace_id TEXT NOT NULL,
423
+ task_ids TEXT NOT NULL,
424
+ status TEXT NOT NULL DEFAULT 'PENDING',
425
+ strategy TEXT NOT NULL DEFAULT 'serial',
426
+ max_parallel INTEGER NOT NULL DEFAULT 4,
427
+ on_failure TEXT NOT NULL DEFAULT 'continue',
428
+ started_at TEXT NOT NULL,
429
+ completed_at TEXT,
430
+ results TEXT,
431
+ engine TEXT NOT NULL DEFAULT 'plan',
432
+ FOREIGN KEY (workspace_id) REFERENCES workspace(id),
433
+ CHECK (status IN ('PENDING', 'RUNNING', 'COMPLETED', 'PARTIAL', 'FAILED', 'CANCELLED'))
434
+ )
435
+ """)
436
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_batch_runs_workspace ON batch_runs(workspace_id)")
437
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_batch_runs_status ON batch_runs(status)")
438
+ conn.commit()
439
+ else:
440
+ # Add engine column if it doesn't exist (migration for existing databases)
441
+ cursor.execute("PRAGMA table_info(batch_runs)")
442
+ batch_columns = {row[1] for row in cursor.fetchall()}
443
+ if "engine" not in batch_columns:
444
+ cursor.execute("ALTER TABLE batch_runs ADD COLUMN engine TEXT NOT NULL DEFAULT 'plan'")
445
+ conn.commit()
446
+
447
+ # Add tech_stack column to workspace table if it doesn't exist
448
+ # First check if workspace table exists
449
+ cursor.execute(
450
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='workspace'"
451
+ )
452
+ if cursor.fetchone():
453
+ cursor.execute("PRAGMA table_info(workspace)")
454
+ columns = {row[1] for row in cursor.fetchall()}
455
+ if "tech_stack" not in columns:
456
+ cursor.execute("ALTER TABLE workspace ADD COLUMN tech_stack TEXT")
457
+ conn.commit()
458
+
459
+ # Add versioning columns to prds table if they don't exist
460
+ # First check if prds table exists
461
+ cursor.execute(
462
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='prds'"
463
+ )
464
+ if cursor.fetchone():
465
+ cursor.execute("PRAGMA table_info(prds)")
466
+ prd_columns = {row[1] for row in cursor.fetchall()}
467
+ if "version" not in prd_columns:
468
+ cursor.execute("ALTER TABLE prds ADD COLUMN version INTEGER DEFAULT 1")
469
+ conn.commit()
470
+ if "parent_id" not in prd_columns:
471
+ cursor.execute("ALTER TABLE prds ADD COLUMN parent_id TEXT")
472
+ conn.commit()
473
+ if "change_summary" not in prd_columns:
474
+ cursor.execute("ALTER TABLE prds ADD COLUMN change_summary TEXT")
475
+ conn.commit()
476
+ if "chain_id" not in prd_columns:
477
+ cursor.execute("ALTER TABLE prds ADD COLUMN chain_id TEXT")
478
+ # Backfill chain_id for existing PRDs (set to their own id if no parent)
479
+ cursor.execute("""
480
+ UPDATE prds SET chain_id = id
481
+ WHERE chain_id IS NULL AND parent_id IS NULL
482
+ """)
483
+ conn.commit()
484
+
485
+ # Add depends_on column to prds table if it doesn't exist
486
+ # Re-check prd_columns as it may have changed
487
+ cursor.execute("PRAGMA table_info(prds)")
488
+ prd_columns = {row[1] for row in cursor.fetchall()}
489
+ if "depends_on" not in prd_columns:
490
+ cursor.execute("ALTER TABLE prds ADD COLUMN depends_on TEXT")
491
+ conn.commit()
492
+
493
+ # Add indexes for PRD version chain queries
494
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_prds_parent ON prds(parent_id)")
495
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_prds_chain ON prds(chain_id)")
496
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_prds_depends_on ON prds(depends_on)")
497
+ conn.commit()
498
+
499
+ # Add new columns to tasks table if they don't exist
500
+ cursor.execute(
501
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'"
502
+ )
503
+ if cursor.fetchone():
504
+ cursor.execute("PRAGMA table_info(tasks)")
505
+ task_columns = {row[1] for row in cursor.fetchall()}
506
+ if "depends_on" not in task_columns:
507
+ cursor.execute("ALTER TABLE tasks ADD COLUMN depends_on TEXT DEFAULT '[]'")
508
+ conn.commit()
509
+ if "estimated_hours" not in task_columns:
510
+ cursor.execute("ALTER TABLE tasks ADD COLUMN estimated_hours REAL")
511
+ conn.commit()
512
+ if "complexity_score" not in task_columns:
513
+ cursor.execute("ALTER TABLE tasks ADD COLUMN complexity_score INTEGER")
514
+ conn.commit()
515
+ if "uncertainty_level" not in task_columns:
516
+ cursor.execute("ALTER TABLE tasks ADD COLUMN uncertainty_level TEXT")
517
+ conn.commit()
518
+ if "parent_id" not in task_columns:
519
+ cursor.execute("ALTER TABLE tasks ADD COLUMN parent_id TEXT")
520
+ conn.commit()
521
+ if "lineage" not in task_columns:
522
+ cursor.execute("ALTER TABLE tasks ADD COLUMN lineage TEXT DEFAULT '[]'")
523
+ conn.commit()
524
+ if "is_leaf" not in task_columns:
525
+ cursor.execute("ALTER TABLE tasks ADD COLUMN is_leaf INTEGER DEFAULT 1")
526
+ conn.commit()
527
+ if "hierarchical_id" not in task_columns:
528
+ cursor.execute("ALTER TABLE tasks ADD COLUMN hierarchical_id TEXT")
529
+ conn.commit()
530
+ if "requirement_ids" not in task_columns:
531
+ cursor.execute("ALTER TABLE tasks ADD COLUMN requirement_ids TEXT DEFAULT '[]'")
532
+ conn.commit()
533
+ if "github_issue_number" not in task_columns:
534
+ cursor.execute("ALTER TABLE tasks ADD COLUMN github_issue_number INTEGER")
535
+ conn.commit()
536
+ if "external_url" not in task_columns:
537
+ cursor.execute("ALTER TABLE tasks ADD COLUMN external_url TEXT")
538
+ conn.commit()
539
+ if "auto_close_github_issue" not in task_columns:
540
+ cursor.execute(
541
+ "ALTER TABLE tasks ADD COLUMN auto_close_github_issue INTEGER DEFAULT 0"
542
+ )
543
+ conn.commit()
544
+ # Atomic duplicate-import protection (#565) for existing workspaces.
545
+ cursor.execute(
546
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_external_url "
547
+ "ON tasks(workspace_id, external_url)"
548
+ )
549
+ conn.commit()
550
+
551
+ # Ensure runs table exists before creating dependent tables (run_logs, diagnostic_reports)
552
+ cursor.execute(
553
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='runs'"
554
+ )
555
+ if not cursor.fetchone():
556
+ cursor.execute("""
557
+ CREATE TABLE IF NOT EXISTS runs (
558
+ id TEXT PRIMARY KEY,
559
+ workspace_id TEXT NOT NULL,
560
+ task_id TEXT NOT NULL,
561
+ status TEXT NOT NULL DEFAULT 'RUNNING',
562
+ started_at TEXT NOT NULL,
563
+ completed_at TEXT,
564
+ FOREIGN KEY (workspace_id) REFERENCES workspace(id),
565
+ FOREIGN KEY (task_id) REFERENCES tasks(id),
566
+ CHECK (status IN ('RUNNING', 'COMPLETED', 'FAILED', 'BLOCKED'))
567
+ )
568
+ """)
569
+ conn.commit()
570
+
571
+ # Add run_logs table if it doesn't exist
572
+ cursor.execute(
573
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='run_logs'"
574
+ )
575
+ if not cursor.fetchone():
576
+ cursor.execute("""
577
+ CREATE TABLE IF NOT EXISTS run_logs (
578
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
579
+ run_id TEXT NOT NULL,
580
+ task_id TEXT NOT NULL,
581
+ timestamp TEXT NOT NULL,
582
+ log_level TEXT NOT NULL DEFAULT 'INFO',
583
+ category TEXT NOT NULL,
584
+ message TEXT NOT NULL,
585
+ metadata TEXT,
586
+ FOREIGN KEY (run_id) REFERENCES runs(id),
587
+ FOREIGN KEY (task_id) REFERENCES tasks(id),
588
+ CHECK (log_level IN ('DEBUG', 'INFO', 'WARNING', 'ERROR'))
589
+ )
590
+ """)
591
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_run_logs_run ON run_logs(run_id)")
592
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_run_logs_task ON run_logs(task_id)")
593
+ conn.commit()
594
+
595
+ # Add diagnostic_reports table if it doesn't exist
596
+ cursor.execute(
597
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='diagnostic_reports'"
598
+ )
599
+ if not cursor.fetchone():
600
+ cursor.execute("""
601
+ CREATE TABLE IF NOT EXISTS diagnostic_reports (
602
+ id TEXT PRIMARY KEY,
603
+ task_id TEXT NOT NULL,
604
+ run_id TEXT NOT NULL,
605
+ root_cause TEXT NOT NULL,
606
+ failure_category TEXT NOT NULL,
607
+ severity TEXT NOT NULL,
608
+ recommendations TEXT NOT NULL,
609
+ log_summary TEXT,
610
+ created_at TEXT NOT NULL,
611
+ FOREIGN KEY (task_id) REFERENCES tasks(id),
612
+ FOREIGN KEY (run_id) REFERENCES runs(id),
613
+ CHECK (severity IN ('critical', 'high', 'medium', 'low'))
614
+ )
615
+ """)
616
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_diagnostic_reports_task ON diagnostic_reports(task_id)")
617
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_diagnostic_reports_run ON diagnostic_reports(run_id)")
618
+ conn.commit()
619
+
620
+ # Add run_engine_log table for engine performance tracking
621
+ cursor.execute("""
622
+ CREATE TABLE IF NOT EXISTS run_engine_log (
623
+ run_id TEXT PRIMARY KEY,
624
+ engine TEXT NOT NULL,
625
+ task_id TEXT NOT NULL,
626
+ workspace_id TEXT NOT NULL,
627
+ status TEXT NOT NULL,
628
+ duration_ms INTEGER,
629
+ tokens_used INTEGER DEFAULT 0,
630
+ gates_passed INTEGER,
631
+ self_corrections INTEGER DEFAULT 0,
632
+ created_at TEXT NOT NULL
633
+ )
634
+ """)
635
+ cursor.execute(
636
+ "CREATE INDEX IF NOT EXISTS idx_run_engine_log_ws_engine "
637
+ "ON run_engine_log(workspace_id, engine)"
638
+ )
639
+ conn.commit()
640
+
641
+ # Add engine_stats table for aggregate engine metrics
642
+ cursor.execute("""
643
+ CREATE TABLE IF NOT EXISTS engine_stats (
644
+ workspace_id TEXT NOT NULL,
645
+ engine TEXT NOT NULL,
646
+ metric TEXT NOT NULL,
647
+ value REAL NOT NULL DEFAULT 0.0,
648
+ updated_at TEXT NOT NULL,
649
+ PRIMARY KEY (workspace_id, engine, metric)
650
+ )
651
+ """)
652
+ cursor.execute(
653
+ "CREATE INDEX IF NOT EXISTS idx_engine_stats_ws "
654
+ "ON engine_stats(workspace_id, engine)"
655
+ )
656
+ conn.commit()
657
+
658
+ # Add execution trace tables for debug/replay mode
659
+ cursor.execute("""
660
+ CREATE TABLE IF NOT EXISTS execution_steps (
661
+ id TEXT PRIMARY KEY,
662
+ run_id TEXT NOT NULL,
663
+ step_number INTEGER NOT NULL,
664
+ step_type TEXT NOT NULL,
665
+ description TEXT NOT NULL,
666
+ started_at TEXT NOT NULL,
667
+ completed_at TEXT,
668
+ status TEXT NOT NULL DEFAULT 'started',
669
+ input_context TEXT,
670
+ output_result TEXT,
671
+ metadata TEXT,
672
+ FOREIGN KEY (run_id) REFERENCES runs(id)
673
+ )
674
+ """)
675
+ cursor.execute("""
676
+ CREATE TABLE IF NOT EXISTS llm_interactions (
677
+ id TEXT PRIMARY KEY,
678
+ run_id TEXT NOT NULL,
679
+ step_id TEXT NOT NULL,
680
+ prompt TEXT NOT NULL,
681
+ response TEXT NOT NULL,
682
+ model TEXT NOT NULL,
683
+ tokens_used INTEGER NOT NULL DEFAULT 0,
684
+ timestamp TEXT NOT NULL,
685
+ purpose TEXT NOT NULL DEFAULT 'execution',
686
+ FOREIGN KEY (run_id) REFERENCES runs(id),
687
+ FOREIGN KEY (step_id) REFERENCES execution_steps(id)
688
+ )
689
+ """)
690
+ cursor.execute("""
691
+ CREATE TABLE IF NOT EXISTS file_operations (
692
+ id TEXT PRIMARY KEY,
693
+ run_id TEXT NOT NULL,
694
+ step_id TEXT NOT NULL,
695
+ operation_type TEXT NOT NULL,
696
+ file_path TEXT NOT NULL,
697
+ content_before TEXT,
698
+ content_after TEXT,
699
+ timestamp TEXT NOT NULL,
700
+ FOREIGN KEY (run_id) REFERENCES runs(id),
701
+ FOREIGN KEY (step_id) REFERENCES execution_steps(id),
702
+ CHECK (operation_type IN ('create', 'edit', 'delete'))
703
+ )
704
+ """)
705
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_execution_steps_run ON execution_steps(run_id)")
706
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_execution_steps_run_step ON execution_steps(run_id, step_number)")
707
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_llm_interactions_run ON llm_interactions(run_id)")
708
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_llm_interactions_step ON llm_interactions(step_id)")
709
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_file_operations_run ON file_operations(run_id)")
710
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_file_operations_step ON file_operations(step_id)")
711
+ # Add cloud_run_metadata table for E2B cloud execution tracking
712
+ cursor.execute("""
713
+ CREATE TABLE IF NOT EXISTS cloud_run_metadata (
714
+ run_id TEXT PRIMARY KEY,
715
+ sandbox_minutes REAL NOT NULL,
716
+ cost_usd_estimate REAL NOT NULL,
717
+ files_uploaded INTEGER NOT NULL,
718
+ files_downloaded INTEGER NOT NULL,
719
+ credential_scan_blocked INTEGER NOT NULL,
720
+ created_at TEXT NOT NULL
721
+ )
722
+ """)
723
+ conn.commit()
724
+
725
+ conn.close()
726
+
727
+
728
+ def create_or_load_workspace(repo_path: Path, tech_stack: Optional[str] = None) -> Workspace:
729
+ """Create a new workspace or load an existing one.
730
+
731
+ This is idempotent - calling it on an already-initialized repo
732
+ will return the existing workspace (tech_stack is ignored if workspace exists).
733
+
734
+ Args:
735
+ repo_path: Path to the repository (must exist)
736
+ tech_stack: Optional natural language description of the project's tech stack
737
+
738
+ Returns:
739
+ Workspace object with metadata
740
+
741
+ Raises:
742
+ FileNotFoundError: If repo_path doesn't exist
743
+ NotADirectoryError: If repo_path is not a directory
744
+ """
745
+ repo_path = repo_path.resolve()
746
+
747
+ if not repo_path.exists():
748
+ raise FileNotFoundError(f"Repository path does not exist: {repo_path}")
749
+
750
+ if not repo_path.is_dir():
751
+ raise NotADirectoryError(f"Repository path is not a directory: {repo_path}")
752
+
753
+ state_dir = _get_state_dir(repo_path)
754
+ db_path = state_dir / STATE_DB_NAME
755
+
756
+ # Check if workspace already exists
757
+ if state_dir.exists() and db_path.exists():
758
+ return get_workspace(repo_path)
759
+
760
+ # Create .codeframe/ directory
761
+ state_dir.mkdir(exist_ok=True)
762
+
763
+ # Initialize database
764
+ _init_database(db_path)
765
+
766
+ # Create workspace record
767
+ workspace_id = str(uuid.uuid4())
768
+ now = _utc_now().isoformat()
769
+
770
+ conn = sqlite3.connect(db_path)
771
+ cursor = conn.cursor()
772
+ cursor.execute(
773
+ "INSERT INTO workspace (id, repo_path, tech_stack, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
774
+ (workspace_id, str(repo_path), tech_stack, now, now),
775
+ )
776
+ conn.commit()
777
+ conn.close()
778
+
779
+ return Workspace(
780
+ id=workspace_id,
781
+ repo_path=repo_path,
782
+ state_dir=state_dir,
783
+ created_at=datetime.fromisoformat(now),
784
+ tech_stack=tech_stack,
785
+ )
786
+
787
+
788
+ def get_workspace(repo_path: Path) -> Workspace:
789
+ """Load an existing workspace.
790
+
791
+ Args:
792
+ repo_path: Path to the repository
793
+
794
+ Returns:
795
+ Workspace object
796
+
797
+ Raises:
798
+ FileNotFoundError: If no workspace exists at this path
799
+ """
800
+ repo_path = repo_path.resolve()
801
+ state_dir = _get_state_dir(repo_path)
802
+ db_path = state_dir / STATE_DB_NAME
803
+
804
+ if not state_dir.exists() or not db_path.exists():
805
+ raise FileNotFoundError(f"No workspace found at {repo_path}")
806
+
807
+ # Ensure schema is up to date for existing workspaces
808
+ _ensure_schema_upgrades(db_path)
809
+
810
+ conn = sqlite3.connect(db_path)
811
+ cursor = conn.cursor()
812
+ cursor.execute("SELECT id, repo_path, tech_stack, created_at FROM workspace LIMIT 1")
813
+ row = cursor.fetchone()
814
+ conn.close()
815
+
816
+ if not row:
817
+ raise FileNotFoundError("Workspace database exists but contains no workspace record")
818
+
819
+ return Workspace(
820
+ id=row[0],
821
+ repo_path=Path(row[1]),
822
+ state_dir=state_dir,
823
+ created_at=datetime.fromisoformat(row[3]),
824
+ tech_stack=row[2],
825
+ )
826
+
827
+
828
+ def get_db_connection(workspace: Workspace) -> sqlite3.Connection:
829
+ """Get a database connection for a workspace.
830
+
831
+ The caller is responsible for closing the connection.
832
+
833
+ Args:
834
+ workspace: Workspace object
835
+
836
+ Returns:
837
+ SQLite connection
838
+ """
839
+ return sqlite3.connect(workspace.db_path)
840
+
841
+
842
+ def workspace_exists(repo_path: Path) -> bool:
843
+ """Check if a workspace exists at the given path.
844
+
845
+ Args:
846
+ repo_path: Path to check
847
+
848
+ Returns:
849
+ True if workspace exists, False otherwise
850
+ """
851
+ state_dir = _get_state_dir(repo_path.resolve())
852
+ db_path = state_dir / STATE_DB_NAME
853
+ return state_dir.exists() and db_path.exists()
854
+
855
+
856
+ def update_workspace_tech_stack(repo_path: Path, tech_stack: Optional[str]) -> Workspace:
857
+ """Update the tech_stack for an existing workspace.
858
+
859
+ Args:
860
+ repo_path: Path to the repository
861
+ tech_stack: New tech stack description (or None to clear)
862
+
863
+ Returns:
864
+ Updated Workspace object
865
+
866
+ Raises:
867
+ FileNotFoundError: If no workspace exists at this path
868
+ """
869
+ repo_path = repo_path.resolve()
870
+ state_dir = _get_state_dir(repo_path)
871
+ db_path = state_dir / STATE_DB_NAME
872
+
873
+ if not state_dir.exists() or not db_path.exists():
874
+ raise FileNotFoundError(f"No workspace found at {repo_path}")
875
+
876
+ now = _utc_now().isoformat()
877
+
878
+ conn = sqlite3.connect(db_path)
879
+ cursor = conn.cursor()
880
+ cursor.execute(
881
+ "UPDATE workspace SET tech_stack = ?, updated_at = ?",
882
+ (tech_stack, now),
883
+ )
884
+ conn.commit()
885
+ conn.close()
886
+
887
+ return get_workspace(repo_path)