refactor: Restructure monorepo to apps/server/ and apps/web/ layout
Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
This commit is contained in:
208
apps/server/agent/credential-handler.ts
Normal file
208
apps/server/agent/credential-handler.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* CredentialHandler — Account selection, credential management, and exhaustion handling.
|
||||
*
|
||||
* Extracted from MultiProviderAgentManager. Handles account lifecycle:
|
||||
* selecting the next available account, writing credentials to disk,
|
||||
* ensuring they're fresh, and marking accounts as exhausted on failure.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { AccountRepository } from '../db/repositories/account-repository.js';
|
||||
import type { AccountCredentialManager } from './credentials/types.js';
|
||||
import type { Account } from '../db/schema.js';
|
||||
import { ensureAccountCredentials } from './accounts/usage.js';
|
||||
import { getAccountConfigDir } from './accounts/paths.js';
|
||||
import { setupAccountConfigDir } from './accounts/setup.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('credential-handler');
|
||||
|
||||
/** Default exhaustion duration: 5 hours */
|
||||
const DEFAULT_EXHAUSTION_HOURS = 5;
|
||||
|
||||
export class CredentialHandler {
|
||||
constructor(
|
||||
private workspaceRoot: string,
|
||||
private accountRepository?: AccountRepository,
|
||||
private credentialManager?: AccountCredentialManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Select the next available account for a provider.
|
||||
* Clears expired exhaustion, returns least-recently-used non-exhausted account.
|
||||
* Returns null if no accounts are available.
|
||||
*/
|
||||
async selectAccount(providerName: string): Promise<{ account: Account; accountId: string; configDir: string } | null> {
|
||||
if (!this.accountRepository) return null;
|
||||
|
||||
await this.accountRepository.clearExpiredExhaustion();
|
||||
const account = await this.accountRepository.findNextAvailable(providerName);
|
||||
if (!account) return null;
|
||||
|
||||
const configDir = getAccountConfigDir(this.workspaceRoot, account.id);
|
||||
await this.accountRepository.updateLastUsed(account.id);
|
||||
|
||||
return { account, accountId: account.id, configDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write account credentials from DB to the convention-based config directory.
|
||||
* Must be called before ensureCredentials so the files exist on disk.
|
||||
*/
|
||||
writeCredentialsToDisk(account: Account, configDir: string): void {
|
||||
if (account.configJson && account.credentials) {
|
||||
setupAccountConfigDir(configDir, {
|
||||
configJson: JSON.parse(account.configJson),
|
||||
credentials: account.credentials,
|
||||
});
|
||||
log.debug({ accountId: account.id, configDir }, 'wrote account credentials from DB to disk');
|
||||
} else {
|
||||
log.warn({ accountId: account.id }, 'account has no stored credentials in DB');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read refreshed credentials from disk and persist back to DB.
|
||||
* Called after credential refresh to keep DB in sync.
|
||||
*/
|
||||
async persistRefreshedCredentials(accountId: string, configDir: string): Promise<void> {
|
||||
if (!this.accountRepository) return;
|
||||
try {
|
||||
const credPath = join(configDir, '.credentials.json');
|
||||
const credentials = readFileSync(credPath, 'utf-8');
|
||||
await this.accountRepository.updateCredentials(accountId, credentials);
|
||||
log.debug({ accountId }, 'persisted refreshed credentials back to DB');
|
||||
} catch (err) {
|
||||
log.warn({ accountId, err: err instanceof Error ? err.message : String(err) }, 'failed to persist refreshed credentials to DB');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure credentials are valid before spawn/resume.
|
||||
* Uses credentialManager if available, otherwise falls back to legacy function.
|
||||
* Returns { valid, refreshed } so callers can persist refresh back to DB.
|
||||
*/
|
||||
async ensureCredentials(configDir: string, accountId?: string): Promise<{ valid: boolean; refreshed: boolean }> {
|
||||
if (this.credentialManager) {
|
||||
const result = await this.credentialManager.ensureValid(configDir, accountId);
|
||||
return { valid: result.valid, refreshed: result.refreshed };
|
||||
}
|
||||
const valid = await ensureAccountCredentials(configDir);
|
||||
return { valid, refreshed: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the access token from a config directory's .credentials.json.
|
||||
* Returns null if credentials file is missing or malformed.
|
||||
* Used for CLAUDE_CODE_OAUTH_TOKEN env var injection.
|
||||
*/
|
||||
readAccessToken(configDir: string): string | null {
|
||||
try {
|
||||
const credPath = join(configDir, '.credentials.json');
|
||||
if (!existsSync(credPath)) return null;
|
||||
const raw = readFileSync(credPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed.claudeAiOauth?.accessToken ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare process environment with account credentials.
|
||||
* Writes credentials to disk, ensures freshness, injects OAuth token.
|
||||
* Used by spawn, resumeForCommit, and resumeInternal.
|
||||
*/
|
||||
async prepareProcessEnv(
|
||||
providerEnv: Record<string, string>,
|
||||
provider: { configDirEnv?: string },
|
||||
accountId: string | null,
|
||||
): Promise<{ processEnv: Record<string, string>; accountConfigDir: string | null }> {
|
||||
const processEnv: Record<string, string> = { ...providerEnv };
|
||||
let accountConfigDir: string | null = null;
|
||||
|
||||
if (accountId && provider.configDirEnv && this.accountRepository) {
|
||||
accountConfigDir = getAccountConfigDir(this.workspaceRoot, accountId);
|
||||
const account = await this.accountRepository.findById(accountId);
|
||||
if (account) {
|
||||
this.writeCredentialsToDisk(account, accountConfigDir);
|
||||
}
|
||||
processEnv[provider.configDirEnv] = accountConfigDir;
|
||||
|
||||
const { valid, refreshed } = await this.ensureCredentials(accountConfigDir, accountId);
|
||||
if (!valid) {
|
||||
log.warn({ accountId }, 'failed to refresh credentials');
|
||||
}
|
||||
if (refreshed) {
|
||||
await this.persistRefreshedCredentials(accountId, accountConfigDir);
|
||||
}
|
||||
|
||||
const accessToken = this.readAccessToken(accountConfigDir);
|
||||
if (accessToken) {
|
||||
processEnv['CLAUDE_CODE_OAUTH_TOKEN'] = accessToken;
|
||||
log.debug({ accountId }, 'CLAUDE_CODE_OAUTH_TOKEN injected');
|
||||
}
|
||||
}
|
||||
|
||||
return { processEnv, accountConfigDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error message indicates usage limit exhaustion.
|
||||
*/
|
||||
isUsageLimitError(errorMessage: string): boolean {
|
||||
const patterns = [
|
||||
'usage limit',
|
||||
'rate limit',
|
||||
'quota exceeded',
|
||||
'too many requests',
|
||||
'capacity',
|
||||
'exhausted',
|
||||
];
|
||||
const lower = errorMessage.toLowerCase();
|
||||
return patterns.some((p) => lower.includes(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle account exhaustion: mark current account exhausted and find next available.
|
||||
* Returns the new account info if failover succeeded, null otherwise.
|
||||
* Does NOT re-spawn — the caller (manager) handles that.
|
||||
*/
|
||||
async handleExhaustion(
|
||||
accountId: string,
|
||||
providerName: string,
|
||||
): Promise<{ account: Account; accountId: string; configDir: string } | null> {
|
||||
if (!this.accountRepository) return null;
|
||||
|
||||
log.warn({ accountId, provider: providerName }, 'account exhausted, attempting failover');
|
||||
|
||||
// Mark current account as exhausted
|
||||
const exhaustedUntil = new Date(Date.now() + DEFAULT_EXHAUSTION_HOURS * 60 * 60 * 1000);
|
||||
await this.accountRepository.markExhausted(accountId, exhaustedUntil);
|
||||
|
||||
// Find next available account
|
||||
const nextAccount = await this.accountRepository.findNextAvailable(providerName);
|
||||
if (!nextAccount) {
|
||||
log.warn({ accountId }, 'account failover failed, no accounts available');
|
||||
return null;
|
||||
}
|
||||
log.info({ previousAccountId: accountId, newAccountId: nextAccount.id }, 'account failover successful');
|
||||
|
||||
// Write credentials and ensure they're fresh
|
||||
const nextConfigDir = getAccountConfigDir(this.workspaceRoot, nextAccount.id);
|
||||
this.writeCredentialsToDisk(nextAccount, nextConfigDir);
|
||||
const { valid, refreshed } = await this.ensureCredentials(nextConfigDir, nextAccount.id);
|
||||
if (!valid) {
|
||||
log.warn({ newAccountId: nextAccount.id }, 'failed to refresh failover account credentials');
|
||||
return null;
|
||||
}
|
||||
if (refreshed) {
|
||||
await this.persistRefreshedCredentials(nextAccount.id, nextConfigDir);
|
||||
}
|
||||
|
||||
await this.accountRepository.updateLastUsed(nextAccount.id);
|
||||
|
||||
return { account: nextAccount, accountId: nextAccount.id, configDir: nextConfigDir };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user