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.
Files changed (45) hide show
  1. ralphx/__init__.py +1 -1
  2. ralphx/api/main.py +9 -1
  3. ralphx/api/routes/auth.py +730 -65
  4. ralphx/api/routes/config.py +3 -56
  5. ralphx/api/routes/export_import.py +795 -0
  6. ralphx/api/routes/loops.py +4 -4
  7. ralphx/api/routes/planning.py +19 -5
  8. ralphx/api/routes/projects.py +84 -2
  9. ralphx/api/routes/templates.py +115 -2
  10. ralphx/api/routes/workflows.py +22 -22
  11. ralphx/cli.py +21 -6
  12. ralphx/core/auth.py +346 -171
  13. ralphx/core/database.py +615 -167
  14. ralphx/core/executor.py +0 -3
  15. ralphx/core/loop.py +15 -2
  16. ralphx/core/loop_templates.py +69 -3
  17. ralphx/core/planning_service.py +109 -21
  18. ralphx/core/preview.py +9 -25
  19. ralphx/core/project_db.py +175 -75
  20. ralphx/core/project_export.py +469 -0
  21. ralphx/core/project_import.py +670 -0
  22. ralphx/core/sample_project.py +430 -0
  23. ralphx/core/templates.py +46 -9
  24. ralphx/core/workflow_executor.py +35 -5
  25. ralphx/core/workflow_export.py +606 -0
  26. ralphx/core/workflow_import.py +1149 -0
  27. ralphx/examples/sample_project/DESIGN.md +345 -0
  28. ralphx/examples/sample_project/README.md +37 -0
  29. ralphx/examples/sample_project/guardrails.md +57 -0
  30. ralphx/examples/sample_project/stories.jsonl +10 -0
  31. ralphx/mcp/__init__.py +6 -2
  32. ralphx/mcp/registry.py +3 -3
  33. ralphx/mcp/server.py +99 -29
  34. ralphx/mcp/tools/__init__.py +4 -0
  35. ralphx/mcp/tools/help.py +204 -0
  36. ralphx/mcp/tools/workflows.py +114 -32
  37. ralphx/mcp_server.py +6 -2
  38. ralphx/static/assets/index-0ovNnfOq.css +1 -0
  39. ralphx/static/assets/index-CY9s08ZB.js +251 -0
  40. ralphx/static/assets/index-CY9s08ZB.js.map +1 -0
  41. ralphx/static/index.html +14 -0
  42. {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/METADATA +34 -12
  43. {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/RECORD +45 -30
  44. {ralphx-0.2.2.dist-info → ralphx-0.3.5.dist-info}/WHEEL +0 -0
  45. {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 = 8
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
- -- OAuth credentials table
149
- CREATE TABLE IF NOT EXISTS credentials (
148
+ -- Accounts table - stores all logged-in Claude accounts
149
+ CREATE TABLE IF NOT EXISTS accounts (
150
150
  id INTEGER PRIMARY KEY AUTOINCREMENT,
151
- scope TEXT NOT NULL, -- 'global' or 'project'
152
- project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
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
- email TEXT, -- User's email address from profile
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
- UNIQUE(scope, project_id) -- One credential per scope/project
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
- # Credential Operations
1363
+ # =========================================================================
1364
+ # Account Operations (Multi-Account Authentication)
1314
1365
  # =========================================================================
1315
1366
 
1316
- def store_credentials(
1367
+ def create_account(
1317
1368
  self,
1318
- scope: str,
1369
+ email: str,
1319
1370
  access_token: str,
1320
1371
  expires_at: int,
1321
1372
  refresh_token: Optional[str] = None,
1322
- project_id: Optional[str] = None,
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
- ) -> int:
1328
- """Store or update OAuth credentials.
1377
+ ) -> dict:
1378
+ """Create a new account or update if email already exists.
1329
1379
 
1330
1380
  Args:
1331
- scope: 'global' or 'project'
1381
+ email: User email (unique identifier)
1332
1382
  access_token: OAuth access token
1333
- expires_at: Unix timestamp (seconds) when token expires
1334
- refresh_token: Optional refresh token
1335
- project_id: Project ID for project-scoped credentials
1336
- email: User's email address from profile
1337
- scopes: JSON array string of OAuth scopes
1338
- subscription_type: Claude subscription type (e.g., 'max')
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
- Credential record ID
1391
+ Account dict with all fields
1343
1392
  """
1344
1393
  now = datetime.utcnow().isoformat()
1394
+
1345
1395
  with self._writer() as conn:
1346
- # Check for existing credential - handle NULL project_id explicitly
1347
- # (SQLite's ON CONFLICT doesn't work with NULL values)
1348
- if project_id is None:
1349
- cursor = conn.execute(
1350
- "SELECT id FROM credentials WHERE scope = ? AND project_id IS NULL",
1351
- (scope,),
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
- if existing:
1361
- # Update existing credential
1362
- conn.execute(
1363
- """
1364
- UPDATE credentials SET
1365
- access_token = ?, refresh_token = ?, expires_at = ?,
1366
- email = ?, scopes = ?, subscription_type = ?,
1367
- rate_limit_tier = ?, updated_at = ?
1368
- WHERE id = ?
1369
- """,
1370
- (access_token, refresh_token, expires_at, email, scopes,
1371
- subscription_type, rate_limit_tier, now, existing[0]),
1372
- )
1373
- return existing[0]
1374
- else:
1375
- # Insert new credential
1376
- cursor = conn.execute(
1377
- """
1378
- INSERT INTO credentials
1379
- (scope, project_id, access_token, refresh_token, expires_at, email,
1380
- scopes, subscription_type, rate_limit_tier, created_at, updated_at)
1381
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1382
- """,
1383
- (scope, project_id, access_token, refresh_token, expires_at, email,
1384
- scopes, subscription_type, rate_limit_tier, now, now),
1385
- )
1386
- return cursor.lastrowid or 0
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 get_credentials(self, project_id: Optional[str] = None) -> Optional[dict]:
1389
- """Get credentials, checking project-specific first then global.
1443
+ def get_account(self, account_id: int) -> Optional[dict]:
1444
+ """Get an account by ID.
1390
1445
 
1391
1446
  Args:
1392
- project_id: Optional project ID to check for project-scoped creds first
1447
+ account_id: Account ID
1393
1448
 
1394
1449
  Returns:
1395
- Credential dict or None if not found
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 credentials WHERE scope = 'global' AND project_id IS NULL"
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 get_credentials_by_scope(
1416
- self, scope: str, project_id: Optional[str] = None
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
- scope: 'global' or 'project'
1425
- project_id: Project ID (required if scope is 'project')
1464
+ email: Account email
1426
1465
 
1427
1466
  Returns:
1428
- Credential dict or None if not found for this exact scope
1467
+ Account dict or None
1429
1468
  """
1430
1469
  with self._reader() as conn:
1431
- if scope == "project" and project_id:
1432
- cursor = conn.execute(
1433
- "SELECT * FROM credentials WHERE scope = 'project' AND project_id = ?",
1434
- (project_id,),
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 get_all_credentials(self) -> list[dict]:
1453
- """Get all stored credentials (global + all projects).
1477
+ def list_accounts(
1478
+ self, include_inactive: bool = False, include_deleted: bool = False
1479
+ ) -> list[dict]:
1480
+ """List all accounts.
1454
1481
 
1455
- Used by background token refresh task to check all credentials.
1482
+ Args:
1483
+ include_inactive: Include disabled accounts
1484
+ include_deleted: Include soft-deleted accounts
1456
1485
 
1457
1486
  Returns:
1458
- List of credential dicts
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("SELECT * FROM credentials")
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 credential update operations
1466
- _CREDENTIAL_UPDATE_COLS = frozenset({
1467
- "access_token", "refresh_token", "expires_at", "updated_at", "email",
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 update_credentials(self, id: int, **kwargs) -> bool:
1472
- """Update credentials by ID.
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._CREDENTIAL_UPDATE_COLS - {"updated_at"}
1531
+ invalid_cols = set(kwargs.keys()) - self._ACCOUNT_UPDATE_COLS - {"updated_at"}
1482
1532
  if invalid_cols:
1483
- raise ValueError(f"Invalid columns for credential update: {invalid_cols}")
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()) + [id]
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
- f"UPDATE credentials SET {set_clause} WHERE id = ?", values
1745
+ "DELETE FROM project_account_assignments WHERE project_id = ?",
1746
+ (project_id,),
1492
1747
  )
1493
1748
  return cursor.rowcount > 0
1494
1749
 
1495
- def delete_credentials(
1496
- self, scope: str, project_id: Optional[str] = None
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
- """Delete credentials for a scope.
1862
+ """Record an error for an account.
1499
1863
 
1500
1864
  Args:
1501
- scope: 'global' or 'project'
1502
- project_id: Project ID for project-scoped credentials
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 deleted, False if not found
1870
+ True if updated, False otherwise
1506
1871
  """
1872
+ now = datetime.utcnow().isoformat()
1873
+
1507
1874
  with self._writer() as conn:
1508
- if scope == "project" and project_id:
1875
+ if increment_failures:
1509
1876
  cursor = conn.execute(
1510
- "DELETE FROM credentials WHERE scope = 'project' AND project_id = ?",
1511
- (project_id,),
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
- "DELETE FROM credentials WHERE scope = 'global' AND project_id IS NULL"
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": json.loads(row[7]) if row[7] else None,
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: Add OAuth credentials table
1821
- (3, """
1822
- CREATE TABLE IF NOT EXISTS credentials (
1823
- id INTEGER PRIMARY KEY AUTOINCREMENT,
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: Add OAuth metadata columns to credentials for Claude Code compatibility
1856
- # These fields are required by Claude Code CLI when reading ~/.claude/.credentials.json:
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
- INSERT INTO logs_new SELECT * FROM logs;
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 index on logs
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