ralphx 0.3.4__py3-none-any.whl → 0.4.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.
- ralphx/__init__.py +1 -1
- ralphx/adapters/base.py +10 -2
- ralphx/adapters/claude_cli.py +222 -82
- ralphx/api/routes/auth.py +780 -98
- ralphx/api/routes/config.py +3 -56
- ralphx/api/routes/export_import.py +6 -9
- ralphx/api/routes/loops.py +4 -4
- ralphx/api/routes/planning.py +882 -19
- ralphx/api/routes/resources.py +528 -6
- ralphx/api/routes/stream.py +58 -56
- ralphx/api/routes/templates.py +2 -2
- ralphx/api/routes/workflows.py +258 -47
- ralphx/cli.py +4 -1
- ralphx/core/auth.py +372 -172
- ralphx/core/database.py +588 -164
- ralphx/core/executor.py +170 -19
- ralphx/core/loop.py +15 -2
- ralphx/core/loop_templates.py +29 -3
- ralphx/core/planning_iteration_executor.py +633 -0
- ralphx/core/planning_service.py +119 -24
- ralphx/core/preview.py +9 -25
- ralphx/core/project_db.py +864 -121
- ralphx/core/project_export.py +1 -5
- ralphx/core/project_import.py +14 -29
- ralphx/core/resources.py +28 -2
- ralphx/core/sample_project.py +1 -5
- ralphx/core/templates.py +9 -9
- ralphx/core/workflow_executor.py +32 -3
- ralphx/core/workflow_export.py +4 -7
- ralphx/core/workflow_import.py +3 -27
- ralphx/mcp/__init__.py +6 -2
- ralphx/mcp/registry.py +3 -3
- ralphx/mcp/tools/diagnostics.py +1 -1
- ralphx/mcp/tools/monitoring.py +10 -16
- ralphx/mcp/tools/workflows.py +115 -33
- ralphx/mcp_server.py +6 -2
- ralphx/static/assets/index-BuLI7ffn.css +1 -0
- ralphx/static/assets/index-DWvlqOTb.js +264 -0
- ralphx/static/assets/index-DWvlqOTb.js.map +1 -0
- ralphx/static/index.html +2 -2
- ralphx/templates/loop_templates/consumer.md +2 -2
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/METADATA +33 -12
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/RECORD +45 -44
- ralphx/static/assets/index-CcRDyY3b.css +0 -1
- ralphx/static/assets/index-CcxfTosc.js +0 -251
- ralphx/static/assets/index-CcxfTosc.js.map +0 -1
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/WHEEL +0 -0
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.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
|
|
|
@@ -1327,212 +1360,563 @@ class Database:
|
|
|
1327
1360
|
return cursor.rowcount > 0
|
|
1328
1361
|
|
|
1329
1362
|
# =========================================================================
|
|
1330
|
-
#
|
|
1363
|
+
# =========================================================================
|
|
1364
|
+
# Account Operations (Multi-Account Authentication)
|
|
1331
1365
|
# =========================================================================
|
|
1332
1366
|
|
|
1333
|
-
def
|
|
1367
|
+
def create_account(
|
|
1334
1368
|
self,
|
|
1335
|
-
|
|
1369
|
+
email: str,
|
|
1336
1370
|
access_token: str,
|
|
1337
1371
|
expires_at: int,
|
|
1338
1372
|
refresh_token: Optional[str] = None,
|
|
1339
|
-
|
|
1340
|
-
email: Optional[str] = None,
|
|
1373
|
+
display_name: Optional[str] = None,
|
|
1341
1374
|
scopes: Optional[str] = None,
|
|
1342
1375
|
subscription_type: Optional[str] = None,
|
|
1343
1376
|
rate_limit_tier: Optional[str] = None,
|
|
1344
|
-
) ->
|
|
1345
|
-
"""
|
|
1377
|
+
) -> dict:
|
|
1378
|
+
"""Create a new account or update if email already exists.
|
|
1346
1379
|
|
|
1347
1380
|
Args:
|
|
1348
|
-
|
|
1381
|
+
email: User email (unique identifier)
|
|
1349
1382
|
access_token: OAuth access token
|
|
1350
|
-
expires_at:
|
|
1351
|
-
refresh_token:
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
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
|
|
1357
1389
|
|
|
1358
1390
|
Returns:
|
|
1359
|
-
|
|
1391
|
+
Account dict with all fields
|
|
1360
1392
|
"""
|
|
1361
1393
|
now = datetime.utcnow().isoformat()
|
|
1394
|
+
|
|
1362
1395
|
with self._writer() as conn:
|
|
1363
|
-
# Check
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
)
|
|
1370
|
-
else:
|
|
1371
|
-
cursor = conn.execute(
|
|
1372
|
-
"SELECT id FROM credentials WHERE scope = ? AND project_id = ?",
|
|
1373
|
-
(scope, project_id),
|
|
1374
|
-
)
|
|
1375
|
-
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
|
|
1376
1402
|
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
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 {}
|
|
1404
1442
|
|
|
1405
|
-
def
|
|
1406
|
-
"""Get
|
|
1443
|
+
def get_account(self, account_id: int) -> Optional[dict]:
|
|
1444
|
+
"""Get an account by ID.
|
|
1407
1445
|
|
|
1408
1446
|
Args:
|
|
1409
|
-
|
|
1447
|
+
account_id: Account ID
|
|
1410
1448
|
|
|
1411
1449
|
Returns:
|
|
1412
|
-
|
|
1450
|
+
Account dict or None
|
|
1413
1451
|
"""
|
|
1414
1452
|
with self._reader() as conn:
|
|
1415
|
-
# Check project-specific first if project_id provided
|
|
1416
|
-
if project_id:
|
|
1417
|
-
cursor = conn.execute(
|
|
1418
|
-
"SELECT * FROM credentials WHERE scope = 'project' AND project_id = ?",
|
|
1419
|
-
(project_id,),
|
|
1420
|
-
)
|
|
1421
|
-
row = cursor.fetchone()
|
|
1422
|
-
if row:
|
|
1423
|
-
return dict(row)
|
|
1424
|
-
|
|
1425
|
-
# Fall back to global
|
|
1426
1453
|
cursor = conn.execute(
|
|
1427
|
-
"SELECT * FROM
|
|
1454
|
+
"SELECT * FROM accounts WHERE id = ? AND is_deleted = 0",
|
|
1455
|
+
(account_id,),
|
|
1428
1456
|
)
|
|
1429
1457
|
row = cursor.fetchone()
|
|
1430
1458
|
return dict(row) if row else None
|
|
1431
1459
|
|
|
1432
|
-
def
|
|
1433
|
-
|
|
1434
|
-
) -> Optional[dict]:
|
|
1435
|
-
"""Get credentials for exact scope match (no fallback).
|
|
1436
|
-
|
|
1437
|
-
Unlike get_credentials(), this returns only the exact scope requested
|
|
1438
|
-
without falling back to global credentials.
|
|
1460
|
+
def get_account_by_email(self, email: str) -> Optional[dict]:
|
|
1461
|
+
"""Get an account by email.
|
|
1439
1462
|
|
|
1440
1463
|
Args:
|
|
1441
|
-
|
|
1442
|
-
project_id: Project ID (required if scope is 'project')
|
|
1464
|
+
email: Account email
|
|
1443
1465
|
|
|
1444
1466
|
Returns:
|
|
1445
|
-
|
|
1467
|
+
Account dict or None
|
|
1446
1468
|
"""
|
|
1447
1469
|
with self._reader() as conn:
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
)
|
|
1453
|
-
elif scope == "global":
|
|
1454
|
-
cursor = conn.execute(
|
|
1455
|
-
"SELECT * FROM credentials WHERE scope = 'global' AND project_id IS NULL"
|
|
1456
|
-
)
|
|
1457
|
-
else:
|
|
1458
|
-
return None
|
|
1459
|
-
row = cursor.fetchone()
|
|
1460
|
-
return dict(row) if row else None
|
|
1461
|
-
|
|
1462
|
-
def get_credentials_by_id(self, id: int) -> Optional[dict]:
|
|
1463
|
-
"""Get credentials by ID."""
|
|
1464
|
-
with self._reader() as conn:
|
|
1465
|
-
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
|
+
)
|
|
1466
1474
|
row = cursor.fetchone()
|
|
1467
1475
|
return dict(row) if row else None
|
|
1468
1476
|
|
|
1469
|
-
def
|
|
1470
|
-
|
|
1477
|
+
def list_accounts(
|
|
1478
|
+
self, include_inactive: bool = False, include_deleted: bool = False
|
|
1479
|
+
) -> list[dict]:
|
|
1480
|
+
"""List all accounts.
|
|
1471
1481
|
|
|
1472
|
-
|
|
1482
|
+
Args:
|
|
1483
|
+
include_inactive: Include disabled accounts
|
|
1484
|
+
include_deleted: Include soft-deleted accounts
|
|
1473
1485
|
|
|
1474
1486
|
Returns:
|
|
1475
|
-
List of
|
|
1487
|
+
List of account dicts
|
|
1476
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
|
+
|
|
1477
1497
|
with self._reader() as conn:
|
|
1478
|
-
cursor = conn.execute(
|
|
1498
|
+
cursor = conn.execute(
|
|
1499
|
+
f"SELECT * FROM accounts {where} ORDER BY is_default DESC, created_at ASC"
|
|
1500
|
+
)
|
|
1479
1501
|
rows = cursor.fetchall()
|
|
1480
1502
|
return [dict(row) for row in rows]
|
|
1481
1503
|
|
|
1482
|
-
# Allowed columns for
|
|
1483
|
-
|
|
1484
|
-
"
|
|
1485
|
-
"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",
|
|
1486
1512
|
})
|
|
1487
1513
|
|
|
1488
|
-
def
|
|
1489
|
-
"""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
|
|
1490
1523
|
|
|
1491
1524
|
Raises:
|
|
1492
|
-
ValueError: If invalid column names are provided
|
|
1525
|
+
ValueError: If invalid column names are provided
|
|
1493
1526
|
"""
|
|
1494
1527
|
if not kwargs:
|
|
1495
1528
|
return False
|
|
1496
1529
|
|
|
1497
1530
|
# Security: validate column names against whitelist
|
|
1498
|
-
invalid_cols = set(kwargs.keys()) - self.
|
|
1531
|
+
invalid_cols = set(kwargs.keys()) - self._ACCOUNT_UPDATE_COLS - {"updated_at"}
|
|
1499
1532
|
if invalid_cols:
|
|
1500
|
-
raise ValueError(f"Invalid columns for
|
|
1533
|
+
raise ValueError(f"Invalid columns for account update: {invalid_cols}")
|
|
1501
1534
|
|
|
1502
1535
|
kwargs["updated_at"] = datetime.utcnow().isoformat()
|
|
1503
1536
|
set_clause = ", ".join(f"{k} = ?" for k in kwargs.keys())
|
|
1504
|
-
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()
|
|
1505
1680
|
|
|
1506
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
|
|
1507
1703
|
cursor = conn.execute(
|
|
1508
|
-
|
|
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.
|
|
1736
|
+
|
|
1737
|
+
Args:
|
|
1738
|
+
project_id: Project ID
|
|
1739
|
+
|
|
1740
|
+
Returns:
|
|
1741
|
+
True if removed, False if not found
|
|
1742
|
+
"""
|
|
1743
|
+
with self._writer() as conn:
|
|
1744
|
+
cursor = conn.execute(
|
|
1745
|
+
"DELETE FROM project_account_assignments WHERE project_id = ?",
|
|
1746
|
+
(project_id,),
|
|
1509
1747
|
)
|
|
1510
1748
|
return cursor.rowcount > 0
|
|
1511
1749
|
|
|
1512
|
-
def
|
|
1513
|
-
|
|
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,
|
|
1514
1829
|
) -> bool:
|
|
1515
|
-
"""
|
|
1830
|
+
"""Update the cached usage data for an account.
|
|
1516
1831
|
|
|
1517
1832
|
Args:
|
|
1518
|
-
|
|
1519
|
-
|
|
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
|
|
1520
1838
|
|
|
1521
1839
|
Returns:
|
|
1522
|
-
True if
|
|
1840
|
+
True if updated, False otherwise
|
|
1523
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,
|
|
1861
|
+
) -> bool:
|
|
1862
|
+
"""Record an error for an account.
|
|
1863
|
+
|
|
1864
|
+
Args:
|
|
1865
|
+
account_id: Account ID
|
|
1866
|
+
error_message: Error message
|
|
1867
|
+
increment_failures: Whether to increment consecutive_failures
|
|
1868
|
+
|
|
1869
|
+
Returns:
|
|
1870
|
+
True if updated, False otherwise
|
|
1871
|
+
"""
|
|
1872
|
+
now = datetime.utcnow().isoformat()
|
|
1873
|
+
|
|
1524
1874
|
with self._writer() as conn:
|
|
1525
|
-
if
|
|
1875
|
+
if increment_failures:
|
|
1526
1876
|
cursor = conn.execute(
|
|
1527
|
-
"
|
|
1528
|
-
|
|
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),
|
|
1529
1884
|
)
|
|
1530
1885
|
else:
|
|
1531
1886
|
cursor = conn.execute(
|
|
1532
|
-
"
|
|
1887
|
+
"""UPDATE accounts SET
|
|
1888
|
+
last_error = ?,
|
|
1889
|
+
last_error_at = ?,
|
|
1890
|
+
updated_at = ?
|
|
1891
|
+
WHERE id = ?""",
|
|
1892
|
+
(error_message, now, now, account_id),
|
|
1533
1893
|
)
|
|
1534
1894
|
return cursor.rowcount > 0
|
|
1535
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
|
+
|
|
1536
1920
|
# =========================================================================
|
|
1537
1921
|
# Log Operations
|
|
1538
1922
|
# =========================================================================
|
|
@@ -1834,24 +2218,10 @@ class Database:
|
|
|
1834
2218
|
CREATE INDEX IF NOT EXISTS idx_work_items_claimed
|
|
1835
2219
|
ON work_items(project_id, claimed_by, claimed_at);
|
|
1836
2220
|
"""),
|
|
1837
|
-
# Migration to v3:
|
|
1838
|
-
(3, """
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
scope TEXT NOT NULL,
|
|
1842
|
-
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
1843
|
-
access_token TEXT NOT NULL,
|
|
1844
|
-
refresh_token TEXT,
|
|
1845
|
-
expires_at INTEGER NOT NULL,
|
|
1846
|
-
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
1847
|
-
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
1848
|
-
UNIQUE(scope, project_id)
|
|
1849
|
-
);
|
|
1850
|
-
"""),
|
|
1851
|
-
# Migration to v4: Add email column to credentials
|
|
1852
|
-
(4, """
|
|
1853
|
-
ALTER TABLE credentials ADD COLUMN email TEXT;
|
|
1854
|
-
"""),
|
|
2221
|
+
# Migration to v3: (Legacy - credentials table removed)
|
|
2222
|
+
(3, """SELECT 1;"""),
|
|
2223
|
+
# Migration to v4: (Legacy - credentials table removed)
|
|
2224
|
+
(4, """SELECT 1;"""),
|
|
1855
2225
|
# Migration to v5: Add category and event columns to logs for structured logging
|
|
1856
2226
|
(5, """
|
|
1857
2227
|
-- Add category and event columns for structured logging
|
|
@@ -1870,17 +2240,8 @@ class Database:
|
|
|
1870
2240
|
-- Composite index for run drilldown (all logs for a run, ordered)
|
|
1871
2241
|
CREATE INDEX IF NOT EXISTS idx_logs_run_time ON logs(run_id, timestamp);
|
|
1872
2242
|
"""),
|
|
1873
|
-
# Migration to v6:
|
|
1874
|
-
|
|
1875
|
-
# - scopes: JSON array of OAuth scopes (e.g., ["user:inference", "user:profile"])
|
|
1876
|
-
# - subscription_type: Claude subscription tier ("free", "pro", "max")
|
|
1877
|
-
# - rate_limit_tier: API rate limit tier (e.g., "default_claude_max_20x")
|
|
1878
|
-
(6, """
|
|
1879
|
-
-- Add OAuth metadata columns for full Claude Code credential compatibility
|
|
1880
|
-
ALTER TABLE credentials ADD COLUMN scopes TEXT;
|
|
1881
|
-
ALTER TABLE credentials ADD COLUMN subscription_type TEXT;
|
|
1882
|
-
ALTER TABLE credentials ADD COLUMN rate_limit_tier TEXT;
|
|
1883
|
-
"""),
|
|
2243
|
+
# Migration to v6: (Legacy - credentials table removed)
|
|
2244
|
+
(6, """SELECT 1;"""),
|
|
1884
2245
|
# Migration to v7: Rename source_loop to namespace for clarity
|
|
1885
2246
|
# NOTE: For databases created after this migration was added, the column
|
|
1886
2247
|
# is already named 'namespace' in the initial schema. This migration only
|
|
@@ -1924,6 +2285,69 @@ class Database:
|
|
|
1924
2285
|
CREATE INDEX IF NOT EXISTS idx_logs_cat_event ON logs(category, event, timestamp DESC);
|
|
1925
2286
|
CREATE INDEX IF NOT EXISTS idx_logs_run_time ON logs(run_id, timestamp);
|
|
1926
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';
|
|
2350
|
+
"""),
|
|
1927
2351
|
]
|
|
1928
2352
|
|
|
1929
2353
|
with self._writer() as conn:
|