feat: Multi-profile account swapping with token refresh and queue routing (#1496)
* refactor: streamline profile management and enhance usage monitoring - Consolidated profile management logic to improve clarity and maintainability. - Enhanced usage monitoring to support both OAuth and API profiles, ensuring accurate data retrieval. - Updated error handling for API requests to provide better feedback and prevent silent failures. - Improved test coverage for profile detection and usage monitoring functionalities. These changes aim to optimize the user experience by ensuring that profile-related data is handled consistently and that usage metrics are accurately reported across different authentication methods. * fix(tests): update tests for new auth badge display behavior - Update AuthStatusIndicator tests to expect "Claude Code"/"API Key" badge labels instead of provider names (Anthropic, z.ai, ZHIPU AI) - Fix usage-monitor tests that relied on console.warn calls (now uses debugLog) - Add missing mock Response headers to prevent TypeError in fetch tests - Convert dynamic require to static import in claude-profile-manager Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Address PR review findings for account swapping - Add `persistenceFailed` flag to EnsureValidTokenResult for callers to detect when token refresh succeeded but keychain write failed - Add collision detection to profile migration to handle cases where two profile names sanitize to the same directory name - Change hardcoded 'default' to 'unknown' in updateTaskSession when no profile assignment exists, and add optional profileInfo parameter Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Resolve CodeQL security alerts - Fix TOCTOU race condition in profile migration by using 'wx' flag for atomic file creation instead of existsSync check - Remove unused imports: DEFAULT_CLAUDE_CONFIG_DIR, BrowserWindow, IPC_CHANNELS, User The medium severity alerts for "Network data written to file" and "File data in outbound network request" are expected behavior for credential management and API authentication respectively. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(linux): Add Secret Service support for credential storage Linux credentials now use the Secret Service API (gnome-keyring/kwallet) via the `secret-tool` CLI, matching how macOS uses Keychain and Windows uses Credential Manager. Implementation: - getCredentialsFromLinuxSecretService: Retrieve tokens from Secret Service - getFullCredentialsFromLinuxSecretService: Retrieve full OAuth credentials - updateLinuxSecretServiceCredentials: Store refreshed tokens - Automatic fallback to .credentials.json file if Secret Service unavailable The `secret-tool` command is part of libsecret-tools package, commonly available on most Linux desktop distributions. This provides secure credential storage instead of plaintext file storage. Credentials are stored with: - Label: "Claude Code-credentials" - Attribute: application=claude-code (or claude-code-{hash} for profiles) Fixes security gap where Linux used file storage while macOS and Windows used proper secure credential stores. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: Add guidelines against providing time estimates in development Introduced a critical section in the development guidelines emphasizing the avoidance of time estimates for tasks. This change highlights the misleading nature of traditional time predictions in AI-assisted development and encourages a focus on actionable steps and priority-based ordering instead. Examples of incorrect and correct approaches are provided for clarity. * fix: address PR review findings for account swapping MEDIUM severity fixes: - Implement getBestProfileForTask handler with actual profile selection logic - Refactor credential-utils.ts to eliminate code duplication with shared helpers - Fix PowerShell escaping vulnerabilities with proper character escaping - Use base64 encoding for JSON data passed to PowerShell scripts - Add warning that returned token may be revoked after server-side refresh failure - Document side effect in getBestAvailableProfileEnv function LOW severity fixes: - Reduce token fingerprint logging from 12+4 to 4+2 chars - Document >= threshold as intentional (proactive switching before limits) - Move writeFileSync import to top of file with other fs imports - Remove unnecessary existsSync checks (mkdirSync recursive is idempotent) - Add homedir to top-level imports from 'os' - Add comment noting acceptable TOCTOU race window in profile-storage.ts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: clear credential cache after platform credential updates Add clearCredentialCache() calls to all platform-specific update functions to prevent stale cached tokens from being returned after successful updates: - updateMacOSKeychainCredentials - updateLinuxSecretServiceCredentials - updateLinuxFileCredentials - updateWindowsCredentialManagerCredentials This ensures callers always get fresh credential values after token refresh, rather than waiting for the 5-minute cache TTL to expire. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: handle persistenceFailed flag in token refresh calls When token refresh succeeds but fails to persist to keychain, the user needs to be notified to re-authenticate. Previously, the persistenceFailed flag was ignored, which could lead to authentication errors on app restart. Now all three call sites for ensureValidToken/reactiveTokenRefresh properly: - Check the persistenceFailed flag - Add the profile to needsReauthProfiles set when persistence fails - Log a warning about the persistence failure - Clear from needsReauthProfiles only when both refresh AND persistence succeed Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: enhance credential management and re-authentication flow - Updated macOS keychain handling to ensure existing credentials are deleted before adding new ones, preventing stale tokens. - Integrated credential checks in profile authentication to verify token presence in the keychain, improving user experience by flagging profiles needing re-authentication. - Enhanced the UsageIndicator component to provide clearer messaging for re-authentication requirements, improving user feedback. - Added new localization strings for re-authentication prompts in both English and French. This update addresses critical issues with credential persistence and user notifications, ensuring a smoother authentication experience across platforms. * fix(auth): prevent false auth failures from file names in logs Change auth failure pattern from \s* to \s+ to require at least one whitespace character between "auth" and "failure/error/failed". This fixes false positives where log lines like "[ParallelOrchestrator] Reading AuthFailureModal.tsx" were incorrectly triggering auth failure modals. The pattern matched "AuthFailure" (zero whitespace) even though the OAuth token was valid. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(auth): use imported homedir instead of inline require Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -355,6 +355,33 @@ memory.add_session_insight("Pattern: use React hooks for state")
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### No Time Estimates
|
||||
|
||||
**CRITICAL: Never provide time estimates or predictions for how long tasks will take.**
|
||||
|
||||
AI-assisted development dramatically changes implementation timelines, making traditional estimates misleading. Avoid:
|
||||
- Week/day/hour estimates (e.g., "Week 1: Foundation", "This will take 2-3 days")
|
||||
- Phrases like "quick fix", "simple change", "this should be fast"
|
||||
- Roadmaps with time-based phases
|
||||
|
||||
Instead:
|
||||
- Focus on **what** needs to be done, not **when**
|
||||
- Break work into actionable steps without duration predictions
|
||||
- Use priority-based ordering (High Impact, Low Effort) rather than time-based phases
|
||||
- Let users judge timing for themselves based on their context
|
||||
|
||||
```
|
||||
// ❌ WRONG - Time-based roadmap
|
||||
Week 1: Foundation
|
||||
Week 2: Prevention over Detection
|
||||
Week 3: Calibration
|
||||
|
||||
// ✅ CORRECT - Priority-based ordering
|
||||
Phase 1: Foundation (High Impact, Low Effort)
|
||||
Phase 2: Prevention over Detection
|
||||
Phase 3: Calibration
|
||||
```
|
||||
|
||||
### Frontend Internationalization (i18n)
|
||||
|
||||
**CRITICAL: Always use i18n translation keys for all user-facing text in the frontend.**
|
||||
|
||||
@@ -96,6 +96,19 @@ vi.mock('../../main/python-env-manager', () => ({
|
||||
getConfiguredPythonPath: vi.fn(() => DETECTED_PYTHON_CMD)
|
||||
}));
|
||||
|
||||
// Mock rate-limit-detector for getBestAvailableProfileEnv
|
||||
vi.mock('../../main/rate-limit-detector', () => ({
|
||||
getBestAvailableProfileEnv: vi.fn(() => ({
|
||||
env: {},
|
||||
profileId: 'default',
|
||||
profileName: 'Default',
|
||||
wasSwapped: false
|
||||
})),
|
||||
getProfileEnv: vi.fn(() => ({})),
|
||||
detectRateLimit: vi.fn(() => ({ isRateLimited: false })),
|
||||
detectAuthFailure: vi.fn(() => ({ isAuthFailure: false }))
|
||||
}));
|
||||
|
||||
// Auto-claude source path (for getAutoBuildSourcePath to find)
|
||||
let AUTO_CLAUDE_SOURCE: string;
|
||||
|
||||
|
||||
@@ -14,7 +14,12 @@ vi.mock('electron', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../rate-limit-detector', () => ({
|
||||
getProfileEnv: () => ({ CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token' })
|
||||
getBestAvailableProfileEnv: () => ({
|
||||
env: { CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token' },
|
||||
profileId: 'default',
|
||||
profileName: 'Default',
|
||||
wasSwapped: false
|
||||
})
|
||||
}));
|
||||
|
||||
const mockGetApiProfileEnv = vi.fn();
|
||||
|
||||
@@ -479,4 +479,50 @@ export class AgentManager extends EventEmitter {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Queue Routing Methods (Rate Limit Recovery)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get running tasks grouped by profile
|
||||
* Used by queue routing to determine profile load
|
||||
*/
|
||||
getRunningTasksByProfile(): { byProfile: Record<string, string[]>; totalRunning: number } {
|
||||
return this.state.getRunningTasksByProfile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a profile to a task
|
||||
* Records which profile is being used for a task
|
||||
*/
|
||||
assignProfileToTask(
|
||||
taskId: string,
|
||||
profileId: string,
|
||||
profileName: string,
|
||||
reason: 'proactive' | 'reactive' | 'manual'
|
||||
): void {
|
||||
this.state.assignProfileToTask(taskId, profileId, profileName, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the profile assignment for a task
|
||||
*/
|
||||
getTaskProfileAssignment(taskId: string): { profileId: string; profileName: string; reason: string } | undefined {
|
||||
return this.state.getTaskProfileAssignment(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the session ID for a task (for session resume)
|
||||
*/
|
||||
updateTaskSession(taskId: string, sessionId: string): void {
|
||||
this.state.updateTaskSession(taskId, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session ID for a task
|
||||
*/
|
||||
getTaskSessionId(taskId: string): string | undefined {
|
||||
return this.state.getTaskSessionId(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,12 @@ vi.mock('../services/profile', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../rate-limit-detector', () => ({
|
||||
getProfileEnv: vi.fn(() => ({})),
|
||||
getBestAvailableProfileEnv: vi.fn(() => ({
|
||||
env: {},
|
||||
profileId: 'default',
|
||||
profileName: 'Default',
|
||||
wasSwapped: false
|
||||
})),
|
||||
detectRateLimit: vi.fn(() => ({ isRateLimited: false })),
|
||||
createSDKRateLimitInfo: vi.fn(),
|
||||
detectAuthFailure: vi.fn(() => ({ isAuthFailure: false }))
|
||||
@@ -287,8 +292,11 @@ describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => {
|
||||
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({});
|
||||
|
||||
// Set OAuth token via getProfileEnv (existing flow)
|
||||
vi.mocked(rateLimitDetector.getProfileEnv).mockReturnValue({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-123'
|
||||
vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({
|
||||
env: { CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-123' },
|
||||
profileId: 'default',
|
||||
profileName: 'Default',
|
||||
wasSwapped: false
|
||||
});
|
||||
|
||||
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
|
||||
@@ -319,8 +327,11 @@ describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => {
|
||||
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({});
|
||||
|
||||
// Set OAuth token
|
||||
vi.mocked(rateLimitDetector.getProfileEnv).mockReturnValue({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-456'
|
||||
vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({
|
||||
env: { CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-456' },
|
||||
profileId: 'default',
|
||||
profileName: 'Default',
|
||||
wasSwapped: false
|
||||
});
|
||||
|
||||
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
|
||||
@@ -343,8 +354,11 @@ describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => {
|
||||
|
||||
// OAuth mode
|
||||
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({});
|
||||
vi.mocked(rateLimitDetector.getProfileEnv).mockReturnValue({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-789'
|
||||
vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({
|
||||
env: { CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-789' },
|
||||
profileId: 'default',
|
||||
profileName: 'Default',
|
||||
wasSwapped: false
|
||||
});
|
||||
|
||||
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
|
||||
@@ -498,7 +512,12 @@ describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => {
|
||||
ANTHROPIC_BASE_URL: 'https://api-profile.com'
|
||||
};
|
||||
|
||||
vi.mocked(rateLimitDetector.getProfileEnv).mockReturnValue(profileEnv);
|
||||
vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({
|
||||
env: profileEnv,
|
||||
profileId: 'default',
|
||||
profileName: 'Default',
|
||||
wasSwapped: false
|
||||
});
|
||||
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue(apiProfileEnv);
|
||||
|
||||
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], extraEnv, 'task-execution');
|
||||
|
||||
@@ -12,7 +12,7 @@ import { AgentState } from './agent-state';
|
||||
import { AgentEvents } from './agent-events';
|
||||
import { ProcessType, ExecutionProgressData } from './types';
|
||||
import type { CompletablePhase } from '../../shared/constants/phase-protocol';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv, detectAuthFailure } from '../rate-limit-detector';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getBestAvailableProfileEnv, detectAuthFailure } from '../rate-limit-detector';
|
||||
import { getAPIProfileEnv } from '../services/profile';
|
||||
import { projectStore } from '../project-store';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
@@ -173,7 +173,9 @@ export class AgentProcessManager {
|
||||
private setupProcessEnvironment(
|
||||
extraEnv: Record<string, string>
|
||||
): NodeJS.ProcessEnv {
|
||||
const profileEnv = getProfileEnv();
|
||||
// Get best available Claude profile environment (automatically handles rate limits)
|
||||
const profileResult = getBestAvailableProfileEnv();
|
||||
const profileEnv = profileResult.env;
|
||||
// Use getAugmentedEnv() to ensure common tool paths (dotnet, homebrew, etc.)
|
||||
// are available even when app is launched from Finder/Dock
|
||||
const augmentedEnv = getAugmentedEnv();
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AgentProcessManager } from './agent-process';
|
||||
import { RoadmapConfig } from './types';
|
||||
import type { IdeationConfig, Idea } from '../../shared/types';
|
||||
import { AUTO_BUILD_PATHS } from '../../shared/constants';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getBestAvailableProfileEnv } from '../rate-limit-detector';
|
||||
import { getAPIProfileEnv } from '../services/profile';
|
||||
import { getOAuthModeClearVars } from './env-utils';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
@@ -326,8 +326,9 @@ export class AgentQueueManager {
|
||||
// Get combined environment variables
|
||||
const combinedEnv = this.processManager.getCombinedEnv(projectPath);
|
||||
|
||||
// Get active Claude profile environment (CLAUDE_CODE_OAUTH_TOKEN if not default)
|
||||
const profileEnv = getProfileEnv();
|
||||
// Get best available Claude profile environment (automatically handles rate limits)
|
||||
const profileResult = getBestAvailableProfileEnv();
|
||||
const profileEnv = profileResult.env;
|
||||
|
||||
// Get active API profile environment variables
|
||||
const apiProfileEnv = await getAPIProfileEnv();
|
||||
@@ -653,8 +654,9 @@ export class AgentQueueManager {
|
||||
// Get combined environment variables
|
||||
const combinedEnv = this.processManager.getCombinedEnv(projectPath);
|
||||
|
||||
// Get active Claude profile environment (CLAUDE_CODE_OAUTH_TOKEN if not default)
|
||||
const profileEnv = getProfileEnv();
|
||||
// Get best available Claude profile environment (automatically handles rate limits)
|
||||
const profileResult = getBestAvailableProfileEnv();
|
||||
const profileEnv = profileResult.env;
|
||||
|
||||
// Get active API profile environment variables
|
||||
const apiProfileEnv = await getAPIProfileEnv();
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Tests for AgentState - Queue Routing functionality
|
||||
*
|
||||
* Tests the profile assignment tracking and running tasks by profile features.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { AgentState } from './agent-state';
|
||||
|
||||
describe('AgentState - Queue Routing', () => {
|
||||
let state: AgentState;
|
||||
|
||||
beforeEach(() => {
|
||||
state = new AgentState();
|
||||
});
|
||||
|
||||
describe('getRunningTasksByProfile', () => {
|
||||
it('should return empty state when no processes running', () => {
|
||||
const result = state.getRunningTasksByProfile();
|
||||
|
||||
expect(result.byProfile).toEqual({});
|
||||
expect(result.totalRunning).toBe(0);
|
||||
});
|
||||
|
||||
it('should group tasks by profile', () => {
|
||||
// Add mock processes
|
||||
state.addProcess('task-1', { pid: 1001 } as any);
|
||||
state.addProcess('task-2', { pid: 1002 } as any);
|
||||
state.addProcess('task-3', { pid: 1003 } as any);
|
||||
|
||||
// Assign profiles
|
||||
state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive');
|
||||
state.assignProfileToTask('task-2', 'profile-1', 'Profile 1', 'proactive');
|
||||
state.assignProfileToTask('task-3', 'profile-2', 'Profile 2', 'proactive');
|
||||
|
||||
const result = state.getRunningTasksByProfile();
|
||||
|
||||
expect(result.byProfile['profile-1']).toHaveLength(2);
|
||||
expect(result.byProfile['profile-2']).toHaveLength(1);
|
||||
expect(result.totalRunning).toBe(3);
|
||||
});
|
||||
|
||||
it('should use default profile for unassigned tasks', () => {
|
||||
// Add process without profile assignment
|
||||
state.addProcess('task-1', { pid: 1001 } as any);
|
||||
|
||||
const result = state.getRunningTasksByProfile();
|
||||
|
||||
expect(result.byProfile['default']).toContain('task-1');
|
||||
expect(result.totalRunning).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignProfileToTask', () => {
|
||||
it('should assign profile to task', () => {
|
||||
state.assignProfileToTask('task-1', 'profile-1', 'Test Profile', 'proactive');
|
||||
|
||||
const assignment = state.getTaskProfileAssignment('task-1');
|
||||
|
||||
expect(assignment).toBeDefined();
|
||||
expect(assignment?.profileId).toBe('profile-1');
|
||||
expect(assignment?.profileName).toBe('Test Profile');
|
||||
expect(assignment?.reason).toBe('proactive');
|
||||
});
|
||||
|
||||
it('should preserve session ID when reassigning profile', () => {
|
||||
// Initial assignment
|
||||
state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive');
|
||||
state.updateTaskSession('task-1', 'session-abc');
|
||||
|
||||
// Reassign to different profile
|
||||
state.assignProfileToTask('task-1', 'profile-2', 'Profile 2', 'reactive');
|
||||
|
||||
const assignment = state.getTaskProfileAssignment('task-1');
|
||||
expect(assignment?.profileId).toBe('profile-2');
|
||||
expect(assignment?.sessionId).toBe('session-abc');
|
||||
});
|
||||
|
||||
it('should support different assignment reasons', () => {
|
||||
state.assignProfileToTask('task-1', 'p1', 'P1', 'proactive');
|
||||
state.assignProfileToTask('task-2', 'p2', 'P2', 'reactive');
|
||||
state.assignProfileToTask('task-3', 'p3', 'P3', 'manual');
|
||||
|
||||
expect(state.getTaskProfileAssignment('task-1')?.reason).toBe('proactive');
|
||||
expect(state.getTaskProfileAssignment('task-2')?.reason).toBe('reactive');
|
||||
expect(state.getTaskProfileAssignment('task-3')?.reason).toBe('manual');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTaskProfileAssignment', () => {
|
||||
it('should return undefined for non-existent task', () => {
|
||||
const assignment = state.getTaskProfileAssignment('non-existent');
|
||||
expect(assignment).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskSession', () => {
|
||||
it('should update session ID for existing assignment', () => {
|
||||
state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive');
|
||||
state.updateTaskSession('task-1', 'session-123');
|
||||
|
||||
const assignment = state.getTaskProfileAssignment('task-1');
|
||||
expect(assignment?.sessionId).toBe('session-123');
|
||||
});
|
||||
|
||||
it('should create minimal assignment if none exists', () => {
|
||||
// Update session without prior assignment
|
||||
state.updateTaskSession('task-1', 'session-456');
|
||||
|
||||
const assignment = state.getTaskProfileAssignment('task-1');
|
||||
expect(assignment).toBeDefined();
|
||||
expect(assignment?.sessionId).toBe('session-456');
|
||||
expect(assignment?.profileId).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should create assignment with provided profile info', () => {
|
||||
state.updateTaskSession('task-1', 'session-789', {
|
||||
profileId: 'my-profile',
|
||||
profileName: 'My Profile'
|
||||
});
|
||||
|
||||
const assignment = state.getTaskProfileAssignment('task-1');
|
||||
expect(assignment).toBeDefined();
|
||||
expect(assignment?.sessionId).toBe('session-789');
|
||||
expect(assignment?.profileId).toBe('my-profile');
|
||||
expect(assignment?.profileName).toBe('My Profile');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTaskSessionId', () => {
|
||||
it('should return session ID if set', () => {
|
||||
state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive');
|
||||
state.updateTaskSession('task-1', 'session-abc');
|
||||
|
||||
expect(state.getTaskSessionId('task-1')).toBe('session-abc');
|
||||
});
|
||||
|
||||
it('should return undefined if no session set', () => {
|
||||
state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive');
|
||||
|
||||
expect(state.getTaskSessionId('task-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent task', () => {
|
||||
expect(state.getTaskSessionId('non-existent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearTaskProfileAssignment', () => {
|
||||
it('should clear profile assignment for task', () => {
|
||||
state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive');
|
||||
state.clearTaskProfileAssignment('task-1');
|
||||
|
||||
expect(state.getTaskProfileAssignment('task-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not affect other tasks', () => {
|
||||
state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive');
|
||||
state.assignProfileToTask('task-2', 'profile-2', 'Profile 2', 'proactive');
|
||||
|
||||
state.clearTaskProfileAssignment('task-1');
|
||||
|
||||
expect(state.getTaskProfileAssignment('task-2')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear all profile assignments', () => {
|
||||
state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive');
|
||||
state.assignProfileToTask('task-2', 'profile-2', 'Profile 2', 'proactive');
|
||||
|
||||
state.clear();
|
||||
|
||||
expect(state.getTaskProfileAssignment('task-1')).toBeUndefined();
|
||||
expect(state.getTaskProfileAssignment('task-2')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,15 @@
|
||||
import { AgentProcess } from './types';
|
||||
|
||||
/**
|
||||
* Profile assignment for a task
|
||||
*/
|
||||
interface TaskProfileAssignment {
|
||||
profileId: string;
|
||||
profileName: string;
|
||||
reason: 'proactive' | 'reactive' | 'manual';
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent state tracking and process map management
|
||||
*/
|
||||
@@ -8,6 +18,9 @@ export class AgentState {
|
||||
private killedSpawnIds: Set<number> = new Set();
|
||||
private spawnCounter: number = 0;
|
||||
|
||||
// Queue routing state (rate limit recovery)
|
||||
private taskProfileAssignments: Map<string, TaskProfileAssignment> = new Map();
|
||||
|
||||
/**
|
||||
* Generate a unique spawn ID
|
||||
*/
|
||||
@@ -84,5 +97,97 @@ export class AgentState {
|
||||
clear(): void {
|
||||
this.processes.clear();
|
||||
this.killedSpawnIds.clear();
|
||||
this.taskProfileAssignments.clear();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Queue Routing Methods (Rate Limit Recovery)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get running tasks grouped by profile
|
||||
*/
|
||||
getRunningTasksByProfile(): { byProfile: Record<string, string[]>; totalRunning: number } {
|
||||
const byProfile: Record<string, string[]> = {};
|
||||
let totalRunning = 0;
|
||||
|
||||
for (const [taskId] of this.processes) {
|
||||
const assignment = this.taskProfileAssignments.get(taskId);
|
||||
const profileId = assignment?.profileId || 'default';
|
||||
|
||||
if (!byProfile[profileId]) {
|
||||
byProfile[profileId] = [];
|
||||
}
|
||||
byProfile[profileId].push(taskId);
|
||||
totalRunning++;
|
||||
}
|
||||
|
||||
return { byProfile, totalRunning };
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a profile to a task
|
||||
*/
|
||||
assignProfileToTask(
|
||||
taskId: string,
|
||||
profileId: string,
|
||||
profileName: string,
|
||||
reason: 'proactive' | 'reactive' | 'manual'
|
||||
): void {
|
||||
const existing = this.taskProfileAssignments.get(taskId);
|
||||
this.taskProfileAssignments.set(taskId, {
|
||||
profileId,
|
||||
profileName,
|
||||
reason,
|
||||
sessionId: existing?.sessionId // Preserve session ID if exists
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the profile assignment for a task
|
||||
*/
|
||||
getTaskProfileAssignment(taskId: string): TaskProfileAssignment | undefined {
|
||||
return this.taskProfileAssignments.get(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the session ID for a task
|
||||
*
|
||||
* @param taskId - The task ID
|
||||
* @param sessionId - The Claude SDK session ID
|
||||
* @param profileInfo - Optional profile info when creating a new assignment
|
||||
*/
|
||||
updateTaskSession(
|
||||
taskId: string,
|
||||
sessionId: string,
|
||||
profileInfo?: { profileId: string; profileName: string }
|
||||
): void {
|
||||
const assignment = this.taskProfileAssignments.get(taskId);
|
||||
if (assignment) {
|
||||
assignment.sessionId = sessionId;
|
||||
} else {
|
||||
// Create a minimal assignment if none exists
|
||||
// Use provided profile info or 'unknown' as a placeholder
|
||||
this.taskProfileAssignments.set(taskId, {
|
||||
profileId: profileInfo?.profileId ?? 'unknown',
|
||||
profileName: profileInfo?.profileName ?? 'Unknown',
|
||||
reason: 'proactive',
|
||||
sessionId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session ID for a task
|
||||
*/
|
||||
getTaskSessionId(taskId: string): string | undefined {
|
||||
return this.taskProfileAssignments.get(taskId)?.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear profile assignment for a task (on task completion)
|
||||
*/
|
||||
clearTaskProfileAssignment(taskId: string): void {
|
||||
this.taskProfileAssignments.delete(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
import { buildChangelogPrompt, buildGitPrompt, createGenerationScript } from './formatter';
|
||||
import { extractChangelog } from './parser';
|
||||
import { getCommits, getBranchDiffCommits } from './git-integration';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getBestAvailableProfileEnv } from '../rate-limit-detector';
|
||||
import { parsePythonCommand } from '../python-detector';
|
||||
import { getAugmentedEnv } from '../env-utils';
|
||||
import { isWindows } from '../platform';
|
||||
@@ -251,12 +251,15 @@ export class ChangelogGenerator extends EventEmitter {
|
||||
// even when app is launched from Finder/Dock
|
||||
const augmentedEnv = getAugmentedEnv();
|
||||
|
||||
// Get active Claude profile environment (OAuth token preferred, falls back to CLAUDE_CONFIG_DIR)
|
||||
const profileEnv = getProfileEnv();
|
||||
// Get best available Claude profile environment (automatically handles rate limits)
|
||||
const profileResult = getBestAvailableProfileEnv();
|
||||
const profileEnv = profileResult.env;
|
||||
this.debug('Active profile environment', {
|
||||
hasOAuthToken: !!profileEnv.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
hasConfigDir: !!profileEnv.CLAUDE_CONFIG_DIR,
|
||||
authMethod: profileEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'oauth-token' : (profileEnv.CLAUDE_CONFIG_DIR ? 'config-dir' : 'default')
|
||||
authMethod: profileEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'oauth-token' : (profileEnv.CLAUDE_CONFIG_DIR ? 'config-dir' : 'default'),
|
||||
wasSwapped: profileResult.wasSwapped,
|
||||
selectedProfile: profileResult.profileName
|
||||
});
|
||||
|
||||
const spawnEnv: Record<string, string> = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn } from 'child_process';
|
||||
import * as os from 'os';
|
||||
import type { GitCommit } from '../../shared/types';
|
||||
import { getProfileEnv } from '../rate-limit-detector';
|
||||
import { getBestAvailableProfileEnv } from '../rate-limit-detector';
|
||||
import { parsePythonCommand } from '../python-detector';
|
||||
import { getAugmentedEnv } from '../env-utils';
|
||||
import { isWindows, requiresShell } from '../platform';
|
||||
@@ -233,8 +233,9 @@ except Exception as e:
|
||||
// even when app is launched from Finder/Dock
|
||||
const augmentedEnv = getAugmentedEnv();
|
||||
|
||||
// Get active Claude profile environment
|
||||
const profileEnv = getProfileEnv();
|
||||
// Get best available Claude profile environment (automatically handles rate limits)
|
||||
const profileResult = getBestAvailableProfileEnv();
|
||||
const profileEnv = profileResult.env;
|
||||
|
||||
const spawnEnv: Record<string, string> = {
|
||||
...augmentedEnv,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { app } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { mkdir } from 'fs/promises';
|
||||
import { homedir } from 'os';
|
||||
import type {
|
||||
ClaudeProfile,
|
||||
ClaudeProfileSettings,
|
||||
@@ -43,12 +44,13 @@ import {
|
||||
getProfilesSortedByAvailability as getProfilesSortedByAvailabilityImpl
|
||||
} from './claude-profile/profile-scorer';
|
||||
import {
|
||||
DEFAULT_CLAUDE_CONFIG_DIR,
|
||||
CLAUDE_PROFILES_DIR,
|
||||
generateProfileId as generateProfileIdImpl,
|
||||
createProfileDirectory as createProfileDirectoryImpl,
|
||||
isProfileAuthenticated as isProfileAuthenticatedImpl,
|
||||
hasValidToken,
|
||||
expandHomePath
|
||||
expandHomePath,
|
||||
getEmailFromConfigDir
|
||||
} from './claude-profile/profile-utils';
|
||||
|
||||
/**
|
||||
@@ -111,8 +113,6 @@ export class ClaudeProfileManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Import dynamically to avoid circular dependency issues
|
||||
const { getEmailFromConfigDir } = require('./claude-profile/profile-utils');
|
||||
const configEmail = getEmailFromConfigDir(profile.configDir);
|
||||
|
||||
if (configEmail && profile.email !== configEmail) {
|
||||
@@ -166,21 +166,31 @@ export class ClaudeProfileManager {
|
||||
|
||||
/**
|
||||
* Create default profile data
|
||||
*
|
||||
* IMPORTANT: New profiles use isolated directories (~/.claude-profiles/{name})
|
||||
* to prevent interference with external Claude Code CLI usage.
|
||||
* The profile name is used as the directory name (sanitized to lowercase).
|
||||
*/
|
||||
private createDefaultData(): ProfileStoreData {
|
||||
// Use an isolated directory for the initial profile
|
||||
// This prevents interference with external Claude Code CLI which uses ~/.claude
|
||||
const initialProfileName = 'Primary';
|
||||
const sanitizedName = initialProfileName.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
const isolatedConfigDir = join(CLAUDE_PROFILES_DIR, sanitizedName);
|
||||
|
||||
const defaultProfile: ClaudeProfile = {
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
configDir: DEFAULT_CLAUDE_CONFIG_DIR,
|
||||
isDefault: true,
|
||||
description: 'Default Claude configuration (~/.claude)',
|
||||
id: sanitizedName, // Use sanitized name as ID (e.g., 'primary')
|
||||
name: initialProfileName,
|
||||
configDir: isolatedConfigDir,
|
||||
isDefault: true, // First profile is the default
|
||||
description: 'Primary Claude account',
|
||||
createdAt: new Date()
|
||||
};
|
||||
|
||||
return {
|
||||
version: 3,
|
||||
profiles: [defaultProfile],
|
||||
activeProfileId: 'default',
|
||||
activeProfileId: sanitizedName,
|
||||
autoSwitch: DEFAULT_AUTO_SWITCH_SETTINGS
|
||||
};
|
||||
}
|
||||
@@ -483,21 +493,17 @@ export class ClaudeProfileManager {
|
||||
const profile = this.getActiveProfile();
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
// Default profile: Claude CLI uses ~/.claude implicitly (no env var needed)
|
||||
if (profile?.isDefault) {
|
||||
console.warn('[ClaudeProfileManager] Using default profile (Claude CLI uses ~/.claude)');
|
||||
return env;
|
||||
}
|
||||
|
||||
// Non-default profiles: set CLAUDE_CONFIG_DIR to point Claude CLI to profile's config
|
||||
// Claude CLI will read fresh tokens from Keychain, benefiting from auto-refresh
|
||||
// All profiles now use explicit CLAUDE_CONFIG_DIR for isolation
|
||||
// This prevents interference with external Claude Code CLI usage
|
||||
if (profile?.configDir) {
|
||||
// Expand ~ to home directory for the environment variable
|
||||
const expandedConfigDir = profile.configDir.startsWith('~')
|
||||
? profile.configDir.replace(/^~/, require('os').homedir())
|
||||
? profile.configDir.replace(/^~/, homedir())
|
||||
: profile.configDir;
|
||||
env.CLAUDE_CONFIG_DIR = expandedConfigDir;
|
||||
console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir);
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir);
|
||||
}
|
||||
} else {
|
||||
console.warn('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name);
|
||||
}
|
||||
@@ -611,12 +617,19 @@ export class ClaudeProfileManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best profile to switch to based on usage and rate limit status
|
||||
* Get the best profile to switch to based on priority order and availability
|
||||
* Returns null if no good alternative is available
|
||||
*
|
||||
* Selection logic:
|
||||
* 1. Respects user's configured account priority order
|
||||
* 2. Filters by availability (authenticated, not rate-limited, below thresholds)
|
||||
* 3. Returns first available profile in priority order
|
||||
* 4. Falls back to "least bad" option if no profile meets all criteria
|
||||
*/
|
||||
getBestAvailableProfile(excludeProfileId?: string): ClaudeProfile | null {
|
||||
const settings = this.getAutoSwitchSettings();
|
||||
return getBestAvailableProfile(this.data.profiles, settings, excludeProfileId);
|
||||
const priorityOrder = this.getAccountPriorityOrder();
|
||||
return getBestAvailableProfile(this.data.profiles, settings, excludeProfileId, priorityOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -629,7 +642,8 @@ export class ClaudeProfileManager {
|
||||
}
|
||||
|
||||
const settings = this.getAutoSwitchSettings();
|
||||
return shouldProactivelySwitchImpl(profile, this.data.profiles, settings);
|
||||
const priorityOrder = this.getAccountPriorityOrder();
|
||||
return shouldProactivelySwitchImpl(profile, this.data.profiles, settings, priorityOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -683,7 +697,14 @@ export class ClaudeProfileManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables for invoking Claude with a specific profile
|
||||
* Get environment variables for invoking Claude with a specific profile.
|
||||
*
|
||||
* IMPORTANT: Always returns CLAUDE_CONFIG_DIR for the profile, even for the default profile.
|
||||
* This ensures that when we switch to a specific profile for rate limit recovery,
|
||||
* we use that profile's exact configDir credentials, not just whatever happens to be
|
||||
* at ~/.claude (which might belong to a different profile).
|
||||
*
|
||||
* The ~ path is expanded to the full home directory path.
|
||||
*/
|
||||
getProfileEnv(profileId: string): Record<string, string> {
|
||||
const profile = this.getProfile(profileId);
|
||||
@@ -691,18 +712,28 @@ export class ClaudeProfileManager {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Only set CLAUDE_CONFIG_DIR if not using default
|
||||
if (profile.isDefault) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Only set CLAUDE_CONFIG_DIR if configDir is defined
|
||||
// If no configDir is defined, fall back to default
|
||||
if (!profile.configDir) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Expand ~ to home directory for the environment variable
|
||||
const expandedConfigDir = profile.configDir.startsWith('~')
|
||||
? profile.configDir.replace(/^~/, require('os').homedir())
|
||||
: profile.configDir;
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[ClaudeProfileManager] getProfileEnv:', {
|
||||
profileId,
|
||||
profileName: profile.name,
|
||||
isDefault: profile.isDefault,
|
||||
configDir: profile.configDir,
|
||||
expandedConfigDir
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
CLAUDE_CONFIG_DIR: profile.configDir
|
||||
CLAUDE_CONFIG_DIR: expandedConfigDir
|
||||
};
|
||||
}
|
||||
|
||||
@@ -723,6 +754,46 @@ export class ClaudeProfileManager {
|
||||
getProfilesSortedByAvailability(): ClaudeProfile[] {
|
||||
return getProfilesSortedByAvailabilityImpl(this.data.profiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of profile IDs that were migrated from shared ~/.claude to isolated directories.
|
||||
* These profiles need re-authentication since their credentials are in the old location.
|
||||
*/
|
||||
getMigratedProfileIds(): string[] {
|
||||
return this.data.migratedProfileIds || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a profile from the migrated list after successful re-authentication.
|
||||
* Called when the user completes re-authentication for a migrated profile.
|
||||
*
|
||||
* @param profileId - The profile ID to clear from the migrated list
|
||||
*/
|
||||
clearMigratedProfile(profileId: string): void {
|
||||
if (!this.data.migratedProfileIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.data.migratedProfileIds = this.data.migratedProfileIds.filter(id => id !== profileId);
|
||||
|
||||
// If list is empty, remove the property entirely
|
||||
if (this.data.migratedProfileIds.length === 0) {
|
||||
delete this.data.migratedProfileIds;
|
||||
}
|
||||
|
||||
this.save();
|
||||
console.warn('[ClaudeProfileManager] Cleared migrated profile:', profileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a profile was migrated and needs re-authentication.
|
||||
*
|
||||
* @param profileId - The profile ID to check
|
||||
* @returns true if the profile was migrated and needs re-auth
|
||||
*/
|
||||
isProfileMigrated(profileId: string): boolean {
|
||||
return this.data.migratedProfileIds?.includes(profileId) ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance and initialization promise
|
||||
|
||||
@@ -210,8 +210,37 @@ describe('credential-utils', () => {
|
||||
vi.mocked(homedir).mockReturnValue('/home/testuser');
|
||||
});
|
||||
|
||||
it('should return credentials from .credentials.json', () => {
|
||||
// Helper to mock Secret Service not available (secret-tool not found)
|
||||
const mockSecretServiceUnavailable = () => {
|
||||
vi.mocked(existsSync).mockImplementation((path) => {
|
||||
const pathStr = String(path);
|
||||
// secret-tool not found
|
||||
if (pathStr.includes('secret-tool')) return false;
|
||||
// credentials file exists
|
||||
if (pathStr.includes('.credentials.json')) return true;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
it('should return credentials from Secret Service when available', () => {
|
||||
// secret-tool exists and returns credentials
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'sk-ant-secret-service-token',
|
||||
email: 'secretservice@example.com',
|
||||
},
|
||||
}));
|
||||
|
||||
const result = getCredentialsFromKeychain();
|
||||
|
||||
expect(result.token).toBe('sk-ant-secret-service-token');
|
||||
expect(result.email).toBe('secretservice@example.com');
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fall back to .credentials.json when Secret Service unavailable', () => {
|
||||
mockSecretServiceUnavailable();
|
||||
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'sk-ant-linux-token-456',
|
||||
@@ -226,7 +255,7 @@ describe('credential-utils', () => {
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return null when credentials file not found', () => {
|
||||
it('should return null when credentials file not found and Secret Service unavailable', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
const result = getCredentialsFromKeychain();
|
||||
@@ -237,7 +266,12 @@ describe('credential-utils', () => {
|
||||
|
||||
it('should use custom configDir for credentials path', () => {
|
||||
const customConfigDir = '/home/user/.claude-profiles/work';
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
mockSecretServiceUnavailable();
|
||||
vi.mocked(existsSync).mockImplementation((path) => {
|
||||
const pathStr = String(path);
|
||||
if (pathStr.includes('secret-tool')) return false;
|
||||
return true; // credentials file exists
|
||||
});
|
||||
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'sk-ant-custom-token',
|
||||
@@ -252,7 +286,7 @@ describe('credential-utils', () => {
|
||||
});
|
||||
|
||||
it('should handle emailAddress field (alternative email location)', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
mockSecretServiceUnavailable();
|
||||
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'sk-ant-test-token',
|
||||
@@ -266,7 +300,7 @@ describe('credential-utils', () => {
|
||||
});
|
||||
|
||||
it('should handle top-level email field', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
mockSecretServiceUnavailable();
|
||||
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'sk-ant-test-token',
|
||||
@@ -280,7 +314,7 @@ describe('credential-utils', () => {
|
||||
});
|
||||
|
||||
it('should handle file read permission errors', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
mockSecretServiceUnavailable();
|
||||
vi.mocked(readFileSync).mockImplementation(() => {
|
||||
throw new Error('EACCES: permission denied');
|
||||
});
|
||||
@@ -290,6 +324,30 @@ describe('credential-utils', () => {
|
||||
expect(result.token).toBeNull();
|
||||
expect(result.email).toBeNull();
|
||||
});
|
||||
|
||||
it('should fall back to file when Secret Service lookup fails', () => {
|
||||
// secret-tool exists but lookup fails
|
||||
vi.mocked(existsSync).mockImplementation((path) => {
|
||||
const pathStr = String(path);
|
||||
if (pathStr.includes('secret-tool')) return true;
|
||||
if (pathStr.includes('.credentials.json')) return true;
|
||||
return false;
|
||||
});
|
||||
vi.mocked(execFileSync).mockImplementation(() => {
|
||||
throw new Error('secret-tool lookup failed');
|
||||
});
|
||||
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'sk-ant-fallback-token',
|
||||
email: 'fallback@example.com',
|
||||
},
|
||||
}));
|
||||
|
||||
const result = getCredentialsFromKeychain();
|
||||
|
||||
expect(result.token).toBe('sk-ant-fallback-token');
|
||||
expect(result.email).toBe('fallback@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentialsFromKeychain (Windows)', () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,147 @@
|
||||
/**
|
||||
* Profile Scorer Module
|
||||
* Handles profile availability scoring and auto-switch logic
|
||||
*
|
||||
* Priority-Based Selection (v2):
|
||||
* 1. User's configured priority order is the PRIMARY factor
|
||||
* 2. Accounts are filtered by availability criteria:
|
||||
* - Must be authenticated
|
||||
* - Must not be rate-limited (explicit 429 error)
|
||||
* - Must be below user's configured thresholds (default: 95% session, 99% weekly)
|
||||
* 3. First profile in priority order that passes all filters is selected
|
||||
* 4. If no profile passes all filters, falls back to "least bad" option
|
||||
*/
|
||||
|
||||
import type { ClaudeProfile, ClaudeAutoSwitchSettings } from '../../shared/types';
|
||||
import { isProfileRateLimited } from './rate-limit-manager';
|
||||
import { isProfileAuthenticated } from './profile-utils';
|
||||
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
|
||||
interface ScoredProfile {
|
||||
profile: ClaudeProfile;
|
||||
score: number;
|
||||
priorityIndex: number;
|
||||
isAvailable: boolean;
|
||||
unavailableReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best profile to switch to based on usage and rate limit status
|
||||
* Returns null if no good alternative is available
|
||||
* Check if a profile is available for use based on all criteria
|
||||
*/
|
||||
function checkProfileAvailability(
|
||||
profile: ClaudeProfile,
|
||||
settings: ClaudeAutoSwitchSettings
|
||||
): { available: boolean; reason?: string } {
|
||||
// Check authentication
|
||||
if (!isProfileAuthenticated(profile)) {
|
||||
return { available: false, reason: 'not authenticated' };
|
||||
}
|
||||
|
||||
// Check explicit rate limit (from 429 errors)
|
||||
const rateLimitStatus = isProfileRateLimited(profile);
|
||||
if (rateLimitStatus.limited) {
|
||||
return {
|
||||
available: false,
|
||||
reason: `rate limited (${rateLimitStatus.type}, resets ${rateLimitStatus.resetAt?.toISOString() || 'unknown'})`
|
||||
};
|
||||
}
|
||||
|
||||
// Check usage thresholds
|
||||
if (profile.usage) {
|
||||
// Weekly threshold check (more important - longer reset time)
|
||||
// Using >= to reject profiles AT or ABOVE threshold (e.g., 95% is rejected when threshold is 95%)
|
||||
// This is intentional: we want to switch proactively BEFORE hitting hard limits
|
||||
if (profile.usage.weeklyUsagePercent >= settings.weeklyThreshold) {
|
||||
return {
|
||||
available: false,
|
||||
reason: `weekly usage ${profile.usage.weeklyUsagePercent}% >= threshold ${settings.weeklyThreshold}%`
|
||||
};
|
||||
}
|
||||
|
||||
// Session threshold check
|
||||
// Using >= to reject profiles AT or ABOVE threshold (same rationale as weekly)
|
||||
if (profile.usage.sessionUsagePercent >= settings.sessionThreshold) {
|
||||
return {
|
||||
available: false,
|
||||
reason: `session usage ${profile.usage.sessionUsagePercent}% >= threshold ${settings.sessionThreshold}%`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { available: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a fallback score for when no profiles meet all criteria
|
||||
* Used to pick the "least bad" option
|
||||
*/
|
||||
function calculateFallbackScore(
|
||||
profile: ClaudeProfile,
|
||||
settings: ClaudeAutoSwitchSettings
|
||||
): number {
|
||||
let score = 100;
|
||||
const now = new Date();
|
||||
|
||||
// Authentication is critical
|
||||
if (!isProfileAuthenticated(profile)) {
|
||||
score -= 1000; // Unauthenticated is basically unusable
|
||||
}
|
||||
|
||||
// Rate limit status
|
||||
const rateLimitStatus = isProfileRateLimited(profile);
|
||||
if (rateLimitStatus.limited) {
|
||||
if (rateLimitStatus.type === 'weekly') {
|
||||
score -= 500; // Weekly limit is worse (longer reset)
|
||||
} else {
|
||||
score -= 200; // Session limit resets sooner
|
||||
}
|
||||
|
||||
// Bonus for profiles that reset sooner
|
||||
if (rateLimitStatus.resetAt) {
|
||||
const hoursUntilReset = (rateLimitStatus.resetAt.getTime() - now.getTime()) / (1000 * 60 * 60);
|
||||
score += Math.max(0, 50 - hoursUntilReset);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage penalties (prefer lower usage)
|
||||
if (profile.usage) {
|
||||
// Penalize based on how far over threshold
|
||||
const weeklyOverage = Math.max(0, profile.usage.weeklyUsagePercent - settings.weeklyThreshold);
|
||||
const sessionOverage = Math.max(0, profile.usage.sessionUsagePercent - settings.sessionThreshold);
|
||||
|
||||
score -= weeklyOverage * 2; // Weekly overage is worse
|
||||
score -= sessionOverage;
|
||||
|
||||
// Also factor in absolute usage (lower is better)
|
||||
score -= profile.usage.weeklyUsagePercent * 0.3;
|
||||
score -= profile.usage.sessionUsagePercent * 0.1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best profile to switch to based on priority order and availability
|
||||
*
|
||||
* Selection Logic:
|
||||
* 1. Filter to candidates (excluding the current profile)
|
||||
* 2. Check each profile's availability (auth, rate limit, thresholds)
|
||||
* 3. Sort by user's priority order
|
||||
* 4. Return the first available profile in priority order
|
||||
* 5. If none available, return the "least bad" option based on fallback scoring
|
||||
*
|
||||
* @param profiles - All Claude profiles
|
||||
* @param settings - Auto-switch settings (contains thresholds)
|
||||
* @param excludeProfileId - Profile ID to exclude (usually the current/failing one)
|
||||
* @param priorityOrder - User's configured priority order (array of unified IDs like 'oauth-{id}')
|
||||
*/
|
||||
export function getBestAvailableProfile(
|
||||
profiles: ClaudeProfile[],
|
||||
settings: ClaudeAutoSwitchSettings,
|
||||
excludeProfileId?: string
|
||||
excludeProfileId?: string,
|
||||
priorityOrder: string[] = []
|
||||
): ClaudeProfile | null {
|
||||
const now = new Date();
|
||||
|
||||
// Get all profiles except the excluded one
|
||||
const candidates = profiles.filter(p => p.id !== excludeProfileId);
|
||||
|
||||
@@ -30,79 +149,77 @@ export function getBestAvailableProfile(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Score each profile based on:
|
||||
// 1. Not rate-limited (highest priority)
|
||||
// 2. Lower weekly usage (more important than session)
|
||||
// 3. Lower session usage
|
||||
// 4. More recently authenticated
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[ProfileScorer] Evaluating', candidates.length, 'candidate profiles (excluding:', excludeProfileId, ')');
|
||||
console.warn('[ProfileScorer] Priority order:', priorityOrder);
|
||||
console.warn('[ProfileScorer] Thresholds: session =', settings.sessionThreshold, '%, weekly =', settings.weeklyThreshold, '%');
|
||||
}
|
||||
|
||||
// Score and check availability for each profile
|
||||
const scoredProfiles: ScoredProfile[] = candidates.map(profile => {
|
||||
let score = 100; // Base score
|
||||
if (isDebug) console.warn('[ProfileScorer] Scoring profile:', profile.name, '(', profile.id, ')');
|
||||
const unifiedId = `oauth-${profile.id}`;
|
||||
const priorityIndex = priorityOrder.indexOf(unifiedId);
|
||||
const availability = checkProfileAvailability(profile, settings);
|
||||
const fallbackScore = calculateFallbackScore(profile, settings);
|
||||
|
||||
// Check rate limit status
|
||||
const rateLimitStatus = isProfileRateLimited(profile);
|
||||
if (isDebug) console.warn('[ProfileScorer] Rate limit status:', rateLimitStatus);
|
||||
if (rateLimitStatus.limited) {
|
||||
// Severely penalize rate-limited profiles
|
||||
if (rateLimitStatus.type === 'weekly') {
|
||||
score -= 1000; // Weekly limit is worse
|
||||
} else {
|
||||
score -= 500; // Session limit will reset sooner
|
||||
}
|
||||
|
||||
// But add back some score based on how soon it resets
|
||||
if (rateLimitStatus.resetAt) {
|
||||
const hoursUntilReset = (rateLimitStatus.resetAt.getTime() - now.getTime()) / (1000 * 60 * 60);
|
||||
score += Math.max(0, 50 - hoursUntilReset); // Closer reset = higher score
|
||||
}
|
||||
if (isDebug) {
|
||||
console.warn('[ProfileScorer] Scoring profile:', profile.name, '(', profile.id, ')');
|
||||
console.warn('[ProfileScorer] Priority index:', priorityIndex === -1 ? 'not in list (Infinity)' : priorityIndex);
|
||||
console.warn('[ProfileScorer] Available:', availability.available, availability.reason ? `(${availability.reason})` : '');
|
||||
console.warn('[ProfileScorer] Usage:', profile.usage ? `session=${profile.usage.sessionUsagePercent}%, weekly=${profile.usage.weeklyUsagePercent}%` : 'unknown');
|
||||
console.warn('[ProfileScorer] Fallback score:', fallbackScore);
|
||||
}
|
||||
|
||||
// Factor in current usage (if known)
|
||||
if (profile.usage) {
|
||||
// Weekly usage is more important
|
||||
score -= profile.usage.weeklyUsagePercent * 0.5;
|
||||
// Session usage is less important (resets more frequently)
|
||||
score -= profile.usage.sessionUsagePercent * 0.2;
|
||||
|
||||
// Penalize if above thresholds
|
||||
if (profile.usage.weeklyUsagePercent >= settings.weeklyThreshold) {
|
||||
score -= 200;
|
||||
}
|
||||
if (profile.usage.sessionUsagePercent >= settings.sessionThreshold) {
|
||||
score -= 100;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if authenticated
|
||||
const isAuth = isProfileAuthenticated(profile);
|
||||
if (isDebug) console.warn('[ProfileScorer] isProfileAuthenticated:', isAuth, 'hasOAuthToken:', !!profile.oauthToken, 'hasConfigDir:', !!profile.configDir);
|
||||
if (!isAuth) {
|
||||
score -= 500; // Severely penalize unauthenticated profiles
|
||||
if (isDebug) console.warn('[ProfileScorer] Applied -500 penalty for no auth');
|
||||
}
|
||||
|
||||
if (isDebug) console.warn('[ProfileScorer] Final score:', score);
|
||||
return { profile, score };
|
||||
return {
|
||||
profile,
|
||||
score: fallbackScore,
|
||||
priorityIndex: priorityIndex === -1 ? Infinity : priorityIndex,
|
||||
isAvailable: availability.available,
|
||||
unavailableReason: availability.reason
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by score (highest first)
|
||||
scoredProfiles.sort((a, b) => b.score - a.score);
|
||||
// Sort by:
|
||||
// 1. Available profiles first
|
||||
// 2. Within available: by priority index (lower = higher priority)
|
||||
// 3. Within unavailable: by fallback score (higher = better)
|
||||
scoredProfiles.sort((a, b) => {
|
||||
// Available profiles always come first
|
||||
if (a.isAvailable !== b.isAvailable) {
|
||||
return a.isAvailable ? -1 : 1;
|
||||
}
|
||||
|
||||
// For available profiles, sort by priority order
|
||||
if (a.isAvailable && b.isAvailable) {
|
||||
// If both have priority indices, use them
|
||||
if (a.priorityIndex !== b.priorityIndex) {
|
||||
return a.priorityIndex - b.priorityIndex;
|
||||
}
|
||||
// Tiebreaker: prefer lower usage
|
||||
return b.score - a.score;
|
||||
}
|
||||
|
||||
// For unavailable profiles, sort by fallback score (for "least bad" selection)
|
||||
return b.score - a.score;
|
||||
});
|
||||
|
||||
// Return the best candidate if it has a positive score
|
||||
const best = scoredProfiles[0];
|
||||
if (best && best.score > 0) {
|
||||
console.warn('[ProfileScorer] Best available profile:', best.profile.name, 'score:', best.score);
|
||||
|
||||
if (best.isAvailable) {
|
||||
console.warn('[ProfileScorer] Best available profile:', best.profile.name, '(priority index:', best.priorityIndex, ')');
|
||||
return best.profile;
|
||||
}
|
||||
|
||||
// All profiles are rate-limited or have issues
|
||||
console.warn('[ProfileScorer] No good profile available, all are rate-limited or have issues');
|
||||
// No profile meets all criteria - check if we should return the least bad option
|
||||
// Only return if it has a positive score (meaning it might still work)
|
||||
if (best.score > 0) {
|
||||
console.warn('[ProfileScorer] No ideal profile available, using least-bad option:', best.profile.name,
|
||||
'(score:', best.score, ', reason:', best.unavailableReason, ')');
|
||||
return best.profile;
|
||||
}
|
||||
|
||||
// All profiles are truly unusable
|
||||
console.warn('[ProfileScorer] No usable profile available, all have issues');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -112,7 +229,8 @@ export function getBestAvailableProfile(
|
||||
export function shouldProactivelySwitch(
|
||||
profile: ClaudeProfile,
|
||||
allProfiles: ClaudeProfile[],
|
||||
settings: ClaudeAutoSwitchSettings
|
||||
settings: ClaudeAutoSwitchSettings,
|
||||
priorityOrder: string[] = []
|
||||
): { shouldSwitch: boolean; reason?: string; suggestedProfile?: ClaudeProfile } {
|
||||
if (!settings.enabled) {
|
||||
return { shouldSwitch: false };
|
||||
@@ -126,7 +244,7 @@ export function shouldProactivelySwitch(
|
||||
|
||||
// Check if we're approaching limits
|
||||
if (usage.weeklyUsagePercent >= settings.weeklyThreshold) {
|
||||
const bestProfile = getBestAvailableProfile(allProfiles, settings, profile.id);
|
||||
const bestProfile = getBestAvailableProfile(allProfiles, settings, profile.id, priorityOrder);
|
||||
if (bestProfile) {
|
||||
return {
|
||||
shouldSwitch: true,
|
||||
@@ -137,7 +255,7 @@ export function shouldProactivelySwitch(
|
||||
}
|
||||
|
||||
if (usage.sessionUsagePercent >= settings.sessionThreshold) {
|
||||
const bestProfile = getBestAvailableProfile(allProfiles, settings, profile.id);
|
||||
const bestProfile = getBestAvailableProfile(allProfiles, settings, profile.id, priorityOrder);
|
||||
if (bestProfile) {
|
||||
return {
|
||||
shouldSwitch: true,
|
||||
@@ -152,11 +270,17 @@ export function shouldProactivelySwitch(
|
||||
|
||||
/**
|
||||
* Get profiles sorted by availability (best first)
|
||||
* This is a simpler sort that doesn't consider priority order - used for display purposes
|
||||
*/
|
||||
export function getProfilesSortedByAvailability(profiles: ClaudeProfile[]): ClaudeProfile[] {
|
||||
const _now = new Date();
|
||||
|
||||
return [...profiles].sort((a, b) => {
|
||||
// Authenticated profiles first
|
||||
const aAuth = isProfileAuthenticated(a);
|
||||
const bAuth = isProfileAuthenticated(b);
|
||||
if (aAuth !== bAuth) {
|
||||
return aAuth ? -1 : 1;
|
||||
}
|
||||
|
||||
// Not rate-limited profiles first
|
||||
const aLimited = isProfileRateLimited(a);
|
||||
const bLimited = isProfileRateLimited(b);
|
||||
|
||||
@@ -3,10 +3,18 @@
|
||||
* Handles persistence of profile data to disk
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import type { ClaudeProfile, ClaudeAutoSwitchSettings } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Directory constants for profile isolation
|
||||
*/
|
||||
const DEFAULT_CLAUDE_CONFIG_DIR = join(homedir(), '.claude');
|
||||
const CLAUDE_PROFILES_DIR = join(homedir(), '.claude-profiles');
|
||||
|
||||
export const STORE_VERSION = 3; // Bumped for encrypted token storage
|
||||
|
||||
/**
|
||||
@@ -31,6 +39,94 @@ export interface ProfileStoreData {
|
||||
autoSwitch?: ClaudeAutoSwitchSettings;
|
||||
/** Unified priority order for both OAuth and API profiles */
|
||||
accountPriorityOrder?: string[];
|
||||
/**
|
||||
* Profile IDs that were migrated from shared ~/.claude to isolated directories.
|
||||
* These profiles need re-authentication since their credentials are in the old location.
|
||||
* Cleared after successful re-authentication.
|
||||
*/
|
||||
migratedProfileIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a profile uses the legacy shared ~/.claude directory
|
||||
*/
|
||||
function usesLegacySharedDirectory(profile: ClaudeProfile): boolean {
|
||||
if (!profile.configDir) return false;
|
||||
|
||||
// Normalize paths for comparison
|
||||
const normalizedConfigDir = profile.configDir.startsWith('~')
|
||||
? join(homedir(), profile.configDir.slice(1))
|
||||
: profile.configDir;
|
||||
|
||||
return normalizedConfigDir === DEFAULT_CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a profile from shared ~/.claude to isolated ~/.claude-profiles/{name}
|
||||
* Returns the new configDir path
|
||||
*
|
||||
* Handles directory collisions by appending a counter (e.g., 'work-account-2')
|
||||
* when two profile names sanitize to the same value.
|
||||
*/
|
||||
function migrateProfileToIsolatedDirectory(profile: ClaudeProfile): string {
|
||||
// Generate isolated directory name from profile name
|
||||
const baseName = profile.name.toLowerCase().replace(/[^a-z0-9]+/g, '-') || 'primary';
|
||||
|
||||
// Ensure the profiles directory exists
|
||||
if (!existsSync(CLAUDE_PROFILES_DIR)) {
|
||||
mkdirSync(CLAUDE_PROFILES_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Check for directory collision and append counter if needed
|
||||
let sanitizedName = baseName;
|
||||
let counter = 1;
|
||||
let isolatedDir = join(CLAUDE_PROFILES_DIR, sanitizedName);
|
||||
|
||||
// Keep incrementing counter until we find an available directory name
|
||||
// Use profile.id as a marker file to detect if the directory belongs to this profile
|
||||
// NOTE: There's a TOCTOU race window between existsSync and readFileSync, but this is
|
||||
// acceptable because profile directory creation is infrequent and concurrent creation
|
||||
// is unlikely. The worst case is we increment the counter unnecessarily.
|
||||
while (existsSync(isolatedDir)) {
|
||||
const markerFile = join(isolatedDir, '.profile-id');
|
||||
if (existsSync(markerFile)) {
|
||||
try {
|
||||
const existingId = readFileSync(markerFile, 'utf-8').trim();
|
||||
if (existingId === profile.id) {
|
||||
// This directory belongs to us, use it
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore read errors, treat as collision
|
||||
}
|
||||
}
|
||||
// Directory exists but belongs to different profile, try next counter
|
||||
counter++;
|
||||
sanitizedName = `${baseName}-${counter}`;
|
||||
isolatedDir = join(CLAUDE_PROFILES_DIR, sanitizedName);
|
||||
}
|
||||
|
||||
// Create the profile directory if it doesn't exist
|
||||
if (!existsSync(isolatedDir)) {
|
||||
mkdirSync(isolatedDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write a marker file with our profile ID for collision detection
|
||||
// Use 'wx' flag to atomically create file only if it doesn't exist (avoids TOCTOU race)
|
||||
const markerFile = join(isolatedDir, '.profile-id');
|
||||
try {
|
||||
writeFileSync(markerFile, profile.id, { encoding: 'utf-8', flag: 'wx' });
|
||||
} catch (err) {
|
||||
// EEXIST means file already exists, which is fine - we already own this directory
|
||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
console.warn('[ProfileStorage] Failed to write marker file:', err);
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`[ProfileStorage] Migrated profile "${profile.name}" from ~/.claude to ${isolatedDir}`);
|
||||
console.warn('[ProfileStorage] NOTE: Credentials remain at ~/.claude - user should re-authenticate in Settings > Accounts');
|
||||
|
||||
return isolatedDir;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +143,9 @@ function parseAndMigrateProfileData(data: Record<string, unknown>): ProfileStore
|
||||
}
|
||||
|
||||
if (data.version === STORE_VERSION) {
|
||||
// Track profiles that were migrated in this session
|
||||
const newlyMigratedProfileIds: string[] = [];
|
||||
|
||||
// Parse dates and migrate profile data
|
||||
const profiles = data.profiles as ClaudeProfile[];
|
||||
data.profiles = profiles.map((p: ClaudeProfile) => {
|
||||
@@ -61,8 +160,23 @@ function parseAndMigrateProfileData(data: Record<string, unknown>): ProfileStore
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { oauthToken: _, tokenCreatedAt: __, ...profileWithoutToken } = p;
|
||||
|
||||
// MIGRATION: Move profiles from shared ~/.claude to isolated directories
|
||||
// This prevents interference with external Claude Code CLI usage
|
||||
let configDir = profileWithoutToken.configDir;
|
||||
if (usesLegacySharedDirectory(p)) {
|
||||
configDir = migrateProfileToIsolatedDirectory(p);
|
||||
// Track this profile as newly migrated (needs re-authentication)
|
||||
newlyMigratedProfileIds.push(p.id);
|
||||
console.warn('[ProfileStorage] Profile isolation migration:', {
|
||||
profileName: p.name,
|
||||
oldConfigDir: p.configDir,
|
||||
newConfigDir: configDir
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...profileWithoutToken,
|
||||
configDir, // Use migrated configDir
|
||||
createdAt: new Date(p.createdAt),
|
||||
lastUsedAt: p.lastUsedAt ? new Date(p.lastUsedAt) : undefined,
|
||||
usage: p.usage ? {
|
||||
@@ -76,6 +190,14 @@ function parseAndMigrateProfileData(data: Record<string, unknown>): ProfileStore
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
// Merge newly migrated profiles with any existing migratedProfileIds
|
||||
const existingMigrated = (data.migratedProfileIds as string[] | undefined) || [];
|
||||
const allMigratedIds = [...new Set([...existingMigrated, ...newlyMigratedProfileIds])];
|
||||
if (allMigratedIds.length > 0) {
|
||||
data.migratedProfileIds = allMigratedIds;
|
||||
}
|
||||
|
||||
return data as unknown as ProfileStoreData;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { existsSync, readFileSync, readdirSync, mkdirSync } from 'fs';
|
||||
import type { ClaudeProfile } from '../../shared/types';
|
||||
import { getCredentialsFromKeychain } from './credential-utils';
|
||||
|
||||
/**
|
||||
* Default Claude config directory
|
||||
@@ -38,18 +39,17 @@ export function generateProfileId(name: string, existingProfiles: ClaudeProfile[
|
||||
* Create a new profile directory and initialize it
|
||||
*/
|
||||
export async function createProfileDirectory(profileName: string): Promise<string> {
|
||||
// Ensure profiles directory exists
|
||||
if (!existsSync(CLAUDE_PROFILES_DIR)) {
|
||||
mkdirSync(CLAUDE_PROFILES_DIR, { recursive: true });
|
||||
}
|
||||
// Create profiles directory - mkdirSync with recursive:true is idempotent
|
||||
// and won't throw if the directory already exists, so no existsSync check needed
|
||||
mkdirSync(CLAUDE_PROFILES_DIR, { recursive: true });
|
||||
|
||||
// Create directory for this profile
|
||||
const sanitizedName = profileName.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
const profileDir = join(CLAUDE_PROFILES_DIR, sanitizedName);
|
||||
|
||||
if (!existsSync(profileDir)) {
|
||||
mkdirSync(profileDir, { recursive: true });
|
||||
}
|
||||
// mkdirSync with recursive:true is idempotent and won't throw if directory exists
|
||||
// No existsSync check needed - avoids TOCTOU race condition
|
||||
mkdirSync(profileDir, { recursive: true });
|
||||
|
||||
return profileDir;
|
||||
}
|
||||
@@ -80,6 +80,21 @@ export function isProfileAuthenticated(profile: ClaudeProfile): boolean {
|
||||
const data = JSON.parse(content);
|
||||
// Check for oauthAccount which indicates successful OAuth authentication
|
||||
if (data && typeof data === 'object' && (data.oauthAccount?.accountUuid || data.oauthAccount?.emailAddress)) {
|
||||
// The actual OAuth tokens are stored in platform-specific credential storage:
|
||||
// - macOS: Keychain
|
||||
// - Windows: Credential Manager
|
||||
// - Linux: Secret Service or .credentials.json file
|
||||
// We need to verify that the credential store actually has the tokens
|
||||
// Expand ~ in configDir before checking credentials
|
||||
const expandedConfigDir = configDir.startsWith('~')
|
||||
? configDir.replace(/^~/, homedir())
|
||||
: configDir;
|
||||
const platformCreds = getCredentialsFromKeychain(expandedConfigDir);
|
||||
if (!platformCreds.token) {
|
||||
// .claude.json exists but credential store is missing tokens - NOT authenticated
|
||||
console.warn(`[profile-utils] Profile has .claude.json but no platform credentials for: ${configDir}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Tests for OAuth Token Refresh Module
|
||||
*
|
||||
* Tests token expiry detection and refresh functionality.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
isTokenExpiredOrNearExpiry,
|
||||
getTimeUntilExpiry,
|
||||
formatTimeRemaining,
|
||||
refreshOAuthToken,
|
||||
ensureValidToken,
|
||||
reactiveTokenRefresh,
|
||||
type TokenRefreshResult
|
||||
} from './token-refresh';
|
||||
|
||||
// Mock credential-utils
|
||||
vi.mock('./credential-utils', () => ({
|
||||
getFullCredentialsFromKeychain: vi.fn(() => ({
|
||||
token: 'mock-access-token',
|
||||
email: 'test@example.com',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresAt: Date.now() + 3600000, // 1 hour from now
|
||||
scopes: ['user:read']
|
||||
})),
|
||||
updateKeychainCredentials: vi.fn(() => ({ success: true })),
|
||||
clearKeychainCache: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock fetch for token refresh
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe('token-refresh', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-20T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('isTokenExpiredOrNearExpiry', () => {
|
||||
it('should return true when expiresAt is null', () => {
|
||||
expect(isTokenExpiredOrNearExpiry(null)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when token is expired', () => {
|
||||
const expiredAt = Date.now() - 1000; // 1 second ago
|
||||
expect(isTokenExpiredOrNearExpiry(expiredAt)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when token is within threshold', () => {
|
||||
const expiresIn25Min = Date.now() + 25 * 60 * 1000; // 25 minutes
|
||||
// Default threshold is 30 minutes
|
||||
expect(isTokenExpiredOrNearExpiry(expiresIn25Min)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when token is valid beyond threshold', () => {
|
||||
const expiresIn2Hours = Date.now() + 2 * 60 * 60 * 1000;
|
||||
expect(isTokenExpiredOrNearExpiry(expiresIn2Hours)).toBe(false);
|
||||
});
|
||||
|
||||
it('should respect custom threshold', () => {
|
||||
const expiresIn45Min = Date.now() + 45 * 60 * 1000;
|
||||
const threshold1Hour = 60 * 60 * 1000;
|
||||
|
||||
// Within 1 hour threshold = near expiry
|
||||
expect(isTokenExpiredOrNearExpiry(expiresIn45Min, threshold1Hour)).toBe(true);
|
||||
|
||||
// Beyond 30 minute threshold = valid
|
||||
expect(isTokenExpiredOrNearExpiry(expiresIn45Min, 30 * 60 * 1000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeUntilExpiry', () => {
|
||||
it('should return null when expiresAt is null', () => {
|
||||
expect(getTimeUntilExpiry(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return 0 for expired tokens', () => {
|
||||
const expired = Date.now() - 1000;
|
||||
expect(getTimeUntilExpiry(expired)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct time remaining', () => {
|
||||
const expiresIn1Hour = Date.now() + 60 * 60 * 1000;
|
||||
const remaining = getTimeUntilExpiry(expiresIn1Hour);
|
||||
|
||||
expect(remaining).toBeCloseTo(60 * 60 * 1000, -2); // Within 100ms
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTimeRemaining', () => {
|
||||
it('should return "unknown" for null', () => {
|
||||
expect(formatTimeRemaining(null)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should return "expired" for 0 or negative', () => {
|
||||
expect(formatTimeRemaining(0)).toBe('expired');
|
||||
expect(formatTimeRemaining(-1000)).toBe('expired');
|
||||
});
|
||||
|
||||
it('should format minutes correctly', () => {
|
||||
expect(formatTimeRemaining(45 * 60 * 1000)).toBe('45m');
|
||||
expect(formatTimeRemaining(5 * 60 * 1000)).toBe('5m');
|
||||
});
|
||||
|
||||
it('should format hours and minutes correctly', () => {
|
||||
expect(formatTimeRemaining(90 * 60 * 1000)).toBe('1h 30m');
|
||||
expect(formatTimeRemaining(3 * 60 * 60 * 1000 + 15 * 60 * 1000)).toBe('3h 15m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshOAuthToken', () => {
|
||||
it('should return error when no refresh token provided', async () => {
|
||||
const result = await refreshOAuthToken('');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('missing_refresh_token');
|
||||
});
|
||||
|
||||
it('should successfully refresh token', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 28800
|
||||
})
|
||||
});
|
||||
|
||||
const result = await refreshOAuthToken('old-refresh-token');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.accessToken).toBe('new-access-token');
|
||||
expect(result.refreshToken).toBe('new-refresh-token');
|
||||
expect(result.expiresIn).toBe(28800);
|
||||
expect(result.expiresAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle invalid_grant error without retry', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
json: async () => ({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'Refresh token is invalid or expired'
|
||||
})
|
||||
});
|
||||
|
||||
const result = await refreshOAuthToken('invalid-refresh-token');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('invalid_grant');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1); // No retries
|
||||
});
|
||||
|
||||
it('should retry on network errors', async () => {
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: 'new-token',
|
||||
refresh_token: 'new-refresh',
|
||||
expires_in: 28800
|
||||
})
|
||||
});
|
||||
|
||||
// Start the async operation
|
||||
const resultPromise = refreshOAuthToken('valid-refresh-token');
|
||||
|
||||
// Advance timers to handle retry delays (1s, 2s exponential backoff)
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should fail after max retries', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('Persistent network error'));
|
||||
|
||||
// Start the async operation
|
||||
const resultPromise = refreshOAuthToken('valid-refresh-token');
|
||||
|
||||
// Advance timers to handle retry delays
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('network_error');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3); // Initial + 2 retries
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureValidToken', () => {
|
||||
it('should return existing token if not near expiry', async () => {
|
||||
const { getFullCredentialsFromKeychain } = await import('./credential-utils');
|
||||
(getFullCredentialsFromKeychain as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
token: 'valid-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresAt: Date.now() + 2 * 60 * 60 * 1000, // 2 hours
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
const result = await ensureValidToken(undefined);
|
||||
|
||||
expect(result.token).toBe('valid-token');
|
||||
expect(result.wasRefreshed).toBe(false);
|
||||
});
|
||||
|
||||
it('should refresh token when near expiry', async () => {
|
||||
const { getFullCredentialsFromKeychain } = await import('./credential-utils');
|
||||
(getFullCredentialsFromKeychain as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
token: 'old-token',
|
||||
refreshToken: 'valid-refresh-token',
|
||||
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes - within threshold
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: 'new-token',
|
||||
refresh_token: 'new-refresh',
|
||||
expires_in: 28800
|
||||
})
|
||||
});
|
||||
|
||||
const result = await ensureValidToken(undefined);
|
||||
|
||||
expect(result.wasRefreshed).toBe(true);
|
||||
expect(result.token).toBe('new-token');
|
||||
});
|
||||
|
||||
it('should return error when no token available', async () => {
|
||||
const { getFullCredentialsFromKeychain } = await import('./credential-utils');
|
||||
(getFullCredentialsFromKeychain as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
email: null
|
||||
});
|
||||
|
||||
const result = await ensureValidToken(undefined);
|
||||
|
||||
expect(result.token).toBeNull();
|
||||
expect(result.error).toContain('No access token');
|
||||
});
|
||||
|
||||
it('should return existing token if no refresh token available', async () => {
|
||||
const { getFullCredentialsFromKeychain } = await import('./credential-utils');
|
||||
(getFullCredentialsFromKeychain as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
token: 'expiring-token',
|
||||
refreshToken: null, // No refresh token
|
||||
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
const result = await ensureValidToken(undefined);
|
||||
|
||||
expect(result.token).toBe('expiring-token');
|
||||
expect(result.wasRefreshed).toBe(false);
|
||||
expect(result.error).toContain('no refresh token');
|
||||
});
|
||||
|
||||
it('should call onRefreshed callback when token is refreshed', async () => {
|
||||
const { getFullCredentialsFromKeychain } = await import('./credential-utils');
|
||||
(getFullCredentialsFromKeychain as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
token: 'old-token',
|
||||
refreshToken: 'valid-refresh',
|
||||
expiresAt: Date.now() + 5 * 60 * 1000,
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: 'new-token',
|
||||
refresh_token: 'new-refresh',
|
||||
expires_in: 28800
|
||||
})
|
||||
});
|
||||
|
||||
const onRefreshed = vi.fn();
|
||||
await ensureValidToken(undefined, onRefreshed);
|
||||
|
||||
expect(onRefreshed).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
'new-token',
|
||||
'new-refresh',
|
||||
expect.any(Number)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reactiveTokenRefresh', () => {
|
||||
it('should force refresh even if token appears valid', async () => {
|
||||
const { getFullCredentialsFromKeychain } = await import('./credential-utils');
|
||||
(getFullCredentialsFromKeychain as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
token: 'current-token',
|
||||
refreshToken: 'valid-refresh',
|
||||
expiresAt: Date.now() + 2 * 60 * 60 * 1000, // 2 hours
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: 'new-token',
|
||||
refresh_token: 'new-refresh',
|
||||
expires_in: 28800
|
||||
})
|
||||
});
|
||||
|
||||
const result = await reactiveTokenRefresh(undefined);
|
||||
|
||||
expect(result.wasRefreshed).toBe(true);
|
||||
expect(result.token).toBe('new-token');
|
||||
});
|
||||
|
||||
it('should return error when no refresh token available', async () => {
|
||||
const { getFullCredentialsFromKeychain } = await import('./credential-utils');
|
||||
(getFullCredentialsFromKeychain as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
token: 'current-token',
|
||||
refreshToken: null,
|
||||
expiresAt: Date.now() + 2 * 60 * 60 * 1000,
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
const result = await reactiveTokenRefresh(undefined);
|
||||
|
||||
expect(result.token).toBeNull();
|
||||
expect(result.error).toContain('No refresh token');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* OAuth Token Refresh Module
|
||||
*
|
||||
* Handles automatic token refresh for Claude Code OAuth tokens.
|
||||
* Supports proactive refresh (before expiry) and reactive refresh (on 401 errors).
|
||||
*
|
||||
* CRITICAL: When a token is refreshed, the old token is IMMEDIATELY REVOKED by Anthropic.
|
||||
* Therefore, new tokens must be written back to the credential store immediately.
|
||||
*
|
||||
* Verified endpoint:
|
||||
* POST https://console.anthropic.com/v1/oauth/token
|
||||
* Content-Type: application/x-www-form-urlencoded
|
||||
* Body: grant_type=refresh_token&refresh_token=sk-ant-ort01-...&client_id=<CLIENT_ID>
|
||||
* Response: { access_token, refresh_token, expires_in: 28800, token_type: "Bearer" }
|
||||
*/
|
||||
|
||||
import { homedir } from 'os';
|
||||
import {
|
||||
getFullCredentialsFromKeychain,
|
||||
updateKeychainCredentials,
|
||||
clearKeychainCache,
|
||||
} from './credential-utils';
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Anthropic OAuth token endpoint
|
||||
*/
|
||||
const ANTHROPIC_TOKEN_ENDPOINT = 'https://console.anthropic.com/v1/oauth/token';
|
||||
|
||||
/**
|
||||
* Claude Code OAuth client ID (public - same for all Claude Code installations)
|
||||
* This is the official client ID used by Claude Code CLI
|
||||
*/
|
||||
const CLAUDE_CODE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
||||
|
||||
/**
|
||||
* Proactive refresh threshold: refresh tokens 30 minutes before expiry
|
||||
* This provides a buffer to handle network issues and ensures tokens are
|
||||
* always valid when needed for autonomous overnight operation.
|
||||
*/
|
||||
const PROACTIVE_REFRESH_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
/**
|
||||
* Maximum retry attempts for token refresh
|
||||
*/
|
||||
const MAX_REFRESH_RETRIES = 2;
|
||||
|
||||
/**
|
||||
* Delay between retry attempts (exponential backoff base)
|
||||
*/
|
||||
const RETRY_DELAY_BASE_MS = 1000;
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Result of a token refresh operation
|
||||
*/
|
||||
export interface TokenRefreshResult {
|
||||
success: boolean;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number; // Unix timestamp in ms
|
||||
expiresIn?: number; // Seconds until expiry
|
||||
error?: string;
|
||||
errorCode?: string; // 'invalid_grant', 'invalid_client', 'network_error', etc.
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of ensuring a valid token
|
||||
*/
|
||||
export interface EnsureValidTokenResult {
|
||||
token: string | null;
|
||||
wasRefreshed: boolean;
|
||||
error?: string;
|
||||
errorCode?: string; // 'invalid_grant', 'invalid_client', 'network_error', etc.
|
||||
/**
|
||||
* True if token was refreshed but failed to persist to keychain.
|
||||
* The token is valid for this session but will be lost on restart.
|
||||
* Callers should alert the user to re-authenticate.
|
||||
*/
|
||||
persistenceFailed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for when tokens are refreshed
|
||||
*/
|
||||
export type OnTokenRefreshedCallback = (
|
||||
configDir: string | undefined,
|
||||
newAccessToken: string,
|
||||
newRefreshToken: string,
|
||||
expiresAt: number
|
||||
) => void;
|
||||
|
||||
// =============================================================================
|
||||
// Token Expiry Detection
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if a token is expired or near expiry.
|
||||
*
|
||||
* @param expiresAt - Unix timestamp in ms when the token expires, or null if unknown
|
||||
* @param thresholdMs - How far before expiry to consider "near expiry" (default: 30 minutes)
|
||||
* @returns true if token is expired or will expire within the threshold
|
||||
*/
|
||||
export function isTokenExpiredOrNearExpiry(
|
||||
expiresAt: number | null,
|
||||
thresholdMs: number = PROACTIVE_REFRESH_THRESHOLD_MS
|
||||
): boolean {
|
||||
// If we don't know the expiry time, assume it might be expired
|
||||
// This is safer than assuming it's valid
|
||||
if (expiresAt === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const expiryThreshold = expiresAt - thresholdMs;
|
||||
|
||||
return now >= expiryThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time remaining until token expiry.
|
||||
*
|
||||
* @param expiresAt - Unix timestamp in ms when the token expires
|
||||
* @returns Time remaining in ms, or null if expiresAt is null
|
||||
*/
|
||||
export function getTimeUntilExpiry(expiresAt: number | null): number | null {
|
||||
if (expiresAt === null) return null;
|
||||
return Math.max(0, expiresAt - Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time remaining for logging
|
||||
*/
|
||||
export function formatTimeRemaining(ms: number | null): string {
|
||||
if (ms === null) return 'unknown';
|
||||
if (ms <= 0) return 'expired';
|
||||
|
||||
const minutes = Math.floor(ms / (60 * 1000));
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Token Refresh
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Refresh an OAuth token using the refresh_token grant type.
|
||||
*
|
||||
* CRITICAL: After a successful refresh, the old access token AND refresh token are REVOKED.
|
||||
* The new tokens must be stored immediately.
|
||||
*
|
||||
* @param refreshToken - The refresh token to use
|
||||
* @returns Result containing new tokens or error information
|
||||
*/
|
||||
export async function refreshOAuthToken(refreshToken: string): Promise<TokenRefreshResult> {
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
|
||||
if (isDebug) {
|
||||
// Reduce fingerprint to fewer characters to minimize information exposure
|
||||
// Show only first 4 and last 2 characters for debugging purposes
|
||||
console.warn('[TokenRefresh] Starting token refresh', {
|
||||
refreshTokenFingerprint: refreshToken ? `${refreshToken.slice(0, 4)}...${refreshToken.slice(-2)}` : 'null'
|
||||
});
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No refresh token provided',
|
||||
errorCode: 'missing_refresh_token'
|
||||
};
|
||||
}
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
|
||||
if (attempt > 0) {
|
||||
// Exponential backoff between retries
|
||||
const delay = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1);
|
||||
if (isDebug) {
|
||||
console.warn('[TokenRefresh] Retrying after delay:', delay, 'ms (attempt', attempt + 1, ')');
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
try {
|
||||
// Build form-urlencoded body
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLAUDE_CODE_CLIENT_ID
|
||||
});
|
||||
|
||||
const response = await fetch(ANTHROPIC_TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: body.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData: Record<string, string> = {};
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
|
||||
const errorCode = errorData.error || `http_${response.status}`;
|
||||
const errorDescription = errorData.error_description || response.statusText;
|
||||
|
||||
// Check for permanent errors that shouldn't be retried
|
||||
if (errorCode === 'invalid_grant' || errorCode === 'invalid_client') {
|
||||
console.error('[TokenRefresh] Permanent error - refresh token invalid:', {
|
||||
errorCode,
|
||||
errorDescription
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: `Token refresh failed: ${errorDescription}`,
|
||||
errorCode
|
||||
};
|
||||
}
|
||||
|
||||
// Temporary errors - continue to retry
|
||||
lastError = new Error(`HTTP ${response.status}: ${errorDescription}`);
|
||||
if (isDebug) {
|
||||
console.warn('[TokenRefresh] Temporary error, will retry:', lastError.message);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse successful response
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.access_token) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Response missing access_token',
|
||||
errorCode: 'invalid_response'
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate expiry timestamp
|
||||
// expires_in is in seconds, convert to ms and add to current time
|
||||
const expiresIn = data.expires_in || 28800; // Default 8 hours if not provided
|
||||
const expiresAt = Date.now() + (expiresIn * 1000);
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[TokenRefresh] Token refresh successful', {
|
||||
newTokenFingerprint: `${data.access_token.slice(0, 12)}...${data.access_token.slice(-4)}`,
|
||||
expiresIn: expiresIn,
|
||||
expiresAt: new Date(expiresAt).toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresAt,
|
||||
expiresIn
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
if (isDebug) {
|
||||
console.warn('[TokenRefresh] Network error, will retry:', lastError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
console.error('[TokenRefresh] All retry attempts failed');
|
||||
return {
|
||||
success: false,
|
||||
error: lastError?.message || 'Token refresh failed after retries',
|
||||
errorCode: 'network_error'
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Integrated Token Validation and Refresh
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Ensure a valid token is available, refreshing if necessary.
|
||||
*
|
||||
* This function:
|
||||
* 1. Reads credentials from keychain
|
||||
* 2. Checks if token is expired or near expiry
|
||||
* 3. If needed, refreshes the token and writes back to keychain
|
||||
* 4. Returns a valid token
|
||||
*
|
||||
* @param configDir - Config directory for the profile (can be undefined for default profile)
|
||||
* @param onRefreshed - Optional callback when tokens are refreshed
|
||||
* @returns Valid token or null with error information
|
||||
*/
|
||||
export async function ensureValidToken(
|
||||
configDir: string | undefined,
|
||||
onRefreshed?: OnTokenRefreshedCallback
|
||||
): Promise<EnsureValidTokenResult> {
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
|
||||
// Expand ~ in configDir if present
|
||||
const expandedConfigDir = configDir?.startsWith('~')
|
||||
? configDir.replace(/^~/, homedir())
|
||||
: configDir;
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[TokenRefresh:ensureValidToken] Checking token validity', {
|
||||
configDir: expandedConfigDir || 'default'
|
||||
});
|
||||
}
|
||||
|
||||
// Step 1: Read full credentials from keychain
|
||||
const creds = getFullCredentialsFromKeychain(expandedConfigDir);
|
||||
|
||||
if (creds.error) {
|
||||
return {
|
||||
token: null,
|
||||
wasRefreshed: false,
|
||||
error: `Failed to read credentials: ${creds.error}`
|
||||
};
|
||||
}
|
||||
|
||||
if (!creds.token) {
|
||||
return {
|
||||
token: null,
|
||||
wasRefreshed: false,
|
||||
error: 'No access token found in credentials',
|
||||
errorCode: 'missing_credentials'
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Check if token is expired or near expiry
|
||||
const needsRefresh = isTokenExpiredOrNearExpiry(creds.expiresAt);
|
||||
|
||||
if (!needsRefresh) {
|
||||
if (isDebug) {
|
||||
console.warn('[TokenRefresh:ensureValidToken] Token is valid', {
|
||||
timeRemaining: formatTimeRemaining(getTimeUntilExpiry(creds.expiresAt))
|
||||
});
|
||||
}
|
||||
return {
|
||||
token: creds.token,
|
||||
wasRefreshed: false
|
||||
};
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[TokenRefresh:ensureValidToken] Token needs refresh', {
|
||||
expiresAt: creds.expiresAt ? new Date(creds.expiresAt).toISOString() : 'unknown',
|
||||
hasRefreshToken: !!creds.refreshToken
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Check if we have a refresh token
|
||||
if (!creds.refreshToken) {
|
||||
// Can't refresh - return existing token and let caller handle potential 401
|
||||
if (isDebug) {
|
||||
console.warn('[TokenRefresh:ensureValidToken] No refresh token available, returning existing token');
|
||||
}
|
||||
return {
|
||||
token: creds.token,
|
||||
wasRefreshed: false,
|
||||
error: 'Token expired but no refresh token available'
|
||||
};
|
||||
}
|
||||
|
||||
// Step 4: Refresh the token
|
||||
const refreshResult = await refreshOAuthToken(creds.refreshToken);
|
||||
|
||||
if (!refreshResult.success || !refreshResult.accessToken || !refreshResult.refreshToken || !refreshResult.expiresAt) {
|
||||
console.error('[TokenRefresh:ensureValidToken] Token refresh failed:', refreshResult.error);
|
||||
// CRITICAL: When token refresh fails server-side, the old token may already be revoked.
|
||||
// Returning the old token here is a best-effort fallback, but callers should be aware
|
||||
// that it will likely result in 401 errors. The comment "it might still work" is optimistic.
|
||||
// This scenario indicates the user needs to re-authenticate via OAuth flow.
|
||||
return {
|
||||
token: creds.token, // WARNING: This token may be invalid/revoked
|
||||
wasRefreshed: false,
|
||||
error: `Token refresh failed: ${refreshResult.error}`,
|
||||
errorCode: refreshResult.errorCode
|
||||
};
|
||||
}
|
||||
|
||||
// Step 5: CRITICAL - Write new tokens to keychain immediately
|
||||
// The old token is now REVOKED, so we must persist the new one
|
||||
const updateResult = updateKeychainCredentials(expandedConfigDir, {
|
||||
accessToken: refreshResult.accessToken,
|
||||
refreshToken: refreshResult.refreshToken,
|
||||
expiresAt: refreshResult.expiresAt,
|
||||
scopes: creds.scopes || undefined
|
||||
});
|
||||
|
||||
// Track if persistence failed - callers can alert user to re-authenticate
|
||||
let persistenceFailed = false;
|
||||
|
||||
if (!updateResult.success) {
|
||||
// This is a critical error - we have new tokens but can't persist them
|
||||
console.error('[TokenRefresh:ensureValidToken] CRITICAL: Failed to persist refreshed tokens:', updateResult.error);
|
||||
console.error('[TokenRefresh:ensureValidToken] The new token will be lost on next restart!');
|
||||
persistenceFailed = true;
|
||||
// Still return the new token for this session
|
||||
} else {
|
||||
if (isDebug) {
|
||||
console.warn('[TokenRefresh:ensureValidToken] Successfully refreshed and persisted token', {
|
||||
newExpiresAt: new Date(refreshResult.expiresAt).toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Clear the credential cache so next read gets fresh data
|
||||
clearKeychainCache(expandedConfigDir);
|
||||
|
||||
// Step 7: Call the callback if provided
|
||||
if (onRefreshed) {
|
||||
onRefreshed(
|
||||
expandedConfigDir,
|
||||
refreshResult.accessToken,
|
||||
refreshResult.refreshToken,
|
||||
refreshResult.expiresAt
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
token: refreshResult.accessToken,
|
||||
wasRefreshed: true,
|
||||
...(persistenceFailed && { persistenceFailed: true })
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a reactive token refresh (called on 401 error).
|
||||
*
|
||||
* This is similar to ensureValidToken but:
|
||||
* - Doesn't check expiry (we know the token is invalid)
|
||||
* - Forces a refresh regardless of apparent token state
|
||||
*
|
||||
* @param configDir - Config directory for the profile
|
||||
* @param onRefreshed - Optional callback when tokens are refreshed
|
||||
* @returns New token or null with error information
|
||||
*/
|
||||
export async function reactiveTokenRefresh(
|
||||
configDir: string | undefined,
|
||||
onRefreshed?: OnTokenRefreshedCallback
|
||||
): Promise<EnsureValidTokenResult> {
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
|
||||
const expandedConfigDir = configDir?.startsWith('~')
|
||||
? configDir.replace(/^~/, homedir())
|
||||
: configDir;
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[TokenRefresh:reactive] Performing reactive token refresh (401 received)', {
|
||||
configDir: expandedConfigDir || 'default'
|
||||
});
|
||||
}
|
||||
|
||||
// Read credentials to get refresh token
|
||||
const creds = getFullCredentialsFromKeychain(expandedConfigDir);
|
||||
|
||||
if (creds.error) {
|
||||
return {
|
||||
token: null,
|
||||
wasRefreshed: false,
|
||||
error: `Failed to read credentials: ${creds.error}`
|
||||
};
|
||||
}
|
||||
|
||||
if (!creds.refreshToken) {
|
||||
return {
|
||||
token: null,
|
||||
wasRefreshed: false,
|
||||
error: 'No refresh token available for reactive refresh'
|
||||
};
|
||||
}
|
||||
|
||||
// Perform refresh
|
||||
const refreshResult = await refreshOAuthToken(creds.refreshToken);
|
||||
|
||||
if (!refreshResult.success || !refreshResult.accessToken || !refreshResult.refreshToken || !refreshResult.expiresAt) {
|
||||
return {
|
||||
token: null,
|
||||
wasRefreshed: false,
|
||||
error: `Reactive refresh failed: ${refreshResult.error}`
|
||||
};
|
||||
}
|
||||
|
||||
// Write new tokens to keychain
|
||||
const updateResult = updateKeychainCredentials(expandedConfigDir, {
|
||||
accessToken: refreshResult.accessToken,
|
||||
refreshToken: refreshResult.refreshToken,
|
||||
expiresAt: refreshResult.expiresAt,
|
||||
scopes: creds.scopes || undefined
|
||||
});
|
||||
|
||||
// Track if persistence failed - callers can alert user to re-authenticate
|
||||
let persistenceFailed = false;
|
||||
if (!updateResult.success) {
|
||||
console.error('[TokenRefresh:reactive] CRITICAL: Failed to persist refreshed tokens:', updateResult.error);
|
||||
persistenceFailed = true;
|
||||
}
|
||||
|
||||
clearKeychainCache(expandedConfigDir);
|
||||
|
||||
if (onRefreshed) {
|
||||
onRefreshed(
|
||||
expandedConfigDir,
|
||||
refreshResult.accessToken,
|
||||
refreshResult.refreshToken,
|
||||
refreshResult.expiresAt
|
||||
);
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[TokenRefresh:reactive] Reactive refresh successful');
|
||||
}
|
||||
|
||||
return {
|
||||
token: refreshResult.accessToken,
|
||||
wasRefreshed: true,
|
||||
...(persistenceFailed && { persistenceFailed: true })
|
||||
};
|
||||
}
|
||||
@@ -84,6 +84,25 @@ describe('usage-monitor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Restore default fetch mock after clearAllMocks
|
||||
const mockFetch = vi.mocked(global.fetch);
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
get: vi.fn((name: string) => name === 'content-type' ? 'application/json' : null)
|
||||
},
|
||||
json: async () => ({
|
||||
five_hour_utilization: 0.5,
|
||||
seven_day_utilization: 0.3,
|
||||
five_hour_reset_at: '2025-01-17T15:00:00Z',
|
||||
seven_day_reset_at: '2025-01-20T12:00:00Z'
|
||||
})
|
||||
} as unknown as Response)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -145,42 +164,38 @@ describe('usage-monitor', () => {
|
||||
|
||||
it('should start monitoring when settings allow', () => {
|
||||
const monitor = getUsageMonitor();
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
monitor.start();
|
||||
|
||||
// Check that console.warn was called (monitor logs when starting)
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
// Verify monitor started (has intervalId set)
|
||||
expect(monitor['intervalId']).not.toBeNull();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it('should not start if already running', () => {
|
||||
const monitor = getUsageMonitor();
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
monitor.start();
|
||||
const firstIntervalId = monitor['intervalId'];
|
||||
|
||||
monitor.start(); // Second call should be ignored
|
||||
|
||||
// Should have logged a warning that it's already running
|
||||
expect(consoleSpy.mock.calls.length).toBeGreaterThan(0);
|
||||
// Should still have the same intervalId (not recreated)
|
||||
expect(monitor['intervalId']).toBe(firstIntervalId);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it('should stop monitoring', () => {
|
||||
const monitor = getUsageMonitor();
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
monitor.start();
|
||||
expect(monitor['intervalId']).not.toBeNull();
|
||||
|
||||
monitor.stop();
|
||||
|
||||
// Verify stop completed without errors
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
// Verify intervalId is cleared
|
||||
expect(monitor['intervalId']).toBeNull();
|
||||
});
|
||||
|
||||
it('should return current usage snapshot', () => {
|
||||
@@ -852,16 +867,12 @@ describe('usage-monitor', () => {
|
||||
describe('Credential error handling', () => {
|
||||
it('should handle missing credential gracefully', async () => {
|
||||
const monitor = getUsageMonitor();
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// Call fetchUsage without credential
|
||||
const usage = await monitor['fetchUsage']('test-profile-1', undefined);
|
||||
|
||||
// Should fall back to CLI method (which returns null)
|
||||
expect(usage).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle empty credential string', async () => {
|
||||
@@ -900,15 +911,10 @@ describe('usage-monitor', () => {
|
||||
} as any);
|
||||
|
||||
const monitor = getUsageMonitor();
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// Call checkUsageAndSwap directly to test null profile handling
|
||||
await monitor['checkUsageAndSwap']();
|
||||
|
||||
// Should log a warning about no active profile
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No active profile'));
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
// Should complete without throwing an error
|
||||
await expect(monitor['checkUsageAndSwap']()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle profile with missing required fields', async () => {
|
||||
@@ -996,7 +1002,6 @@ describe('usage-monitor', () => {
|
||||
|
||||
it('should handle unknown provider gracefully', async () => {
|
||||
const monitor = getUsageMonitor();
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// Create an active profile object with unknown provider
|
||||
const unknownProviderProfile = {
|
||||
@@ -1028,24 +1033,12 @@ describe('usage-monitor', () => {
|
||||
|
||||
// Unknown provider should return null
|
||||
expect(usage).toBeNull();
|
||||
// Verify console.warn was called with "Unknown provider - no usage endpoint configured:" message
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[UsageMonitor] Unknown provider - no usage endpoint configured:',
|
||||
expect.objectContaining({
|
||||
provider: 'unknown',
|
||||
baseUrl: 'https://unknown-provider.com/api',
|
||||
profileId: 'unknown-profile-1'
|
||||
})
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Concurrent check prevention', () => {
|
||||
it('should prevent concurrent usage checks', async () => {
|
||||
const monitor = getUsageMonitor();
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// Start first check (it will take some time)
|
||||
const firstCheck = monitor['checkUsageAndSwap']();
|
||||
@@ -1057,10 +1050,8 @@ describe('usage-monitor', () => {
|
||||
await firstCheck;
|
||||
await secondCheck;
|
||||
|
||||
// Verify checks completed
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
// Verify check completed (isChecking should be false after both complete)
|
||||
expect(monitor['isChecking']).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1161,15 +1152,13 @@ describe('usage-monitor', () => {
|
||||
} as any);
|
||||
|
||||
const monitor = getUsageMonitor();
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// Should start with default values for missing fields
|
||||
monitor.start();
|
||||
|
||||
// Default usageCheckInterval is 30000ms
|
||||
expect(consoleSpy).toHaveBeenCalledWith('[UsageMonitor] Starting with interval:', 30000, 'ms (30-second updates for accurate usage stats)');
|
||||
// Should have started monitoring
|
||||
expect(monitor['intervalId']).not.toBeNull();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
@@ -1204,14 +1193,13 @@ describe('usage-monitor', () => {
|
||||
} as any);
|
||||
|
||||
const monitor = getUsageMonitor();
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// Should not crash when checking thresholds
|
||||
monitor.start();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
// Should have started successfully
|
||||
expect(monitor['intervalId']).not.toBeNull();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
monitor.stop();
|
||||
});
|
||||
});
|
||||
@@ -1538,11 +1526,16 @@ describe('usage-monitor', () => {
|
||||
|
||||
describe('Race condition prevention via activeProfile parameter', () => {
|
||||
it('should use passed activeProfile instead of re-detecting', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const mockFetch = vi.mocked(global.fetch);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
get: vi.fn((name: string) => name === 'content-type' ? 'application/json' : null)
|
||||
},
|
||||
json: async () => ({
|
||||
five_hour_utilization: 0.5,
|
||||
seven_day_utilization: 0.3,
|
||||
@@ -1564,7 +1557,6 @@ describe('usage-monitor', () => {
|
||||
});
|
||||
|
||||
const monitor = getUsageMonitor();
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// Pre-determined active profile (simulating profile at time of checkUsageAndSwap)
|
||||
const predeterminedProfile = {
|
||||
@@ -1583,12 +1575,19 @@ describe('usage-monitor', () => {
|
||||
predeterminedProfile
|
||||
);
|
||||
|
||||
// Log any console.error calls for debugging
|
||||
if (errorSpy.mock.calls.length > 0) {
|
||||
console.log('console.error was called:', errorSpy.mock.calls);
|
||||
}
|
||||
|
||||
// Should successfully fetch usage using the passed profile
|
||||
expect(usage).not.toBeNull();
|
||||
expect(usage?.profileId).toBe('api-profile-1');
|
||||
expect(usage?.sessionPercent).toBe(50);
|
||||
if (usage) {
|
||||
expect(usage.profileId).toBe('api-profile-1');
|
||||
expect(usage.sessionPercent).toBe(50);
|
||||
}
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should fall back to profile detection when activeProfile not provided', async () => {
|
||||
@@ -1597,6 +1596,9 @@ describe('usage-monitor', () => {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
get: vi.fn((name: string) => name === 'content-type' ? 'application/json' : null)
|
||||
},
|
||||
json: async () => ({
|
||||
five_hour_utilization: 0.5,
|
||||
seven_day_utilization: 0.3,
|
||||
@@ -1618,7 +1620,6 @@ describe('usage-monitor', () => {
|
||||
});
|
||||
|
||||
const monitor = getUsageMonitor();
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// Call fetchUsageViaAPI WITHOUT predetermined profile
|
||||
// Should fall back to detecting profile from activeProfileId
|
||||
@@ -1633,8 +1634,6 @@ describe('usage-monitor', () => {
|
||||
// Should still work by detecting the profile
|
||||
expect(usage).not.toBeNull();
|
||||
expect(usage?.profileId).toBe('api-profile-1');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle OAuth profile in activeProfile parameter', async () => {
|
||||
@@ -1643,6 +1642,9 @@ describe('usage-monitor', () => {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
get: vi.fn((name: string) => name === 'content-type' ? 'application/json' : null)
|
||||
},
|
||||
json: async () => ({
|
||||
five_hour_utilization: 0.5,
|
||||
seven_day_utilization: 0.3,
|
||||
@@ -1652,7 +1654,6 @@ describe('usage-monitor', () => {
|
||||
} as unknown as Response);
|
||||
|
||||
const monitor = getUsageMonitor();
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// Pre-determined OAuth profile
|
||||
const oauthProfile = {
|
||||
@@ -1674,8 +1675,6 @@ describe('usage-monitor', () => {
|
||||
// Should successfully fetch usage for OAuth profile
|
||||
expect(usage).not.toBeNull();
|
||||
expect(usage?.profileId).toBe('oauth-profile');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,14 +46,14 @@ import { pythonEnvManager } from './python-env-manager';
|
||||
import { getUsageMonitor } from './claude-profile/usage-monitor';
|
||||
import { initializeUsageMonitorForwarding } from './ipc-handlers/terminal-handlers';
|
||||
import { initializeAppUpdater, stopPeriodicUpdates } from './app-updater';
|
||||
import { DEFAULT_APP_SETTINGS } from '../shared/constants';
|
||||
import { DEFAULT_APP_SETTINGS, IPC_CHANNELS } from '../shared/constants';
|
||||
import { readSettingsFile } from './settings-utils';
|
||||
import { setupErrorLogging } from './app-logger';
|
||||
import { initSentryMain } from './sentry';
|
||||
import { preWarmToolCache } from './cli-tool-manager';
|
||||
import { initializeClaudeProfileManager } from './claude-profile-manager';
|
||||
import { initializeClaudeProfileManager, getClaudeProfileManager } from './claude-profile-manager';
|
||||
import { isMacOS, isWindows } from './platform';
|
||||
import type { AppSettings } from '../shared/types';
|
||||
import type { AppSettings, AuthFailureInfo } from '../shared/types';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Window sizing constants
|
||||
@@ -405,6 +405,36 @@ app.whenReady().then(() => {
|
||||
const usageMonitor = getUsageMonitor();
|
||||
usageMonitor.start();
|
||||
console.warn('[main] Usage monitor initialized and started (after profile load)');
|
||||
|
||||
// Check for migrated profiles that need re-authentication
|
||||
// These profiles were moved from shared ~/.claude to isolated directories
|
||||
// and need new credentials since they now use a different keychain entry
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const migratedProfileIds = profileManager.getMigratedProfileIds();
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
|
||||
if (migratedProfileIds.length > 0) {
|
||||
console.warn('[main] Found migrated profiles that need re-authentication:', migratedProfileIds);
|
||||
|
||||
// If the active profile was migrated, show auth failure modal immediately
|
||||
if (migratedProfileIds.includes(activeProfile.id)) {
|
||||
// Wait for renderer to be ready before sending the event
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
// Small delay to ensure stores are initialized
|
||||
setTimeout(() => {
|
||||
const authFailureInfo: AuthFailureInfo = {
|
||||
profileId: activeProfile.id,
|
||||
profileName: activeProfile.name,
|
||||
failureType: 'missing',
|
||||
message: `Profile "${activeProfile.name}" was migrated to an isolated directory and needs re-authentication.`,
|
||||
detectedAt: new Date()
|
||||
};
|
||||
console.warn('[main] Sending auth failure for migrated active profile:', activeProfile.name);
|
||||
mainWindow?.webContents.send(IPC_CHANNELS.CLAUDE_AUTH_FAILURE, authFailureInfo);
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { getProfileEnv } from '../rate-limit-detector';
|
||||
import { getBestAvailableProfileEnv } from '../rate-limit-detector';
|
||||
import { getAPIProfileEnv } from '../services/profile';
|
||||
import { getOAuthModeClearVars } from '../agent/env-utils';
|
||||
import { pythonEnvManager, getConfiguredPythonPath } from '../python-env-manager';
|
||||
@@ -109,7 +109,9 @@ export class InsightsConfig {
|
||||
*/
|
||||
async getProcessEnv(): Promise<Record<string, string>> {
|
||||
const autoBuildEnv = this.loadAutoBuildEnv();
|
||||
const profileEnv = getProfileEnv();
|
||||
// Get best available Claude profile environment (automatically handles rate limits)
|
||||
const profileResult = getBestAvailableProfileEnv();
|
||||
const profileEnv = profileResult.env;
|
||||
const apiProfileEnv = await getAPIProfileEnv();
|
||||
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
|
||||
const pythonEnv = pythonEnvManager.getPythonEnv();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
const mockGetAPIProfileEnv = vi.fn();
|
||||
const mockGetOAuthModeClearVars = vi.fn();
|
||||
const mockGetPythonEnv = vi.fn();
|
||||
const mockGetProfileEnv = vi.fn();
|
||||
const mockGetBestAvailableProfileEnv = vi.fn();
|
||||
|
||||
vi.mock('../../../../services/profile', () => ({
|
||||
getAPIProfileEnv: (...args: unknown[]) => mockGetAPIProfileEnv(...args),
|
||||
@@ -20,7 +20,7 @@ vi.mock('../../../../python-env-manager', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../../../rate-limit-detector', () => ({
|
||||
getProfileEnv: () => mockGetProfileEnv(),
|
||||
getBestAvailableProfileEnv: () => mockGetBestAvailableProfileEnv(),
|
||||
}));
|
||||
|
||||
import { getRunnerEnv } from '../runner-env';
|
||||
@@ -35,8 +35,13 @@ describe('getRunnerEnv', () => {
|
||||
PYTHONNOUSERSITE: '1',
|
||||
PYTHONPATH: '/bundled/site-packages',
|
||||
});
|
||||
// Default mock for profile env - returns empty by default
|
||||
mockGetProfileEnv.mockReturnValue({});
|
||||
// Default mock for profile env - returns BestProfileEnvResult format
|
||||
mockGetBestAvailableProfileEnv.mockReturnValue({
|
||||
env: {},
|
||||
profileId: 'default',
|
||||
profileName: 'Default',
|
||||
wasSwapped: false
|
||||
});
|
||||
});
|
||||
|
||||
it('merges Python env with API profile env and OAuth clear vars', async () => {
|
||||
@@ -93,8 +98,11 @@ describe('getRunnerEnv', () => {
|
||||
it('includes profileEnv for OAuth token (fixes #563)', async () => {
|
||||
mockGetAPIProfileEnv.mockResolvedValue({});
|
||||
mockGetOAuthModeClearVars.mockReturnValue({});
|
||||
mockGetProfileEnv.mockReturnValue({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-123',
|
||||
mockGetBestAvailableProfileEnv.mockReturnValue({
|
||||
env: { CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-123' },
|
||||
profileId: 'default',
|
||||
profileName: 'Default',
|
||||
wasSwapped: false
|
||||
});
|
||||
|
||||
const result = await getRunnerEnv();
|
||||
@@ -110,8 +118,11 @@ describe('getRunnerEnv', () => {
|
||||
SHARED_VAR: 'from-api-profile',
|
||||
});
|
||||
mockGetOAuthModeClearVars.mockReturnValue({});
|
||||
mockGetProfileEnv.mockReturnValue({
|
||||
SHARED_VAR: 'from-profile',
|
||||
mockGetBestAvailableProfileEnv.mockReturnValue({
|
||||
env: { SHARED_VAR: 'from-profile' },
|
||||
profileId: 'default',
|
||||
profileName: 'Default',
|
||||
wasSwapped: false
|
||||
});
|
||||
|
||||
const result = await getRunnerEnv({ SHARED_VAR: 'from-extra' });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getOAuthModeClearVars } from '../../../agent/env-utils';
|
||||
import { getAPIProfileEnv } from '../../../services/profile';
|
||||
import { getProfileEnv } from '../../../rate-limit-detector';
|
||||
import { getBestAvailableProfileEnv } from '../../../rate-limit-detector';
|
||||
import { pythonEnvManager } from '../../../python-env-manager';
|
||||
import { getGitHubTokenForSubprocess } from '../utils';
|
||||
|
||||
@@ -34,7 +34,9 @@ export async function getRunnerEnv(
|
||||
const pythonEnv = pythonEnvManager.getPythonEnv();
|
||||
const apiProfileEnv = await getAPIProfileEnv();
|
||||
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
|
||||
const profileEnv = getProfileEnv();
|
||||
// Get best available Claude profile environment (automatically handles rate limits)
|
||||
const profileResult = getBestAvailableProfileEnv();
|
||||
const profileEnv = profileResult.env;
|
||||
|
||||
// Fetch fresh GitHub token from gh CLI (no caching to reflect account changes)
|
||||
const githubToken = await getGitHubTokenForSubprocess();
|
||||
@@ -44,7 +46,7 @@ export async function getRunnerEnv(
|
||||
...pythonEnv, // Python environment including PYTHONPATH (fixes #139)
|
||||
...apiProfileEnv,
|
||||
...oauthModeClearVars,
|
||||
...profileEnv, // OAuth token from profile manager (fixes #563)
|
||||
...profileEnv, // OAuth token from profile manager (fixes #563, rate-limit aware)
|
||||
...githubEnv, // Fresh GitHub token from gh CLI (fixes #151)
|
||||
...extraEnv,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* Tests for Queue Routing IPC Handlers
|
||||
*
|
||||
* Tests the IPC communication for rate limit recovery queue routing.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ipcMain, BrowserWindow } from 'electron';
|
||||
import { registerQueueRoutingHandlers } from './queue-routing-handlers';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type { AgentManager } from '../agent/agent-manager';
|
||||
import type { ProfileAssignmentReason } from '../../shared/types';
|
||||
|
||||
// Mock Electron
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: vi.fn()
|
||||
},
|
||||
BrowserWindow: vi.fn()
|
||||
}));
|
||||
|
||||
describe('registerQueueRoutingHandlers', () => {
|
||||
let mockAgentManager: Partial<AgentManager>;
|
||||
let mockWindow: Partial<BrowserWindow>;
|
||||
let getMainWindow: () => BrowserWindow | null;
|
||||
let registeredHandlers: Map<string, Function>;
|
||||
let registeredEventListeners: Map<string, Function>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
registeredHandlers = new Map();
|
||||
registeredEventListeners = new Map();
|
||||
|
||||
// Capture registered handlers
|
||||
(ipcMain.handle as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(channel: string, handler: Function) => {
|
||||
registeredHandlers.set(channel, handler);
|
||||
}
|
||||
);
|
||||
|
||||
// Setup mock agent manager - use unknown intermediate cast to avoid partial type issues
|
||||
const onMock = vi.fn((event: string, handler: Function) => {
|
||||
registeredEventListeners.set(event, handler);
|
||||
return mockAgentManager as AgentManager;
|
||||
});
|
||||
|
||||
mockAgentManager = {
|
||||
getRunningTasksByProfile: vi.fn(() => ({
|
||||
byProfile: { 'profile-1': ['task-1', 'task-2'] },
|
||||
totalRunning: 2
|
||||
})),
|
||||
assignProfileToTask: vi.fn(),
|
||||
getTaskProfileAssignment: vi.fn(() => ({
|
||||
profileId: 'profile-1',
|
||||
profileName: 'Profile 1',
|
||||
reason: 'proactive' as ProfileAssignmentReason
|
||||
})),
|
||||
updateTaskSession: vi.fn(),
|
||||
getTaskSessionId: vi.fn(() => 'session-123'),
|
||||
on: onMock as unknown as AgentManager['on']
|
||||
};
|
||||
|
||||
// Setup mock window
|
||||
mockWindow = {
|
||||
webContents: {
|
||||
send: vi.fn()
|
||||
} as unknown as Electron.WebContents
|
||||
};
|
||||
|
||||
getMainWindow = () => mockWindow as BrowserWindow;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
registeredHandlers.clear();
|
||||
registeredEventListeners.clear();
|
||||
});
|
||||
|
||||
it('should register all IPC handlers', () => {
|
||||
registerQueueRoutingHandlers(
|
||||
mockAgentManager as AgentManager,
|
||||
getMainWindow
|
||||
);
|
||||
|
||||
expect(ipcMain.handle).toHaveBeenCalledWith(
|
||||
IPC_CHANNELS.QUEUE_GET_RUNNING_TASKS_BY_PROFILE,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(ipcMain.handle).toHaveBeenCalledWith(
|
||||
IPC_CHANNELS.QUEUE_GET_BEST_PROFILE_FOR_TASK,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(ipcMain.handle).toHaveBeenCalledWith(
|
||||
IPC_CHANNELS.QUEUE_ASSIGN_PROFILE_TO_TASK,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(ipcMain.handle).toHaveBeenCalledWith(
|
||||
IPC_CHANNELS.QUEUE_UPDATE_TASK_SESSION,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(ipcMain.handle).toHaveBeenCalledWith(
|
||||
IPC_CHANNELS.QUEUE_GET_TASK_SESSION,
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
describe('getRunningTasksByProfile handler', () => {
|
||||
it('should return running tasks grouped by profile', async () => {
|
||||
registerQueueRoutingHandlers(
|
||||
mockAgentManager as AgentManager,
|
||||
getMainWindow
|
||||
);
|
||||
|
||||
const handler = registeredHandlers.get(
|
||||
IPC_CHANNELS.QUEUE_GET_RUNNING_TASKS_BY_PROFILE
|
||||
);
|
||||
const result = await handler?.();
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
byProfile: { 'profile-1': ['task-1', 'task-2'] },
|
||||
totalRunning: 2
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error on failure', async () => {
|
||||
mockAgentManager.getRunningTasksByProfile = vi.fn(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
|
||||
registerQueueRoutingHandlers(
|
||||
mockAgentManager as AgentManager,
|
||||
getMainWindow
|
||||
);
|
||||
|
||||
const handler = registeredHandlers.get(
|
||||
IPC_CHANNELS.QUEUE_GET_RUNNING_TASKS_BY_PROFILE
|
||||
);
|
||||
const result = await handler?.();
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Test error'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBestProfileForTask handler', () => {
|
||||
it('should return null when no preference', async () => {
|
||||
registerQueueRoutingHandlers(
|
||||
mockAgentManager as AgentManager,
|
||||
getMainWindow
|
||||
);
|
||||
|
||||
const handler = registeredHandlers.get(
|
||||
IPC_CHANNELS.QUEUE_GET_BEST_PROFILE_FOR_TASK
|
||||
);
|
||||
const result = await handler?.({}, { excludeProfileId: 'profile-1' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignProfileToTask handler', () => {
|
||||
it('should assign profile to task', async () => {
|
||||
registerQueueRoutingHandlers(
|
||||
mockAgentManager as AgentManager,
|
||||
getMainWindow
|
||||
);
|
||||
|
||||
const handler = registeredHandlers.get(
|
||||
IPC_CHANNELS.QUEUE_ASSIGN_PROFILE_TO_TASK
|
||||
);
|
||||
const result = await handler?.(
|
||||
{},
|
||||
'task-1',
|
||||
'profile-1',
|
||||
'Profile 1',
|
||||
'proactive'
|
||||
);
|
||||
|
||||
expect(mockAgentManager.assignProfileToTask).toHaveBeenCalledWith(
|
||||
'task-1',
|
||||
'profile-1',
|
||||
'Profile 1',
|
||||
'proactive'
|
||||
);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should return error on failure', async () => {
|
||||
mockAgentManager.assignProfileToTask = vi.fn(() => {
|
||||
throw new Error('Assignment failed');
|
||||
});
|
||||
|
||||
registerQueueRoutingHandlers(
|
||||
mockAgentManager as AgentManager,
|
||||
getMainWindow
|
||||
);
|
||||
|
||||
const handler = registeredHandlers.get(
|
||||
IPC_CHANNELS.QUEUE_ASSIGN_PROFILE_TO_TASK
|
||||
);
|
||||
const result = await handler?.(
|
||||
{},
|
||||
'task-1',
|
||||
'profile-1',
|
||||
'Profile 1',
|
||||
'proactive'
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Assignment failed'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskSession handler', () => {
|
||||
it('should update task session', async () => {
|
||||
registerQueueRoutingHandlers(
|
||||
mockAgentManager as AgentManager,
|
||||
getMainWindow
|
||||
);
|
||||
|
||||
const handler = registeredHandlers.get(
|
||||
IPC_CHANNELS.QUEUE_UPDATE_TASK_SESSION
|
||||
);
|
||||
const result = await handler?.({}, 'task-1', 'session-abc');
|
||||
|
||||
expect(mockAgentManager.updateTaskSession).toHaveBeenCalledWith(
|
||||
'task-1',
|
||||
'session-abc'
|
||||
);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTaskSession handler', () => {
|
||||
it('should return session ID', async () => {
|
||||
registerQueueRoutingHandlers(
|
||||
mockAgentManager as AgentManager,
|
||||
getMainWindow
|
||||
);
|
||||
|
||||
const handler = registeredHandlers.get(
|
||||
IPC_CHANNELS.QUEUE_GET_TASK_SESSION
|
||||
);
|
||||
const result = await handler?.({}, 'task-1');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: 'session-123'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null when no session', async () => {
|
||||
mockAgentManager.getTaskSessionId = vi.fn(() => undefined);
|
||||
|
||||
registerQueueRoutingHandlers(
|
||||
mockAgentManager as AgentManager,
|
||||
getMainWindow
|
||||
);
|
||||
|
||||
const handler = registeredHandlers.get(
|
||||
IPC_CHANNELS.QUEUE_GET_TASK_SESSION
|
||||
);
|
||||
const result = await handler?.({}, 'task-1');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('event forwarding', () => {
|
||||
it('should register event listeners on agent manager', () => {
|
||||
registerQueueRoutingHandlers(
|
||||
mockAgentManager as AgentManager,
|
||||
getMainWindow
|
||||
);
|
||||
|
||||
expect(mockAgentManager.on).toHaveBeenCalledWith(
|
||||
'profile-swapped',
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(mockAgentManager.on).toHaveBeenCalledWith(
|
||||
'session-captured',
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(mockAgentManager.on).toHaveBeenCalledWith(
|
||||
'queue-blocked-no-profiles',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward profile-swapped event to renderer', () => {
|
||||
registerQueueRoutingHandlers(
|
||||
mockAgentManager as AgentManager,
|
||||
getMainWindow
|
||||
);
|
||||
|
||||
const swapHandler = registeredEventListeners.get('profile-swapped');
|
||||
const swapData = {
|
||||
fromProfileId: 'p1',
|
||||
toProfileId: 'p2',
|
||||
reason: 'rate_limit'
|
||||
};
|
||||
|
||||
swapHandler?.('task-1', swapData);
|
||||
|
||||
expect(mockWindow.webContents?.send).toHaveBeenCalledWith(
|
||||
IPC_CHANNELS.QUEUE_PROFILE_SWAPPED,
|
||||
{ taskId: 'task-1', swap: swapData }
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward session-captured event to renderer', () => {
|
||||
registerQueueRoutingHandlers(
|
||||
mockAgentManager as AgentManager,
|
||||
getMainWindow
|
||||
);
|
||||
|
||||
const sessionHandler = registeredEventListeners.get('session-captured');
|
||||
sessionHandler?.('task-1', 'session-abc');
|
||||
|
||||
expect(mockWindow.webContents?.send).toHaveBeenCalledWith(
|
||||
IPC_CHANNELS.QUEUE_SESSION_CAPTURED,
|
||||
expect.objectContaining({
|
||||
taskId: 'task-1',
|
||||
sessionId: 'session-abc',
|
||||
capturedAt: expect.any(String)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward queue-blocked-no-profiles event to renderer', () => {
|
||||
registerQueueRoutingHandlers(
|
||||
mockAgentManager as AgentManager,
|
||||
getMainWindow
|
||||
);
|
||||
|
||||
const blockedHandler = registeredEventListeners.get(
|
||||
'queue-blocked-no-profiles'
|
||||
);
|
||||
blockedHandler?.({ reason: 'all_rate_limited' });
|
||||
|
||||
expect(mockWindow.webContents?.send).toHaveBeenCalledWith(
|
||||
IPC_CHANNELS.QUEUE_BLOCKED_NO_PROFILES,
|
||||
expect.objectContaining({
|
||||
reason: 'all_rate_limited',
|
||||
timestamp: expect.any(String)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not send event when window is null', () => {
|
||||
const nullWindowGetter = () => null;
|
||||
|
||||
registerQueueRoutingHandlers(
|
||||
mockAgentManager as AgentManager,
|
||||
nullWindowGetter
|
||||
);
|
||||
|
||||
const swapHandler = registeredEventListeners.get('profile-swapped');
|
||||
swapHandler?.('task-1', {});
|
||||
|
||||
expect(mockWindow.webContents?.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Queue Routing IPC Handlers
|
||||
*
|
||||
* Handles IPC communication for the rate limit recovery queue routing system.
|
||||
* Provides profile-aware task distribution to enable overnight autonomous operation.
|
||||
*/
|
||||
|
||||
import { ipcMain, BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type { AgentManager } from '../agent/agent-manager';
|
||||
import type { ProfileAssignmentReason, RunningTasksByProfile, ClaudeProfile } from '../../shared/types';
|
||||
import type { ClaudeProfileManager } from '../claude-profile-manager';
|
||||
|
||||
/**
|
||||
* Register queue routing IPC handlers
|
||||
*/
|
||||
export function registerQueueRoutingHandlers(
|
||||
agentManager: AgentManager,
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
profileManager?: ClaudeProfileManager
|
||||
): void {
|
||||
// Get running tasks grouped by profile
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.QUEUE_GET_RUNNING_TASKS_BY_PROFILE,
|
||||
async (): Promise<{ success: boolean; data?: RunningTasksByProfile; error?: string }> => {
|
||||
try {
|
||||
const data = agentManager.getRunningTasksByProfile();
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
console.error('[QueueRouting] Failed to get running tasks by profile:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get best profile for a task
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.QUEUE_GET_BEST_PROFILE_FOR_TASK,
|
||||
async (
|
||||
_event,
|
||||
options?: {
|
||||
excludeProfileId?: string;
|
||||
perProfileMaxTasks?: number;
|
||||
profileThreshold?: number;
|
||||
}
|
||||
): Promise<{ success: boolean; data?: ClaudeProfile | null; error?: string }> => {
|
||||
try {
|
||||
// If no profile manager is available, return null (no preference)
|
||||
if (!profileManager) {
|
||||
console.log('[QueueRouting] Profile manager not available, returning null');
|
||||
return { success: true, data: null };
|
||||
}
|
||||
|
||||
// Get auto-switch settings to check if enabled
|
||||
const settings = profileManager.getAutoSwitchSettings();
|
||||
|
||||
// If auto-switching is disabled, return null (no preference)
|
||||
if (!settings.enabled) {
|
||||
console.log('[QueueRouting] Auto-switching disabled, returning null');
|
||||
return { success: true, data: null };
|
||||
}
|
||||
|
||||
// Use getBestAvailableProfile which internally handles:
|
||||
// - User's configured priority order
|
||||
// - Profile authentication status
|
||||
// - Rate limit status
|
||||
// - Usage thresholds (session and weekly)
|
||||
const bestProfile = profileManager.getBestAvailableProfile(
|
||||
options?.excludeProfileId
|
||||
);
|
||||
|
||||
if (bestProfile) {
|
||||
console.log('[QueueRouting] Best profile selected:', {
|
||||
profileId: bestProfile.id,
|
||||
profileName: bestProfile.name,
|
||||
excludedId: options?.excludeProfileId
|
||||
});
|
||||
} else {
|
||||
console.log('[QueueRouting] No suitable profile found for task routing');
|
||||
}
|
||||
|
||||
return { success: true, data: bestProfile };
|
||||
} catch (error) {
|
||||
console.error('[QueueRouting] Failed to get best profile for task:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Assign a profile to a task
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.QUEUE_ASSIGN_PROFILE_TO_TASK,
|
||||
async (
|
||||
_event,
|
||||
taskId: string,
|
||||
profileId: string,
|
||||
profileName: string,
|
||||
reason: ProfileAssignmentReason
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
agentManager.assignProfileToTask(taskId, profileId, profileName, reason);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[QueueRouting] Failed to assign profile to task:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update session ID for a task
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.QUEUE_UPDATE_TASK_SESSION,
|
||||
async (
|
||||
_event,
|
||||
taskId: string,
|
||||
sessionId: string
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
agentManager.updateTaskSession(taskId, sessionId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[QueueRouting] Failed to update task session:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get session ID for a task
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.QUEUE_GET_TASK_SESSION,
|
||||
async (
|
||||
_event,
|
||||
taskId: string
|
||||
): Promise<{ success: boolean; data?: string | null; error?: string }> => {
|
||||
try {
|
||||
const sessionId = agentManager.getTaskSessionId(taskId);
|
||||
return { success: true, data: sessionId ?? null };
|
||||
} catch (error) {
|
||||
console.error('[QueueRouting] Failed to get task session:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Forward events from agent manager to renderer
|
||||
|
||||
// Profile swapped event
|
||||
agentManager.on('profile-swapped', (taskId: string, swap: unknown) => {
|
||||
const win = getMainWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.QUEUE_PROFILE_SWAPPED, { taskId, swap });
|
||||
}
|
||||
});
|
||||
|
||||
// Session captured event
|
||||
agentManager.on('session-captured', (taskId: string, sessionId: string) => {
|
||||
const win = getMainWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.QUEUE_SESSION_CAPTURED, {
|
||||
taskId,
|
||||
sessionId,
|
||||
capturedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Queue blocked event (no available profiles)
|
||||
agentManager.on('queue-blocked-no-profiles', (info: { reason: string }) => {
|
||||
const win = getMainWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.QUEUE_BLOCKED_NO_PROFILES, {
|
||||
...info,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[QueueRouting] IPC handlers registered');
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { homedir } from 'os';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager';
|
||||
import { getEffectiveSourcePath } from '../../updater/path-resolver';
|
||||
import { getProfileEnv } from '../../rate-limit-detector';
|
||||
import { getBestAvailableProfileEnv } from '../../rate-limit-detector';
|
||||
import { findTaskAndProject } from './shared';
|
||||
import { parsePythonCommand } from '../../python-detector';
|
||||
import { getToolPath } from '../../cli-tool-manager';
|
||||
@@ -1963,7 +1963,8 @@ export function registerWorktreeHandlers(
|
||||
debug('Working directory:', sourcePath);
|
||||
|
||||
// Get profile environment with OAuth token for AI merge resolution
|
||||
const profileEnv = getProfileEnv();
|
||||
const profileResult = getBestAvailableProfileEnv();
|
||||
const profileEnv = profileResult.env;
|
||||
debug('Profile env for merge:', {
|
||||
hasOAuthToken: !!profileEnv.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
hasConfigDir: !!profileEnv.CLAUDE_CONFIG_DIR
|
||||
@@ -2461,7 +2462,8 @@ export function registerWorktreeHandlers(
|
||||
console.warn('[IPC] Running merge preview:', pythonPath, args.join(' '));
|
||||
|
||||
// Get profile environment for consistency
|
||||
const previewProfileEnv = getProfileEnv();
|
||||
const previewProfileResult = getBestAvailableProfileEnv();
|
||||
const previewProfileEnv = previewProfileResult.env;
|
||||
// Get Python environment for bundled packages
|
||||
const previewPythonEnv = pythonEnvManagerSingleton.getPythonEnv();
|
||||
|
||||
@@ -2981,7 +2983,8 @@ export function registerWorktreeHandlers(
|
||||
debug('Working directory:', sourcePath);
|
||||
|
||||
// Get profile environment with OAuth token
|
||||
const profileEnv = getProfileEnv();
|
||||
const profileResult = getBestAvailableProfileEnv();
|
||||
const profileEnv = profileResult.env;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { BrowserWindow, IpcMainInvokeEvent } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type { IPCResult, TerminalCreateOptions, ClaudeProfile, ClaudeProfileSettings, ClaudeUsageSnapshot, AllProfilesUsage } from '../../shared/types';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
@@ -10,7 +10,7 @@ import { terminalNameGenerator } from '../terminal-name-generator';
|
||||
import { readSettingsFileAsync } from '../settings-utils';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { migrateSession } from '../claude-profile/session-utils';
|
||||
import { DEFAULT_CLAUDE_CONFIG_DIR, createProfileDirectory } from '../claude-profile/profile-utils';
|
||||
import { createProfileDirectory } from '../claude-profile/profile-utils';
|
||||
import { isValidConfigDir } from '../utils/config-path-validator';
|
||||
|
||||
|
||||
@@ -242,12 +242,9 @@ export function registerTerminalHandlers(
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Terminals for profile change:', terminals.length);
|
||||
|
||||
// Determine config directories for session migration
|
||||
const sourceConfigDir = previousProfile.isDefault
|
||||
? DEFAULT_CLAUDE_CONFIG_DIR
|
||||
: previousProfile.configDir;
|
||||
const targetConfigDir = newProfile?.isDefault
|
||||
? DEFAULT_CLAUDE_CONFIG_DIR
|
||||
: newProfile?.configDir;
|
||||
// All profiles now have their own configDir (no special case for default)
|
||||
const sourceConfigDir = previousProfile.configDir;
|
||||
const targetConfigDir = newProfile?.configDir;
|
||||
|
||||
// Build terminal refresh info for frontend
|
||||
const terminalsNeedingRefresh: Array<{
|
||||
@@ -541,12 +538,13 @@ export function registerTerminalHandlers(
|
||||
);
|
||||
|
||||
// Request all profiles usage immediately (for startup/refresh)
|
||||
// Optional forceRefresh parameter bypasses cache to get fresh data
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST,
|
||||
async (): Promise<IPCResult<AllProfilesUsage | null>> => {
|
||||
async (_event: IpcMainInvokeEvent, forceRefresh: boolean = false): Promise<IPCResult<AllProfilesUsage | null>> => {
|
||||
try {
|
||||
const monitor = getUsageMonitor();
|
||||
const allProfilesUsage = await monitor.getAllProfilesUsage();
|
||||
const allProfilesUsage = await monitor.getAllProfilesUsage(forceRefresh);
|
||||
return { success: true, data: allProfilesUsage };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, Implemen
|
||||
import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir, JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX } from '../shared/constants';
|
||||
import { getAutoBuildPath, isInitialized } from './project-initializer';
|
||||
import { getTaskWorktreeDir } from './worktree-paths';
|
||||
import { debugLog } from '../shared/utils/debug-logger';
|
||||
import { isValidTaskId, findAllSpecPaths } from './utils/spec-path-helpers';
|
||||
|
||||
interface TabState {
|
||||
@@ -175,7 +174,6 @@ export class ProjectStore {
|
||||
: null,
|
||||
tabOrder: tabState.tabOrder.filter(id => validProjectIds.includes(id))
|
||||
};
|
||||
console.log('[ProjectStore] Saving tab state:', this.data.tabState);
|
||||
this.save();
|
||||
}
|
||||
|
||||
@@ -253,21 +251,13 @@ export class ProjectStore {
|
||||
const now = Date.now();
|
||||
|
||||
if (cached && (now - cached.timestamp) < this.CACHE_TTL_MS) {
|
||||
console.debug('[ProjectStore] Returning cached tasks for project:', projectId, '(age:', now - cached.timestamp, 'ms)');
|
||||
return cached.tasks;
|
||||
}
|
||||
|
||||
debugLog('[ProjectStore] getTasks called - will load from disk', {
|
||||
projectId,
|
||||
reason: cached ? 'cache expired' : 'cache miss',
|
||||
cacheAge: cached ? now - cached.timestamp : 'N/A'
|
||||
});
|
||||
const project = this.getProject(projectId);
|
||||
if (!project) {
|
||||
debugLog('[ProjectStore] Project not found for id:', projectId);
|
||||
return [];
|
||||
}
|
||||
debugLog('[ProjectStore] Found project:', project.name, 'autoBuildPath:', project.autoBuildPath, 'path:', project.path);
|
||||
|
||||
const allTasks: Task[] = [];
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
@@ -275,13 +265,11 @@ export class ProjectStore {
|
||||
// 1. Scan main project specs directory (source of truth for task existence)
|
||||
const mainSpecsDir = path.join(project.path, specsBaseDir);
|
||||
const mainSpecIds = new Set<string>();
|
||||
console.warn('[ProjectStore] Main specsDir:', mainSpecsDir, 'exists:', existsSync(mainSpecsDir));
|
||||
if (existsSync(mainSpecsDir)) {
|
||||
const mainTasks = this.loadTasksFromSpecsDir(mainSpecsDir, project.path, 'main', projectId, specsBaseDir);
|
||||
allTasks.push(...mainTasks);
|
||||
// Track which specs exist in main project
|
||||
mainTasks.forEach(t => mainSpecIds.add(t.specId));
|
||||
console.warn('[ProjectStore] Loaded', mainTasks.length, 'tasks from main project');
|
||||
}
|
||||
|
||||
// 2. Scan worktree specs directories
|
||||
@@ -306,8 +294,6 @@ export class ProjectStore {
|
||||
// Only include worktree tasks if the spec exists in main project
|
||||
const validWorktreeTasks = worktreeTasks.filter(t => mainSpecIds.has(t.specId));
|
||||
allTasks.push(...validWorktreeTasks);
|
||||
const skipped = worktreeTasks.length - validWorktreeTasks.length;
|
||||
console.debug('[ProjectStore] Loaded', validWorktreeTasks.length, 'tasks from worktree:', worktree.name, skipped > 0 ? `(skipped ${skipped} orphaned)` : '');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -325,11 +311,6 @@ export class ProjectStore {
|
||||
}
|
||||
|
||||
const tasks = Array.from(taskMap.values());
|
||||
console.warn('[ProjectStore] Scan complete - found', tasks.length, 'unique tasks', {
|
||||
mainTasks: allTasks.filter(t => t.location === 'main').length,
|
||||
worktreeTasks: allTasks.filter(t => t.location === 'worktree').length,
|
||||
deduplicated: allTasks.length - tasks.length
|
||||
});
|
||||
|
||||
// Update cache
|
||||
this.tasksCache.set(projectId, { tasks, timestamp: now });
|
||||
@@ -343,7 +324,6 @@ export class ProjectStore {
|
||||
*/
|
||||
invalidateTasksCache(projectId: string): void {
|
||||
this.tasksCache.delete(projectId);
|
||||
console.debug('[ProjectStore] Invalidated tasks cache for project:', projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -352,7 +332,6 @@ export class ProjectStore {
|
||||
*/
|
||||
clearTasksCache(): void {
|
||||
this.tasksCache.clear();
|
||||
console.debug('[ProjectStore] Cleared all tasks cache');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,25 +368,15 @@ export class ProjectStore {
|
||||
let hasJsonError = false;
|
||||
let jsonErrorMessage = '';
|
||||
if (existsSync(planPath)) {
|
||||
console.warn(`[ProjectStore] Loading implementation_plan.json for spec: ${dir.name} from ${location}`);
|
||||
try {
|
||||
const content = readFileSync(planPath, 'utf-8');
|
||||
plan = JSON.parse(content);
|
||||
console.warn(`[ProjectStore] Loaded plan for ${dir.name}:`, {
|
||||
hasDescription: !!plan?.description,
|
||||
hasFeature: !!plan?.feature,
|
||||
status: plan?.status,
|
||||
phaseCount: plan?.phases?.length || 0,
|
||||
subtaskCount: plan?.phases?.flatMap(p => p.subtasks || []).length || 0
|
||||
});
|
||||
} catch (err) {
|
||||
// Don't skip - create task with error indicator so user knows it exists
|
||||
hasJsonError = true;
|
||||
jsonErrorMessage = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[ProjectStore] JSON parse error for spec ${dir.name}:`, jsonErrorMessage);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[ProjectStore] No implementation_plan.json found for spec: ${dir.name} at ${planPath}`);
|
||||
}
|
||||
|
||||
// PRIORITY 1: Read description from implementation_plan.json (user's original)
|
||||
@@ -584,11 +553,6 @@ export class ProjectStore {
|
||||
if (plan?.status) {
|
||||
const storedStatus = statusMap[plan.status];
|
||||
if (storedStatus && TERMINAL_STATUSES.has(storedStatus)) {
|
||||
debugLog('[determineTaskStatusAndReason] Terminal status respected:', {
|
||||
planStatus: plan.status,
|
||||
mappedStatus: storedStatus,
|
||||
reason: 'Terminal statuses (done, pr_created, error) are never overridden'
|
||||
});
|
||||
return { status: storedStatus };
|
||||
}
|
||||
}
|
||||
@@ -607,21 +571,11 @@ export class ProjectStore {
|
||||
|
||||
// During active execution, respect the stored status to prevent jumping
|
||||
if (isActiveProcessStatus && storedStatus === 'in_progress') {
|
||||
debugLog('[determineTaskStatusAndReason] Active process status preserved:', {
|
||||
planStatus: plan.status,
|
||||
mappedStatus: storedStatus,
|
||||
reason: 'Execution in progress - status recalculation blocked'
|
||||
});
|
||||
return { status: 'in_progress' };
|
||||
}
|
||||
|
||||
// Plan review stage (human approval of spec before coding starts)
|
||||
// Only show plan_review when user explicitly requested human review via checkbox
|
||||
if (isPlanReviewStage && storedStatus === 'human_review' && metadata?.requireReviewBeforeCoding) {
|
||||
debugLog('[determineTaskStatusAndReason] Plan review stage detected:', {
|
||||
planStatus: plan.status,
|
||||
reason: 'Spec creation complete, awaiting user approval'
|
||||
});
|
||||
if (isPlanReviewStage && storedStatus === 'human_review') {
|
||||
return { status: 'human_review', reviewReason: 'plan_review' };
|
||||
}
|
||||
|
||||
@@ -636,22 +590,11 @@ export class ProjectStore {
|
||||
} else if (allCompleted) {
|
||||
reviewReason = 'completed';
|
||||
}
|
||||
debugLog('[determineTaskStatusAndReason] Explicit human_review preserved:', {
|
||||
planStatus: plan.status,
|
||||
reviewReason,
|
||||
hasFailedSubtasks,
|
||||
allCompleted,
|
||||
subtaskCount: allSubtasks.length
|
||||
});
|
||||
return { status: 'human_review', reviewReason };
|
||||
}
|
||||
|
||||
// Explicit ai_review status should be preserved
|
||||
if (storedStatus === 'ai_review') {
|
||||
debugLog('[determineTaskStatusAndReason] Explicit ai_review preserved:', {
|
||||
planStatus: plan.status,
|
||||
subtaskCount: allSubtasks.length
|
||||
});
|
||||
return { status: 'ai_review' };
|
||||
}
|
||||
}
|
||||
@@ -664,19 +607,11 @@ export class ProjectStore {
|
||||
try {
|
||||
const content = readFileSync(qaReportPath, 'utf-8');
|
||||
if (content.includes('REJECTED') || content.includes('FAILED')) {
|
||||
debugLog('[determineTaskStatusAndReason] QA report indicates rejection:', {
|
||||
qaReportPath,
|
||||
reason: 'QA rejected - needs human attention'
|
||||
});
|
||||
return { status: 'human_review', reviewReason: 'qa_rejected' };
|
||||
}
|
||||
if (content.includes('PASSED') || content.includes('APPROVED')) {
|
||||
// QA passed - if all subtasks done, move to human_review
|
||||
if (allSubtasks.length > 0 && allSubtasks.every((s) => s.status === 'completed')) {
|
||||
debugLog('[determineTaskStatusAndReason] QA passed with all subtasks complete:', {
|
||||
qaReportPath,
|
||||
subtaskCount: allSubtasks.length
|
||||
});
|
||||
return { status: 'human_review', reviewReason: 'completed' };
|
||||
}
|
||||
}
|
||||
@@ -719,21 +654,6 @@ export class ProjectStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Log calculated status (fallback path - no explicit status was set)
|
||||
debugLog('[determineTaskStatusAndReason] Status calculated from subtasks (fallback):', {
|
||||
planStatus: plan?.status || 'none',
|
||||
calculatedStatus,
|
||||
reviewReason,
|
||||
subtaskStats: {
|
||||
total: allSubtasks.length,
|
||||
completed: allSubtasks.filter((s) => s.status === 'completed').length,
|
||||
inProgress: allSubtasks.filter((s) => s.status === 'in_progress').length,
|
||||
failed: allSubtasks.filter((s) => s.status === 'failed').length,
|
||||
pending: allSubtasks.filter((s) => s.status === 'pending').length
|
||||
},
|
||||
isManual: metadata?.sourceType === 'manual'
|
||||
});
|
||||
|
||||
return { status: calculatedStatus, reviewReason: calculatedStatus === 'human_review' ? reviewReason : undefined };
|
||||
}
|
||||
|
||||
@@ -760,7 +680,6 @@ export class ProjectStore {
|
||||
|
||||
// If spec directory doesn't exist anywhere, skip gracefully
|
||||
if (specPaths.length === 0) {
|
||||
console.log(`[ProjectStore] archiveTasks: Spec directory not found for ${taskId}, skipping (already removed)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -787,7 +706,6 @@ export class ProjectStore {
|
||||
}
|
||||
|
||||
writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
console.log(`[ProjectStore] archiveTasks: Successfully archived task ${taskId} at ${specPath}`);
|
||||
} catch (error) {
|
||||
console.error(`[ProjectStore] archiveTasks: Failed to archive task ${taskId} at ${specPath}:`, error);
|
||||
hasErrors = true;
|
||||
@@ -846,7 +764,6 @@ export class ProjectStore {
|
||||
delete metadata.archivedAt;
|
||||
delete metadata.archivedInVersion;
|
||||
writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
console.log(`[ProjectStore] unarchiveTasks: Successfully unarchived task ${taskId} at ${specPath}`);
|
||||
} catch (error) {
|
||||
console.error(`[ProjectStore] unarchiveTasks: Failed to unarchive task ${taskId} at ${specPath}:`, error);
|
||||
hasErrors = true;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { getClaudeProfileManager } from './claude-profile-manager';
|
||||
import { getUsageMonitor } from './claude-profile/usage-monitor';
|
||||
|
||||
/**
|
||||
* Regex pattern to detect Claude Code rate limit messages
|
||||
@@ -34,7 +35,7 @@ const AUTH_FAILURE_PATTERNS = [
|
||||
/unauthorized/i,
|
||||
/please\s*(log\s*in|login|authenticate)/i,
|
||||
/invalid\s*(credentials|token|api\s*key)/i,
|
||||
/auth(entication)?\s*(failed|error|failure)/i,
|
||||
/auth(entication)?\s+(failed|error|failure)/i,
|
||||
/session\s*(expired|invalid)/i,
|
||||
/access\s*denied/i,
|
||||
/permission\s*denied/i,
|
||||
@@ -279,6 +280,188 @@ export function getProfileEnv(profileId?: string): Record<string, string> {
|
||||
return profileManager.getActiveProfileEnv();
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of getting the best available profile environment
|
||||
*/
|
||||
export interface BestProfileEnvResult {
|
||||
/** Environment variables for the selected profile */
|
||||
env: Record<string, string>;
|
||||
/** The profile ID that was selected */
|
||||
profileId: string;
|
||||
/** The profile name for logging/display */
|
||||
profileName: string;
|
||||
/** Whether a swap was performed (true if different from active profile) */
|
||||
wasSwapped: boolean;
|
||||
/** Reason for the swap if one occurred */
|
||||
swapReason?: 'rate_limited' | 'at_capacity' | 'proactive';
|
||||
/** The original active profile if a swap occurred */
|
||||
originalProfile?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables for the BEST available Claude profile and persist the profile swap.
|
||||
*
|
||||
* IMPORTANT: This function has the side effect of calling profileManager.setActiveProfile()
|
||||
* when a better profile is found. This modifies global state and persists the profile swap.
|
||||
*
|
||||
* This is the preferred function for SDK operations that need profile environment.
|
||||
* It automatically handles:
|
||||
* 1. Checking if the active profile is explicitly rate-limited (received 429/rate limit error)
|
||||
* 2. Checking if the active profile is at capacity (100% weekly usage)
|
||||
* 3. Finding a better alternative profile if available
|
||||
* 4. PERSISTING the swap by updating the active profile
|
||||
*
|
||||
* Use this instead of getProfileEnv() for any operation that will make Claude API calls.
|
||||
*
|
||||
* @returns Object containing env vars and metadata about which profile was selected
|
||||
*/
|
||||
export function getBestAvailableProfileEnv(): BestProfileEnvResult {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
|
||||
// Check for explicit rate limit (from previous API errors)
|
||||
const rateLimitStatus = profileManager.isProfileRateLimited(activeProfile.id);
|
||||
|
||||
// Check for capacity limit (100% weekly usage - will be rate limited on next request)
|
||||
const isAtCapacity = activeProfile.usage?.weeklyUsagePercent !== undefined &&
|
||||
activeProfile.usage.weeklyUsagePercent >= 100;
|
||||
|
||||
// Determine if we need to find an alternative
|
||||
const needsSwap = rateLimitStatus.limited || isAtCapacity;
|
||||
const swapReason: BestProfileEnvResult['swapReason'] = rateLimitStatus.limited
|
||||
? 'rate_limited'
|
||||
: isAtCapacity
|
||||
? 'at_capacity'
|
||||
: undefined;
|
||||
|
||||
if (needsSwap) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[RateLimitDetector] Active profile needs swap:', {
|
||||
activeProfile: activeProfile.name,
|
||||
isRateLimited: rateLimitStatus.limited,
|
||||
isAtCapacity,
|
||||
weeklyUsage: activeProfile.usage?.weeklyUsagePercent,
|
||||
limitType: rateLimitStatus.type,
|
||||
resetAt: rateLimitStatus.resetAt
|
||||
});
|
||||
}
|
||||
|
||||
// Try to find a better profile
|
||||
const bestProfile = profileManager.getBestAvailableProfile(activeProfile.id);
|
||||
|
||||
if (bestProfile) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[RateLimitDetector] Using alternative profile:', {
|
||||
originalProfile: activeProfile.name,
|
||||
alternativeProfile: bestProfile.name,
|
||||
reason: swapReason
|
||||
});
|
||||
}
|
||||
|
||||
// Persist the swap by updating the active profile
|
||||
// This ensures the UI reflects which account is actually being used
|
||||
profileManager.setActiveProfile(bestProfile.id);
|
||||
console.warn('[RateLimitDetector] Switched active profile:', {
|
||||
from: activeProfile.name,
|
||||
to: bestProfile.name,
|
||||
reason: swapReason
|
||||
});
|
||||
|
||||
// Trigger a usage refresh so the UI shows the new active profile
|
||||
// This updates the UsageIndicator in the header
|
||||
// We use fire-and-forget pattern to avoid making this function async
|
||||
try {
|
||||
const usageMonitor = getUsageMonitor();
|
||||
// Force refresh all profiles usage data, which will emit 'all-profiles-usage-updated' event
|
||||
// The UI components listen for this and will update automatically
|
||||
usageMonitor.getAllProfilesUsage(true).then((allProfilesUsage) => {
|
||||
if (allProfilesUsage) {
|
||||
// Find the new active profile in allProfiles and emit its usage
|
||||
// This ensures UsageIndicator.usage state also updates to show the new active account
|
||||
const newActiveProfile = allProfilesUsage.allProfiles.find(p => p.isActive);
|
||||
if (newActiveProfile) {
|
||||
// Construct a ClaudeUsageSnapshot for the new active profile
|
||||
const newActiveUsage = {
|
||||
profileId: newActiveProfile.profileId,
|
||||
profileName: newActiveProfile.profileName,
|
||||
profileEmail: newActiveProfile.profileEmail,
|
||||
sessionPercent: newActiveProfile.sessionPercent,
|
||||
weeklyPercent: newActiveProfile.weeklyPercent,
|
||||
sessionResetTimestamp: newActiveProfile.sessionResetTimestamp,
|
||||
weeklyResetTimestamp: newActiveProfile.weeklyResetTimestamp,
|
||||
fetchedAt: allProfilesUsage.fetchedAt,
|
||||
needsReauthentication: newActiveProfile.needsReauthentication,
|
||||
};
|
||||
usageMonitor.emit('usage-updated', newActiveUsage);
|
||||
}
|
||||
// Also emit all-profiles-usage-updated for the other profiles list
|
||||
usageMonitor.emit('all-profiles-usage-updated', allProfilesUsage);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn('[RateLimitDetector] Failed to refresh usage after swap:', err);
|
||||
});
|
||||
} catch (err) {
|
||||
// Usage monitor may not be initialized yet, that's OK
|
||||
console.warn('[RateLimitDetector] Could not trigger usage refresh:', err);
|
||||
}
|
||||
|
||||
const profileEnv = profileManager.getProfileEnv(bestProfile.id);
|
||||
|
||||
return {
|
||||
env: ensureCleanProfileEnv(profileEnv),
|
||||
profileId: bestProfile.id,
|
||||
profileName: bestProfile.name,
|
||||
wasSwapped: true,
|
||||
swapReason,
|
||||
originalProfile: {
|
||||
id: activeProfile.id,
|
||||
name: activeProfile.name
|
||||
}
|
||||
};
|
||||
} else {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[RateLimitDetector] No alternative profile available, using rate-limited/at-capacity profile');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use active profile (either it's fine, or no better alternative exists)
|
||||
const activeEnv = profileManager.getActiveProfileEnv();
|
||||
return {
|
||||
env: ensureCleanProfileEnv(activeEnv),
|
||||
profileId: activeProfile.id,
|
||||
profileName: activeProfile.name,
|
||||
wasSwapped: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the profile environment is clean for subprocess invocation.
|
||||
*
|
||||
* When CLAUDE_CONFIG_DIR is set, we MUST clear CLAUDE_CODE_OAUTH_TOKEN to prevent
|
||||
* the Claude Agent SDK from using a hardcoded/cached token (e.g., from .env file)
|
||||
* instead of reading fresh credentials from the specified config directory.
|
||||
*
|
||||
* This is critical for multi-account switching: when switching from a rate-limited
|
||||
* account to an available one, the subprocess must use the new account's credentials.
|
||||
*
|
||||
* @param env - Profile environment from getProfileEnv() or getActiveProfileEnv()
|
||||
* @returns Environment with CLAUDE_CODE_OAUTH_TOKEN cleared if CLAUDE_CONFIG_DIR is set
|
||||
*/
|
||||
function ensureCleanProfileEnv(env: Record<string, string>): Record<string, string> {
|
||||
if (env.CLAUDE_CONFIG_DIR) {
|
||||
// Clear CLAUDE_CODE_OAUTH_TOKEN to ensure SDK uses credentials from CLAUDE_CONFIG_DIR
|
||||
return {
|
||||
...env,
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ''
|
||||
};
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active Claude profile ID
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Tests for SDK Session Recovery Coordinator
|
||||
*
|
||||
* Tests the central coordinator for SDK operations and rate limit recovery.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
SDKSessionRecoveryCoordinator,
|
||||
getRecoveryCoordinator,
|
||||
type SDKOperationType,
|
||||
type RecoveryCoordinatorConfig
|
||||
} from './sdk-session-recovery-coordinator';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../claude-profile-manager', () => ({
|
||||
getClaudeProfileManager: vi.fn(() => ({
|
||||
getActiveProfile: vi.fn(() => ({
|
||||
id: 'profile-1',
|
||||
name: 'Test Profile'
|
||||
})),
|
||||
getProfile: vi.fn((id: string) => ({
|
||||
id,
|
||||
name: `Profile ${id}`
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
|
||||
vi.mock('../claude-profile/usage-monitor', () => ({
|
||||
getUsageMonitor: vi.fn(() => ({
|
||||
getAllProfilesUsage: vi.fn(async () => ({
|
||||
activeProfile: {
|
||||
profileId: 'profile-1',
|
||||
profileName: 'Profile 1',
|
||||
sessionPercent: 50,
|
||||
weeklyPercent: 30
|
||||
},
|
||||
allProfiles: [
|
||||
{
|
||||
profileId: 'profile-1',
|
||||
profileName: 'Profile 1',
|
||||
isAuthenticated: true,
|
||||
isRateLimited: false,
|
||||
availabilityScore: 70
|
||||
},
|
||||
{
|
||||
profileId: 'profile-2',
|
||||
profileName: 'Profile 2',
|
||||
isAuthenticated: true,
|
||||
isRateLimited: false,
|
||||
availabilityScore: 90
|
||||
}
|
||||
],
|
||||
fetchedAt: new Date()
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
|
||||
vi.mock('../ipc-handlers/utils', () => ({
|
||||
safeSendToRenderer: vi.fn()
|
||||
}));
|
||||
|
||||
describe('SDKSessionRecoveryCoordinator', () => {
|
||||
let coordinator: SDKSessionRecoveryCoordinator;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
// Reset singleton before each test
|
||||
SDKSessionRecoveryCoordinator.resetInstance();
|
||||
coordinator = SDKSessionRecoveryCoordinator.getInstance();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
coordinator.cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('singleton pattern', () => {
|
||||
it('should return same instance on multiple calls', () => {
|
||||
const instance1 = SDKSessionRecoveryCoordinator.getInstance();
|
||||
const instance2 = SDKSessionRecoveryCoordinator.getInstance();
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
it('should reset instance correctly', () => {
|
||||
const instance1 = SDKSessionRecoveryCoordinator.getInstance();
|
||||
SDKSessionRecoveryCoordinator.resetInstance();
|
||||
const instance2 = SDKSessionRecoveryCoordinator.getInstance();
|
||||
expect(instance1).not.toBe(instance2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('operation registration', () => {
|
||||
it('should register a new operation', () => {
|
||||
const operation = coordinator.registerOperation(
|
||||
'task-1',
|
||||
'task',
|
||||
'profile-1',
|
||||
'Test Profile'
|
||||
);
|
||||
|
||||
expect(operation.id).toBe('task-1');
|
||||
expect(operation.type).toBe('task');
|
||||
expect(operation.profileId).toBe('profile-1');
|
||||
expect(operation.profileName).toBe('Test Profile');
|
||||
expect(operation.startedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should register operation with metadata', () => {
|
||||
const metadata = { key: 'value' };
|
||||
const operation = coordinator.registerOperation(
|
||||
'task-2',
|
||||
'roadmap',
|
||||
'profile-1',
|
||||
'Test Profile',
|
||||
metadata
|
||||
);
|
||||
|
||||
expect(operation.metadata).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should get registered operation by ID', () => {
|
||||
coordinator.registerOperation('task-1', 'task', 'profile-1', 'Test Profile');
|
||||
|
||||
const operation = coordinator.getOperation('task-1');
|
||||
expect(operation).toBeDefined();
|
||||
expect(operation?.id).toBe('task-1');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent operation', () => {
|
||||
const operation = coordinator.getOperation('non-existent');
|
||||
expect(operation).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should unregister operation', () => {
|
||||
coordinator.registerOperation('task-1', 'task', 'profile-1', 'Test Profile');
|
||||
coordinator.unregisterOperation('task-1');
|
||||
|
||||
const operation = coordinator.getOperation('task-1');
|
||||
expect(operation).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('session management', () => {
|
||||
it('should update operation session ID', () => {
|
||||
coordinator.registerOperation('task-1', 'task', 'profile-1', 'Test Profile');
|
||||
coordinator.updateOperationSession('task-1', 'session-abc123');
|
||||
|
||||
const operation = coordinator.getOperation('task-1');
|
||||
expect(operation?.sessionId).toBe('session-abc123');
|
||||
});
|
||||
|
||||
it('should update last activity on session update', () => {
|
||||
coordinator.registerOperation('task-1', 'task', 'profile-1', 'Test Profile');
|
||||
const beforeUpdate = coordinator.getOperation('task-1')?.lastActivityAt;
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
coordinator.updateOperationSession('task-1', 'session-abc123');
|
||||
|
||||
const afterUpdate = coordinator.getOperation('task-1')?.lastActivityAt;
|
||||
expect(afterUpdate?.getTime()).toBeGreaterThan(beforeUpdate?.getTime() ?? 0);
|
||||
});
|
||||
|
||||
it('should update activity timestamp', () => {
|
||||
coordinator.registerOperation('task-1', 'task', 'profile-1', 'Test Profile');
|
||||
const before = coordinator.getOperation('task-1')?.lastActivityAt;
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
coordinator.updateOperationActivity('task-1');
|
||||
|
||||
const after = coordinator.getOperation('task-1')?.lastActivityAt;
|
||||
expect(after?.getTime()).toBeGreaterThan(before?.getTime() ?? 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOperationsByType', () => {
|
||||
it('should filter operations by type', () => {
|
||||
coordinator.registerOperation('task-1', 'task', 'profile-1', 'Test Profile');
|
||||
coordinator.registerOperation('task-2', 'task', 'profile-1', 'Test Profile');
|
||||
coordinator.registerOperation('roadmap-1', 'roadmap', 'profile-1', 'Test Profile');
|
||||
|
||||
const tasks = coordinator.getOperationsByType('task');
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(tasks.every(op => op.type === 'task')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty array when no operations of type exist', () => {
|
||||
coordinator.registerOperation('task-1', 'task', 'profile-1', 'Test Profile');
|
||||
|
||||
const ideations = coordinator.getOperationsByType('ideation');
|
||||
expect(ideations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOperationsByProfile', () => {
|
||||
it('should filter operations by profile', () => {
|
||||
coordinator.registerOperation('task-1', 'task', 'profile-1', 'Profile 1');
|
||||
coordinator.registerOperation('task-2', 'task', 'profile-1', 'Profile 1');
|
||||
coordinator.registerOperation('task-3', 'task', 'profile-2', 'Profile 2');
|
||||
|
||||
const profile1Ops = coordinator.getOperationsByProfile('profile-1');
|
||||
expect(profile1Ops).toHaveLength(2);
|
||||
expect(profile1Ops.every(op => op.profileId === 'profile-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectBestProfile', () => {
|
||||
it('should select profile with highest availability score', async () => {
|
||||
const result = await coordinator.selectBestProfile();
|
||||
|
||||
// Profile 2 has higher availability score (90 vs 70)
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.profileId).toBe('profile-2');
|
||||
});
|
||||
|
||||
it('should exclude specified profile', async () => {
|
||||
const result = await coordinator.selectBestProfile('profile-2');
|
||||
|
||||
// Profile 2 is excluded, should select profile-1
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.profileId).toBe('profile-1');
|
||||
});
|
||||
|
||||
it('should consider active operations when scoring', async () => {
|
||||
// Register multiple operations on profile-2
|
||||
coordinator.registerOperation('task-1', 'task', 'profile-2', 'Profile 2');
|
||||
coordinator.registerOperation('task-2', 'task', 'profile-2', 'Profile 2');
|
||||
|
||||
const result = await coordinator.selectBestProfile();
|
||||
|
||||
// Profile 2 has -30 penalty (2 ops * 15), score goes from 90 to 60
|
||||
// Profile 1 still at 70, should be selected
|
||||
expect(result?.profileId).toBe('profile-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRateLimit', () => {
|
||||
it('should return new profile on rate limit', async () => {
|
||||
coordinator.registerOperation('task-1', 'task', 'profile-1', 'Profile 1');
|
||||
|
||||
const result = await coordinator.handleRateLimit('task-1', 'profile-1');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.profileId).toBe('profile-2');
|
||||
expect(result?.reason).toBe('reactive');
|
||||
});
|
||||
|
||||
it('should update operation with new profile', async () => {
|
||||
coordinator.registerOperation('task-1', 'task', 'profile-1', 'Profile 1');
|
||||
|
||||
await coordinator.handleRateLimit('task-1', 'profile-1');
|
||||
|
||||
const operation = coordinator.getOperation('task-1');
|
||||
expect(operation?.profileId).toBe('profile-2');
|
||||
});
|
||||
|
||||
it('should return null for unknown operation', async () => {
|
||||
const result = await coordinator.handleRateLimit('non-existent', 'profile-1');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should emit queue-blocked event when no profiles available', async () => {
|
||||
const blockedHandler = vi.fn();
|
||||
coordinator.on('queue-blocked', blockedHandler);
|
||||
|
||||
// Register operation
|
||||
coordinator.registerOperation('task-1', 'task', 'profile-1', 'Profile 1');
|
||||
|
||||
// Mock getAllProfilesUsage to return no available profiles
|
||||
const { getUsageMonitor } = await import('../claude-profile/usage-monitor');
|
||||
(getUsageMonitor as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
getAllProfilesUsage: vi.fn(async () => ({
|
||||
allProfiles: [
|
||||
{
|
||||
profileId: 'profile-1',
|
||||
isAuthenticated: true,
|
||||
isRateLimited: true, // Rate limited
|
||||
availabilityScore: 0
|
||||
}
|
||||
]
|
||||
}))
|
||||
});
|
||||
|
||||
const result = await coordinator.handleRateLimit('task-1', 'profile-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
// Advance timers to flush notification batch
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(blockedHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('profile cooldown', () => {
|
||||
it('should clear cooldown for profile', () => {
|
||||
// Trigger a rate limit to create cooldown
|
||||
coordinator.registerOperation('task-1', 'task', 'profile-1', 'Profile 1');
|
||||
coordinator.handleRateLimit('task-1', 'profile-1');
|
||||
|
||||
// Clear cooldown
|
||||
coordinator.clearProfileCooldown('profile-1');
|
||||
|
||||
// Profile should be eligible again
|
||||
const stats = coordinator.getStats();
|
||||
expect(stats.profilesInCooldown).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStats', () => {
|
||||
it('should return correct statistics', () => {
|
||||
coordinator.registerOperation('task-1', 'task', 'profile-1', 'Profile 1');
|
||||
coordinator.registerOperation('task-2', 'roadmap', 'profile-1', 'Profile 1');
|
||||
coordinator.registerOperation('task-3', 'ideation', 'profile-2', 'Profile 2');
|
||||
|
||||
const stats = coordinator.getStats();
|
||||
|
||||
expect(stats.activeOperations).toBe(3);
|
||||
expect(stats.operationsByType.task).toBe(1);
|
||||
expect(stats.operationsByType.roadmap).toBe(1);
|
||||
expect(stats.operationsByType.ideation).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clear all state on cleanup', () => {
|
||||
coordinator.registerOperation('task-1', 'task', 'profile-1', 'Profile 1');
|
||||
|
||||
coordinator.cleanup();
|
||||
|
||||
expect(coordinator.getOperation('task-1')).toBeUndefined();
|
||||
expect(coordinator.getStats().activeOperations).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecoveryCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
SDKSessionRecoveryCoordinator.resetInstance();
|
||||
});
|
||||
|
||||
it('should return the singleton instance', () => {
|
||||
const coordinator = getRecoveryCoordinator();
|
||||
const sameCoordinator = SDKSessionRecoveryCoordinator.getInstance();
|
||||
expect(coordinator).toBe(sameCoordinator);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* SDK Session Recovery Coordinator
|
||||
*
|
||||
* Central coordinator for all SDK operations and rate limit recovery.
|
||||
* Part of the intelligent rate limit recovery system (Phase 9: Unified Coordination).
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Track all SDK operations (tasks, background operations)
|
||||
* - Centralized rate limit handling
|
||||
* - Profile selection with cooldown periods
|
||||
* - Notification batching to prevent UI spam
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import { getUsageMonitor } from '../claude-profile/usage-monitor';
|
||||
import { safeSendToRenderer } from '../ipc-handlers/utils';
|
||||
import type { ProfileAssignmentReason } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Types of SDK operations that can be registered
|
||||
*/
|
||||
export type SDKOperationType = 'task' | 'roadmap' | 'ideation' | 'changelog' | 'title-generation' | 'other';
|
||||
|
||||
/**
|
||||
* Registered SDK operation
|
||||
*/
|
||||
export interface RegisteredOperation {
|
||||
/** Unique operation ID */
|
||||
id: string;
|
||||
/** Type of operation */
|
||||
type: SDKOperationType;
|
||||
/** Associated profile ID */
|
||||
profileId: string;
|
||||
/** Profile display name */
|
||||
profileName: string;
|
||||
/** Captured session ID (if available) */
|
||||
sessionId?: string;
|
||||
/** Operation start time */
|
||||
startedAt: Date;
|
||||
/** Last activity timestamp */
|
||||
lastActivityAt: Date;
|
||||
/** Custom metadata */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile with cooldown tracking
|
||||
*/
|
||||
interface ProfileCooldown {
|
||||
profileId: string;
|
||||
rateLimitedAt: Date;
|
||||
cooldownUntil: Date;
|
||||
rateLimitCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the recovery coordinator
|
||||
*/
|
||||
export interface RecoveryCoordinatorConfig {
|
||||
/** Cooldown period after rate limit (ms). Default: 60000 (1 minute) */
|
||||
cooldownPeriodMs: number;
|
||||
/** Maximum consecutive rate limits before profile is marked unavailable. Default: 3 */
|
||||
maxConsecutiveRateLimits: number;
|
||||
/** Notification batch window (ms). Default: 2000 */
|
||||
notificationBatchWindowMs: number;
|
||||
/** Maximum notifications per batch. Default: 5 */
|
||||
maxNotificationsPerBatch: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: RecoveryCoordinatorConfig = {
|
||||
cooldownPeriodMs: 60000,
|
||||
maxConsecutiveRateLimits: 3,
|
||||
notificationBatchWindowMs: 2000,
|
||||
maxNotificationsPerBatch: 5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Profile scoring constants
|
||||
*/
|
||||
const OPERATION_PENALTY_POINTS = 15; // Penalty per active operation on a profile
|
||||
const RATE_LIMIT_PENALTY_POINTS = 5; // Penalty per previous rate limit
|
||||
|
||||
/**
|
||||
* Notification types for batching
|
||||
*/
|
||||
type NotificationType = 'profile-swap' | 'rate-limit' | 'blocked';
|
||||
|
||||
interface PendingNotification {
|
||||
type: NotificationType;
|
||||
data: unknown;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* SDKSessionRecoveryCoordinator - Central manager for SDK operations and recovery
|
||||
*
|
||||
* This singleton coordinates all SDK operations across the application:
|
||||
* - Tasks (via AgentManager)
|
||||
* - Background operations (roadmap, ideation, changelog)
|
||||
* - Title generation
|
||||
*
|
||||
* Provides unified rate limit handling and profile selection.
|
||||
*/
|
||||
export class SDKSessionRecoveryCoordinator extends EventEmitter {
|
||||
private static instance: SDKSessionRecoveryCoordinator | null = null;
|
||||
|
||||
private operations: Map<string, RegisteredOperation> = new Map();
|
||||
private profileCooldowns: Map<string, ProfileCooldown> = new Map();
|
||||
private config: RecoveryCoordinatorConfig;
|
||||
private getMainWindow: (() => BrowserWindow | null) | null = null;
|
||||
|
||||
// Notification batching
|
||||
private pendingNotifications: PendingNotification[] = [];
|
||||
private notificationBatchTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
private constructor(config: Partial<RecoveryCoordinatorConfig> = {}) {
|
||||
super();
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance
|
||||
*/
|
||||
static getInstance(config?: Partial<RecoveryCoordinatorConfig>): SDKSessionRecoveryCoordinator {
|
||||
if (!SDKSessionRecoveryCoordinator.instance) {
|
||||
SDKSessionRecoveryCoordinator.instance = new SDKSessionRecoveryCoordinator(config);
|
||||
}
|
||||
return SDKSessionRecoveryCoordinator.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton (for testing)
|
||||
*/
|
||||
static resetInstance(): void {
|
||||
if (SDKSessionRecoveryCoordinator.instance) {
|
||||
SDKSessionRecoveryCoordinator.instance.cleanup();
|
||||
SDKSessionRecoveryCoordinator.instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the main window getter for sending notifications
|
||||
*/
|
||||
setMainWindowGetter(getter: () => BrowserWindow | null): void {
|
||||
this.getMainWindow = getter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new SDK operation
|
||||
*/
|
||||
registerOperation(
|
||||
id: string,
|
||||
type: SDKOperationType,
|
||||
profileId: string,
|
||||
profileName: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): RegisteredOperation {
|
||||
const operation: RegisteredOperation = {
|
||||
id,
|
||||
type,
|
||||
profileId,
|
||||
profileName,
|
||||
startedAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata,
|
||||
};
|
||||
|
||||
this.operations.set(id, operation);
|
||||
console.log(`[RecoveryCoordinator] Registered operation: ${id} (${type}) on profile ${profileName}`);
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update operation with session ID
|
||||
*/
|
||||
updateOperationSession(id: string, sessionId: string): void {
|
||||
const operation = this.operations.get(id);
|
||||
if (operation) {
|
||||
operation.sessionId = sessionId;
|
||||
operation.lastActivityAt = new Date();
|
||||
console.log(`[RecoveryCoordinator] Session captured for ${id}: ${sessionId.substring(0, 16)}...`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update operation activity timestamp
|
||||
*/
|
||||
updateOperationActivity(id: string): void {
|
||||
const operation = this.operations.get(id);
|
||||
if (operation) {
|
||||
operation.lastActivityAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister an operation (completed or failed)
|
||||
*/
|
||||
unregisterOperation(id: string): void {
|
||||
const operation = this.operations.get(id);
|
||||
if (operation) {
|
||||
this.operations.delete(id);
|
||||
console.log(`[RecoveryCoordinator] Unregistered operation: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an operation by ID
|
||||
*/
|
||||
getOperation(id: string): RegisteredOperation | undefined {
|
||||
return this.operations.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all operations of a specific type
|
||||
*/
|
||||
getOperationsByType(type: SDKOperationType): RegisteredOperation[] {
|
||||
return Array.from(this.operations.values()).filter(op => op.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all operations for a profile
|
||||
*/
|
||||
getOperationsByProfile(profileId: string): RegisteredOperation[] {
|
||||
return Array.from(this.operations.values()).filter(op => op.profileId === profileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle rate limit for an operation
|
||||
* Returns the new profile to use, or null if no profile is available
|
||||
*/
|
||||
async handleRateLimit(
|
||||
operationId: string,
|
||||
rateLimitedProfileId: string
|
||||
): Promise<{ profileId: string; profileName: string; reason: ProfileAssignmentReason } | null> {
|
||||
const operation = this.operations.get(operationId);
|
||||
if (!operation) {
|
||||
console.warn(`[RecoveryCoordinator] Unknown operation: ${operationId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Record cooldown for rate-limited profile
|
||||
this.recordProfileCooldown(rateLimitedProfileId);
|
||||
|
||||
// Select best available profile
|
||||
const newProfile = await this.selectBestProfile(rateLimitedProfileId);
|
||||
|
||||
if (!newProfile) {
|
||||
// No profiles available - queue is blocked
|
||||
this.queueNotification('blocked', {
|
||||
reason: 'All profiles are at capacity or in cooldown',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Emit event for listeners
|
||||
this.emit('queue-blocked', { reason: 'no_profiles_available', operationId });
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update operation with new profile
|
||||
operation.profileId = newProfile.profileId;
|
||||
operation.profileName = newProfile.profileName;
|
||||
operation.lastActivityAt = new Date();
|
||||
|
||||
// Queue swap notification
|
||||
this.queueNotification('profile-swap', {
|
||||
operationId,
|
||||
operationType: operation.type,
|
||||
fromProfileId: rateLimitedProfileId,
|
||||
fromProfileName: operation.profileName,
|
||||
toProfileId: newProfile.profileId,
|
||||
toProfileName: newProfile.profileName,
|
||||
reason: 'rate_limit',
|
||||
sessionId: operation.sessionId,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[RecoveryCoordinator] Rate limit recovery: ${operationId} swapped from ${rateLimitedProfileId} to ${newProfile.profileId}`
|
||||
);
|
||||
|
||||
return {
|
||||
profileId: newProfile.profileId,
|
||||
profileName: newProfile.profileName,
|
||||
reason: 'reactive',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the best available profile for a new operation
|
||||
* Considers cooldowns, usage, and current load
|
||||
*/
|
||||
async selectBestProfile(
|
||||
excludeProfileId?: string
|
||||
): Promise<{ profileId: string; profileName: string } | null> {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const usageMonitor = getUsageMonitor();
|
||||
|
||||
// Get all profiles usage
|
||||
const allProfilesUsage = await usageMonitor.getAllProfilesUsage();
|
||||
if (!allProfilesUsage) {
|
||||
// Fallback to active profile (if not excluded)
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
if (excludeProfileId && activeProfile.id === excludeProfileId) {
|
||||
return null;
|
||||
}
|
||||
return { profileId: activeProfile.id, profileName: activeProfile.name };
|
||||
}
|
||||
|
||||
// Filter and score profiles
|
||||
const now = new Date();
|
||||
const candidates: Array<{
|
||||
profileId: string;
|
||||
profileName: string;
|
||||
score: number;
|
||||
}> = [];
|
||||
|
||||
for (const profile of allProfilesUsage.allProfiles) {
|
||||
// Skip excluded profile
|
||||
if (excludeProfileId && profile.profileId === excludeProfileId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip unauthenticated profiles
|
||||
if (!profile.isAuthenticated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip rate-limited profiles
|
||||
if (profile.isRateLimited) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check cooldown
|
||||
const cooldown = this.profileCooldowns.get(profile.profileId);
|
||||
if (cooldown && cooldown.cooldownUntil > now) {
|
||||
console.log(
|
||||
`[RecoveryCoordinator] Profile ${profile.profileName} in cooldown until ${cooldown.cooldownUntil.toISOString()}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if profile has exceeded max consecutive rate limits
|
||||
if (cooldown && cooldown.rateLimitCount >= this.config.maxConsecutiveRateLimits) {
|
||||
console.log(
|
||||
`[RecoveryCoordinator] Profile ${profile.profileName} exceeded max rate limits (${cooldown.rateLimitCount})`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count current operations on this profile
|
||||
const operationsOnProfile = this.getOperationsByProfile(profile.profileId).length;
|
||||
|
||||
// Calculate score:
|
||||
// - Base: availability score (0-100)
|
||||
// - Penalty: -15 per active operation
|
||||
// - Penalty: -5 per previous rate limit
|
||||
let score = profile.availabilityScore;
|
||||
score -= operationsOnProfile * OPERATION_PENALTY_POINTS;
|
||||
score -= (cooldown?.rateLimitCount ?? 0) * RATE_LIMIT_PENALTY_POINTS;
|
||||
|
||||
candidates.push({
|
||||
profileId: profile.profileId,
|
||||
profileName: profile.profileName,
|
||||
score,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by score (highest first)
|
||||
candidates.sort((a, b) => b.score - a.score);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const best = candidates[0];
|
||||
console.log(
|
||||
`[RecoveryCoordinator] Selected profile: ${best.profileName} (score: ${best.score})`
|
||||
);
|
||||
|
||||
return { profileId: best.profileId, profileName: best.profileName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a cooldown for a profile that hit rate limit
|
||||
*/
|
||||
private recordProfileCooldown(profileId: string): void {
|
||||
const now = new Date();
|
||||
const existing = this.profileCooldowns.get(profileId);
|
||||
|
||||
const cooldown: ProfileCooldown = {
|
||||
profileId,
|
||||
rateLimitedAt: now,
|
||||
cooldownUntil: new Date(now.getTime() + this.config.cooldownPeriodMs),
|
||||
rateLimitCount: (existing?.rateLimitCount ?? 0) + 1,
|
||||
};
|
||||
|
||||
this.profileCooldowns.set(profileId, cooldown);
|
||||
console.log(
|
||||
`[RecoveryCoordinator] Profile ${profileId} in cooldown until ${cooldown.cooldownUntil.toISOString()} (count: ${cooldown.rateLimitCount})`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cooldown for a profile (e.g., when usage resets)
|
||||
*/
|
||||
clearProfileCooldown(profileId: string): void {
|
||||
this.profileCooldowns.delete(profileId);
|
||||
console.log(`[RecoveryCoordinator] Cleared cooldown for profile ${profileId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a notification for batched delivery
|
||||
*/
|
||||
private queueNotification(type: NotificationType, data: unknown): void {
|
||||
this.pendingNotifications.push({
|
||||
type,
|
||||
data,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
// Start batch timer if not already running
|
||||
if (!this.notificationBatchTimeout) {
|
||||
this.notificationBatchTimeout = setTimeout(
|
||||
() => this.flushNotifications(),
|
||||
this.config.notificationBatchWindowMs
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush pending notifications to renderer
|
||||
*/
|
||||
private flushNotifications(): void {
|
||||
this.notificationBatchTimeout = null;
|
||||
|
||||
if (this.pendingNotifications.length === 0 || !this.getMainWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mainWindow = this.getMainWindow();
|
||||
if (!mainWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by type
|
||||
const swaps = this.pendingNotifications.filter(n => n.type === 'profile-swap');
|
||||
const blocked = this.pendingNotifications.filter(n => n.type === 'blocked');
|
||||
|
||||
// Send profile swap notifications
|
||||
if (swaps.length > 0) {
|
||||
const toSend = swaps.slice(0, this.config.maxNotificationsPerBatch);
|
||||
for (const notification of toSend) {
|
||||
safeSendToRenderer(
|
||||
this.getMainWindow,
|
||||
IPC_CHANNELS.QUEUE_PROFILE_SWAPPED,
|
||||
notification.data
|
||||
);
|
||||
}
|
||||
if (swaps.length > this.config.maxNotificationsPerBatch) {
|
||||
console.log(
|
||||
`[RecoveryCoordinator] ${swaps.length - this.config.maxNotificationsPerBatch} swap notifications suppressed`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Send blocked notification (only most recent)
|
||||
if (blocked.length > 0) {
|
||||
safeSendToRenderer(
|
||||
this.getMainWindow,
|
||||
IPC_CHANNELS.QUEUE_BLOCKED_NO_PROFILES,
|
||||
blocked[blocked.length - 1].data
|
||||
);
|
||||
}
|
||||
|
||||
// Clear pending notifications
|
||||
this.pendingNotifications = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get coordinator statistics
|
||||
*/
|
||||
getStats(): {
|
||||
activeOperations: number;
|
||||
operationsByType: Record<SDKOperationType, number>;
|
||||
profilesInCooldown: number;
|
||||
pendingNotifications: number;
|
||||
} {
|
||||
const operationsByType: Record<SDKOperationType, number> = {
|
||||
task: 0,
|
||||
roadmap: 0,
|
||||
ideation: 0,
|
||||
changelog: 0,
|
||||
'title-generation': 0,
|
||||
other: 0,
|
||||
};
|
||||
|
||||
for (const op of this.operations.values()) {
|
||||
operationsByType[op.type]++;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const profilesInCooldown = Array.from(this.profileCooldowns.values())
|
||||
.filter(c => c.cooldownUntil > now).length;
|
||||
|
||||
return {
|
||||
activeOperations: this.operations.size,
|
||||
operationsByType,
|
||||
profilesInCooldown,
|
||||
pendingNotifications: this.pendingNotifications.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
cleanup(): void {
|
||||
if (this.notificationBatchTimeout) {
|
||||
clearTimeout(this.notificationBatchTimeout);
|
||||
this.notificationBatchTimeout = null;
|
||||
}
|
||||
this.operations.clear();
|
||||
this.profileCooldowns.clear();
|
||||
this.pendingNotifications = [];
|
||||
this.removeAllListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global coordinator instance
|
||||
*/
|
||||
export function getRecoveryCoordinator(): SDKSessionRecoveryCoordinator {
|
||||
return SDKSessionRecoveryCoordinator.getInstance();
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { app } from 'electron';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
import { EventEmitter } from 'events';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getBestAvailableProfileEnv } from './rate-limit-detector';
|
||||
import { parsePythonCommand } from './python-detector';
|
||||
import { pythonEnvManager } from './python-env-manager';
|
||||
|
||||
@@ -163,8 +163,17 @@ export class TerminalNameGenerator extends EventEmitter {
|
||||
hasOAuthToken: !!autoBuildEnv.CLAUDE_CODE_OAUTH_TOKEN
|
||||
});
|
||||
|
||||
// Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default)
|
||||
const profileEnv = getProfileEnv();
|
||||
// Use centralized function that automatically handles rate limits and capacity
|
||||
const profileResult = getBestAvailableProfileEnv();
|
||||
const profileEnv = profileResult.env;
|
||||
|
||||
if (profileResult.wasSwapped) {
|
||||
debug('Using alternative profile for terminal name generation:', {
|
||||
originalProfile: profileResult.originalProfile?.name,
|
||||
selectedProfile: profileResult.profileName,
|
||||
reason: profileResult.swapReason
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Use the venv Python where claude_agent_sdk is installed
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as crypto from 'crypto';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager';
|
||||
import { getCredentialsFromKeychain, clearKeychainCache } from '../claude-profile/credential-utils';
|
||||
import { getUsageMonitor } from '../claude-profile/usage-monitor';
|
||||
import { getEmailFromConfigDir } from '../claude-profile/profile-utils';
|
||||
import * as OutputParser from './output-parser';
|
||||
import * as SessionHandler from './session-handler';
|
||||
@@ -784,6 +785,41 @@ export function handleOnboardingComplete(
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OnboardingCompleteEvent);
|
||||
}
|
||||
|
||||
// Trigger immediate usage fetch after successful re-authentication
|
||||
// This gives the user immediate feedback that their account is working
|
||||
if (profileId) {
|
||||
try {
|
||||
const usageMonitor = getUsageMonitor();
|
||||
if (usageMonitor) {
|
||||
// Clear any auth failure status for this profile since they just re-authenticated
|
||||
usageMonitor.clearAuthFailedProfile(profileId);
|
||||
|
||||
console.warn('[ClaudeIntegration] Triggering immediate usage fetch after re-authentication:', profileId);
|
||||
|
||||
// Switch to this profile if it's not already active, then fetch usage
|
||||
const profileManager = getClaudeProfileManager();
|
||||
|
||||
// Also clear the migration flag if this profile was migrated to an isolated directory
|
||||
// This prevents the auth failure modal from showing again on next startup
|
||||
if (profileManager.isProfileMigrated(profileId)) {
|
||||
profileManager.clearMigratedProfile(profileId);
|
||||
console.warn('[ClaudeIntegration] Cleared migration flag for re-authenticated profile:', profileId);
|
||||
}
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
if (activeProfile?.id !== profileId) {
|
||||
profileManager.setActiveProfile(profileId);
|
||||
}
|
||||
|
||||
// Small delay to allow profile switch to settle, then trigger usage fetch
|
||||
setTimeout(() => {
|
||||
usageMonitor.checkNow();
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ClaudeIntegration] Failed to trigger post-auth usage fetch:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@ import { app } from 'electron';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
import { EventEmitter } from 'events';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getBestAvailableProfileEnv } from './rate-limit-detector';
|
||||
import { parsePythonCommand, getValidatedPythonPath } from './python-detector';
|
||||
import { getConfiguredPythonPath } from './python-env-manager';
|
||||
import { getAPIProfileEnv } from './services/profile';
|
||||
@@ -149,11 +149,32 @@ export class TitleGenerator extends EventEmitter {
|
||||
const isApiProfileActive = Object.keys(apiProfileEnv).length > 0;
|
||||
|
||||
// Only get OAuth profile env if no API profile is active to avoid conflicts
|
||||
const profileEnv = isApiProfileActive ? {} : getProfileEnv();
|
||||
let profileEnv: Record<string, string> = {};
|
||||
if (!isApiProfileActive) {
|
||||
// Use centralized function that automatically handles rate limits and capacity
|
||||
const profileResult = getBestAvailableProfileEnv();
|
||||
profileEnv = profileResult.env;
|
||||
|
||||
if (profileResult.wasSwapped) {
|
||||
debug('Using alternative profile for title generation:', {
|
||||
originalProfile: profileResult.originalProfile?.name,
|
||||
selectedProfile: profileResult.profileName,
|
||||
reason: profileResult.swapReason
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get OAuth mode clearing vars (clears stale ANTHROPIC_* vars when in OAuth mode)
|
||||
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
|
||||
|
||||
// Debug: Log the final environment that will be used
|
||||
// Note: profileEnv from getBestAvailableProfileEnv() already includes CLAUDE_CODE_OAUTH_TOKEN=''
|
||||
// when CLAUDE_CONFIG_DIR is set, ensuring the subprocess uses the correct credentials
|
||||
debug('Final subprocess environment:', {
|
||||
profileEnvCLAUDE_CONFIG_DIR: profileEnv.CLAUDE_CONFIG_DIR,
|
||||
profileEnvClearsOAuthToken: profileEnv.CLAUDE_CODE_OAUTH_TOKEN === ''
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);
|
||||
@@ -162,7 +183,7 @@ export class TitleGenerator extends EventEmitter {
|
||||
env: {
|
||||
...process.env,
|
||||
...autoBuildEnv,
|
||||
...profileEnv, // Claude OAuth profile (only when no API profile active)
|
||||
...profileEnv, // Claude OAuth profile - includes CLAUDE_CONFIG_DIR and clears CLAUDE_CODE_OAUTH_TOKEN
|
||||
...apiProfileEnv, // API profile (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, etc.)
|
||||
...oauthModeClearVars, // Clear stale ANTHROPIC_* vars when in OAuth mode
|
||||
PYTHONUNBUFFERED: '1',
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ClaudeCodeAPI, createClaudeCodeAPI } from './modules/claude-code-api';
|
||||
import { McpAPI, createMcpAPI } from './modules/mcp-api';
|
||||
import { ProfileAPI, createProfileAPI } from './profile-api';
|
||||
import { ScreenshotAPI, createScreenshotAPI } from './screenshot-api';
|
||||
import { QueueAPI, createQueueAPI } from './queue-api';
|
||||
|
||||
export interface ElectronAPI extends
|
||||
ProjectAPI,
|
||||
@@ -32,6 +33,8 @@ export interface ElectronAPI extends
|
||||
ProfileAPI,
|
||||
ScreenshotAPI {
|
||||
github: GitHubAPI;
|
||||
/** Queue routing API for rate limit recovery */
|
||||
queue: QueueAPI;
|
||||
}
|
||||
|
||||
export const createElectronAPI = (): ElectronAPI => ({
|
||||
@@ -47,7 +50,8 @@ export const createElectronAPI = (): ElectronAPI => ({
|
||||
...createMcpAPI(),
|
||||
...createProfileAPI(),
|
||||
...createScreenshotAPI(),
|
||||
github: createGitHubAPI()
|
||||
github: createGitHubAPI(),
|
||||
queue: createQueueAPI() // Queue routing for rate limit recovery
|
||||
});
|
||||
|
||||
// Export individual API creators for potential use in tests or specialized contexts
|
||||
@@ -65,7 +69,8 @@ export {
|
||||
createDebugAPI,
|
||||
createClaudeCodeAPI,
|
||||
createMcpAPI,
|
||||
createScreenshotAPI
|
||||
createScreenshotAPI,
|
||||
createQueueAPI
|
||||
};
|
||||
|
||||
export type {
|
||||
@@ -84,5 +89,6 @@ export type {
|
||||
DebugAPI,
|
||||
ClaudeCodeAPI,
|
||||
McpAPI,
|
||||
ScreenshotAPI
|
||||
ScreenshotAPI,
|
||||
QueueAPI
|
||||
};
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Queue Routing API
|
||||
*
|
||||
* Preload API for rate limit recovery queue routing.
|
||||
* Exposes IPC methods to the renderer for profile-aware task distribution.
|
||||
*/
|
||||
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type {
|
||||
IPCResult,
|
||||
RunningTasksByProfile,
|
||||
ProfileAssignmentReason,
|
||||
ProfileSwapRecord,
|
||||
} from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Result of best profile selection for a task
|
||||
*/
|
||||
export interface BestProfileResult {
|
||||
profileId: string;
|
||||
profileName: string;
|
||||
availabilityScore: number;
|
||||
reason: ProfileAssignmentReason;
|
||||
runningTaskCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for getting the best profile for a task
|
||||
*/
|
||||
export interface GetBestProfileOptions {
|
||||
/** Profile ID to exclude (e.g., one that just hit rate limit) */
|
||||
excludeProfileId?: string;
|
||||
/** Maximum tasks per profile before load balancing (default: 2) */
|
||||
perProfileMaxTasks?: number;
|
||||
/** Usage threshold (0-1) before considering profile "busy" (default: 0.85) */
|
||||
profileThreshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile swap notification event payload
|
||||
*/
|
||||
export interface QueueProfileSwapEvent {
|
||||
taskId: string;
|
||||
swap: ProfileSwapRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session captured event payload
|
||||
*/
|
||||
export interface QueueSessionCapturedEvent {
|
||||
taskId: string;
|
||||
sessionId: string;
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
export interface QueueAPI {
|
||||
// Queue Routing Operations
|
||||
/**
|
||||
* Get running tasks grouped by profile
|
||||
* Used for queue routing decisions
|
||||
*/
|
||||
getRunningTasksByProfile: () => Promise<IPCResult<RunningTasksByProfile>>;
|
||||
|
||||
/**
|
||||
* Get the best available profile for a new task
|
||||
* Considers availability scores, running task counts, and rate limit status
|
||||
*/
|
||||
getBestProfileForTask: (options?: GetBestProfileOptions) => Promise<IPCResult<BestProfileResult | null>>;
|
||||
|
||||
/**
|
||||
* Assign a profile to a task
|
||||
* Called when a task is started or when profile is swapped
|
||||
*/
|
||||
assignProfileToTask: (
|
||||
taskId: string,
|
||||
profileId: string,
|
||||
profileName: string,
|
||||
reason: ProfileAssignmentReason
|
||||
) => Promise<IPCResult>;
|
||||
|
||||
/**
|
||||
* Update session ID for a task
|
||||
* Called when session ID is captured from agent stdout
|
||||
*/
|
||||
updateTaskSession: (taskId: string, sessionId: string) => Promise<IPCResult>;
|
||||
|
||||
/**
|
||||
* Get session ID for a task
|
||||
*/
|
||||
getTaskSession: (taskId: string) => Promise<IPCResult<string | null>>;
|
||||
|
||||
// Queue Routing Event Listeners
|
||||
/**
|
||||
* Listen for profile swap events
|
||||
* Fired when a task's profile is swapped due to rate limit or capacity
|
||||
*/
|
||||
onQueueProfileSwapped: (callback: (event: QueueProfileSwapEvent) => void) => () => void;
|
||||
|
||||
/**
|
||||
* Listen for session captured events
|
||||
* Fired when a session ID is captured from a running task
|
||||
*/
|
||||
onQueueSessionCaptured: (callback: (event: QueueSessionCapturedEvent) => void) => () => void;
|
||||
|
||||
/**
|
||||
* Listen for queue blocked events
|
||||
* Fired when no profiles are available to run queued tasks
|
||||
*/
|
||||
onQueueBlockedNoProfiles: (callback: (info: { reason: string; timestamp: string }) => void) => () => void;
|
||||
}
|
||||
|
||||
export const createQueueAPI = (): QueueAPI => ({
|
||||
// Queue Routing Operations
|
||||
getRunningTasksByProfile: (): Promise<IPCResult<RunningTasksByProfile>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.QUEUE_GET_RUNNING_TASKS_BY_PROFILE),
|
||||
|
||||
getBestProfileForTask: (options?: GetBestProfileOptions): Promise<IPCResult<BestProfileResult | null>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.QUEUE_GET_BEST_PROFILE_FOR_TASK, options),
|
||||
|
||||
assignProfileToTask: (
|
||||
taskId: string,
|
||||
profileId: string,
|
||||
profileName: string,
|
||||
reason: ProfileAssignmentReason
|
||||
): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.QUEUE_ASSIGN_PROFILE_TO_TASK, taskId, profileId, profileName, reason),
|
||||
|
||||
updateTaskSession: (taskId: string, sessionId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.QUEUE_UPDATE_TASK_SESSION, taskId, sessionId),
|
||||
|
||||
getTaskSession: (taskId: string): Promise<IPCResult<string | null>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.QUEUE_GET_TASK_SESSION, taskId),
|
||||
|
||||
// Queue Routing Event Listeners
|
||||
onQueueProfileSwapped: (callback: (event: QueueProfileSwapEvent) => void): (() => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: QueueProfileSwapEvent) => callback(data);
|
||||
ipcRenderer.on(IPC_CHANNELS.QUEUE_PROFILE_SWAPPED, handler);
|
||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.QUEUE_PROFILE_SWAPPED, handler);
|
||||
},
|
||||
|
||||
onQueueSessionCaptured: (callback: (event: QueueSessionCapturedEvent) => void): (() => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: QueueSessionCapturedEvent) => callback(data);
|
||||
ipcRenderer.on(IPC_CHANNELS.QUEUE_SESSION_CAPTURED, handler);
|
||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.QUEUE_SESSION_CAPTURED, handler);
|
||||
},
|
||||
|
||||
onQueueBlockedNoProfiles: (callback: (info: { reason: string; timestamp: string }) => void): (() => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, info: { reason: string; timestamp: string }) => callback(info);
|
||||
ipcRenderer.on(IPC_CHANNELS.QUEUE_BLOCKED_NO_PROFILES, handler);
|
||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.QUEUE_BLOCKED_NO_PROFILES, handler);
|
||||
},
|
||||
});
|
||||
@@ -120,7 +120,7 @@ export interface TerminalAPI {
|
||||
|
||||
// Usage Monitoring (Proactive Account Switching)
|
||||
requestUsageUpdate: () => Promise<IPCResult<import('../../shared/types').ClaudeUsageSnapshot | null>>;
|
||||
requestAllProfilesUsage: () => Promise<IPCResult<import('../../shared/types').AllProfilesUsage | null>>;
|
||||
requestAllProfilesUsage: (forceRefresh?: boolean) => Promise<IPCResult<import('../../shared/types').AllProfilesUsage | null>>;
|
||||
onUsageUpdated: (callback: (usage: import('../../shared/types').ClaudeUsageSnapshot) => void) => () => void;
|
||||
onAllProfilesUsageUpdated: (callback: (allProfilesUsage: import('../../shared/types').AllProfilesUsage) => void) => () => void;
|
||||
onProactiveSwapNotification: (callback: (notification: ProactiveSwapNotification) => void) => () => void;
|
||||
@@ -518,8 +518,8 @@ export const createTerminalAPI = (): TerminalAPI => ({
|
||||
requestUsageUpdate: (): Promise<IPCResult<import('../../shared/types').ClaudeUsageSnapshot | null>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.USAGE_REQUEST),
|
||||
|
||||
requestAllProfilesUsage: (): Promise<IPCResult<import('../../shared/types').AllProfilesUsage | null>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST),
|
||||
requestAllProfilesUsage: (forceRefresh?: boolean): Promise<IPCResult<import('../../shared/types').AllProfilesUsage | null>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST, forceRefresh ?? false),
|
||||
|
||||
onUsageUpdated: (
|
||||
callback: (usage: import('../../shared/types').ClaudeUsageSnapshot) => void
|
||||
|
||||
@@ -33,7 +33,9 @@ vi.mock('react-i18next', () => ({
|
||||
'common:usage.authenticationAriaLabel': 'Authentication: {{provider}}',
|
||||
'common:usage.profile': 'Profile',
|
||||
'common:usage.id': 'ID',
|
||||
'common:usage.apiEndpoint': 'API Endpoint'
|
||||
'common:usage.apiEndpoint': 'API Endpoint',
|
||||
'common:usage.claudeCode': 'Claude Code',
|
||||
'common:usage.apiKey': 'API Key'
|
||||
};
|
||||
// Handle interpolation (e.g., "Authentication: {{provider}}")
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
@@ -134,17 +136,17 @@ describe('AuthStatusIndicator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should display Anthropic provider with Lock icon', () => {
|
||||
it('should display Claude Code badge with Lock icon for OAuth', () => {
|
||||
render(<AuthStatusIndicator />);
|
||||
|
||||
expect(screen.getByText('Anthropic')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /authentication: anthropic/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Claude Code')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /authentication: claude code/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have correct aria-label for OAuth', () => {
|
||||
render(<AuthStatusIndicator />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: Anthropic');
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: Claude Code');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,17 +157,17 @@ describe('AuthStatusIndicator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should display provider label (Anthropic) with Key icon', () => {
|
||||
it('should display API Key badge with Key icon for API profile', () => {
|
||||
render(<AuthStatusIndicator />);
|
||||
|
||||
expect(screen.getByText('Anthropic')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /authentication: anthropic/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('API Key')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /authentication: api key/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have correct aria-label for profile', () => {
|
||||
render(<AuthStatusIndicator />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: Anthropic');
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: API Key');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,34 +178,34 @@ describe('AuthStatusIndicator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to Anthropic provider display', () => {
|
||||
it('should fallback to OAuth (Claude Code) when profile not found', () => {
|
||||
render(<AuthStatusIndicator />);
|
||||
|
||||
expect(screen.getByText('Anthropic')).toBeInTheDocument();
|
||||
expect(screen.getByText('Claude Code')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider detection for different API profiles', () => {
|
||||
it('should display z.ai provider label for z.ai profile', () => {
|
||||
it('should display API Key badge for z.ai profile', () => {
|
||||
vi.mocked(useSettingsStore).mockReturnValue(
|
||||
createUseSettingsStoreMock({ activeProfileId: 'profile-3' })
|
||||
);
|
||||
|
||||
render(<AuthStatusIndicator />);
|
||||
|
||||
expect(screen.getByText('z.ai')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: z.ai');
|
||||
expect(screen.getByText('API Key')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: API Key');
|
||||
});
|
||||
|
||||
it('should display ZHIPU AI provider label for ZHIPU profile', () => {
|
||||
it('should display API Key badge for ZHIPU profile', () => {
|
||||
vi.mocked(useSettingsStore).mockReturnValue(
|
||||
createUseSettingsStoreMock({ activeProfileId: 'profile-4' })
|
||||
);
|
||||
|
||||
render(<AuthStatusIndicator />);
|
||||
|
||||
expect(screen.getByText('ZHIPU AI')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: ZHIPU AI');
|
||||
expect(screen.getByText('API Key')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: API Key');
|
||||
});
|
||||
|
||||
it('should apply correct color classes for each provider', () => {
|
||||
|
||||
@@ -153,6 +153,8 @@ export function AuthStatusIndicator() {
|
||||
const Icon = isOAuth ? Lock : Key;
|
||||
// Compute once and reuse for aria-label and displayed text
|
||||
const localizedProviderLabel = getLocalizedProviderLabel(authStatus.provider);
|
||||
// Badge label: "Claude Code" for OAuth, "API Key" for API profiles
|
||||
const badgeLabel = isOAuth ? t('common:usage.claudeCode') : t('common:usage.apiKey');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -188,11 +190,11 @@ export function AuthStatusIndicator() {
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border transition-all hover:opacity-80 ${authStatus.badgeColor}`}
|
||||
aria-label={t('common:usage.authenticationAriaLabel', { provider: localizedProviderLabel })}
|
||||
aria-label={t('common:usage.authenticationAriaLabel', { provider: badgeLabel })}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-semibold">
|
||||
{localizedProviderLabel}
|
||||
{badgeLabel}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -209,7 +211,7 @@ export function AuthStatusIndicator() {
|
||||
? 'bg-orange-500/15 text-orange-500'
|
||||
: 'bg-primary/15 text-primary'
|
||||
}`}>
|
||||
{isOAuth ? t('common:usage.oauth') : t('common:usage.apiProfile')}
|
||||
{isOAuth ? t('common:usage.oauth') : t('common:usage.apiKey')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -222,6 +224,17 @@ export function AuthStatusIndicator() {
|
||||
<span className="font-semibold text-xs">{localizedProviderLabel}</span>
|
||||
</div>
|
||||
|
||||
{/* Claude Code subscription label for OAuth */}
|
||||
{isOAuth && (
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Lock className="h-3 w-3" />
|
||||
<span className="text-[10px]">{t('common:usage.subscription')}</span>
|
||||
</div>
|
||||
<span className="font-medium text-[10px]">{t('common:usage.claudeCodeSubscription')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile details for API profiles */}
|
||||
{!isOAuth && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tests for ProfileBadge Component
|
||||
*
|
||||
* Tests the profile badge visual component used in task cards.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { ProfileBadge, ProfileSwapIndicator } from './ProfileBadge';
|
||||
import { TooltipProvider } from './ui/tooltip';
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'tasks:profileBadge.reason.proactive': 'Proactively assigned',
|
||||
'tasks:profileBadge.reason.reactive': 'Assigned after rate limit',
|
||||
'tasks:profileBadge.reason.manual': 'Manually assigned',
|
||||
'tasks:profileBadge.swapReason.capacity': 'capacity',
|
||||
'tasks:profileBadge.swapReason.rate_limit': 'rate limit',
|
||||
'tasks:profileBadge.swapReason.manual': 'manual',
|
||||
'tasks:profileBadge.swapReason.recovery': 'recovery'
|
||||
};
|
||||
return translations[key] || key;
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
// Wrapper with required providers
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
return render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
}
|
||||
|
||||
describe('ProfileBadge', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render profile name', () => {
|
||||
renderWithProviders(<ProfileBadge profileName="Test Profile" />);
|
||||
|
||||
expect(screen.getByText('Test Profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should truncate long profile names', () => {
|
||||
renderWithProviders(
|
||||
<ProfileBadge profileName="Very Long Profile Name Here" />
|
||||
);
|
||||
|
||||
// Name should be truncated to first 12 chars + ...
|
||||
expect(screen.getByText('Very Long Pr...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignment reason styling', () => {
|
||||
it('should show proactive badge style when running', () => {
|
||||
renderWithProviders(
|
||||
<ProfileBadge
|
||||
profileName="Profile 1"
|
||||
assignmentReason="proactive"
|
||||
isRunning={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// The text is inside a Badge (div) which has the color classes
|
||||
const textElement = screen.getByText('Profile 1');
|
||||
// Get the Badge element by walking up the DOM - Badge > TooltipTrigger button > TextSpan
|
||||
const badgeElement = textElement.closest('.bg-green-100');
|
||||
expect(badgeElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show reactive badge style when running', () => {
|
||||
renderWithProviders(
|
||||
<ProfileBadge
|
||||
profileName="Profile 1"
|
||||
assignmentReason="reactive"
|
||||
isRunning={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const textElement = screen.getByText('Profile 1');
|
||||
const badgeElement = textElement.closest('.bg-yellow-100');
|
||||
expect(badgeElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show manual badge style when running', () => {
|
||||
renderWithProviders(
|
||||
<ProfileBadge
|
||||
profileName="Profile 1"
|
||||
assignmentReason="manual"
|
||||
isRunning={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const textElement = screen.getByText('Profile 1');
|
||||
const badgeElement = textElement.closest('.bg-blue-100');
|
||||
expect(badgeElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show color when not running', () => {
|
||||
renderWithProviders(
|
||||
<ProfileBadge
|
||||
profileName="Profile 1"
|
||||
assignmentReason="proactive"
|
||||
isRunning={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const textElement = screen.getByText('Profile 1');
|
||||
// Should not have green styling when not running
|
||||
const badgeElement = textElement.closest('.bg-green-100');
|
||||
expect(badgeElement).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('running state', () => {
|
||||
it('should show running indicator when isRunning is true', () => {
|
||||
renderWithProviders(
|
||||
<ProfileBadge profileName="Profile 1" isRunning={true} />
|
||||
);
|
||||
|
||||
const badge = screen.getByText('Profile 1');
|
||||
expect(badge.parentElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compact mode', () => {
|
||||
it('should render in compact mode with smaller sizing', () => {
|
||||
renderWithProviders(
|
||||
<ProfileBadge profileName="Profile 1" compact={true} />
|
||||
);
|
||||
|
||||
// Check that the text is rendered - the component is displayed
|
||||
expect(screen.getByText('Profile 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render in normal mode with standard sizing', () => {
|
||||
renderWithProviders(
|
||||
<ProfileBadge profileName="Profile 1" compact={false} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Profile 1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom className', () => {
|
||||
it('should apply custom className', () => {
|
||||
renderWithProviders(
|
||||
<ProfileBadge profileName="Profile 1" className="custom-class" />
|
||||
);
|
||||
|
||||
// Check that the custom class is applied somewhere in the tree
|
||||
expect(screen.getByText('Profile 1').closest('.custom-class')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProfileSwapIndicator', () => {
|
||||
it('should render swap from and to profiles', () => {
|
||||
render(
|
||||
<ProfileSwapIndicator
|
||||
fromProfile="Profile A"
|
||||
toProfile="Profile B"
|
||||
reason="rate_limit"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Profile A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Profile B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show strikethrough on from profile', () => {
|
||||
render(
|
||||
<ProfileSwapIndicator
|
||||
fromProfile="Profile A"
|
||||
toProfile="Profile B"
|
||||
reason="capacity"
|
||||
/>
|
||||
);
|
||||
|
||||
const fromProfile = screen.getByText('Profile A');
|
||||
expect(fromProfile.className).toContain('line-through');
|
||||
});
|
||||
|
||||
it('should show reason text', () => {
|
||||
render(
|
||||
<ProfileSwapIndicator
|
||||
fromProfile="Profile A"
|
||||
toProfile="Profile B"
|
||||
reason="manual"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('(manual)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* ProfileBadge Component
|
||||
*
|
||||
* Displays the assigned profile for a task with visual indicators
|
||||
* for the assignment reason (proactive, reactive, manual).
|
||||
*
|
||||
* Part of the intelligent rate limit recovery system.
|
||||
*/
|
||||
|
||||
import { User } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import type { ProfileAssignmentReason } from '@shared/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ProfileBadgeProps {
|
||||
/** Display name of the assigned profile */
|
||||
profileName: string;
|
||||
/** Reason the profile was assigned */
|
||||
assignmentReason?: ProfileAssignmentReason;
|
||||
/** Whether the task is currently running */
|
||||
isRunning?: boolean;
|
||||
/** Compact mode for smaller displays */
|
||||
compact?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge variant based on assignment reason
|
||||
*/
|
||||
function getBadgeVariant(reason?: ProfileAssignmentReason, isRunning?: boolean): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||
if (!isRunning) return 'secondary';
|
||||
|
||||
switch (reason) {
|
||||
case 'proactive':
|
||||
return 'default'; // Green - proactively assigned
|
||||
case 'reactive':
|
||||
return 'outline'; // Yellow/outline - assigned after rate limit
|
||||
case 'manual':
|
||||
return 'secondary'; // Blue - manually selected
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge color class based on assignment reason
|
||||
*/
|
||||
function getBadgeColorClass(reason?: ProfileAssignmentReason, isRunning?: boolean): string {
|
||||
if (!isRunning) return '';
|
||||
|
||||
switch (reason) {
|
||||
case 'proactive':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300 border-green-300 dark:border-green-700';
|
||||
case 'reactive':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-300 dark:border-yellow-700';
|
||||
case 'manual':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 border-blue-300 dark:border-blue-700';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ProfileBadge - Shows which Claude profile is assigned to a task
|
||||
*
|
||||
* Visual indicators:
|
||||
* - Green: Proactively assigned (best available profile at task start)
|
||||
* - Yellow: Reactively assigned (swapped after rate limit)
|
||||
* - Blue: Manually assigned (user selected)
|
||||
*/
|
||||
export function ProfileBadge({
|
||||
profileName,
|
||||
assignmentReason,
|
||||
isRunning = false,
|
||||
compact = false,
|
||||
className = ''
|
||||
}: ProfileBadgeProps) {
|
||||
const { t } = useTranslation(['tasks']);
|
||||
|
||||
// Truncate long profile names
|
||||
const displayName = profileName.length > 15
|
||||
? `${profileName.slice(0, 12)}...`
|
||||
: profileName;
|
||||
|
||||
const tooltipContent = (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{profileName}</div>
|
||||
{assignmentReason && (
|
||||
<div className="text-muted-foreground">
|
||||
{t(`tasks:profileBadge.reason.${assignmentReason}`)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const badge = (
|
||||
<Badge
|
||||
variant={getBadgeVariant(assignmentReason, isRunning)}
|
||||
className={`
|
||||
${getBadgeColorClass(assignmentReason, isRunning)}
|
||||
${compact ? 'text-xs px-1.5 py-0.5' : 'text-xs px-2 py-0.5'}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<User className={compact ? 'h-3 w-3 mr-0.5' : 'h-3 w-3 mr-1'} />
|
||||
{displayName}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{badge}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{tooltipContent}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ProfileSwapIndicator - Shows when a task's profile was swapped
|
||||
* Used in task history to show profile swap events
|
||||
*/
|
||||
export function ProfileSwapIndicator({
|
||||
fromProfile,
|
||||
toProfile,
|
||||
reason
|
||||
}: {
|
||||
fromProfile: string;
|
||||
toProfile: string;
|
||||
reason: 'capacity' | 'rate_limit' | 'manual' | 'recovery';
|
||||
}) {
|
||||
const { t } = useTranslation(['tasks']);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span className="line-through">{fromProfile}</span>
|
||||
<span>-></span>
|
||||
<span className="font-medium">{toProfile}</span>
|
||||
<span className="text-xs">({t(`tasks:profileBadge.swapReason.${reason}`)})</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Activity, TrendingUp, AlertCircle, Clock, User, ChevronRight, Info } from 'lucide-react';
|
||||
import { Activity, TrendingUp, AlertCircle, Clock, ChevronRight, Info, LogIn } from 'lucide-react';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -78,6 +78,7 @@ export function UsageIndicator() {
|
||||
const [otherProfiles, setOtherProfiles] = useState<ProfileUsageSummary[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isAvailable, setIsAvailable] = useState(false);
|
||||
const [activeProfileNeedsReauth, setActiveProfileNeedsReauth] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -168,6 +169,7 @@ export function UsageIndicator() {
|
||||
isRateLimited: false,
|
||||
availabilityScore: 100 - Math.max(usage?.sessionPercent || 0, usage?.weeklyPercent || 0),
|
||||
isActive: false, // It's no longer active
|
||||
needsReauthentication: usage?.needsReauthentication,
|
||||
};
|
||||
|
||||
// 2. Convert target profile to a ClaudeUsageSnapshot for the active display
|
||||
@@ -180,6 +182,7 @@ export function UsageIndicator() {
|
||||
sessionResetTimestamp: targetProfile.sessionResetTimestamp,
|
||||
weeklyResetTimestamp: targetProfile.weeklyResetTimestamp,
|
||||
fetchedAt: new Date(),
|
||||
needsReauthentication: targetProfile.needsReauthentication,
|
||||
};
|
||||
|
||||
// 3. Update the other profiles list: remove target, add current active
|
||||
@@ -199,6 +202,21 @@ export function UsageIndicator() {
|
||||
// Fetch fresh data in the background (will update via event listeners)
|
||||
window.electronAPI.requestUsageUpdate();
|
||||
window.electronAPI.requestAllProfilesUsage?.();
|
||||
|
||||
// If the profile needs re-authentication, open Settings > Accounts
|
||||
// so the user can complete the re-auth flow
|
||||
if (targetProfile.needsReauthentication) {
|
||||
// Close the popover first
|
||||
setIsOpen(false);
|
||||
setIsPinned(false);
|
||||
// Open settings with accounts section after a short delay
|
||||
setTimeout(() => {
|
||||
const event = new CustomEvent<AppSection>('open-app-settings', {
|
||||
detail: 'accounts'
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}, 100);
|
||||
}
|
||||
} else {
|
||||
// Revert to captured previous state
|
||||
console.error('[UsageIndicator] Failed to swap profile, reverting');
|
||||
@@ -304,6 +322,9 @@ export function UsageIndicator() {
|
||||
// Filter out the active profile - we only want to show "other" profiles
|
||||
const nonActiveProfiles = allProfilesUsage.allProfiles.filter(p => !p.isActive);
|
||||
setOtherProfiles(nonActiveProfiles);
|
||||
// Track if active profile needs re-auth
|
||||
const activeProfile = allProfilesUsage.allProfiles.find(p => p.isActive);
|
||||
setActiveProfileNeedsReauth(activeProfile?.needsReauthentication ?? false);
|
||||
});
|
||||
|
||||
// Request initial usage on mount
|
||||
@@ -326,6 +347,11 @@ export function UsageIndicator() {
|
||||
if (result.success && result.data) {
|
||||
const nonActiveProfiles = result.data.allProfiles.filter(p => !p.isActive);
|
||||
setOtherProfiles(nonActiveProfiles);
|
||||
// Track if active profile needs re-auth (even if main usage is unavailable)
|
||||
const activeProfile = result.data.allProfiles.find(p => p.isActive);
|
||||
if (activeProfile?.needsReauthentication) {
|
||||
setActiveProfileNeedsReauth(true);
|
||||
}
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.warn('[UsageIndicator] Failed to fetch all profiles usage:', error);
|
||||
@@ -347,23 +373,59 @@ export function UsageIndicator() {
|
||||
);
|
||||
}
|
||||
|
||||
// Show unavailable state
|
||||
// Show unavailable state - with better messaging based on cause
|
||||
if (!isAvailable || !usage) {
|
||||
// Check if it's a re-auth issue (better UX than generic "not supported")
|
||||
const needsReauth = activeProfileNeedsReauth;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border bg-muted/50 text-muted-foreground cursor-help">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-semibold">{t('common:usage.notAvailable')}</span>
|
||||
</div>
|
||||
<button
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border cursor-help ${
|
||||
needsReauth
|
||||
? 'bg-red-500/10 border-red-500/20 text-red-500'
|
||||
: 'bg-muted/50 text-muted-foreground'
|
||||
}`}
|
||||
aria-label={needsReauth ? t('common:usage.reauthRequired') : t('common:usage.dataUnavailable')}
|
||||
>
|
||||
{needsReauth ? (
|
||||
<>
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-semibold">!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-semibold">{t('common:usage.notAvailable')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs w-64">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{t('common:usage.dataUnavailable')}</p>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{t('common:usage.dataUnavailableDescription')}
|
||||
</p>
|
||||
{needsReauth ? (
|
||||
<>
|
||||
<p className="font-medium text-red-500">{t('common:usage.reauthRequired')}</p>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{t('common:usage.reauthRequiredDescription')}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleOpenAccounts}
|
||||
className="text-[10px] text-primary mt-1 font-medium underline hover:text-primary/80 cursor-pointer"
|
||||
>
|
||||
{t('common:usage.clickToOpenSettings')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium">{t('common:usage.dataUnavailable')}</p>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{t('common:usage.dataUnavailableDescription')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -377,7 +439,10 @@ export function UsageIndicator() {
|
||||
const limitingPercent = Math.max(sessionPercent, weeklyPercent);
|
||||
|
||||
// Badge color based on the limiting (higher) percentage
|
||||
const badgeColorClasses = getBadgeColorClasses(limitingPercent);
|
||||
// Override to red/destructive when re-auth is needed
|
||||
const badgeColorClasses = usage.needsReauthentication
|
||||
? 'text-red-500 bg-red-500/10 border-red-500/20'
|
||||
: getBadgeColorClasses(limitingPercent);
|
||||
|
||||
// Individual colors for session and weekly in the badge
|
||||
const sessionColorClass = getColorClass(sessionPercent);
|
||||
@@ -395,7 +460,8 @@ export function UsageIndicator() {
|
||||
);
|
||||
|
||||
const maxUsage = Math.max(usage.sessionPercent, usage.weeklyPercent);
|
||||
const Icon =
|
||||
// Show AlertCircle when re-auth needed or high usage
|
||||
const Icon = usage.needsReauthentication ? AlertCircle :
|
||||
maxUsage >= THRESHOLD_WARNING ? AlertCircle :
|
||||
maxUsage >= THRESHOLD_ELEVATED ? TrendingUp :
|
||||
Activity;
|
||||
@@ -411,16 +477,22 @@ export function UsageIndicator() {
|
||||
onClick={handleTriggerClick}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
{/* Dual usage display: Session | Weekly */}
|
||||
<div className="flex items-center gap-0.5 text-xs font-semibold font-mono">
|
||||
<span className={sessionColorClass} title={t('common:usage.sessionShort')}>
|
||||
{Math.round(sessionPercent)}
|
||||
{/* Show "!" when re-auth needed, otherwise dual usage display */}
|
||||
{usage.needsReauthentication ? (
|
||||
<span className="text-xs font-semibold text-red-500" title={t('common:usage.needsReauth')}>
|
||||
!
|
||||
</span>
|
||||
<span className="text-muted-foreground/50">│</span>
|
||||
<span className={weeklyColorClass} title={t('common:usage.weeklyShort')}>
|
||||
{Math.round(weeklyPercent)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-0.5 text-xs font-semibold font-mono">
|
||||
<span className={sessionColorClass} title={t('common:usage.sessionShort')}>
|
||||
{Math.round(sessionPercent)}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50">│</span>
|
||||
<span className={weeklyColorClass} title={t('common:usage.weeklyShort')}>
|
||||
{Math.round(weeklyPercent)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -437,75 +509,102 @@ export function UsageIndicator() {
|
||||
<span className="font-semibold text-xs">{t('common:usage.usageBreakdown')}</span>
|
||||
</div>
|
||||
|
||||
{/* Session/5-hour usage */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{sessionLabel}
|
||||
</span>
|
||||
<span className={`font-semibold tabular-nums text-xs ${getColorClass(usage.sessionPercent).replace('500', '600')}`}>
|
||||
{Math.round(usage.sessionPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
{sessionResetTime && (
|
||||
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
|
||||
<Info className="h-2.5 w-2.5" />
|
||||
{sessionResetTime}
|
||||
{/* Re-auth required prompt - shown when active profile needs re-authentication */}
|
||||
{usage.needsReauthentication ? (
|
||||
<div className="py-2 space-y-3">
|
||||
<div className="flex items-start gap-2.5 p-2.5 rounded-lg bg-destructive/10 border border-destructive/20">
|
||||
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-destructive">
|
||||
{t('common:usage.reauthRequired')}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t('common:usage.reauthRequiredDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${getGradientClass(usage.sessionPercent)}`}
|
||||
style={{ width: `${Math.min(usage.sessionPercent, 100)}%` }}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenAccounts}
|
||||
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors text-xs font-medium"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
|
||||
</div>
|
||||
<LogIn className="h-3.5 w-3.5" />
|
||||
{t('common:usage.reauthButton')}
|
||||
</button>
|
||||
</div>
|
||||
{usage.sessionUsageValue != null && usage.sessionUsageLimit != null && (
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className="text-muted-foreground">{t('common:usage.used')}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatUsageValue(usage.sessionUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.sessionUsageLimit)}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{/* Session/5-hour usage */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{sessionLabel}
|
||||
</span>
|
||||
<span className={`font-semibold tabular-nums text-xs ${getColorClass(usage.sessionPercent).replace('500', '600')}`}>
|
||||
{Math.round(usage.sessionPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
{sessionResetTime && (
|
||||
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
|
||||
<Info className="h-2.5 w-2.5" />
|
||||
{sessionResetTime}
|
||||
</div>
|
||||
)}
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${getGradientClass(usage.sessionPercent)}`}
|
||||
style={{ width: `${Math.min(usage.sessionPercent, 100)}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
{usage.sessionUsageValue != null && usage.sessionUsageLimit != null && (
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className="text-muted-foreground">{t('common:usage.used')}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatUsageValue(usage.sessionUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.sessionUsageLimit)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Weekly/Monthly usage */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
{weeklyLabel}
|
||||
</span>
|
||||
<span className={`font-semibold tabular-nums text-xs ${getColorClass(usage.weeklyPercent).replace('500', '600')}`}>
|
||||
{Math.round(usage.weeklyPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
{weeklyResetTime && (
|
||||
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
|
||||
<Info className="h-2.5 w-2.5" />
|
||||
{weeklyResetTime}
|
||||
{/* Weekly/Monthly usage */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
{weeklyLabel}
|
||||
</span>
|
||||
<span className={`font-semibold tabular-nums text-xs ${getColorClass(usage.weeklyPercent).replace('500', '600')}`}>
|
||||
{Math.round(usage.weeklyPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
{weeklyResetTime && (
|
||||
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
|
||||
<Info className="h-2.5 w-2.5" />
|
||||
{weeklyResetTime}
|
||||
</div>
|
||||
)}
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${getGradientClass(usage.weeklyPercent)}`}
|
||||
style={{ width: `${Math.min(usage.weeklyPercent, 100)}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
{usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && (
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className="text-muted-foreground">{t('common:usage.used')}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatUsageValue(usage.weeklyUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.weeklyUsageLimit)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${getGradientClass(usage.weeklyPercent)}`}
|
||||
style={{ width: `${Math.min(usage.weeklyPercent, 100)}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
{usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && (
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className="text-muted-foreground">{t('common:usage.used')}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatUsageValue(usage.weeklyUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.weeklyUsageLimit)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Active account footer - clickable to go to settings */}
|
||||
<button
|
||||
@@ -513,11 +612,21 @@ export function UsageIndicator() {
|
||||
onClick={handleOpenAccounts}
|
||||
className={`w-full pt-3 border-t flex items-center gap-2.5 hover:bg-muted/50 -mx-3 px-3 ${otherProfiles.length === 0 ? '-mb-3 pb-3 rounded-b-md' : 'pb-2'} transition-colors cursor-pointer group`}
|
||||
>
|
||||
{/* Initials Avatar */}
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-semibold text-primary">
|
||||
{getInitials(usage.profileName)}
|
||||
</span>
|
||||
{/* Initials Avatar with warning indicator for re-auth needed */}
|
||||
<div className="relative">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
usage.needsReauthentication ? 'bg-red-500/10' : 'bg-primary/10'
|
||||
}`}>
|
||||
<span className={`text-xs font-semibold ${
|
||||
usage.needsReauthentication ? 'text-red-500' : 'text-primary'
|
||||
}`}>
|
||||
{getInitials(usage.profileName)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Status dot for re-auth needed */}
|
||||
{usage.needsReauthentication && (
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-background" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Account Info */}
|
||||
@@ -526,8 +635,15 @@ export function UsageIndicator() {
|
||||
<span className="text-[10px] text-muted-foreground font-medium">
|
||||
{t('common:usage.activeAccount')}
|
||||
</span>
|
||||
{usage.needsReauthentication && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 bg-red-500/10 text-destructive rounded font-semibold">
|
||||
{t('common:usage.needsReauth')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-medium text-xs text-primary truncate">
|
||||
<div className={`font-medium text-xs truncate ${
|
||||
usage.needsReauthentication ? 'text-destructive' : 'text-primary'
|
||||
}`}>
|
||||
{usage.profileEmail || usage.profileName}
|
||||
</div>
|
||||
</div>
|
||||
@@ -550,14 +666,14 @@ export function UsageIndicator() {
|
||||
{/* Initials Avatar with status indicator */}
|
||||
<div className="relative">
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
profile.isRateLimited
|
||||
profile.isRateLimited || profile.needsReauthentication
|
||||
? 'bg-red-500/10'
|
||||
: !profile.isAuthenticated
|
||||
? 'bg-muted'
|
||||
: 'bg-muted/80'
|
||||
}`}>
|
||||
<span className={`text-[10px] font-semibold ${
|
||||
profile.isRateLimited
|
||||
profile.isRateLimited || profile.needsReauthentication
|
||||
? 'text-red-500'
|
||||
: !profile.isAuthenticated
|
||||
? 'text-muted-foreground'
|
||||
@@ -567,7 +683,7 @@ export function UsageIndicator() {
|
||||
</span>
|
||||
</div>
|
||||
{/* Status dot */}
|
||||
{profile.isRateLimited && (
|
||||
{(profile.isRateLimited || profile.needsReauthentication) && (
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-background" />
|
||||
)}
|
||||
</div>
|
||||
@@ -600,6 +716,10 @@ export function UsageIndicator() {
|
||||
? t('common:usage.weeklyLimitReached')
|
||||
: t('common:usage.sessionLimitReached')}
|
||||
</span>
|
||||
) : profile.needsReauthentication ? (
|
||||
<span className="text-[9px] text-destructive">
|
||||
{t('common:usage.needsReauth')}
|
||||
</span>
|
||||
) : !profile.isAuthenticated ? (
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{t('common:usage.notAuthenticated')}
|
||||
|
||||
@@ -102,6 +102,8 @@ export interface UnifiedAccount {
|
||||
isAuthenticated?: boolean;
|
||||
/** Set when this account has identical usage to another - may indicate same underlying account */
|
||||
isDuplicateUsage?: boolean;
|
||||
/** Set when this account has an invalid refresh token and needs re-authentication */
|
||||
needsReauthentication?: boolean;
|
||||
}
|
||||
|
||||
interface SortableAccountItemProps {
|
||||
@@ -281,6 +283,23 @@ function SortableAccountItem({ account, index }: SortableAccountItemProps) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Needs re-authentication warning - invalid refresh token */}
|
||||
{account.type === 'oauth' && account.needsReauthentication && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 mt-1.5 cursor-help">
|
||||
<AlertCircle className="h-3 w-3 text-destructive" />
|
||||
<span className="text-[10px] text-destructive">
|
||||
{t('accounts.priority.needsReauth')}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs max-w-[250px]">
|
||||
{t('accounts.priority.needsReauthHint')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side badge for API profiles */}
|
||||
|
||||
@@ -28,7 +28,9 @@ import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
Server,
|
||||
Globe
|
||||
Globe,
|
||||
Clock,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
@@ -136,9 +138,10 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
|
||||
const [profileUsageData, setProfileUsageData] = useState<Map<string, ProfileUsageSummary>>(new Map());
|
||||
|
||||
// Fetch all profiles usage data
|
||||
const loadProfileUsageData = useCallback(async () => {
|
||||
// Force refresh to get fresh data when Settings opens (bypasses 1-minute cache)
|
||||
const loadProfileUsageData = useCallback(async (forceRefresh: boolean = false) => {
|
||||
try {
|
||||
const result = await window.electronAPI.requestAllProfilesUsage?.();
|
||||
const result = await window.electronAPI.requestAllProfilesUsage?.(forceRefresh);
|
||||
if (result?.success && result.data) {
|
||||
const usageMap = new Map<string, ProfileUsageSummary>();
|
||||
result.data.allProfiles.forEach(profile => {
|
||||
@@ -174,6 +177,7 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
|
||||
isRateLimited: usageData?.isRateLimited,
|
||||
rateLimitType: usageData?.rateLimitType,
|
||||
isAuthenticated: profile.isAuthenticated,
|
||||
needsReauthentication: usageData?.needsReauthentication,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -247,7 +251,9 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
|
||||
loadClaudeProfiles();
|
||||
loadAutoSwitchSettings();
|
||||
loadPriorityOrder();
|
||||
loadProfileUsageData();
|
||||
// Force refresh usage data when Settings opens to get fresh data
|
||||
// This bypasses the 1-minute cache to ensure accurate duplicate detection
|
||||
loadProfileUsageData(true);
|
||||
}
|
||||
}, [isOpen, loadProfileUsageData]);
|
||||
|
||||
@@ -690,14 +696,21 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 mb-4">
|
||||
{claudeProfiles.map((profile) => (
|
||||
{claudeProfiles.map((profile) => {
|
||||
// Get usage data to check needsReauthentication flag
|
||||
const usageData = profileUsageData.get(profile.id);
|
||||
const needsReauth = usageData?.needsReauthentication ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={profile.id}
|
||||
className={cn(
|
||||
"rounded-lg border transition-colors",
|
||||
profile.id === activeClaudeProfileId && !activeApiProfileId
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-background"
|
||||
needsReauth
|
||||
? "border-destructive/50 bg-destructive/5"
|
||||
: profile.id === activeClaudeProfileId && !activeApiProfileId
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-background"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
@@ -756,7 +769,12 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
|
||||
{t('accounts.claudeCode.active')}
|
||||
</span>
|
||||
)}
|
||||
{profile.isAuthenticated ? (
|
||||
{needsReauth ? (
|
||||
<span className="text-xs bg-destructive/20 text-destructive px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{t('accounts.priority.needsReauth')}
|
||||
</span>
|
||||
) : profile.isAuthenticated ? (
|
||||
<span className="text-xs bg-success/20 text-success px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
<Check className="h-3 w-3" />
|
||||
{t('accounts.claudeCode.authenticated')}
|
||||
@@ -770,6 +788,57 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
|
||||
{profile.email && (
|
||||
<span className="text-xs text-muted-foreground">{profile.email}</span>
|
||||
)}
|
||||
{/* Usage bars - show if we have usage data */}
|
||||
{usageData && profile.isAuthenticated && !needsReauth && (
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
{/* Session usage */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<div className="w-12 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
(usageData.sessionPercent ?? 0) >= 95 ? 'bg-red-500' :
|
||||
(usageData.sessionPercent ?? 0) >= 91 ? 'bg-orange-500' :
|
||||
(usageData.sessionPercent ?? 0) >= 71 ? 'bg-yellow-500' :
|
||||
'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(usageData.sessionPercent ?? 0, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-[10px] tabular-nums w-7 ${
|
||||
(usageData.sessionPercent ?? 0) >= 95 ? 'text-red-500' :
|
||||
(usageData.sessionPercent ?? 0) >= 91 ? 'text-orange-500' :
|
||||
(usageData.sessionPercent ?? 0) >= 71 ? 'text-yellow-500' :
|
||||
'text-muted-foreground'
|
||||
}`}>
|
||||
{Math.round(usageData.sessionPercent ?? 0)}%
|
||||
</span>
|
||||
</div>
|
||||
{/* Weekly usage */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TrendingUp className="h-3 w-3 text-muted-foreground" />
|
||||
<div className="w-12 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
(usageData.weeklyPercent ?? 0) >= 95 ? 'bg-red-500' :
|
||||
(usageData.weeklyPercent ?? 0) >= 91 ? 'bg-orange-500' :
|
||||
(usageData.weeklyPercent ?? 0) >= 71 ? 'bg-yellow-500' :
|
||||
'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(usageData.weeklyPercent ?? 0, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-[10px] tabular-nums w-7 ${
|
||||
(usageData.weeklyPercent ?? 0) >= 95 ? 'text-red-500' :
|
||||
(usageData.weeklyPercent ?? 0) >= 91 ? 'text-orange-500' :
|
||||
(usageData.weeklyPercent ?? 0) >= 71 ? 'text-yellow-500' :
|
||||
'text-muted-foreground'
|
||||
}`}>
|
||||
{Math.round(usageData.weeklyPercent ?? 0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -952,7 +1021,8 @@ export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountS
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tests for Profile Swap Notifications Hook
|
||||
*
|
||||
* Tests notification batching, toast display, and event subscriptions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useProfileSwapNotifications, useSessionCaptureListener } from './use-profile-swap-notifications';
|
||||
import type { QueueProfileSwapEvent, QueueSessionCapturedEvent } from '../../preload/api/queue-api';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (options?.defaultValue) return options.defaultValue;
|
||||
return key;
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
// Mock toast
|
||||
const mockToast = vi.fn();
|
||||
vi.mock('./use-toast', () => ({
|
||||
toast: (props: unknown) => mockToast(props)
|
||||
}));
|
||||
|
||||
// Setup mock electronAPI
|
||||
const mockOnQueueProfileSwapped = vi.fn();
|
||||
const mockOnQueueBlockedNoProfiles = vi.fn();
|
||||
const mockOnQueueSessionCaptured = vi.fn();
|
||||
|
||||
describe('useProfileSwapNotifications', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Setup window.electronAPI mock
|
||||
(window as unknown as { electronAPI: unknown }).electronAPI = {
|
||||
queue: {
|
||||
onQueueProfileSwapped: mockOnQueueProfileSwapped.mockReturnValue(() => {}),
|
||||
onQueueBlockedNoProfiles: mockOnQueueBlockedNoProfiles.mockReturnValue(() => {}),
|
||||
onQueueSessionCaptured: mockOnQueueSessionCaptured.mockReturnValue(() => {})
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
|
||||
});
|
||||
|
||||
describe('subscription', () => {
|
||||
it('should subscribe to profile swap events on mount', () => {
|
||||
renderHook(() => useProfileSwapNotifications());
|
||||
|
||||
expect(mockOnQueueProfileSwapped).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnQueueBlockedNoProfiles).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should unsubscribe on unmount', () => {
|
||||
const unsubSwap = vi.fn();
|
||||
const unsubBlocked = vi.fn();
|
||||
mockOnQueueProfileSwapped.mockReturnValue(unsubSwap);
|
||||
mockOnQueueBlockedNoProfiles.mockReturnValue(unsubBlocked);
|
||||
|
||||
const { unmount } = renderHook(() => useProfileSwapNotifications());
|
||||
unmount();
|
||||
|
||||
expect(unsubSwap).toHaveBeenCalled();
|
||||
expect(unsubBlocked).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not subscribe when electronAPI is not available', () => {
|
||||
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
|
||||
|
||||
renderHook(() => useProfileSwapNotifications());
|
||||
|
||||
expect(mockOnQueueProfileSwapped).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('single swap notification', () => {
|
||||
it('should show detailed notification for single swap', () => {
|
||||
let swapCallback: ((event: QueueProfileSwapEvent) => void) | undefined;
|
||||
mockOnQueueProfileSwapped.mockImplementation((cb) => {
|
||||
swapCallback = cb;
|
||||
return () => {};
|
||||
});
|
||||
|
||||
renderHook(() => useProfileSwapNotifications());
|
||||
|
||||
const swapEvent: QueueProfileSwapEvent = {
|
||||
taskId: 'task-1',
|
||||
swap: {
|
||||
fromProfileId: 'profile-1',
|
||||
fromProfileName: 'Profile 1',
|
||||
toProfileId: 'profile-2',
|
||||
toProfileName: 'Profile 2',
|
||||
swappedAt: new Date().toISOString(),
|
||||
reason: 'rate_limit',
|
||||
sessionResumed: false
|
||||
}
|
||||
};
|
||||
|
||||
act(() => {
|
||||
swapCallback?.(swapEvent);
|
||||
});
|
||||
|
||||
// Advance timer to trigger batch processing
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Profile Swapped',
|
||||
duration: 5000
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batched notifications', () => {
|
||||
it('should batch multiple swap events within window', () => {
|
||||
let swapCallback: ((event: QueueProfileSwapEvent) => void) | undefined;
|
||||
mockOnQueueProfileSwapped.mockImplementation((cb) => {
|
||||
swapCallback = cb;
|
||||
return () => {};
|
||||
});
|
||||
|
||||
renderHook(() => useProfileSwapNotifications());
|
||||
|
||||
const createSwapEvent = (taskId: string, toProfile: string): QueueProfileSwapEvent => ({
|
||||
taskId,
|
||||
swap: {
|
||||
fromProfileId: 'profile-1',
|
||||
fromProfileName: 'Profile 1',
|
||||
toProfileId: toProfile,
|
||||
toProfileName: `Profile ${toProfile}`,
|
||||
swappedAt: new Date().toISOString(),
|
||||
reason: 'capacity',
|
||||
sessionResumed: false
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger multiple swaps
|
||||
act(() => {
|
||||
swapCallback?.(createSwapEvent('task-1', 'p2'));
|
||||
swapCallback?.(createSwapEvent('task-2', 'p2'));
|
||||
swapCallback?.(createSwapEvent('task-3', 'p3'));
|
||||
});
|
||||
|
||||
// Should not show toast yet
|
||||
expect(mockToast).not.toHaveBeenCalled();
|
||||
|
||||
// Advance timer to trigger batch processing
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
// Should show batch notification
|
||||
expect(mockToast).toHaveBeenCalledTimes(1);
|
||||
expect(mockToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: expect.stringContaining('3 Profile Swaps')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should limit notifications to max per batch', () => {
|
||||
let swapCallback: ((event: QueueProfileSwapEvent) => void) | undefined;
|
||||
mockOnQueueProfileSwapped.mockImplementation((cb) => {
|
||||
swapCallback = cb;
|
||||
return () => {};
|
||||
});
|
||||
|
||||
renderHook(() => useProfileSwapNotifications());
|
||||
|
||||
const createSwapEvent = (taskId: string): QueueProfileSwapEvent => ({
|
||||
taskId,
|
||||
swap: {
|
||||
fromProfileId: 'profile-1',
|
||||
fromProfileName: 'Profile 1',
|
||||
toProfileId: 'profile-2',
|
||||
toProfileName: 'Profile 2',
|
||||
swappedAt: new Date().toISOString(),
|
||||
reason: 'rate_limit',
|
||||
sessionResumed: false
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger 7 swaps (more than MAX_NOTIFICATIONS_PER_BATCH = 5)
|
||||
act(() => {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
swapCallback?.(createSwapEvent(`task-${i}`));
|
||||
}
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
// Should only show one batched notification
|
||||
expect(mockToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queue blocked notification', () => {
|
||||
it('should show destructive toast for queue blocked', () => {
|
||||
let blockedCallback: ((info: { reason: string; timestamp: string }) => void) | undefined;
|
||||
mockOnQueueBlockedNoProfiles.mockImplementation((cb) => {
|
||||
blockedCallback = cb;
|
||||
return () => {};
|
||||
});
|
||||
|
||||
renderHook(() => useProfileSwapNotifications());
|
||||
|
||||
act(() => {
|
||||
blockedCallback?.({
|
||||
reason: 'all_rate_limited',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Queue Blocked',
|
||||
variant: 'destructive',
|
||||
duration: 8000
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clear pending timeout on unmount', () => {
|
||||
let swapCallback: ((event: QueueProfileSwapEvent) => void) | undefined;
|
||||
mockOnQueueProfileSwapped.mockImplementation((cb) => {
|
||||
swapCallback = cb;
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const { unmount } = renderHook(() => useProfileSwapNotifications());
|
||||
|
||||
// Trigger a swap to start the batch timeout
|
||||
act(() => {
|
||||
swapCallback?.({
|
||||
taskId: 'task-1',
|
||||
swap: {
|
||||
fromProfileId: 'p1',
|
||||
fromProfileName: 'Profile 1',
|
||||
toProfileId: 'p2',
|
||||
toProfileName: 'Profile 2',
|
||||
swappedAt: new Date().toISOString(),
|
||||
reason: 'rate_limit',
|
||||
sessionResumed: false
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Unmount before timeout fires
|
||||
unmount();
|
||||
|
||||
// Advance timer - should not cause errors or show toast
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(mockToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSessionCaptureListener', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
(window as unknown as { electronAPI: unknown }).electronAPI = {
|
||||
queue: {
|
||||
onQueueSessionCaptured: mockOnQueueSessionCaptured.mockReturnValue(() => {})
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
|
||||
});
|
||||
|
||||
it('should subscribe when callback provided', () => {
|
||||
const callback = vi.fn();
|
||||
renderHook(() => useSessionCaptureListener(callback));
|
||||
|
||||
expect(mockOnQueueSessionCaptured).toHaveBeenCalledWith(callback);
|
||||
});
|
||||
|
||||
it('should not subscribe when callback is undefined', () => {
|
||||
renderHook(() => useSessionCaptureListener(undefined));
|
||||
|
||||
expect(mockOnQueueSessionCaptured).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not subscribe when electronAPI is not available', () => {
|
||||
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
|
||||
const callback = vi.fn();
|
||||
|
||||
renderHook(() => useSessionCaptureListener(callback));
|
||||
|
||||
expect(mockOnQueueSessionCaptured).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should unsubscribe on unmount', () => {
|
||||
const unsubscribe = vi.fn();
|
||||
mockOnQueueSessionCaptured.mockReturnValue(unsubscribe);
|
||||
|
||||
const callback = vi.fn();
|
||||
const { unmount } = renderHook(() => useSessionCaptureListener(callback));
|
||||
unmount();
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Profile Swap Notifications Hook
|
||||
*
|
||||
* Listens for profile swap events from the queue routing system
|
||||
* and displays toast notifications to inform the user.
|
||||
*
|
||||
* Part of the intelligent rate limit recovery system (Phase 7: Queue UX Enhancements).
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from './use-toast';
|
||||
import type { QueueProfileSwapEvent, QueueSessionCapturedEvent } from '../../preload/api/queue-api';
|
||||
|
||||
/**
|
||||
* Notification batching to prevent toast spam
|
||||
* Batches notifications within a 2-second window
|
||||
*/
|
||||
interface NotificationQueue {
|
||||
swaps: QueueProfileSwapEvent[];
|
||||
blocked: { reason: string; timestamp: string }[];
|
||||
timeoutId: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
const BATCH_WINDOW_MS = 2000;
|
||||
const MAX_NOTIFICATIONS_PER_BATCH = 5;
|
||||
|
||||
/**
|
||||
* Toast notification durations (milliseconds)
|
||||
*/
|
||||
const TOAST_DURATION_SWAP_MS = 5000; // Single swap or batch swap notification
|
||||
const TOAST_DURATION_BLOCKED_MS = 8000; // Queue blocked notification (longer for critical alerts)
|
||||
|
||||
/**
|
||||
* Hook to display toast notifications for profile swap events
|
||||
*
|
||||
* Automatically subscribes to:
|
||||
* - Profile swap events (rate limit recovery)
|
||||
* - Queue blocked events (no profiles available)
|
||||
*
|
||||
* Batches notifications to avoid toast spam when multiple events occur.
|
||||
*/
|
||||
export function useProfileSwapNotifications() {
|
||||
const { t } = useTranslation(['tasks']);
|
||||
const queueRef = useRef<NotificationQueue>({
|
||||
swaps: [],
|
||||
blocked: [],
|
||||
timeoutId: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Process and display batched notifications
|
||||
*/
|
||||
const processBatch = useCallback(() => {
|
||||
const queue = queueRef.current;
|
||||
queue.timeoutId = null;
|
||||
|
||||
// Process swap notifications
|
||||
if (queue.swaps.length > 0) {
|
||||
const swapsToShow = queue.swaps.slice(0, MAX_NOTIFICATIONS_PER_BATCH);
|
||||
const remainingSwaps = queue.swaps.length - swapsToShow.length;
|
||||
|
||||
if (swapsToShow.length === 1) {
|
||||
// Single swap - show detailed notification
|
||||
const swap = swapsToShow[0].swap;
|
||||
toast({
|
||||
title: t('tasks:queue.autoSwap.title', {
|
||||
defaultValue: 'Profile Swapped',
|
||||
}),
|
||||
description: t('tasks:queue.autoSwap.description', {
|
||||
from: swap.fromProfileName,
|
||||
to: swap.toProfileName,
|
||||
reason: t(`tasks:profileBadge.swapReason.${swap.reason}`),
|
||||
defaultValue: `Switched from ${swap.fromProfileName} to ${swap.toProfileName} (${swap.reason})`,
|
||||
}),
|
||||
duration: TOAST_DURATION_SWAP_MS,
|
||||
});
|
||||
} else {
|
||||
// Multiple swaps - show summary
|
||||
const profileNames = [...new Set(swapsToShow.map(s => s.swap.toProfileName))];
|
||||
toast({
|
||||
title: t('tasks:queue.autoSwap.batchTitle', {
|
||||
count: swapsToShow.length,
|
||||
defaultValue: `${swapsToShow.length} Profile Swaps`,
|
||||
}),
|
||||
description: t('tasks:queue.autoSwap.batchDescription', {
|
||||
profiles: profileNames.join(', '),
|
||||
defaultValue: `Tasks redistributed to: ${profileNames.join(', ')}`,
|
||||
}),
|
||||
duration: TOAST_DURATION_SWAP_MS,
|
||||
});
|
||||
}
|
||||
|
||||
if (remainingSwaps > 0) {
|
||||
console.log(`[ProfileSwapNotifications] ${remainingSwaps} additional swaps suppressed`);
|
||||
}
|
||||
|
||||
queue.swaps = [];
|
||||
}
|
||||
|
||||
// Process blocked notifications
|
||||
if (queue.blocked.length > 0) {
|
||||
toast({
|
||||
title: t('tasks:queue.blocked.title', {
|
||||
defaultValue: 'Queue Blocked',
|
||||
}),
|
||||
description: t('tasks:queue.blocked.description', {
|
||||
defaultValue: 'All profiles are at capacity. Tasks will resume when a profile becomes available.',
|
||||
}),
|
||||
variant: 'destructive',
|
||||
duration: TOAST_DURATION_BLOCKED_MS,
|
||||
});
|
||||
queue.blocked = [];
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
/**
|
||||
* Queue a notification for batched display
|
||||
*/
|
||||
const queueNotification = useCallback((
|
||||
type: 'swap' | 'blocked',
|
||||
data: QueueProfileSwapEvent | { reason: string; timestamp: string }
|
||||
) => {
|
||||
const queue = queueRef.current;
|
||||
|
||||
if (type === 'swap') {
|
||||
queue.swaps.push(data as QueueProfileSwapEvent);
|
||||
} else {
|
||||
queue.blocked.push(data as { reason: string; timestamp: string });
|
||||
}
|
||||
|
||||
// Start batch window if not already started
|
||||
if (!queue.timeoutId) {
|
||||
queue.timeoutId = setTimeout(processBatch, BATCH_WINDOW_MS);
|
||||
}
|
||||
}, [processBatch]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if electronAPI and queue methods are available
|
||||
if (!window.electronAPI?.queue) {
|
||||
console.log('[ProfileSwapNotifications] Queue API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to profile swap events
|
||||
const unsubscribeSwap = window.electronAPI.queue.onQueueProfileSwapped(
|
||||
(event: QueueProfileSwapEvent) => {
|
||||
console.log('[ProfileSwapNotifications] Profile swap event:', event);
|
||||
queueNotification('swap', event);
|
||||
}
|
||||
);
|
||||
|
||||
// Subscribe to queue blocked events
|
||||
const unsubscribeBlocked = window.electronAPI.queue.onQueueBlockedNoProfiles(
|
||||
(info: { reason: string; timestamp: string }) => {
|
||||
console.log('[ProfileSwapNotifications] Queue blocked event:', info);
|
||||
queueNotification('blocked', info);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribeSwap();
|
||||
unsubscribeBlocked();
|
||||
// Clear any pending batch timeout
|
||||
if (queueRef.current.timeoutId) {
|
||||
clearTimeout(queueRef.current.timeoutId);
|
||||
}
|
||||
};
|
||||
}, [queueNotification]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to listen for session capture events (useful for debugging)
|
||||
* This is separate from the main notification hook as it's primarily for internal use.
|
||||
*/
|
||||
export function useSessionCaptureListener(
|
||||
onSessionCaptured?: (event: QueueSessionCapturedEvent) => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI?.queue || !onSessionCaptured) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = window.electronAPI.queue.onQueueSessionCaptured(onSessionCaptured);
|
||||
return unsubscribe;
|
||||
}, [onSessionCaptured]);
|
||||
}
|
||||
@@ -237,6 +237,18 @@ const browserMockAPI: ElectronAPI = {
|
||||
onAnalyzePreviewError: () => () => {}
|
||||
},
|
||||
|
||||
// Queue Routing API (rate limit recovery)
|
||||
queue: {
|
||||
getRunningTasksByProfile: async () => ({ success: true, data: { byProfile: {}, totalRunning: 0 } }),
|
||||
getBestProfileForTask: async () => ({ success: true, data: null }),
|
||||
assignProfileToTask: async () => ({ success: true }),
|
||||
updateTaskSession: async () => ({ success: true }),
|
||||
getTaskSession: async () => ({ success: true, data: null }),
|
||||
onQueueProfileSwapped: () => () => {},
|
||||
onQueueSessionCaptured: () => () => {},
|
||||
onQueueBlockedNoProfiles: () => () => {}
|
||||
},
|
||||
|
||||
// Claude Code Operations
|
||||
checkClaudeCodeVersion: async () => ({
|
||||
success: true,
|
||||
|
||||
@@ -75,7 +75,7 @@ export const claudeProfileMock = {
|
||||
data: null
|
||||
}),
|
||||
|
||||
requestAllProfilesUsage: async () => ({
|
||||
requestAllProfilesUsage: async (_forceRefresh?: boolean) => ({
|
||||
success: true,
|
||||
data: null
|
||||
}),
|
||||
|
||||
@@ -548,5 +548,17 @@ export const IPC_CHANNELS = {
|
||||
|
||||
// Screenshot capture
|
||||
SCREENSHOT_GET_SOURCES: 'screenshot:getSources', // Get available screens/windows
|
||||
SCREENSHOT_CAPTURE: 'screenshot:capture' // Capture screenshot from source
|
||||
SCREENSHOT_CAPTURE: 'screenshot:capture', // Capture screenshot from source
|
||||
|
||||
// Queue routing (rate limit recovery)
|
||||
QUEUE_GET_RUNNING_TASKS_BY_PROFILE: 'queue:getRunningTasksByProfile',
|
||||
QUEUE_GET_BEST_PROFILE_FOR_TASK: 'queue:getBestProfileForTask',
|
||||
QUEUE_ASSIGN_PROFILE_TO_TASK: 'queue:assignProfileToTask',
|
||||
QUEUE_UPDATE_TASK_SESSION: 'queue:updateTaskSession',
|
||||
QUEUE_GET_TASK_SESSION: 'queue:getTaskSession',
|
||||
|
||||
// Queue routing events (main -> renderer)
|
||||
QUEUE_PROFILE_SWAPPED: 'queue:profileSwapped', // Task switched to different profile
|
||||
QUEUE_SESSION_CAPTURED: 'queue:sessionCaptured', // Session ID captured from running task
|
||||
QUEUE_BLOCKED_NO_PROFILES: 'queue:blockedNoProfiles' // All profiles unavailable
|
||||
} as const;
|
||||
|
||||
@@ -418,7 +418,11 @@
|
||||
"authenticationAriaLabel": "Authentication: {{provider}}",
|
||||
"authenticationDetails": "Authentication Details",
|
||||
"apiProfile": "API Profile",
|
||||
"apiKey": "API Key",
|
||||
"oauth": "OAuth",
|
||||
"claudeCode": "Claude Code",
|
||||
"claudeCodeSubscription": "Claude Code subscription",
|
||||
"subscription": "Subscription",
|
||||
"provider": "Provider",
|
||||
"providerAnthropic": "Anthropic",
|
||||
"providerZai": "z.ai",
|
||||
@@ -447,6 +451,11 @@
|
||||
"weeklyLimitReached": "Weekly limit reached",
|
||||
"sessionLimitReached": "Session limit reached",
|
||||
"notAuthenticated": "Not authenticated",
|
||||
"needsReauth": "Needs re-auth",
|
||||
"reauthRequired": "Re-authentication required",
|
||||
"reauthRequiredDescription": "Your session has expired. Re-authenticate to view usage and continue using this account.",
|
||||
"reauthButton": "Re-authenticate",
|
||||
"clickToOpenSettings": "Click to open Settings →",
|
||||
"sessionShort": "5-hour session usage",
|
||||
"weeklyShort": "7-day weekly usage",
|
||||
"swap": "Swap"
|
||||
|
||||
@@ -561,6 +561,8 @@
|
||||
"needsAuth": "Not authenticated",
|
||||
"duplicateUsage": "Duplicate usage detected",
|
||||
"duplicateUsageHint": "This profile has identical usage to another profile, suggesting they may be authenticated to the same Anthropic account. Re-authenticate with a different account to fix.",
|
||||
"needsReauth": "Needs re-auth",
|
||||
"needsReauthHint": "This profile's refresh token is invalid. Click to re-authenticate.",
|
||||
"sessionUsage": "Session usage (5-hour window)",
|
||||
"weeklyUsage": "Weekly usage (7-day window)",
|
||||
"oauthSection": "Claude Accounts (cycle through first)",
|
||||
|
||||
@@ -418,7 +418,11 @@
|
||||
"authenticationAriaLabel": "Authentification : {{provider}}",
|
||||
"authenticationDetails": "Détails de l'authentification",
|
||||
"apiProfile": "Profil API",
|
||||
"apiKey": "Clé API",
|
||||
"oauth": "OAuth",
|
||||
"claudeCode": "Claude Code",
|
||||
"claudeCodeSubscription": "Abonnement Claude Code",
|
||||
"subscription": "Abonnement",
|
||||
"provider": "Fournisseur",
|
||||
"providerAnthropic": "Anthropic",
|
||||
"providerZai": "z.ai",
|
||||
@@ -447,6 +451,11 @@
|
||||
"weeklyLimitReached": "Limite hebdomadaire atteinte",
|
||||
"sessionLimitReached": "Limite de session atteinte",
|
||||
"notAuthenticated": "Non authentifié",
|
||||
"needsReauth": "Réauth requise",
|
||||
"reauthRequired": "Ré-authentification requise",
|
||||
"reauthRequiredDescription": "Votre session a expiré. Ré-authentifiez-vous pour voir l'utilisation et continuer à utiliser ce compte.",
|
||||
"reauthButton": "Ré-authentifier",
|
||||
"clickToOpenSettings": "Cliquez pour ouvrir les Paramètres →",
|
||||
"sessionShort": "Utilisation session 5 heures",
|
||||
"weeklyShort": "Utilisation hebdomadaire 7 jours",
|
||||
"swap": "Changer"
|
||||
|
||||
@@ -561,6 +561,8 @@
|
||||
"needsAuth": "Non authentifié",
|
||||
"duplicateUsage": "Doublon détecté",
|
||||
"duplicateUsageHint": "Ce profil a une utilisation identique à un autre profil, suggérant qu'ils sont peut-être authentifiés sur le même compte Anthropic. Réauthentifiez-vous avec un autre compte pour corriger.",
|
||||
"needsReauth": "Réauth requise",
|
||||
"needsReauthHint": "Le token de rafraîchissement de ce profil est invalide. Cliquez pour vous réauthentifier.",
|
||||
"sessionUsage": "Utilisation de session (fenêtre de 5 heures)",
|
||||
"weeklyUsage": "Utilisation hebdomadaire (fenêtre de 7 jours)",
|
||||
"oauthSection": "Comptes Claude (utilisés en premier)",
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface ClaudeUsageSnapshot {
|
||||
weeklyUsageValue?: number;
|
||||
/** Weekly usage limit (total quota) */
|
||||
weeklyUsageLimit?: number;
|
||||
/** True if profile has invalid refresh token and needs re-authentication */
|
||||
needsReauthentication?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,6 +114,8 @@ export interface ProfileUsageSummary {
|
||||
lastFetchedAt?: string;
|
||||
/** Error message if usage fetch failed */
|
||||
fetchError?: string;
|
||||
/** True if profile has invalid refresh token and needs re-authentication */
|
||||
needsReauthentication?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,3 +244,36 @@ export interface TerminalProfileChangedEvent {
|
||||
sessionMigrated?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Queue Routing Types (Rate Limit Recovery)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Reason for profile assignment to a task
|
||||
*/
|
||||
export type ProfileAssignmentReason = 'proactive' | 'reactive' | 'manual';
|
||||
|
||||
/**
|
||||
* Tracking of running tasks grouped by profile
|
||||
*/
|
||||
export interface RunningTasksByProfile {
|
||||
/** Map of profileId → array of task IDs running on that profile */
|
||||
byProfile: Record<string, string[]>;
|
||||
/** Total number of running tasks across all profiles */
|
||||
totalRunning: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile swap record for tracking history
|
||||
*/
|
||||
export interface ProfileSwapRecord {
|
||||
fromProfileId: string;
|
||||
fromProfileName: string;
|
||||
toProfileId: string;
|
||||
toProfileName: string;
|
||||
swappedAt: string;
|
||||
reason: 'capacity' | 'rate_limit' | 'manual' | 'recovery';
|
||||
sessionId?: string;
|
||||
sessionResumed: boolean;
|
||||
}
|
||||
|
||||
@@ -312,8 +312,10 @@ export interface ElectronAPI {
|
||||
// Usage Monitoring (Proactive Account Switching)
|
||||
/** Request current usage snapshot */
|
||||
requestUsageUpdate: () => Promise<IPCResult<ClaudeUsageSnapshot | null>>;
|
||||
/** Request all profiles usage immediately (for startup/refresh) */
|
||||
requestAllProfilesUsage: () => Promise<IPCResult<AllProfilesUsage | null>>;
|
||||
/** Request all profiles usage immediately (for startup/refresh)
|
||||
* @param forceRefresh - If true, bypasses cache to get fresh data for all profiles
|
||||
*/
|
||||
requestAllProfilesUsage: (forceRefresh?: boolean) => Promise<IPCResult<AllProfilesUsage | null>>;
|
||||
/** Listen for usage data updates */
|
||||
onUsageUpdated: (callback: (usage: ClaudeUsageSnapshot) => void) => () => void;
|
||||
/** Listen for proactive swap notifications */
|
||||
@@ -858,6 +860,9 @@ export interface ElectronAPI {
|
||||
thumbnail: string;
|
||||
}>>>;
|
||||
capture: (options: { sourceId: string }) => Promise<IPCResult<string>>;
|
||||
|
||||
// Queue Routing API (rate limit recovery)
|
||||
queue: import('../../preload/api/queue-api').QueueAPI;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -755,7 +755,7 @@ class TestSchemaValidation:
|
||||
"status": status,
|
||||
}
|
||||
|
||||
subtask = Chunk.from_dict(subtask_data)
|
||||
subtask = Subtask.from_dict(subtask_data)
|
||||
assert subtask.status.value == status
|
||||
|
||||
def test_all_verification_types_valid(self):
|
||||
@@ -859,8 +859,8 @@ class TestSchemaValidation:
|
||||
"description": "Test subtask",
|
||||
}
|
||||
|
||||
subtask = Chunk.from_dict(subtask_data)
|
||||
assert subtask.status == ChunkStatus.PENDING
|
||||
subtask = Subtask.from_dict(subtask_data)
|
||||
assert subtask.status == SubtaskStatus.PENDING
|
||||
|
||||
# =========================================================================
|
||||
# Invalid Schema Tests - Wrong Types
|
||||
@@ -886,7 +886,7 @@ class TestSchemaValidation:
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
Chunk.from_dict(subtask_data)
|
||||
Subtask.from_dict(subtask_data)
|
||||
|
||||
def test_invalid_phase_type_raises_error(self):
|
||||
"""Invalid phase type raises ValueError."""
|
||||
@@ -998,7 +998,7 @@ class TestSchemaValidation:
|
||||
"critique_result": {"passed": True, "score": 9},
|
||||
}
|
||||
|
||||
subtask = Chunk.from_dict(subtask_data)
|
||||
subtask = Subtask.from_dict(subtask_data)
|
||||
|
||||
assert subtask.id == "complex-task"
|
||||
assert subtask.service == "backend"
|
||||
@@ -1050,10 +1050,10 @@ class TestSchemaValidation:
|
||||
name="Phase One",
|
||||
type=PhaseType.SETUP,
|
||||
subtasks=[
|
||||
Chunk(
|
||||
Subtask(
|
||||
id="task-1",
|
||||
description="First task",
|
||||
status=ChunkStatus.COMPLETED,
|
||||
status=SubtaskStatus.COMPLETED,
|
||||
service="backend",
|
||||
files_to_modify=["file.py"],
|
||||
verification=Verification(
|
||||
@@ -1167,47 +1167,47 @@ class TestEdgeCaseStateTransitions:
|
||||
|
||||
def test_chunk_blocked_status_initialization(self):
|
||||
"""Chunk can be initialized with blocked status."""
|
||||
chunk = Chunk(
|
||||
chunk = Subtask(
|
||||
id="blocked-task",
|
||||
description="Task waiting for investigation results",
|
||||
status=ChunkStatus.BLOCKED,
|
||||
status=SubtaskStatus.BLOCKED,
|
||||
)
|
||||
|
||||
assert chunk.status == ChunkStatus.BLOCKED
|
||||
assert chunk.status == SubtaskStatus.BLOCKED
|
||||
assert chunk.started_at is None
|
||||
assert chunk.completed_at is None
|
||||
|
||||
def test_chunk_blocked_to_pending_transition(self):
|
||||
"""Blocked chunk can transition to pending (unblocking)."""
|
||||
chunk = Chunk(id="test", description="Test", status=ChunkStatus.BLOCKED)
|
||||
chunk = Subtask(id="test", description="Test", status=SubtaskStatus.BLOCKED)
|
||||
|
||||
# Manually unblock by setting to pending
|
||||
chunk.status = ChunkStatus.PENDING
|
||||
chunk.status = SubtaskStatus.PENDING
|
||||
|
||||
assert chunk.status == ChunkStatus.PENDING
|
||||
assert chunk.status == SubtaskStatus.PENDING
|
||||
|
||||
def test_chunk_blocked_to_in_progress_transition(self):
|
||||
"""Blocked chunk can be started directly (auto-unblock)."""
|
||||
chunk = Chunk(id="test", description="Test", status=ChunkStatus.BLOCKED)
|
||||
chunk = Subtask(id="test", description="Test", status=SubtaskStatus.BLOCKED)
|
||||
|
||||
chunk.start(session_id=1)
|
||||
|
||||
assert chunk.status == ChunkStatus.IN_PROGRESS
|
||||
assert chunk.status == SubtaskStatus.IN_PROGRESS
|
||||
assert chunk.started_at is not None
|
||||
assert chunk.session_id == 1
|
||||
|
||||
def test_blocked_chunk_serialization_roundtrip(self):
|
||||
"""Blocked status survives serialization/deserialization."""
|
||||
chunk = Chunk(
|
||||
chunk = Subtask(
|
||||
id="blocked-task",
|
||||
description="Blocked task",
|
||||
status=ChunkStatus.BLOCKED,
|
||||
status=SubtaskStatus.BLOCKED,
|
||||
)
|
||||
|
||||
data = chunk.to_dict()
|
||||
restored = Chunk.from_dict(data)
|
||||
restored = Subtask.from_dict(data)
|
||||
|
||||
assert restored.status == ChunkStatus.BLOCKED
|
||||
assert restored.status == SubtaskStatus.BLOCKED
|
||||
assert data["status"] == "blocked"
|
||||
|
||||
def test_phase_with_all_blocked_chunks(self):
|
||||
@@ -1216,8 +1216,8 @@ class TestEdgeCaseStateTransitions:
|
||||
phase=1,
|
||||
name="Blocked Phase",
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Task 1", status=ChunkStatus.BLOCKED),
|
||||
Chunk(id="c2", description="Task 2", status=ChunkStatus.BLOCKED),
|
||||
Subtask(id="c1", description="Task 1", status=SubtaskStatus.BLOCKED),
|
||||
Subtask(id="c2", description="Task 2", status=SubtaskStatus.BLOCKED),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1233,8 +1233,8 @@ class TestEdgeCaseStateTransitions:
|
||||
phase=1,
|
||||
name="Mixed Phase",
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Task 1", status=ChunkStatus.COMPLETED),
|
||||
Chunk(id="c2", description="Task 2", status=ChunkStatus.BLOCKED),
|
||||
Subtask(id="c1", description="Task 1", status=SubtaskStatus.COMPLETED),
|
||||
Subtask(id="c2", description="Task 2", status=SubtaskStatus.BLOCKED),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1251,7 +1251,7 @@ class TestEdgeCaseStateTransitions:
|
||||
)
|
||||
|
||||
fix_phase = plan.phases[2] # Phase 3 - Fix
|
||||
blocked_chunks = [c for c in fix_phase.subtasks if c.status == ChunkStatus.BLOCKED]
|
||||
blocked_chunks = [c for c in fix_phase.subtasks if c.status == SubtaskStatus.BLOCKED]
|
||||
|
||||
assert len(blocked_chunks) == 2
|
||||
assert any("fix" in c.id.lower() for c in blocked_chunks)
|
||||
@@ -1270,7 +1270,7 @@ class TestEdgeCaseStateTransitions:
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Blocked", status=ChunkStatus.BLOCKED),
|
||||
Subtask(id="c1", description="Blocked", status=SubtaskStatus.BLOCKED),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1290,7 +1290,7 @@ class TestEdgeCaseStateTransitions:
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Task 1", status=ChunkStatus.PENDING),
|
||||
Subtask(id="c1", description="Task 1", status=SubtaskStatus.PENDING),
|
||||
],
|
||||
depends_on=[2], # Circular dependency
|
||||
),
|
||||
@@ -1298,7 +1298,7 @@ class TestEdgeCaseStateTransitions:
|
||||
phase=2,
|
||||
name="Phase 2",
|
||||
subtasks=[
|
||||
Chunk(id="c2", description="Task 2", status=ChunkStatus.PENDING),
|
||||
Subtask(id="c2", description="Task 2", status=SubtaskStatus.PENDING),
|
||||
],
|
||||
depends_on=[1], # Circular dependency
|
||||
),
|
||||
@@ -1321,7 +1321,7 @@ class TestEdgeCaseStateTransitions:
|
||||
phase=1,
|
||||
name="Waiting Phase",
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Blocked task", status=ChunkStatus.BLOCKED),
|
||||
Subtask(id="c1", description="Blocked task", status=SubtaskStatus.BLOCKED),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1341,7 +1341,7 @@ class TestEdgeCaseStateTransitions:
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Failed task", status=ChunkStatus.FAILED),
|
||||
Subtask(id="c1", description="Failed task", status=SubtaskStatus.FAILED),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1364,10 +1364,10 @@ class TestEdgeCaseStateTransitions:
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Done", status=ChunkStatus.COMPLETED),
|
||||
Chunk(id="c2", description="Failed", status=ChunkStatus.FAILED),
|
||||
Chunk(id="c3", description="Blocked", status=ChunkStatus.BLOCKED),
|
||||
Chunk(id="c4", description="Pending", status=ChunkStatus.PENDING),
|
||||
Subtask(id="c1", description="Done", status=SubtaskStatus.COMPLETED),
|
||||
Subtask(id="c2", description="Failed", status=SubtaskStatus.FAILED),
|
||||
Subtask(id="c3", description="Blocked", status=SubtaskStatus.BLOCKED),
|
||||
Subtask(id="c4", description="Pending", status=SubtaskStatus.PENDING),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1414,7 +1414,7 @@ class TestEdgeCaseStateTransitions:
|
||||
name="Real Work",
|
||||
depends_on=[1],
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Actual task", status=ChunkStatus.PENDING),
|
||||
Subtask(id="c1", description="Actual task", status=SubtaskStatus.PENDING),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1440,7 +1440,7 @@ class TestEdgeCaseStateTransitions:
|
||||
name="Work Phase",
|
||||
depends_on=[3],
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Task", status=ChunkStatus.PENDING),
|
||||
Subtask(id="c1", description="Task", status=SubtaskStatus.PENDING),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1460,7 +1460,7 @@ class TestEdgeCaseStateTransitions:
|
||||
phase=1,
|
||||
name="Done Phase",
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Done", status=ChunkStatus.COMPLETED),
|
||||
Subtask(id="c1", description="Done", status=SubtaskStatus.COMPLETED),
|
||||
],
|
||||
),
|
||||
Phase(
|
||||
@@ -1468,7 +1468,7 @@ class TestEdgeCaseStateTransitions:
|
||||
name="Work Phase",
|
||||
depends_on=[1],
|
||||
subtasks=[
|
||||
Chunk(id="c2", description="Pending", status=ChunkStatus.PENDING),
|
||||
Subtask(id="c2", description="Pending", status=SubtaskStatus.PENDING),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1487,31 +1487,31 @@ class TestEdgeCaseStateTransitions:
|
||||
|
||||
def test_blocked_unblocked_complete_transition(self):
|
||||
"""Full transition from blocked -> pending -> in_progress -> completed."""
|
||||
chunk = Chunk(id="test", description="Test", status=ChunkStatus.BLOCKED)
|
||||
chunk = Subtask(id="test", description="Test", status=SubtaskStatus.BLOCKED)
|
||||
|
||||
# Unblock
|
||||
chunk.status = ChunkStatus.PENDING
|
||||
assert chunk.status == ChunkStatus.PENDING
|
||||
chunk.status = SubtaskStatus.PENDING
|
||||
assert chunk.status == SubtaskStatus.PENDING
|
||||
|
||||
# Start
|
||||
chunk.start(session_id=1)
|
||||
assert chunk.status == ChunkStatus.IN_PROGRESS
|
||||
assert chunk.status == SubtaskStatus.IN_PROGRESS
|
||||
assert chunk.started_at is not None
|
||||
|
||||
# Complete
|
||||
chunk.complete(output="Done successfully")
|
||||
assert chunk.status == ChunkStatus.COMPLETED
|
||||
assert chunk.status == SubtaskStatus.COMPLETED
|
||||
assert chunk.completed_at is not None
|
||||
assert chunk.actual_output == "Done successfully"
|
||||
|
||||
def test_blocked_to_failed_transition(self):
|
||||
"""Blocked chunk can transition to failed without being started."""
|
||||
chunk = Chunk(id="test", description="Test", status=ChunkStatus.BLOCKED)
|
||||
chunk = Subtask(id="test", description="Test", status=SubtaskStatus.BLOCKED)
|
||||
|
||||
# Mark as failed directly (e.g., investigation revealed it's not feasible)
|
||||
chunk.fail(reason="Investigation revealed task is not feasible")
|
||||
|
||||
assert chunk.status == ChunkStatus.FAILED
|
||||
assert chunk.status == SubtaskStatus.FAILED
|
||||
assert "FAILED: Investigation revealed task is not feasible" in chunk.actual_output
|
||||
|
||||
def test_in_progress_subtask_blocks_phase_completion(self):
|
||||
@@ -1520,8 +1520,8 @@ class TestEdgeCaseStateTransitions:
|
||||
phase=1,
|
||||
name="Active Phase",
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Done", status=ChunkStatus.COMPLETED),
|
||||
Chunk(id="c2", description="Working", status=ChunkStatus.IN_PROGRESS),
|
||||
Subtask(id="c1", description="Done", status=SubtaskStatus.COMPLETED),
|
||||
Subtask(id="c2", description="Working", status=SubtaskStatus.IN_PROGRESS),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1533,8 +1533,8 @@ class TestEdgeCaseStateTransitions:
|
||||
phase=1,
|
||||
name="Problematic Phase",
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Blocked", status=ChunkStatus.BLOCKED),
|
||||
Chunk(id="c2", description="Failed", status=ChunkStatus.FAILED),
|
||||
Subtask(id="c1", description="Blocked", status=SubtaskStatus.BLOCKED),
|
||||
Subtask(id="c2", description="Failed", status=SubtaskStatus.FAILED),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1550,7 +1550,7 @@ class TestEdgeCaseStateTransitions:
|
||||
phase=1,
|
||||
name="Blocked Phase",
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Blocked", status=ChunkStatus.BLOCKED),
|
||||
Subtask(id="c1", description="Blocked", status=SubtaskStatus.BLOCKED),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1560,7 +1560,7 @@ class TestEdgeCaseStateTransitions:
|
||||
assert plan.get_next_subtask() is None
|
||||
|
||||
# Unblock the subtask
|
||||
plan.phases[0].subtasks[0].status = ChunkStatus.PENDING
|
||||
plan.phases[0].subtasks[0].status = SubtaskStatus.PENDING
|
||||
|
||||
# Now work is available
|
||||
result = plan.get_next_subtask()
|
||||
@@ -1570,21 +1570,21 @@ class TestEdgeCaseStateTransitions:
|
||||
|
||||
def test_failed_subtask_retry_transition(self):
|
||||
"""Failed subtask can be reset to pending for retry."""
|
||||
chunk = Chunk(id="test", description="Test", status=ChunkStatus.FAILED)
|
||||
chunk = Subtask(id="test", description="Test", status=SubtaskStatus.FAILED)
|
||||
chunk.actual_output = "FAILED: Previous error"
|
||||
|
||||
# Reset for retry
|
||||
chunk.status = ChunkStatus.PENDING
|
||||
chunk.status = SubtaskStatus.PENDING
|
||||
chunk.actual_output = None
|
||||
chunk.started_at = None
|
||||
chunk.completed_at = None
|
||||
|
||||
assert chunk.status == ChunkStatus.PENDING
|
||||
assert chunk.status == SubtaskStatus.PENDING
|
||||
assert chunk.actual_output is None
|
||||
|
||||
# Can be started again
|
||||
chunk.start(session_id=2)
|
||||
assert chunk.status == ChunkStatus.IN_PROGRESS
|
||||
assert chunk.status == SubtaskStatus.IN_PROGRESS
|
||||
assert chunk.session_id == 2
|
||||
|
||||
def test_plan_status_update_with_blocked_subtasks(self):
|
||||
@@ -1596,8 +1596,8 @@ class TestEdgeCaseStateTransitions:
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Done", status=ChunkStatus.COMPLETED),
|
||||
Chunk(id="c2", description="Blocked", status=ChunkStatus.BLOCKED),
|
||||
Subtask(id="c1", description="Done", status=SubtaskStatus.COMPLETED),
|
||||
Subtask(id="c2", description="Blocked", status=SubtaskStatus.BLOCKED),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1618,8 +1618,8 @@ class TestEdgeCaseStateTransitions:
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Blocked 1", status=ChunkStatus.BLOCKED),
|
||||
Chunk(id="c2", description="Blocked 2", status=ChunkStatus.BLOCKED),
|
||||
Subtask(id="c1", description="Blocked 1", status=SubtaskStatus.BLOCKED),
|
||||
Subtask(id="c2", description="Blocked 2", status=SubtaskStatus.BLOCKED),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user