ralphx 0.2.2__py3-none-any.whl → 0.3.5__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.
- ralphx/__init__.py +1 -1
- ralphx/api/main.py +9 -1
- ralphx/api/routes/auth.py +730 -65
- ralphx/api/routes/config.py +3 -56
- ralphx/api/routes/export_import.py +795 -0
- ralphx/api/routes/loops.py +4 -4
- ralphx/api/routes/planning.py +19 -5
- ralphx/api/routes/projects.py +84 -2
- ralphx/api/routes/templates.py +115 -2
- ralphx/api/routes/workflows.py +22 -22
- ralphx/cli.py +21 -6
- ralphx/core/auth.py +346 -171
- ralphx/core/database.py +615 -167
- ralphx/core/executor.py +0 -3
- ralphx/core/loop.py +15 -2
- ralphx/core/loop_templates.py +69 -3
- ralphx/core/planning_service.py +109 -21
- ralphx/core/preview.py +9 -25
- ralphx/core/project_db.py +175 -75
- ralphx/core/project_export.py +469 -0
- ralphx/core/project_import.py +670 -0
- ralphx/core/sample_project.py +430 -0
- ralphx/core/templates.py +46 -9
- ralphx/core/workflow_executor.py +35 -5
- ralphx/core/workflow_export.py +606 -0
- ralphx/core/workflow_import.py +1149 -0
- ralphx/examples/sample_project/DESIGN.md +345 -0
- ralphx/examples/sample_project/README.md +37 -0
- ralphx/examples/sample_project/guardrails.md +57 -0
- ralphx/examples/sample_project/stories.jsonl +10 -0
- ralphx/mcp/__init__.py +6 -2
- ralphx/mcp/registry.py +3 -3
- ralphx/mcp/server.py +99 -29
- ralphx/mcp/tools/__init__.py +4 -0
- ralphx/mcp/tools/help.py +204 -0
- ralphx/mcp/tools/workflows.py +114 -32
- ralphx/mcp_server.py +6 -2
- ralphx/static/assets/index-0ovNnfOq.css +1 -0
- ralphx/static/assets/index-CY9s08ZB.js +251 -0
- ralphx/static/assets/index-CY9s08ZB.js.map +1 -0
- ralphx/static/index.html +14 -0
- {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/METADATA +34 -12
- {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/RECORD +45 -30
- {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/WHEEL +0 -0
- {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/entry_points.txt +0 -0
ralphx/core/database.py
CHANGED
|
@@ -25,7 +25,7 @@ from ralphx.core.workspace import get_backups_path, get_database_path
|
|
|
25
25
|
# Schema version for migrations
|
|
26
26
|
# NOTE: When the initial schema is updated, also update this version and ensure
|
|
27
27
|
# migrations are idempotent (use IF EXISTS / IF NOT EXISTS clauses).
|
|
28
|
-
SCHEMA_VERSION =
|
|
28
|
+
SCHEMA_VERSION = 10
|
|
29
29
|
|
|
30
30
|
# SQL schema
|
|
31
31
|
SCHEMA_SQL = """
|
|
@@ -145,21 +145,46 @@ CREATE TABLE IF NOT EXISTS schema_version (
|
|
|
145
145
|
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
146
146
|
);
|
|
147
147
|
|
|
148
|
-
--
|
|
149
|
-
CREATE TABLE IF NOT EXISTS
|
|
148
|
+
-- Accounts table - stores all logged-in Claude accounts
|
|
149
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
150
150
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
email TEXT NOT NULL UNIQUE, -- Primary identifier
|
|
152
|
+
display_name TEXT, -- Optional friendly name
|
|
153
153
|
access_token TEXT NOT NULL,
|
|
154
154
|
refresh_token TEXT,
|
|
155
155
|
expires_at INTEGER NOT NULL, -- Unix timestamp (seconds)
|
|
156
|
-
|
|
157
|
-
scopes TEXT, -- JSON array of OAuth scopes (e.g., '["user:inference", "user:profile"]')
|
|
156
|
+
scopes TEXT, -- JSON array of OAuth scopes
|
|
158
157
|
subscription_type TEXT, -- Claude subscription tier ("free", "pro", "max")
|
|
159
158
|
rate_limit_tier TEXT, -- API rate limit tier (e.g., "default_claude_max_20x")
|
|
159
|
+
is_default BOOLEAN DEFAULT FALSE, -- User-selected default account
|
|
160
|
+
is_active BOOLEAN DEFAULT TRUE, -- Account enabled/disabled
|
|
161
|
+
is_deleted BOOLEAN DEFAULT FALSE, -- Soft delete for safety
|
|
162
|
+
last_used_at TIMESTAMP,
|
|
163
|
+
-- Usage cache (avoid N API calls)
|
|
164
|
+
cached_usage_5h REAL,
|
|
165
|
+
cached_usage_7d REAL,
|
|
166
|
+
cached_5h_resets_at TEXT, -- ISO timestamp string
|
|
167
|
+
cached_7d_resets_at TEXT, -- ISO timestamp string
|
|
168
|
+
usage_cached_at INTEGER, -- Unix timestamp when cache was updated
|
|
169
|
+
-- Error tracking
|
|
170
|
+
last_error TEXT,
|
|
171
|
+
last_error_at TIMESTAMP,
|
|
172
|
+
consecutive_failures INTEGER DEFAULT 0,
|
|
173
|
+
-- Token validation status
|
|
174
|
+
last_validated_at INTEGER, -- Unix timestamp of last validation check
|
|
175
|
+
validation_status TEXT DEFAULT 'unknown', -- 'unknown', 'valid', 'invalid', 'checking'
|
|
160
176
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
161
|
-
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
162
|
-
|
|
177
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
-- Project account assignments table - links projects to accounts
|
|
181
|
+
CREATE TABLE IF NOT EXISTS project_account_assignments (
|
|
182
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
183
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
184
|
+
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT, -- RESTRICT prevents deletion while assigned
|
|
185
|
+
allow_fallback BOOLEAN DEFAULT TRUE, -- Allow falling back to other accounts on usage limit
|
|
186
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
187
|
+
UNIQUE(project_id) -- One account per project
|
|
163
188
|
);
|
|
164
189
|
"""
|
|
165
190
|
|
|
@@ -177,6 +202,14 @@ CREATE INDEX IF NOT EXISTS idx_guardrails_project ON guardrails(project_id, enab
|
|
|
177
202
|
CREATE INDEX IF NOT EXISTS idx_guardrails_source ON guardrails(source);
|
|
178
203
|
CREATE INDEX IF NOT EXISTS idx_logs_run ON logs(run_id, timestamp);
|
|
179
204
|
CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(project_id, level, timestamp);
|
|
205
|
+
|
|
206
|
+
-- Accounts indexes
|
|
207
|
+
CREATE INDEX IF NOT EXISTS idx_accounts_active ON accounts(is_active, is_deleted);
|
|
208
|
+
CREATE INDEX IF NOT EXISTS idx_accounts_email ON accounts(email);
|
|
209
|
+
|
|
210
|
+
-- Project account assignments indexes
|
|
211
|
+
CREATE INDEX IF NOT EXISTS idx_assignments_project ON project_account_assignments(project_id);
|
|
212
|
+
CREATE INDEX IF NOT EXISTS idx_assignments_account ON project_account_assignments(account_id);
|
|
180
213
|
"""
|
|
181
214
|
|
|
182
215
|
|
|
@@ -278,6 +311,23 @@ class Database:
|
|
|
278
311
|
self._local.connection.close()
|
|
279
312
|
self._local.connection = None
|
|
280
313
|
|
|
314
|
+
def _parse_metadata(self, value: Any) -> Optional[dict]:
|
|
315
|
+
"""Safely parse metadata that may be JSON or plain text.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
value: Raw metadata value from database (may be JSON string, plain text, or None)
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Parsed dict if valid JSON, None otherwise
|
|
322
|
+
"""
|
|
323
|
+
if not value or not isinstance(value, str) or not value.strip():
|
|
324
|
+
return None
|
|
325
|
+
try:
|
|
326
|
+
return json.loads(value)
|
|
327
|
+
except (json.JSONDecodeError, ValueError):
|
|
328
|
+
# Not valid JSON - return None (legacy data may have plain strings)
|
|
329
|
+
return None
|
|
330
|
+
|
|
281
331
|
# =========================================================================
|
|
282
332
|
# Project Operations
|
|
283
333
|
# =========================================================================
|
|
@@ -1310,212 +1360,563 @@ class Database:
|
|
|
1310
1360
|
return cursor.rowcount > 0
|
|
1311
1361
|
|
|
1312
1362
|
# =========================================================================
|
|
1313
|
-
#
|
|
1363
|
+
# =========================================================================
|
|
1364
|
+
# Account Operations (Multi-Account Authentication)
|
|
1314
1365
|
# =========================================================================
|
|
1315
1366
|
|
|
1316
|
-
def
|
|
1367
|
+
def create_account(
|
|
1317
1368
|
self,
|
|
1318
|
-
|
|
1369
|
+
email: str,
|
|
1319
1370
|
access_token: str,
|
|
1320
1371
|
expires_at: int,
|
|
1321
1372
|
refresh_token: Optional[str] = None,
|
|
1322
|
-
|
|
1323
|
-
email: Optional[str] = None,
|
|
1373
|
+
display_name: Optional[str] = None,
|
|
1324
1374
|
scopes: Optional[str] = None,
|
|
1325
1375
|
subscription_type: Optional[str] = None,
|
|
1326
1376
|
rate_limit_tier: Optional[str] = None,
|
|
1327
|
-
) ->
|
|
1328
|
-
"""
|
|
1377
|
+
) -> dict:
|
|
1378
|
+
"""Create a new account or update if email already exists.
|
|
1329
1379
|
|
|
1330
1380
|
Args:
|
|
1331
|
-
|
|
1381
|
+
email: User email (unique identifier)
|
|
1332
1382
|
access_token: OAuth access token
|
|
1333
|
-
expires_at:
|
|
1334
|
-
refresh_token:
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
rate_limit_tier: Rate limit tier (e.g., 'default_claude_max_20x')
|
|
1383
|
+
expires_at: Token expiry as Unix timestamp
|
|
1384
|
+
refresh_token: OAuth refresh token
|
|
1385
|
+
display_name: Optional friendly name
|
|
1386
|
+
scopes: JSON string of OAuth scopes
|
|
1387
|
+
subscription_type: Claude subscription tier
|
|
1388
|
+
rate_limit_tier: API rate limit tier
|
|
1340
1389
|
|
|
1341
1390
|
Returns:
|
|
1342
|
-
|
|
1391
|
+
Account dict with all fields
|
|
1343
1392
|
"""
|
|
1344
1393
|
now = datetime.utcnow().isoformat()
|
|
1394
|
+
|
|
1345
1395
|
with self._writer() as conn:
|
|
1346
|
-
# Check
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
)
|
|
1353
|
-
else:
|
|
1354
|
-
cursor = conn.execute(
|
|
1355
|
-
"SELECT id FROM credentials WHERE scope = ? AND project_id = ?",
|
|
1356
|
-
(scope, project_id),
|
|
1357
|
-
)
|
|
1358
|
-
existing = cursor.fetchone()
|
|
1396
|
+
# Check if this is the first account (make it default)
|
|
1397
|
+
cursor = conn.execute(
|
|
1398
|
+
"SELECT COUNT(*) FROM accounts WHERE is_deleted = 0"
|
|
1399
|
+
)
|
|
1400
|
+
count = cursor.fetchone()[0]
|
|
1401
|
+
is_default = count == 0
|
|
1359
1402
|
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1403
|
+
# Use INSERT OR REPLACE to handle existing accounts
|
|
1404
|
+
cursor = conn.execute(
|
|
1405
|
+
"""INSERT INTO accounts (
|
|
1406
|
+
email, access_token, refresh_token, expires_at, display_name,
|
|
1407
|
+
scopes, subscription_type, rate_limit_tier, is_default,
|
|
1408
|
+
is_active, is_deleted, created_at, updated_at
|
|
1409
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?)
|
|
1410
|
+
ON CONFLICT(email) DO UPDATE SET
|
|
1411
|
+
access_token = excluded.access_token,
|
|
1412
|
+
refresh_token = excluded.refresh_token,
|
|
1413
|
+
expires_at = excluded.expires_at,
|
|
1414
|
+
scopes = excluded.scopes,
|
|
1415
|
+
subscription_type = excluded.subscription_type,
|
|
1416
|
+
rate_limit_tier = excluded.rate_limit_tier,
|
|
1417
|
+
is_active = 1,
|
|
1418
|
+
is_deleted = 0,
|
|
1419
|
+
updated_at = excluded.updated_at
|
|
1420
|
+
""",
|
|
1421
|
+
(
|
|
1422
|
+
email,
|
|
1423
|
+
access_token,
|
|
1424
|
+
refresh_token,
|
|
1425
|
+
expires_at,
|
|
1426
|
+
display_name,
|
|
1427
|
+
scopes,
|
|
1428
|
+
subscription_type,
|
|
1429
|
+
rate_limit_tier,
|
|
1430
|
+
is_default,
|
|
1431
|
+
now,
|
|
1432
|
+
now,
|
|
1433
|
+
),
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
# Get the account
|
|
1437
|
+
cursor = conn.execute(
|
|
1438
|
+
"SELECT * FROM accounts WHERE email = ?", (email,)
|
|
1439
|
+
)
|
|
1440
|
+
row = cursor.fetchone()
|
|
1441
|
+
return dict(row) if row else {}
|
|
1387
1442
|
|
|
1388
|
-
def
|
|
1389
|
-
"""Get
|
|
1443
|
+
def get_account(self, account_id: int) -> Optional[dict]:
|
|
1444
|
+
"""Get an account by ID.
|
|
1390
1445
|
|
|
1391
1446
|
Args:
|
|
1392
|
-
|
|
1447
|
+
account_id: Account ID
|
|
1393
1448
|
|
|
1394
1449
|
Returns:
|
|
1395
|
-
|
|
1450
|
+
Account dict or None
|
|
1396
1451
|
"""
|
|
1397
1452
|
with self._reader() as conn:
|
|
1398
|
-
# Check project-specific first if project_id provided
|
|
1399
|
-
if project_id:
|
|
1400
|
-
cursor = conn.execute(
|
|
1401
|
-
"SELECT * FROM credentials WHERE scope = 'project' AND project_id = ?",
|
|
1402
|
-
(project_id,),
|
|
1403
|
-
)
|
|
1404
|
-
row = cursor.fetchone()
|
|
1405
|
-
if row:
|
|
1406
|
-
return dict(row)
|
|
1407
|
-
|
|
1408
|
-
# Fall back to global
|
|
1409
1453
|
cursor = conn.execute(
|
|
1410
|
-
"SELECT * FROM
|
|
1454
|
+
"SELECT * FROM accounts WHERE id = ? AND is_deleted = 0",
|
|
1455
|
+
(account_id,),
|
|
1411
1456
|
)
|
|
1412
1457
|
row = cursor.fetchone()
|
|
1413
1458
|
return dict(row) if row else None
|
|
1414
1459
|
|
|
1415
|
-
def
|
|
1416
|
-
|
|
1417
|
-
) -> Optional[dict]:
|
|
1418
|
-
"""Get credentials for exact scope match (no fallback).
|
|
1419
|
-
|
|
1420
|
-
Unlike get_credentials(), this returns only the exact scope requested
|
|
1421
|
-
without falling back to global credentials.
|
|
1460
|
+
def get_account_by_email(self, email: str) -> Optional[dict]:
|
|
1461
|
+
"""Get an account by email.
|
|
1422
1462
|
|
|
1423
1463
|
Args:
|
|
1424
|
-
|
|
1425
|
-
project_id: Project ID (required if scope is 'project')
|
|
1464
|
+
email: Account email
|
|
1426
1465
|
|
|
1427
1466
|
Returns:
|
|
1428
|
-
|
|
1467
|
+
Account dict or None
|
|
1429
1468
|
"""
|
|
1430
1469
|
with self._reader() as conn:
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
)
|
|
1436
|
-
elif scope == "global":
|
|
1437
|
-
cursor = conn.execute(
|
|
1438
|
-
"SELECT * FROM credentials WHERE scope = 'global' AND project_id IS NULL"
|
|
1439
|
-
)
|
|
1440
|
-
else:
|
|
1441
|
-
return None
|
|
1442
|
-
row = cursor.fetchone()
|
|
1443
|
-
return dict(row) if row else None
|
|
1444
|
-
|
|
1445
|
-
def get_credentials_by_id(self, id: int) -> Optional[dict]:
|
|
1446
|
-
"""Get credentials by ID."""
|
|
1447
|
-
with self._reader() as conn:
|
|
1448
|
-
cursor = conn.execute("SELECT * FROM credentials WHERE id = ?", (id,))
|
|
1470
|
+
cursor = conn.execute(
|
|
1471
|
+
"SELECT * FROM accounts WHERE email = ? AND is_deleted = 0",
|
|
1472
|
+
(email,),
|
|
1473
|
+
)
|
|
1449
1474
|
row = cursor.fetchone()
|
|
1450
1475
|
return dict(row) if row else None
|
|
1451
1476
|
|
|
1452
|
-
def
|
|
1453
|
-
|
|
1477
|
+
def list_accounts(
|
|
1478
|
+
self, include_inactive: bool = False, include_deleted: bool = False
|
|
1479
|
+
) -> list[dict]:
|
|
1480
|
+
"""List all accounts.
|
|
1454
1481
|
|
|
1455
|
-
|
|
1482
|
+
Args:
|
|
1483
|
+
include_inactive: Include disabled accounts
|
|
1484
|
+
include_deleted: Include soft-deleted accounts
|
|
1456
1485
|
|
|
1457
1486
|
Returns:
|
|
1458
|
-
List of
|
|
1487
|
+
List of account dicts
|
|
1459
1488
|
"""
|
|
1489
|
+
conditions = []
|
|
1490
|
+
if not include_deleted:
|
|
1491
|
+
conditions.append("is_deleted = 0")
|
|
1492
|
+
if not include_inactive:
|
|
1493
|
+
conditions.append("is_active = 1")
|
|
1494
|
+
|
|
1495
|
+
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
1496
|
+
|
|
1460
1497
|
with self._reader() as conn:
|
|
1461
|
-
cursor = conn.execute(
|
|
1498
|
+
cursor = conn.execute(
|
|
1499
|
+
f"SELECT * FROM accounts {where} ORDER BY is_default DESC, created_at ASC"
|
|
1500
|
+
)
|
|
1462
1501
|
rows = cursor.fetchall()
|
|
1463
1502
|
return [dict(row) for row in rows]
|
|
1464
1503
|
|
|
1465
|
-
# Allowed columns for
|
|
1466
|
-
|
|
1467
|
-
"
|
|
1468
|
-
"scopes", "subscription_type", "rate_limit_tier"
|
|
1504
|
+
# Allowed columns for account update operations
|
|
1505
|
+
_ACCOUNT_UPDATE_COLS = frozenset({
|
|
1506
|
+
"display_name", "access_token", "refresh_token", "expires_at",
|
|
1507
|
+
"scopes", "subscription_type", "rate_limit_tier", "is_active",
|
|
1508
|
+
"last_used_at", "cached_usage_5h", "cached_usage_7d",
|
|
1509
|
+
"cached_5h_resets_at", "cached_7d_resets_at", "usage_cached_at",
|
|
1510
|
+
"last_error", "last_error_at", "consecutive_failures",
|
|
1511
|
+
"last_validated_at", "validation_status",
|
|
1469
1512
|
})
|
|
1470
1513
|
|
|
1471
|
-
def
|
|
1472
|
-
"""Update
|
|
1514
|
+
def update_account(self, account_id: int, **kwargs) -> bool:
|
|
1515
|
+
"""Update an account by ID.
|
|
1516
|
+
|
|
1517
|
+
Args:
|
|
1518
|
+
account_id: Account ID
|
|
1519
|
+
**kwargs: Fields to update
|
|
1520
|
+
|
|
1521
|
+
Returns:
|
|
1522
|
+
True if updated, False otherwise
|
|
1473
1523
|
|
|
1474
1524
|
Raises:
|
|
1475
|
-
ValueError: If invalid column names are provided
|
|
1525
|
+
ValueError: If invalid column names are provided
|
|
1476
1526
|
"""
|
|
1477
1527
|
if not kwargs:
|
|
1478
1528
|
return False
|
|
1479
1529
|
|
|
1480
1530
|
# Security: validate column names against whitelist
|
|
1481
|
-
invalid_cols = set(kwargs.keys()) - self.
|
|
1531
|
+
invalid_cols = set(kwargs.keys()) - self._ACCOUNT_UPDATE_COLS - {"updated_at"}
|
|
1482
1532
|
if invalid_cols:
|
|
1483
|
-
raise ValueError(f"Invalid columns for
|
|
1533
|
+
raise ValueError(f"Invalid columns for account update: {invalid_cols}")
|
|
1484
1534
|
|
|
1485
1535
|
kwargs["updated_at"] = datetime.utcnow().isoformat()
|
|
1486
1536
|
set_clause = ", ".join(f"{k} = ?" for k in kwargs.keys())
|
|
1487
|
-
values = list(kwargs.values()) + [
|
|
1537
|
+
values = list(kwargs.values()) + [account_id]
|
|
1538
|
+
|
|
1539
|
+
with self._writer() as conn:
|
|
1540
|
+
cursor = conn.execute(
|
|
1541
|
+
f"UPDATE accounts SET {set_clause} WHERE id = ? AND is_deleted = 0",
|
|
1542
|
+
values,
|
|
1543
|
+
)
|
|
1544
|
+
return cursor.rowcount > 0
|
|
1545
|
+
|
|
1546
|
+
def delete_account(self, account_id: int, hard_delete: bool = False) -> bool:
|
|
1547
|
+
"""Delete an account (soft delete by default).
|
|
1548
|
+
|
|
1549
|
+
Args:
|
|
1550
|
+
account_id: Account ID
|
|
1551
|
+
hard_delete: If True, permanently delete instead of soft delete
|
|
1552
|
+
|
|
1553
|
+
Returns:
|
|
1554
|
+
True if deleted, False otherwise
|
|
1555
|
+
"""
|
|
1556
|
+
with self._writer() as conn:
|
|
1557
|
+
# Check if account has project assignments (ON DELETE RESTRICT)
|
|
1558
|
+
cursor = conn.execute(
|
|
1559
|
+
"SELECT COUNT(*) FROM project_account_assignments WHERE account_id = ?",
|
|
1560
|
+
(account_id,),
|
|
1561
|
+
)
|
|
1562
|
+
if cursor.fetchone()[0] > 0:
|
|
1563
|
+
raise ValueError(
|
|
1564
|
+
"Cannot delete account: still assigned to projects. "
|
|
1565
|
+
"Reassign or remove project assignments first."
|
|
1566
|
+
)
|
|
1567
|
+
|
|
1568
|
+
if hard_delete:
|
|
1569
|
+
cursor = conn.execute(
|
|
1570
|
+
"DELETE FROM accounts WHERE id = ?", (account_id,)
|
|
1571
|
+
)
|
|
1572
|
+
else:
|
|
1573
|
+
cursor = conn.execute(
|
|
1574
|
+
"""UPDATE accounts
|
|
1575
|
+
SET is_deleted = 1, is_default = 0, updated_at = ?
|
|
1576
|
+
WHERE id = ?""",
|
|
1577
|
+
(datetime.utcnow().isoformat(), account_id),
|
|
1578
|
+
)
|
|
1579
|
+
return cursor.rowcount > 0
|
|
1580
|
+
|
|
1581
|
+
def set_default_account(self, account_id: int) -> bool:
|
|
1582
|
+
"""Set an account as the default.
|
|
1583
|
+
|
|
1584
|
+
Args:
|
|
1585
|
+
account_id: Account ID to set as default
|
|
1586
|
+
|
|
1587
|
+
Returns:
|
|
1588
|
+
True if updated, False otherwise
|
|
1589
|
+
"""
|
|
1590
|
+
now = datetime.utcnow().isoformat()
|
|
1591
|
+
|
|
1592
|
+
with self._writer() as conn:
|
|
1593
|
+
# Check account exists and is active
|
|
1594
|
+
cursor = conn.execute(
|
|
1595
|
+
"SELECT is_active FROM accounts WHERE id = ? AND is_deleted = 0",
|
|
1596
|
+
(account_id,),
|
|
1597
|
+
)
|
|
1598
|
+
row = cursor.fetchone()
|
|
1599
|
+
if not row or not row["is_active"]:
|
|
1600
|
+
return False
|
|
1601
|
+
|
|
1602
|
+
# Clear existing default
|
|
1603
|
+
conn.execute(
|
|
1604
|
+
"UPDATE accounts SET is_default = 0, updated_at = ? WHERE is_default = 1",
|
|
1605
|
+
(now,),
|
|
1606
|
+
)
|
|
1607
|
+
|
|
1608
|
+
# Set new default
|
|
1609
|
+
cursor = conn.execute(
|
|
1610
|
+
"UPDATE accounts SET is_default = 1, updated_at = ? WHERE id = ?",
|
|
1611
|
+
(now, account_id),
|
|
1612
|
+
)
|
|
1613
|
+
return cursor.rowcount > 0
|
|
1614
|
+
|
|
1615
|
+
def get_default_account(self) -> Optional[dict]:
|
|
1616
|
+
"""Get the default account.
|
|
1617
|
+
|
|
1618
|
+
Returns:
|
|
1619
|
+
Account dict or None
|
|
1620
|
+
"""
|
|
1621
|
+
with self._reader() as conn:
|
|
1622
|
+
cursor = conn.execute(
|
|
1623
|
+
"SELECT * FROM accounts WHERE is_default = 1 AND is_active = 1 AND is_deleted = 0"
|
|
1624
|
+
)
|
|
1625
|
+
row = cursor.fetchone()
|
|
1626
|
+
return dict(row) if row else None
|
|
1627
|
+
|
|
1628
|
+
def get_first_active_account(self) -> Optional[dict]:
|
|
1629
|
+
"""Get the first active account (fallback when no default).
|
|
1630
|
+
|
|
1631
|
+
Returns:
|
|
1632
|
+
Account dict or None
|
|
1633
|
+
"""
|
|
1634
|
+
with self._reader() as conn:
|
|
1635
|
+
cursor = conn.execute(
|
|
1636
|
+
"""SELECT * FROM accounts
|
|
1637
|
+
WHERE is_active = 1 AND is_deleted = 0
|
|
1638
|
+
ORDER BY created_at ASC LIMIT 1"""
|
|
1639
|
+
)
|
|
1640
|
+
row = cursor.fetchone()
|
|
1641
|
+
return dict(row) if row else None
|
|
1642
|
+
|
|
1643
|
+
def count_projects_using_account(self, account_id: int) -> int:
|
|
1644
|
+
"""Count how many projects are assigned to an account.
|
|
1645
|
+
|
|
1646
|
+
Args:
|
|
1647
|
+
account_id: Account ID
|
|
1648
|
+
|
|
1649
|
+
Returns:
|
|
1650
|
+
Number of projects using this account
|
|
1651
|
+
"""
|
|
1652
|
+
with self._reader() as conn:
|
|
1653
|
+
cursor = conn.execute(
|
|
1654
|
+
"SELECT COUNT(*) FROM project_account_assignments WHERE account_id = ?",
|
|
1655
|
+
(account_id,),
|
|
1656
|
+
)
|
|
1657
|
+
return cursor.fetchone()[0]
|
|
1658
|
+
|
|
1659
|
+
# =========================================================================
|
|
1660
|
+
# Project Account Assignment Operations
|
|
1661
|
+
# =========================================================================
|
|
1662
|
+
|
|
1663
|
+
def assign_account_to_project(
|
|
1664
|
+
self,
|
|
1665
|
+
project_id: str,
|
|
1666
|
+
account_id: int,
|
|
1667
|
+
allow_fallback: bool = True,
|
|
1668
|
+
) -> dict:
|
|
1669
|
+
"""Assign an account to a project.
|
|
1670
|
+
|
|
1671
|
+
Args:
|
|
1672
|
+
project_id: Project ID
|
|
1673
|
+
account_id: Account ID
|
|
1674
|
+
allow_fallback: Allow fallback to other accounts on rate limit
|
|
1675
|
+
|
|
1676
|
+
Returns:
|
|
1677
|
+
Assignment dict
|
|
1678
|
+
"""
|
|
1679
|
+
now = datetime.utcnow().isoformat()
|
|
1680
|
+
|
|
1681
|
+
with self._writer() as conn:
|
|
1682
|
+
# Verify account exists and is active
|
|
1683
|
+
cursor = conn.execute(
|
|
1684
|
+
"SELECT id FROM accounts WHERE id = ? AND is_active = 1 AND is_deleted = 0",
|
|
1685
|
+
(account_id,),
|
|
1686
|
+
)
|
|
1687
|
+
if not cursor.fetchone():
|
|
1688
|
+
raise ValueError(f"Account {account_id} not found or inactive")
|
|
1689
|
+
|
|
1690
|
+
# Upsert assignment
|
|
1691
|
+
cursor = conn.execute(
|
|
1692
|
+
"""INSERT INTO project_account_assignments (
|
|
1693
|
+
project_id, account_id, allow_fallback, created_at
|
|
1694
|
+
) VALUES (?, ?, ?, ?)
|
|
1695
|
+
ON CONFLICT(project_id) DO UPDATE SET
|
|
1696
|
+
account_id = excluded.account_id,
|
|
1697
|
+
allow_fallback = excluded.allow_fallback
|
|
1698
|
+
""",
|
|
1699
|
+
(project_id, account_id, allow_fallback, now),
|
|
1700
|
+
)
|
|
1701
|
+
|
|
1702
|
+
# Return the assignment
|
|
1703
|
+
cursor = conn.execute(
|
|
1704
|
+
"SELECT * FROM project_account_assignments WHERE project_id = ?",
|
|
1705
|
+
(project_id,),
|
|
1706
|
+
)
|
|
1707
|
+
row = cursor.fetchone()
|
|
1708
|
+
return dict(row) if row else {}
|
|
1709
|
+
|
|
1710
|
+
def get_project_account_assignment(self, project_id: str) -> Optional[dict]:
|
|
1711
|
+
"""Get the account assignment for a project.
|
|
1712
|
+
|
|
1713
|
+
Args:
|
|
1714
|
+
project_id: Project ID
|
|
1715
|
+
|
|
1716
|
+
Returns:
|
|
1717
|
+
Assignment dict or None
|
|
1718
|
+
"""
|
|
1719
|
+
with self._reader() as conn:
|
|
1720
|
+
cursor = conn.execute(
|
|
1721
|
+
"""SELECT paa.*, a.email, a.display_name, a.subscription_type,
|
|
1722
|
+
a.is_active, a.is_default,
|
|
1723
|
+
a.cached_usage_5h, a.cached_usage_7d,
|
|
1724
|
+
a.cached_5h_resets_at, a.cached_7d_resets_at,
|
|
1725
|
+
a.usage_cached_at, a.expires_at
|
|
1726
|
+
FROM project_account_assignments paa
|
|
1727
|
+
JOIN accounts a ON a.id = paa.account_id
|
|
1728
|
+
WHERE paa.project_id = ? AND a.is_deleted = 0""",
|
|
1729
|
+
(project_id,),
|
|
1730
|
+
)
|
|
1731
|
+
row = cursor.fetchone()
|
|
1732
|
+
return dict(row) if row else None
|
|
1733
|
+
|
|
1734
|
+
def unassign_account_from_project(self, project_id: str) -> bool:
|
|
1735
|
+
"""Remove account assignment from a project.
|
|
1488
1736
|
|
|
1737
|
+
Args:
|
|
1738
|
+
project_id: Project ID
|
|
1739
|
+
|
|
1740
|
+
Returns:
|
|
1741
|
+
True if removed, False if not found
|
|
1742
|
+
"""
|
|
1489
1743
|
with self._writer() as conn:
|
|
1490
1744
|
cursor = conn.execute(
|
|
1491
|
-
|
|
1745
|
+
"DELETE FROM project_account_assignments WHERE project_id = ?",
|
|
1746
|
+
(project_id,),
|
|
1492
1747
|
)
|
|
1493
1748
|
return cursor.rowcount > 0
|
|
1494
1749
|
|
|
1495
|
-
def
|
|
1496
|
-
|
|
1750
|
+
def get_effective_account(self, project_id: Optional[str] = None) -> Optional[dict]:
|
|
1751
|
+
"""Get the effective account for a project.
|
|
1752
|
+
|
|
1753
|
+
Resolution order:
|
|
1754
|
+
1. If project has assignment -> use that account
|
|
1755
|
+
2. Else -> use default account
|
|
1756
|
+
3. If no default -> use first active account
|
|
1757
|
+
|
|
1758
|
+
Args:
|
|
1759
|
+
project_id: Optional project ID
|
|
1760
|
+
|
|
1761
|
+
Returns:
|
|
1762
|
+
Account dict or None
|
|
1763
|
+
"""
|
|
1764
|
+
# Check project assignment first
|
|
1765
|
+
if project_id:
|
|
1766
|
+
assignment = self.get_project_account_assignment(project_id)
|
|
1767
|
+
if assignment and assignment.get("is_active"):
|
|
1768
|
+
account = self.get_account(assignment["account_id"])
|
|
1769
|
+
if account:
|
|
1770
|
+
return account
|
|
1771
|
+
|
|
1772
|
+
# Fall back to default account
|
|
1773
|
+
account = self.get_default_account()
|
|
1774
|
+
if account:
|
|
1775
|
+
return account
|
|
1776
|
+
|
|
1777
|
+
# Last resort: first active account
|
|
1778
|
+
return self.get_first_active_account()
|
|
1779
|
+
|
|
1780
|
+
def get_fallback_account(
|
|
1781
|
+
self,
|
|
1782
|
+
exclude_ids: Optional[list[int]] = None,
|
|
1783
|
+
prefer_lowest_usage: bool = True,
|
|
1784
|
+
) -> Optional[dict]:
|
|
1785
|
+
"""Get a fallback account (for 429 rate limit handling).
|
|
1786
|
+
|
|
1787
|
+
Args:
|
|
1788
|
+
exclude_ids: Account IDs to exclude (already failed)
|
|
1789
|
+
prefer_lowest_usage: Sort by lowest cached usage
|
|
1790
|
+
|
|
1791
|
+
Returns:
|
|
1792
|
+
Account dict or None
|
|
1793
|
+
"""
|
|
1794
|
+
exclude_ids = exclude_ids or []
|
|
1795
|
+
|
|
1796
|
+
with self._reader() as conn:
|
|
1797
|
+
placeholders = ",".join("?" for _ in exclude_ids) if exclude_ids else ""
|
|
1798
|
+
exclude_clause = f"AND id NOT IN ({placeholders})" if exclude_ids else ""
|
|
1799
|
+
|
|
1800
|
+
# Order by lowest usage (5h usage takes priority)
|
|
1801
|
+
order_clause = """
|
|
1802
|
+
ORDER BY
|
|
1803
|
+
COALESCE(cached_usage_5h, 0) ASC,
|
|
1804
|
+
COALESCE(cached_usage_7d, 0) ASC,
|
|
1805
|
+
consecutive_failures ASC,
|
|
1806
|
+
created_at ASC
|
|
1807
|
+
""" if prefer_lowest_usage else "ORDER BY created_at ASC"
|
|
1808
|
+
|
|
1809
|
+
cursor = conn.execute(
|
|
1810
|
+
f"""SELECT * FROM accounts
|
|
1811
|
+
WHERE is_active = 1
|
|
1812
|
+
AND is_deleted = 0
|
|
1813
|
+
AND consecutive_failures < 3
|
|
1814
|
+
{exclude_clause}
|
|
1815
|
+
{order_clause}
|
|
1816
|
+
LIMIT 1""",
|
|
1817
|
+
exclude_ids,
|
|
1818
|
+
)
|
|
1819
|
+
row = cursor.fetchone()
|
|
1820
|
+
return dict(row) if row else None
|
|
1821
|
+
|
|
1822
|
+
def update_account_usage_cache(
|
|
1823
|
+
self,
|
|
1824
|
+
account_id: int,
|
|
1825
|
+
five_hour: Optional[float] = None,
|
|
1826
|
+
seven_day: Optional[float] = None,
|
|
1827
|
+
five_hour_resets_at: Optional[str] = None,
|
|
1828
|
+
seven_day_resets_at: Optional[str] = None,
|
|
1829
|
+
) -> bool:
|
|
1830
|
+
"""Update the cached usage data for an account.
|
|
1831
|
+
|
|
1832
|
+
Args:
|
|
1833
|
+
account_id: Account ID
|
|
1834
|
+
five_hour: 5-hour utilization percentage (0-100)
|
|
1835
|
+
seven_day: 7-day utilization percentage (0-100)
|
|
1836
|
+
five_hour_resets_at: ISO timestamp when 5h limit resets
|
|
1837
|
+
seven_day_resets_at: ISO timestamp when 7d limit resets
|
|
1838
|
+
|
|
1839
|
+
Returns:
|
|
1840
|
+
True if updated, False otherwise
|
|
1841
|
+
"""
|
|
1842
|
+
import time
|
|
1843
|
+
|
|
1844
|
+
updates = {"usage_cached_at": int(time.time())}
|
|
1845
|
+
if five_hour is not None:
|
|
1846
|
+
updates["cached_usage_5h"] = five_hour
|
|
1847
|
+
if seven_day is not None:
|
|
1848
|
+
updates["cached_usage_7d"] = seven_day
|
|
1849
|
+
if five_hour_resets_at is not None:
|
|
1850
|
+
updates["cached_5h_resets_at"] = five_hour_resets_at
|
|
1851
|
+
if seven_day_resets_at is not None:
|
|
1852
|
+
updates["cached_7d_resets_at"] = seven_day_resets_at
|
|
1853
|
+
|
|
1854
|
+
return self.update_account(account_id, **updates)
|
|
1855
|
+
|
|
1856
|
+
def record_account_error(
|
|
1857
|
+
self,
|
|
1858
|
+
account_id: int,
|
|
1859
|
+
error_message: str,
|
|
1860
|
+
increment_failures: bool = True,
|
|
1497
1861
|
) -> bool:
|
|
1498
|
-
"""
|
|
1862
|
+
"""Record an error for an account.
|
|
1499
1863
|
|
|
1500
1864
|
Args:
|
|
1501
|
-
|
|
1502
|
-
|
|
1865
|
+
account_id: Account ID
|
|
1866
|
+
error_message: Error message
|
|
1867
|
+
increment_failures: Whether to increment consecutive_failures
|
|
1503
1868
|
|
|
1504
1869
|
Returns:
|
|
1505
|
-
True if
|
|
1870
|
+
True if updated, False otherwise
|
|
1506
1871
|
"""
|
|
1872
|
+
now = datetime.utcnow().isoformat()
|
|
1873
|
+
|
|
1507
1874
|
with self._writer() as conn:
|
|
1508
|
-
if
|
|
1875
|
+
if increment_failures:
|
|
1509
1876
|
cursor = conn.execute(
|
|
1510
|
-
"
|
|
1511
|
-
|
|
1877
|
+
"""UPDATE accounts SET
|
|
1878
|
+
last_error = ?,
|
|
1879
|
+
last_error_at = ?,
|
|
1880
|
+
consecutive_failures = consecutive_failures + 1,
|
|
1881
|
+
updated_at = ?
|
|
1882
|
+
WHERE id = ?""",
|
|
1883
|
+
(error_message, now, now, account_id),
|
|
1512
1884
|
)
|
|
1513
1885
|
else:
|
|
1514
1886
|
cursor = conn.execute(
|
|
1515
|
-
"
|
|
1887
|
+
"""UPDATE accounts SET
|
|
1888
|
+
last_error = ?,
|
|
1889
|
+
last_error_at = ?,
|
|
1890
|
+
updated_at = ?
|
|
1891
|
+
WHERE id = ?""",
|
|
1892
|
+
(error_message, now, now, account_id),
|
|
1516
1893
|
)
|
|
1517
1894
|
return cursor.rowcount > 0
|
|
1518
1895
|
|
|
1896
|
+
def clear_account_errors(self, account_id: int) -> bool:
|
|
1897
|
+
"""Clear error state for an account (on successful use).
|
|
1898
|
+
|
|
1899
|
+
Args:
|
|
1900
|
+
account_id: Account ID
|
|
1901
|
+
|
|
1902
|
+
Returns:
|
|
1903
|
+
True if updated, False otherwise
|
|
1904
|
+
"""
|
|
1905
|
+
now = datetime.utcnow().isoformat()
|
|
1906
|
+
|
|
1907
|
+
with self._writer() as conn:
|
|
1908
|
+
cursor = conn.execute(
|
|
1909
|
+
"""UPDATE accounts SET
|
|
1910
|
+
last_error = NULL,
|
|
1911
|
+
last_error_at = NULL,
|
|
1912
|
+
consecutive_failures = 0,
|
|
1913
|
+
last_used_at = ?,
|
|
1914
|
+
updated_at = ?
|
|
1915
|
+
WHERE id = ?""",
|
|
1916
|
+
(now, now, account_id),
|
|
1917
|
+
)
|
|
1918
|
+
return cursor.rowcount > 0
|
|
1919
|
+
|
|
1519
1920
|
# =========================================================================
|
|
1520
1921
|
# Log Operations
|
|
1521
1922
|
# =========================================================================
|
|
@@ -1636,7 +2037,7 @@ class Database:
|
|
|
1636
2037
|
"message": row[4],
|
|
1637
2038
|
"project_id": row[5],
|
|
1638
2039
|
"run_id": row[6],
|
|
1639
|
-
"metadata":
|
|
2040
|
+
"metadata": self._parse_metadata(row[7]),
|
|
1640
2041
|
"timestamp": row[8],
|
|
1641
2042
|
}
|
|
1642
2043
|
for row in rows
|
|
@@ -1817,29 +2218,16 @@ class Database:
|
|
|
1817
2218
|
CREATE INDEX IF NOT EXISTS idx_work_items_claimed
|
|
1818
2219
|
ON work_items(project_id, claimed_by, claimed_at);
|
|
1819
2220
|
"""),
|
|
1820
|
-
# Migration to v3:
|
|
1821
|
-
(3, """
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
scope TEXT NOT NULL,
|
|
1825
|
-
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
1826
|
-
access_token TEXT NOT NULL,
|
|
1827
|
-
refresh_token TEXT,
|
|
1828
|
-
expires_at INTEGER NOT NULL,
|
|
1829
|
-
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
1830
|
-
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
1831
|
-
UNIQUE(scope, project_id)
|
|
1832
|
-
);
|
|
1833
|
-
"""),
|
|
1834
|
-
# Migration to v4: Add email column to credentials
|
|
1835
|
-
(4, """
|
|
1836
|
-
ALTER TABLE credentials ADD COLUMN email TEXT;
|
|
1837
|
-
"""),
|
|
2221
|
+
# Migration to v3: (Legacy - credentials table removed)
|
|
2222
|
+
(3, """SELECT 1;"""),
|
|
2223
|
+
# Migration to v4: (Legacy - credentials table removed)
|
|
2224
|
+
(4, """SELECT 1;"""),
|
|
1838
2225
|
# Migration to v5: Add category and event columns to logs for structured logging
|
|
1839
2226
|
(5, """
|
|
1840
2227
|
-- Add category and event columns for structured logging
|
|
1841
2228
|
ALTER TABLE logs ADD COLUMN category TEXT DEFAULT 'system';
|
|
1842
2229
|
ALTER TABLE logs ADD COLUMN event TEXT DEFAULT 'log';
|
|
2230
|
+
ALTER TABLE logs ADD COLUMN project_id TEXT;
|
|
1843
2231
|
|
|
1844
2232
|
-- Indexes for common query patterns
|
|
1845
2233
|
CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp DESC);
|
|
@@ -1852,17 +2240,8 @@ class Database:
|
|
|
1852
2240
|
-- Composite index for run drilldown (all logs for a run, ordered)
|
|
1853
2241
|
CREATE INDEX IF NOT EXISTS idx_logs_run_time ON logs(run_id, timestamp);
|
|
1854
2242
|
"""),
|
|
1855
|
-
# Migration to v6:
|
|
1856
|
-
|
|
1857
|
-
# - scopes: JSON array of OAuth scopes (e.g., ["user:inference", "user:profile"])
|
|
1858
|
-
# - subscription_type: Claude subscription tier ("free", "pro", "max")
|
|
1859
|
-
# - rate_limit_tier: API rate limit tier (e.g., "default_claude_max_20x")
|
|
1860
|
-
(6, """
|
|
1861
|
-
-- Add OAuth metadata columns for full Claude Code credential compatibility
|
|
1862
|
-
ALTER TABLE credentials ADD COLUMN scopes TEXT;
|
|
1863
|
-
ALTER TABLE credentials ADD COLUMN subscription_type TEXT;
|
|
1864
|
-
ALTER TABLE credentials ADD COLUMN rate_limit_tier TEXT;
|
|
1865
|
-
"""),
|
|
2243
|
+
# Migration to v6: (Legacy - credentials table removed)
|
|
2244
|
+
(6, """SELECT 1;"""),
|
|
1866
2245
|
# Migration to v7: Rename source_loop to namespace for clarity
|
|
1867
2246
|
# NOTE: For databases created after this migration was added, the column
|
|
1868
2247
|
# is already named 'namespace' in the initial schema. This migration only
|
|
@@ -1892,13 +2271,82 @@ class Database:
|
|
|
1892
2271
|
metadata TEXT,
|
|
1893
2272
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
1894
2273
|
);
|
|
1895
|
-
|
|
2274
|
+
-- Explicitly map columns (old schema may not have all columns)
|
|
2275
|
+
INSERT INTO logs_new (id, project_id, run_id, level, category, event, message, metadata, timestamp)
|
|
2276
|
+
SELECT id, project_id, run_id, level, category, event, message, metadata, timestamp FROM logs;
|
|
1896
2277
|
DROP TABLE logs;
|
|
1897
2278
|
ALTER TABLE logs_new RENAME TO logs;
|
|
1898
2279
|
|
|
1899
|
-
-- Recreate
|
|
2280
|
+
-- Recreate indexes on logs
|
|
1900
2281
|
CREATE INDEX IF NOT EXISTS idx_logs_project ON logs(project_id);
|
|
1901
2282
|
CREATE INDEX IF NOT EXISTS idx_logs_run ON logs(run_id);
|
|
2283
|
+
CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp DESC);
|
|
2284
|
+
CREATE INDEX IF NOT EXISTS idx_logs_category ON logs(category);
|
|
2285
|
+
CREATE INDEX IF NOT EXISTS idx_logs_cat_event ON logs(category, event, timestamp DESC);
|
|
2286
|
+
CREATE INDEX IF NOT EXISTS idx_logs_run_time ON logs(run_id, timestamp);
|
|
2287
|
+
"""),
|
|
2288
|
+
# Migration to v9: Add multi-account authentication tables
|
|
2289
|
+
# accounts: stores all logged-in Claude accounts
|
|
2290
|
+
# project_account_assignments: links projects to accounts
|
|
2291
|
+
(9, """
|
|
2292
|
+
-- Create accounts table
|
|
2293
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
2294
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2295
|
+
email TEXT NOT NULL UNIQUE,
|
|
2296
|
+
display_name TEXT,
|
|
2297
|
+
access_token TEXT NOT NULL,
|
|
2298
|
+
refresh_token TEXT,
|
|
2299
|
+
expires_at INTEGER NOT NULL,
|
|
2300
|
+
scopes TEXT,
|
|
2301
|
+
subscription_type TEXT,
|
|
2302
|
+
rate_limit_tier TEXT,
|
|
2303
|
+
is_default BOOLEAN DEFAULT FALSE,
|
|
2304
|
+
is_active BOOLEAN DEFAULT TRUE,
|
|
2305
|
+
is_deleted BOOLEAN DEFAULT FALSE,
|
|
2306
|
+
last_used_at TIMESTAMP,
|
|
2307
|
+
cached_usage_5h REAL,
|
|
2308
|
+
cached_usage_7d REAL,
|
|
2309
|
+
cached_5h_resets_at TEXT,
|
|
2310
|
+
cached_7d_resets_at TEXT,
|
|
2311
|
+
usage_cached_at INTEGER,
|
|
2312
|
+
last_error TEXT,
|
|
2313
|
+
last_error_at TIMESTAMP,
|
|
2314
|
+
consecutive_failures INTEGER DEFAULT 0,
|
|
2315
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
2316
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
2317
|
+
);
|
|
2318
|
+
|
|
2319
|
+
-- Create project account assignments table
|
|
2320
|
+
CREATE TABLE IF NOT EXISTS project_account_assignments (
|
|
2321
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2322
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
2323
|
+
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT,
|
|
2324
|
+
allow_fallback BOOLEAN DEFAULT TRUE,
|
|
2325
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
2326
|
+
UNIQUE(project_id)
|
|
2327
|
+
);
|
|
2328
|
+
|
|
2329
|
+
-- Indexes for accounts
|
|
2330
|
+
CREATE INDEX IF NOT EXISTS idx_accounts_active ON accounts(is_active, is_deleted);
|
|
2331
|
+
CREATE INDEX IF NOT EXISTS idx_accounts_email ON accounts(email);
|
|
2332
|
+
|
|
2333
|
+
-- Indexes for project account assignments
|
|
2334
|
+
CREATE INDEX IF NOT EXISTS idx_assignments_project ON project_account_assignments(project_id);
|
|
2335
|
+
CREATE INDEX IF NOT EXISTS idx_assignments_account ON project_account_assignments(account_id);
|
|
2336
|
+
|
|
2337
|
+
-- Partial unique index: enforce single default account among active, non-deleted accounts
|
|
2338
|
+
-- SQLite supports partial indexes via WHERE clause
|
|
2339
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_single_default
|
|
2340
|
+
ON accounts (is_default) WHERE is_default = 1 AND is_deleted = 0;
|
|
2341
|
+
|
|
2342
|
+
-- Drop legacy credentials table if it exists
|
|
2343
|
+
DROP TABLE IF EXISTS credentials;
|
|
2344
|
+
"""),
|
|
2345
|
+
# Migration to v10: Add async token validation columns to accounts
|
|
2346
|
+
(10, """
|
|
2347
|
+
-- Add validation status columns to accounts
|
|
2348
|
+
ALTER TABLE accounts ADD COLUMN last_validated_at INTEGER;
|
|
2349
|
+
ALTER TABLE accounts ADD COLUMN validation_status TEXT DEFAULT 'unknown';
|
|
1902
2350
|
"""),
|
|
1903
2351
|
]
|
|
1904
2352
|
|