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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Control-plane database management for CodeFRAME.
|
|
2
|
+
|
|
3
|
+
The global database is a **control-plane store** only: auth (users/accounts/
|
|
4
|
+
sessions/verification), API keys, audit logs, interactive sessions, and token
|
|
5
|
+
usage. All v2 domain data (tasks/blockers/PRD/...) lives in the per-workspace
|
|
6
|
+
``.codeframe/state.db`` via ``codeframe.core.workspace`` — not here.
|
|
7
|
+
|
|
8
|
+
The class acts as a thin facade, delegating to the surviving control-plane
|
|
9
|
+
repositories. Supports both synchronous (sqlite3) and asynchronous (aiosqlite)
|
|
10
|
+
operations.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import os
|
|
15
|
+
import sqlite3
|
|
16
|
+
import threading
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
import logging
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import aiosqlite
|
|
23
|
+
|
|
24
|
+
from codeframe.platform_store.schema_manager import SchemaManager
|
|
25
|
+
from codeframe.platform_store.repositories import (
|
|
26
|
+
TokenRepository,
|
|
27
|
+
AuditRepository,
|
|
28
|
+
APIKeyRepository,
|
|
29
|
+
WorkspaceRegistryRepository,
|
|
30
|
+
)
|
|
31
|
+
from codeframe.platform_store.repositories.interactive_sessions import InteractiveSessionRepository
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# Audit verbosity configuration
|
|
36
|
+
AUDIT_VERBOSITY = os.getenv("AUDIT_VERBOSITY", "low").lower()
|
|
37
|
+
if AUDIT_VERBOSITY not in ("low", "high"):
|
|
38
|
+
logger.warning(f"Invalid AUDIT_VERBOSITY='{AUDIT_VERBOSITY}', defaulting to 'low'")
|
|
39
|
+
AUDIT_VERBOSITY = "low"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Database:
|
|
43
|
+
"""SQLite manager for the global control-plane store.
|
|
44
|
+
|
|
45
|
+
Repositories:
|
|
46
|
+
- api_keys: API key issuance and lookup
|
|
47
|
+
- audit_logs: Audit logging
|
|
48
|
+
- interactive_sessions: Interactive agent session records
|
|
49
|
+
- token_usage: LLM token usage tracking (also used per-workspace)
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, db_path: Path | str):
|
|
53
|
+
"""Initialize database manager.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
db_path: Path to SQLite database file or ":memory:"
|
|
57
|
+
"""
|
|
58
|
+
self.db_path = Path(db_path) if db_path != ":memory:" else db_path
|
|
59
|
+
self.conn: Optional[sqlite3.Connection] = None
|
|
60
|
+
self._async_conn: Optional[aiosqlite.Connection] = None
|
|
61
|
+
self._async_lock = asyncio.Lock()
|
|
62
|
+
self._sync_lock = threading.RLock() # Reentrant lock for thread-safe access
|
|
63
|
+
|
|
64
|
+
# Control-plane repositories (set after connections are created)
|
|
65
|
+
self.token_usage: Optional[TokenRepository] = None
|
|
66
|
+
self.audit_logs: Optional[AuditRepository] = None
|
|
67
|
+
self.api_keys: Optional[APIKeyRepository] = None
|
|
68
|
+
self.interactive_sessions: Optional[InteractiveSessionRepository] = None
|
|
69
|
+
self.workspace_registry: Optional[WorkspaceRegistryRepository] = None
|
|
70
|
+
|
|
71
|
+
def initialize(self) -> None:
|
|
72
|
+
"""Initialize database schema and repositories."""
|
|
73
|
+
# Create parent directories if needed
|
|
74
|
+
if self.db_path != ":memory:":
|
|
75
|
+
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
|
|
77
|
+
# Create sync connection
|
|
78
|
+
self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
|
79
|
+
self.conn.row_factory = sqlite3.Row
|
|
80
|
+
self.conn.execute("PRAGMA foreign_keys = ON")
|
|
81
|
+
# Enable WAL mode for better concurrent access (allows reads during writes)
|
|
82
|
+
self.conn.execute("PRAGMA journal_mode = WAL")
|
|
83
|
+
# Set busy timeout to handle concurrent access contention
|
|
84
|
+
self.conn.execute("PRAGMA busy_timeout = 5000")
|
|
85
|
+
|
|
86
|
+
# Create schema using SchemaManager
|
|
87
|
+
schema_mgr = SchemaManager(self.conn)
|
|
88
|
+
schema_mgr.create_schema()
|
|
89
|
+
|
|
90
|
+
# Initialize all repositories with sync connection
|
|
91
|
+
self._initialize_repositories()
|
|
92
|
+
|
|
93
|
+
def _initialize_repositories(self) -> None:
|
|
94
|
+
"""Initialize all repository instances."""
|
|
95
|
+
# Pass both sync and async connections to support mixed operations.
|
|
96
|
+
# Also pass self (Database instance) for cross-repository operations,
|
|
97
|
+
# and sync_lock for thread-safe access to the shared connection.
|
|
98
|
+
self.token_usage = TokenRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock)
|
|
99
|
+
self.audit_logs = AuditRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock)
|
|
100
|
+
self.api_keys = APIKeyRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock)
|
|
101
|
+
self.interactive_sessions = InteractiveSessionRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock)
|
|
102
|
+
self.workspace_registry = WorkspaceRegistryRepository(sync_conn=self.conn, async_conn=self._async_conn, database=self, sync_lock=self._sync_lock)
|
|
103
|
+
|
|
104
|
+
# Connection management methods
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
"""Close database connection (sync only)."""
|
|
107
|
+
if self.conn:
|
|
108
|
+
self.conn.close()
|
|
109
|
+
self.conn = None
|
|
110
|
+
|
|
111
|
+
async def close_async(self) -> None:
|
|
112
|
+
"""Close async database connection."""
|
|
113
|
+
if self._async_conn:
|
|
114
|
+
await self._async_conn.close()
|
|
115
|
+
self._async_conn = None
|
|
116
|
+
|
|
117
|
+
async def close_all(self) -> None:
|
|
118
|
+
"""Close both sync and async database connections."""
|
|
119
|
+
self.close()
|
|
120
|
+
await self.close_async()
|
|
121
|
+
|
|
122
|
+
def __del__(self) -> None:
|
|
123
|
+
"""Destructor with warning for unclosed connections."""
|
|
124
|
+
if self._async_conn is not None:
|
|
125
|
+
logger.warning(
|
|
126
|
+
f"Database async connection for {self.db_path} was not explicitly closed. "
|
|
127
|
+
"Use 'async with db:' or call close_async() to properly close async connections."
|
|
128
|
+
)
|
|
129
|
+
if self.conn is not None:
|
|
130
|
+
self.close()
|
|
131
|
+
|
|
132
|
+
async def initialize_async(self) -> None:
|
|
133
|
+
"""Explicitly initialize the async database connection."""
|
|
134
|
+
async with self._async_lock:
|
|
135
|
+
if self._async_conn is None:
|
|
136
|
+
self._async_conn = await aiosqlite.connect(str(self.db_path))
|
|
137
|
+
self._async_conn.row_factory = aiosqlite.Row
|
|
138
|
+
# Match sync connection pragmas for consistency
|
|
139
|
+
await self._async_conn.execute("PRAGMA foreign_keys = ON")
|
|
140
|
+
await self._async_conn.execute("PRAGMA journal_mode = WAL")
|
|
141
|
+
await self._async_conn.execute("PRAGMA busy_timeout = 5000")
|
|
142
|
+
logger.debug(f"Async connection initialized for {self.db_path}")
|
|
143
|
+
# Update repository async connections
|
|
144
|
+
if self.token_usage:
|
|
145
|
+
self._update_repository_async_connections()
|
|
146
|
+
|
|
147
|
+
def _update_repository_async_connections(self) -> None:
|
|
148
|
+
"""Update async connections in all repositories."""
|
|
149
|
+
for repo in [self.token_usage, self.audit_logs, self.api_keys, self.interactive_sessions, self.workspace_registry]:
|
|
150
|
+
if repo:
|
|
151
|
+
repo._async_conn = self._async_conn
|
|
152
|
+
|
|
153
|
+
async def _get_async_conn(self) -> aiosqlite.Connection:
|
|
154
|
+
"""Get async connection with health check and automatic reconnection."""
|
|
155
|
+
async with self._async_lock:
|
|
156
|
+
if self._async_conn is None:
|
|
157
|
+
self._async_conn = await aiosqlite.connect(str(self.db_path))
|
|
158
|
+
self._async_conn.row_factory = aiosqlite.Row
|
|
159
|
+
# Match sync connection pragmas for consistency
|
|
160
|
+
await self._async_conn.execute("PRAGMA foreign_keys = ON")
|
|
161
|
+
await self._async_conn.execute("PRAGMA journal_mode = WAL")
|
|
162
|
+
await self._async_conn.execute("PRAGMA busy_timeout = 5000")
|
|
163
|
+
logger.debug(f"Async connection created (lazy init) for {self.db_path}")
|
|
164
|
+
self._update_repository_async_connections()
|
|
165
|
+
return self._async_conn
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
await self._async_conn.execute("SELECT 1")
|
|
169
|
+
return self._async_conn
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.warning(f"Async connection health check failed: {e}, reconnecting...")
|
|
172
|
+
try:
|
|
173
|
+
await self._async_conn.close()
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
self._async_conn = await aiosqlite.connect(str(self.db_path))
|
|
178
|
+
self._async_conn.row_factory = aiosqlite.Row
|
|
179
|
+
# Match sync connection pragmas for consistency
|
|
180
|
+
await self._async_conn.execute("PRAGMA foreign_keys = ON")
|
|
181
|
+
await self._async_conn.execute("PRAGMA journal_mode = WAL")
|
|
182
|
+
await self._async_conn.execute("PRAGMA busy_timeout = 5000")
|
|
183
|
+
logger.info(f"Async connection reconnected for {self.db_path}")
|
|
184
|
+
self._update_repository_async_connections()
|
|
185
|
+
return self._async_conn
|
|
186
|
+
|
|
187
|
+
# Context managers
|
|
188
|
+
def __enter__(self) -> "Database":
|
|
189
|
+
"""Context manager entry."""
|
|
190
|
+
if not self.conn:
|
|
191
|
+
self.initialize()
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
195
|
+
"""Context manager exit."""
|
|
196
|
+
self.close()
|
|
197
|
+
|
|
198
|
+
async def __aenter__(self) -> "Database":
|
|
199
|
+
"""Async context manager entry."""
|
|
200
|
+
if not self.conn:
|
|
201
|
+
self.initialize()
|
|
202
|
+
await self.initialize_async()
|
|
203
|
+
return self
|
|
204
|
+
|
|
205
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
206
|
+
"""Async context manager exit."""
|
|
207
|
+
await self.close_async()
|
|
208
|
+
|
|
209
|
+
@contextlib.contextmanager
|
|
210
|
+
def transaction(self):
|
|
211
|
+
"""Context manager for explicit transaction control.
|
|
212
|
+
|
|
213
|
+
Yields:
|
|
214
|
+
self: The database instance for chaining operations
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
RuntimeError: If called while already inside a transaction
|
|
218
|
+
"""
|
|
219
|
+
if not self.conn:
|
|
220
|
+
self.initialize()
|
|
221
|
+
|
|
222
|
+
# Acquire reentrant lock to ensure thread-safe access to connection state
|
|
223
|
+
with self._sync_lock:
|
|
224
|
+
# Guard against nested transactions
|
|
225
|
+
if self.conn.in_transaction:
|
|
226
|
+
raise RuntimeError(
|
|
227
|
+
"Cannot start a nested transaction. "
|
|
228
|
+
"Complete the current transaction first."
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# SQLite uses autocommit by default; disable it for this transaction
|
|
232
|
+
old_isolation = self.conn.isolation_level
|
|
233
|
+
self.conn.isolation_level = None # Manual transaction mode
|
|
234
|
+
cursor = self.conn.cursor()
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
cursor.execute("BEGIN")
|
|
238
|
+
yield self
|
|
239
|
+
self.conn.commit()
|
|
240
|
+
except Exception:
|
|
241
|
+
# Only rollback if a transaction was actually started
|
|
242
|
+
if self.conn.in_transaction:
|
|
243
|
+
self.conn.rollback()
|
|
244
|
+
raise
|
|
245
|
+
finally:
|
|
246
|
+
self.conn.isolation_level = old_isolation
|
|
247
|
+
|
|
248
|
+
# ----- Token usage (dual-use facade) -----
|
|
249
|
+
# Note: ``token_usage`` is the one repository whose backing table is NOT in
|
|
250
|
+
# ``SchemaManager`` (control-plane). It is also created by the per-workspace
|
|
251
|
+
# schema in ``core/workspace.py``, and ``react_agent``/``stats_commands``
|
|
252
|
+
# instantiate ``Database(workspace.db_path)`` to record token usage via
|
|
253
|
+
# ``MetricsTracker``. Keep the delegations alongside the control-plane ones.
|
|
254
|
+
def save_token_usage(self, *args, **kwargs):
|
|
255
|
+
"""Delegate to token_usage.save_token_usage()."""
|
|
256
|
+
return self.token_usage.save_token_usage(*args, **kwargs)
|
|
257
|
+
|
|
258
|
+
def get_token_usage(self, *args, **kwargs):
|
|
259
|
+
"""Delegate to token_usage.get_token_usage()."""
|
|
260
|
+
return self.token_usage.get_token_usage(*args, **kwargs)
|
|
261
|
+
|
|
262
|
+
def get_task_token_summary(self, *args, **kwargs):
|
|
263
|
+
"""Delegate to token_usage.get_task_token_summary()."""
|
|
264
|
+
return self.token_usage.get_task_token_summary(*args, **kwargs)
|
|
265
|
+
|
|
266
|
+
def get_batch_token_usage(self, *args, **kwargs):
|
|
267
|
+
"""Delegate to token_usage.get_batch_token_usage()."""
|
|
268
|
+
return self.token_usage.get_batch_token_usage(*args, **kwargs)
|
|
269
|
+
|
|
270
|
+
def get_workspace_token_usage(self, *args, **kwargs):
|
|
271
|
+
"""Delegate to token_usage.get_workspace_token_usage()."""
|
|
272
|
+
return self.token_usage.get_workspace_token_usage(*args, **kwargs)
|
|
273
|
+
|
|
274
|
+
# ----- Audit log -----
|
|
275
|
+
def create_audit_log(self, *args, **kwargs):
|
|
276
|
+
"""Delegate to audit_logs.create_audit_log()."""
|
|
277
|
+
return self.audit_logs.create_audit_log(*args, **kwargs)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Control-plane repository exports.
|
|
2
|
+
|
|
3
|
+
Only the repositories backing the global control-plane store survive: API keys,
|
|
4
|
+
audit logs, and token usage (plus the shared base). The v1 domain repositories
|
|
5
|
+
(projects/issues/tasks/agents/...) were removed with the v2 cleanup — that data
|
|
6
|
+
now lives per-workspace via ``codeframe.core.workspace``. The interactive-session
|
|
7
|
+
repository is imported directly from its module by ``database.py``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from codeframe.platform_store.repositories.base import BaseRepository
|
|
11
|
+
from codeframe.platform_store.repositories.token_repository import TokenRepository
|
|
12
|
+
from codeframe.platform_store.repositories.audit_repository import AuditRepository
|
|
13
|
+
from codeframe.platform_store.repositories.api_key_repository import APIKeyRepository
|
|
14
|
+
from codeframe.platform_store.repositories.workspace_registry_repository import (
|
|
15
|
+
WorkspaceRegistryRepository,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"BaseRepository",
|
|
20
|
+
"TokenRepository",
|
|
21
|
+
"AuditRepository",
|
|
22
|
+
"APIKeyRepository",
|
|
23
|
+
"WorkspaceRegistryRepository",
|
|
24
|
+
]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Repository for API key database operations.
|
|
2
|
+
|
|
3
|
+
Handles CRUD operations for API keys used for programmatic server access.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Optional, Dict, Any, List
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from codeframe.platform_store.repositories.base import BaseRepository
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class APIKeyRepository(BaseRepository):
|
|
18
|
+
"""Repository for API key operations."""
|
|
19
|
+
|
|
20
|
+
def create(
|
|
21
|
+
self,
|
|
22
|
+
user_id: int,
|
|
23
|
+
name: str,
|
|
24
|
+
key_hash: str,
|
|
25
|
+
prefix: str,
|
|
26
|
+
scopes: List[str],
|
|
27
|
+
expires_at: Optional[datetime] = None,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Create a new API key record.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
user_id: Owner user ID
|
|
33
|
+
name: Human-readable name for the key
|
|
34
|
+
key_hash: SHA256 or bcrypt hash of the full key
|
|
35
|
+
prefix: First 12 chars for efficient lookup
|
|
36
|
+
scopes: List of permission scopes
|
|
37
|
+
expires_at: Optional expiration timestamp
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Generated key ID (UUID)
|
|
41
|
+
"""
|
|
42
|
+
key_id = str(uuid.uuid4())
|
|
43
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
44
|
+
|
|
45
|
+
# Normalize expires_at to UTC for consistent string comparison
|
|
46
|
+
expires_at_utc = None
|
|
47
|
+
if expires_at:
|
|
48
|
+
if expires_at.tzinfo is None:
|
|
49
|
+
# Treat naive datetime as UTC
|
|
50
|
+
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
|
51
|
+
expires_at_utc = expires_at.astimezone(timezone.utc).isoformat()
|
|
52
|
+
|
|
53
|
+
self._execute(
|
|
54
|
+
"""
|
|
55
|
+
INSERT INTO api_keys (
|
|
56
|
+
id, user_id, name, key_hash, prefix, scopes,
|
|
57
|
+
created_at, expires_at, is_active
|
|
58
|
+
)
|
|
59
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
60
|
+
""",
|
|
61
|
+
(
|
|
62
|
+
key_id,
|
|
63
|
+
user_id,
|
|
64
|
+
name,
|
|
65
|
+
key_hash,
|
|
66
|
+
prefix,
|
|
67
|
+
json.dumps(scopes),
|
|
68
|
+
now,
|
|
69
|
+
expires_at_utc,
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
self._commit()
|
|
73
|
+
|
|
74
|
+
logger.debug(f"Created API key {key_id} for user {user_id}")
|
|
75
|
+
return key_id
|
|
76
|
+
|
|
77
|
+
def get_by_id(self, key_id: str) -> Optional[Dict[str, Any]]:
|
|
78
|
+
"""Get an API key by its ID.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
key_id: The key's UUID
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Key record dict or None if not found
|
|
85
|
+
"""
|
|
86
|
+
row = self._fetchone(
|
|
87
|
+
"""
|
|
88
|
+
SELECT id, user_id, name, key_hash, prefix, scopes,
|
|
89
|
+
created_at, last_used_at, expires_at, is_active
|
|
90
|
+
FROM api_keys
|
|
91
|
+
WHERE id = ?
|
|
92
|
+
""",
|
|
93
|
+
(key_id,),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if row is None:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
return self._row_to_api_key(row)
|
|
100
|
+
|
|
101
|
+
def get_by_prefix(self, prefix: str) -> Optional[Dict[str, Any]]:
|
|
102
|
+
"""Get an active API key by its prefix.
|
|
103
|
+
|
|
104
|
+
Only returns active, non-expired keys for authentication.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
prefix: The key prefix (first 12 characters)
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Key record dict or None if not found/inactive
|
|
111
|
+
"""
|
|
112
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
113
|
+
|
|
114
|
+
row = self._fetchone(
|
|
115
|
+
"""
|
|
116
|
+
SELECT id, user_id, name, key_hash, prefix, scopes,
|
|
117
|
+
created_at, last_used_at, expires_at, is_active
|
|
118
|
+
FROM api_keys
|
|
119
|
+
WHERE prefix = ?
|
|
120
|
+
AND is_active = 1
|
|
121
|
+
AND (expires_at IS NULL OR expires_at > ?)
|
|
122
|
+
""",
|
|
123
|
+
(prefix, now),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if row is None:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
return self._row_to_api_key(row)
|
|
130
|
+
|
|
131
|
+
def list_user_keys(self, user_id: int) -> List[Dict[str, Any]]:
|
|
132
|
+
"""List all API keys for a user (active and inactive).
|
|
133
|
+
|
|
134
|
+
Does NOT include key_hash for security.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
user_id: The user's ID
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
List of key records (without key_hash)
|
|
141
|
+
"""
|
|
142
|
+
rows = self._fetchall(
|
|
143
|
+
"""
|
|
144
|
+
SELECT id, user_id, name, prefix, scopes,
|
|
145
|
+
created_at, last_used_at, expires_at, is_active
|
|
146
|
+
FROM api_keys
|
|
147
|
+
WHERE user_id = ?
|
|
148
|
+
ORDER BY created_at DESC
|
|
149
|
+
""",
|
|
150
|
+
(user_id,),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return [self._row_to_api_key_safe(row) for row in rows]
|
|
154
|
+
|
|
155
|
+
def update_last_used(self, key_id: str) -> None:
|
|
156
|
+
"""Update the last_used_at timestamp.
|
|
157
|
+
|
|
158
|
+
Called after successful authentication.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
key_id: The key's UUID
|
|
162
|
+
"""
|
|
163
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
164
|
+
|
|
165
|
+
self._execute(
|
|
166
|
+
"""
|
|
167
|
+
UPDATE api_keys
|
|
168
|
+
SET last_used_at = ?
|
|
169
|
+
WHERE id = ?
|
|
170
|
+
""",
|
|
171
|
+
(now, key_id),
|
|
172
|
+
)
|
|
173
|
+
self._commit()
|
|
174
|
+
|
|
175
|
+
def revoke(self, key_id: str, user_id: int) -> bool:
|
|
176
|
+
"""Revoke an API key (soft delete).
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
key_id: The key's UUID
|
|
180
|
+
user_id: The user attempting to revoke (must be owner)
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
True if revoked, False if not found or not owned
|
|
184
|
+
"""
|
|
185
|
+
cursor = self._execute(
|
|
186
|
+
"""
|
|
187
|
+
UPDATE api_keys
|
|
188
|
+
SET is_active = 0
|
|
189
|
+
WHERE id = ? AND user_id = ?
|
|
190
|
+
""",
|
|
191
|
+
(key_id, user_id),
|
|
192
|
+
)
|
|
193
|
+
self._commit()
|
|
194
|
+
|
|
195
|
+
return cursor.rowcount > 0
|
|
196
|
+
|
|
197
|
+
def delete(self, key_id: str, user_id: int) -> bool:
|
|
198
|
+
"""Hard delete an API key.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
key_id: The key's UUID
|
|
202
|
+
user_id: The user attempting to delete (must be owner)
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
True if deleted, False if not found or not owned
|
|
206
|
+
"""
|
|
207
|
+
cursor = self._execute(
|
|
208
|
+
"""
|
|
209
|
+
DELETE FROM api_keys
|
|
210
|
+
WHERE id = ? AND user_id = ?
|
|
211
|
+
""",
|
|
212
|
+
(key_id, user_id),
|
|
213
|
+
)
|
|
214
|
+
self._commit()
|
|
215
|
+
|
|
216
|
+
return cursor.rowcount > 0
|
|
217
|
+
|
|
218
|
+
def _row_to_api_key(self, row) -> Dict[str, Any]:
|
|
219
|
+
"""Convert database row to API key dict (includes hash)."""
|
|
220
|
+
return {
|
|
221
|
+
"id": row["id"],
|
|
222
|
+
"user_id": row["user_id"],
|
|
223
|
+
"name": row["name"],
|
|
224
|
+
"key_hash": row["key_hash"],
|
|
225
|
+
"prefix": row["prefix"],
|
|
226
|
+
"scopes": json.loads(row["scopes"]),
|
|
227
|
+
"created_at": self._ensure_rfc3339(row["created_at"]),
|
|
228
|
+
"last_used_at": self._ensure_rfc3339(row["last_used_at"]) if row["last_used_at"] else None,
|
|
229
|
+
"expires_at": self._ensure_rfc3339(row["expires_at"]) if row["expires_at"] else None,
|
|
230
|
+
"is_active": bool(row["is_active"]),
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
def _row_to_api_key_safe(self, row) -> Dict[str, Any]:
|
|
234
|
+
"""Convert database row to API key dict (excludes hash for listing)."""
|
|
235
|
+
return {
|
|
236
|
+
"id": row["id"],
|
|
237
|
+
"user_id": row["user_id"],
|
|
238
|
+
"name": row["name"],
|
|
239
|
+
"prefix": row["prefix"],
|
|
240
|
+
"scopes": json.loads(row["scopes"]),
|
|
241
|
+
"created_at": self._ensure_rfc3339(row["created_at"]),
|
|
242
|
+
"last_used_at": self._ensure_rfc3339(row["last_used_at"]) if row["last_used_at"] else None,
|
|
243
|
+
"expires_at": self._ensure_rfc3339(row["expires_at"]) if row["expires_at"] else None,
|
|
244
|
+
"is_active": bool(row["is_active"]),
|
|
245
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Repository for Audit Repository operations.
|
|
2
|
+
|
|
3
|
+
Extracted from monolithic Database class for better maintainability.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Optional, Dict, Any
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
from codeframe.platform_store.repositories.base import BaseRepository
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuditRepository(BaseRepository):
|
|
18
|
+
"""Repository for audit repository operations."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_audit_log(
|
|
22
|
+
self,
|
|
23
|
+
event_type: str,
|
|
24
|
+
user_id: Optional[int],
|
|
25
|
+
resource_type: str,
|
|
26
|
+
resource_id: Optional[int],
|
|
27
|
+
ip_address: Optional[str],
|
|
28
|
+
metadata: Optional[Dict[str, Any]],
|
|
29
|
+
timestamp: datetime,
|
|
30
|
+
) -> int:
|
|
31
|
+
"""Create an audit log entry (Issue #132).
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
event_type: Type of event (e.g., "auth.login.success")
|
|
35
|
+
user_id: User ID (if authenticated)
|
|
36
|
+
resource_type: Type of resource (e.g., "project", "task")
|
|
37
|
+
resource_id: ID of the resource
|
|
38
|
+
ip_address: Client IP address
|
|
39
|
+
metadata: Additional event metadata (stored as JSON)
|
|
40
|
+
timestamp: Event timestamp
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
ID of the created audit log entry
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
cursor = self.conn.cursor()
|
|
47
|
+
cursor.execute(
|
|
48
|
+
"""
|
|
49
|
+
INSERT INTO audit_logs (
|
|
50
|
+
event_type, user_id, resource_type, resource_id,
|
|
51
|
+
ip_address, metadata, timestamp
|
|
52
|
+
)
|
|
53
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
54
|
+
""",
|
|
55
|
+
(
|
|
56
|
+
event_type,
|
|
57
|
+
user_id,
|
|
58
|
+
resource_type,
|
|
59
|
+
resource_id,
|
|
60
|
+
ip_address,
|
|
61
|
+
json.dumps(metadata) if metadata else None,
|
|
62
|
+
timestamp.isoformat(),
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
self.conn.commit()
|
|
66
|
+
return cursor.lastrowid
|
|
67
|
+
|