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.
Files changed (48) hide show
  1. ralphx/__init__.py +1 -1
  2. ralphx/adapters/base.py +10 -2
  3. ralphx/adapters/claude_cli.py +222 -82
  4. ralphx/api/routes/auth.py +780 -98
  5. ralphx/api/routes/config.py +3 -56
  6. ralphx/api/routes/export_import.py +6 -9
  7. ralphx/api/routes/loops.py +4 -4
  8. ralphx/api/routes/planning.py +882 -19
  9. ralphx/api/routes/resources.py +528 -6
  10. ralphx/api/routes/stream.py +58 -56
  11. ralphx/api/routes/templates.py +2 -2
  12. ralphx/api/routes/workflows.py +258 -47
  13. ralphx/cli.py +4 -1
  14. ralphx/core/auth.py +372 -172
  15. ralphx/core/database.py +588 -164
  16. ralphx/core/executor.py +170 -19
  17. ralphx/core/loop.py +15 -2
  18. ralphx/core/loop_templates.py +29 -3
  19. ralphx/core/planning_iteration_executor.py +633 -0
  20. ralphx/core/planning_service.py +119 -24
  21. ralphx/core/preview.py +9 -25
  22. ralphx/core/project_db.py +864 -121
  23. ralphx/core/project_export.py +1 -5
  24. ralphx/core/project_import.py +14 -29
  25. ralphx/core/resources.py +28 -2
  26. ralphx/core/sample_project.py +1 -5
  27. ralphx/core/templates.py +9 -9
  28. ralphx/core/workflow_executor.py +32 -3
  29. ralphx/core/workflow_export.py +4 -7
  30. ralphx/core/workflow_import.py +3 -27
  31. ralphx/mcp/__init__.py +6 -2
  32. ralphx/mcp/registry.py +3 -3
  33. ralphx/mcp/tools/diagnostics.py +1 -1
  34. ralphx/mcp/tools/monitoring.py +10 -16
  35. ralphx/mcp/tools/workflows.py +115 -33
  36. ralphx/mcp_server.py +6 -2
  37. ralphx/static/assets/index-BuLI7ffn.css +1 -0
  38. ralphx/static/assets/index-DWvlqOTb.js +264 -0
  39. ralphx/static/assets/index-DWvlqOTb.js.map +1 -0
  40. ralphx/static/index.html +2 -2
  41. ralphx/templates/loop_templates/consumer.md +2 -2
  42. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/METADATA +33 -12
  43. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/RECORD +45 -44
  44. ralphx/static/assets/index-CcRDyY3b.css +0 -1
  45. ralphx/static/assets/index-CcxfTosc.js +0 -251
  46. ralphx/static/assets/index-CcxfTosc.js.map +0 -1
  47. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/WHEEL +0 -0
  48. {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 = 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
 
@@ -1327,212 +1360,563 @@ class Database:
1327
1360
  return cursor.rowcount > 0
1328
1361
 
1329
1362
  # =========================================================================
1330
- # Credential Operations
1363
+ # =========================================================================
1364
+ # Account Operations (Multi-Account Authentication)
1331
1365
  # =========================================================================
1332
1366
 
1333
- def store_credentials(
1367
+ def create_account(
1334
1368
  self,
1335
- scope: str,
1369
+ email: str,
1336
1370
  access_token: str,
1337
1371
  expires_at: int,
1338
1372
  refresh_token: Optional[str] = None,
1339
- project_id: Optional[str] = None,
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
- ) -> int:
1345
- """Store or update OAuth credentials.
1377
+ ) -> dict:
1378
+ """Create a new account or update if email already exists.
1346
1379
 
1347
1380
  Args:
1348
- scope: 'global' or 'project'
1381
+ email: User email (unique identifier)
1349
1382
  access_token: OAuth access token
1350
- expires_at: Unix timestamp (seconds) when token expires
1351
- refresh_token: Optional refresh token
1352
- project_id: Project ID for project-scoped credentials
1353
- email: User's email address from profile
1354
- scopes: JSON array string of OAuth scopes
1355
- subscription_type: Claude subscription type (e.g., 'max')
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
- Credential record ID
1391
+ Account dict with all fields
1360
1392
  """
1361
1393
  now = datetime.utcnow().isoformat()
1394
+
1362
1395
  with self._writer() as conn:
1363
- # Check for existing credential - handle NULL project_id explicitly
1364
- # (SQLite's ON CONFLICT doesn't work with NULL values)
1365
- if project_id is None:
1366
- cursor = conn.execute(
1367
- "SELECT id FROM credentials WHERE scope = ? AND project_id IS NULL",
1368
- (scope,),
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
- if existing:
1378
- # Update existing credential
1379
- conn.execute(
1380
- """
1381
- UPDATE credentials SET
1382
- access_token = ?, refresh_token = ?, expires_at = ?,
1383
- email = ?, scopes = ?, subscription_type = ?,
1384
- rate_limit_tier = ?, updated_at = ?
1385
- WHERE id = ?
1386
- """,
1387
- (access_token, refresh_token, expires_at, email, scopes,
1388
- subscription_type, rate_limit_tier, now, existing[0]),
1389
- )
1390
- return existing[0]
1391
- else:
1392
- # Insert new credential
1393
- cursor = conn.execute(
1394
- """
1395
- INSERT INTO credentials
1396
- (scope, project_id, access_token, refresh_token, expires_at, email,
1397
- scopes, subscription_type, rate_limit_tier, created_at, updated_at)
1398
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1399
- """,
1400
- (scope, project_id, access_token, refresh_token, expires_at, email,
1401
- scopes, subscription_type, rate_limit_tier, now, now),
1402
- )
1403
- 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 {}
1404
1442
 
1405
- def get_credentials(self, project_id: Optional[str] = None) -> Optional[dict]:
1406
- """Get credentials, checking project-specific first then global.
1443
+ def get_account(self, account_id: int) -> Optional[dict]:
1444
+ """Get an account by ID.
1407
1445
 
1408
1446
  Args:
1409
- project_id: Optional project ID to check for project-scoped creds first
1447
+ account_id: Account ID
1410
1448
 
1411
1449
  Returns:
1412
- Credential dict or None if not found
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 credentials WHERE scope = 'global' AND project_id IS NULL"
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 get_credentials_by_scope(
1433
- self, scope: str, project_id: Optional[str] = None
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
- scope: 'global' or 'project'
1442
- project_id: Project ID (required if scope is 'project')
1464
+ email: Account email
1443
1465
 
1444
1466
  Returns:
1445
- Credential dict or None if not found for this exact scope
1467
+ Account dict or None
1446
1468
  """
1447
1469
  with self._reader() as conn:
1448
- if scope == "project" and project_id:
1449
- cursor = conn.execute(
1450
- "SELECT * FROM credentials WHERE scope = 'project' AND project_id = ?",
1451
- (project_id,),
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 get_all_credentials(self) -> list[dict]:
1470
- """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.
1471
1481
 
1472
- 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
1473
1485
 
1474
1486
  Returns:
1475
- List of credential dicts
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("SELECT * FROM credentials")
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 credential update operations
1483
- _CREDENTIAL_UPDATE_COLS = frozenset({
1484
- "access_token", "refresh_token", "expires_at", "updated_at", "email",
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 update_credentials(self, id: int, **kwargs) -> bool:
1489
- """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
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._CREDENTIAL_UPDATE_COLS - {"updated_at"}
1531
+ invalid_cols = set(kwargs.keys()) - self._ACCOUNT_UPDATE_COLS - {"updated_at"}
1499
1532
  if invalid_cols:
1500
- raise ValueError(f"Invalid columns for credential update: {invalid_cols}")
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()) + [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()
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
- f"UPDATE credentials SET {set_clause} WHERE id = ?", values
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 delete_credentials(
1513
- 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,
1514
1829
  ) -> bool:
1515
- """Delete credentials for a scope.
1830
+ """Update the cached usage data for an account.
1516
1831
 
1517
1832
  Args:
1518
- scope: 'global' or 'project'
1519
- project_id: Project ID for project-scoped credentials
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 deleted, False if not found
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 scope == "project" and project_id:
1875
+ if increment_failures:
1526
1876
  cursor = conn.execute(
1527
- "DELETE FROM credentials WHERE scope = 'project' AND project_id = ?",
1528
- (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),
1529
1884
  )
1530
1885
  else:
1531
1886
  cursor = conn.execute(
1532
- "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),
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: Add OAuth credentials table
1838
- (3, """
1839
- CREATE TABLE IF NOT EXISTS credentials (
1840
- id INTEGER PRIMARY KEY AUTOINCREMENT,
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: Add OAuth metadata columns to credentials for Claude Code compatibility
1874
- # These fields are required by Claude Code CLI when reading ~/.claude/.credentials.json:
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: