mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 10:03:08 +00:00
fix: Remove unused vars and improve type safety. Improve task recovery
This commit is contained in:
@@ -444,17 +444,11 @@ export class AgentExecutor {
|
||||
callbacks: AgentExecutorCallbacks
|
||||
): Promise<{ responseText: string; tasksCompleted: number }> {
|
||||
const {
|
||||
workDir,
|
||||
featureId,
|
||||
projectPath,
|
||||
abortController,
|
||||
branchName = null,
|
||||
planningMode = 'skip',
|
||||
provider,
|
||||
effectiveBareModel,
|
||||
credentials,
|
||||
claudeCompatibleProvider,
|
||||
mcpServers,
|
||||
sdkOptions,
|
||||
} = options;
|
||||
let responseText = initialResponseText,
|
||||
|
||||
@@ -15,11 +15,9 @@ import {
|
||||
loadContextFiles,
|
||||
createLogger,
|
||||
classifyError,
|
||||
getUserFriendlyErrorMessage,
|
||||
} from '@automaker/utils';
|
||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
|
||||
@@ -158,10 +158,7 @@ export class AutoLoopCoordinator {
|
||||
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
||||
if (!projectState) return;
|
||||
const { projectPath, branchName } = projectState.config;
|
||||
let iterationCount = 0;
|
||||
|
||||
while (projectState.isRunning && !projectState.abortController.signal.aborted) {
|
||||
iterationCount++;
|
||||
try {
|
||||
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
||||
if (runningCount >= projectState.config.maxConcurrency) {
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { TypedEventBus } from '../typed-event-bus.js';
|
||||
|
||||
@@ -295,7 +295,6 @@ export class ClaudeUsageService {
|
||||
}
|
||||
// Don't fail if we have data - return it instead
|
||||
// Check cleaned output since raw output has ANSI codes between words
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleanedForCheck = output
|
||||
.replace(/\x1B\[(\d+)C/g, (_m: string, n: string) => ' '.repeat(parseInt(n, 10)))
|
||||
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '');
|
||||
@@ -332,7 +331,6 @@ export class ClaudeUsageService {
|
||||
// Convert cursor forward (ESC[nC) to spaces first to preserve word boundaries,
|
||||
// then strip remaining ANSI sequences. Without this, the Claude CLI TUI output
|
||||
// like "Current week (all models)" becomes "Currentweek(allmodels)".
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleanOutput = output
|
||||
.replace(/\x1B\[(\d+)C/g, (_match: string, n: string) => ' '.repeat(parseInt(n, 10)))
|
||||
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '');
|
||||
@@ -492,7 +490,6 @@ export class ClaudeUsageService {
|
||||
// First, convert cursor movement sequences to whitespace to preserve word boundaries.
|
||||
// The Claude CLI TUI uses ESC[nC (cursor forward) instead of actual spaces between words.
|
||||
// Without this, "Current week (all models)" becomes "Currentweek(allmodels)" after stripping.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
let clean = text
|
||||
// Cursor forward (CSI n C): replace with n spaces to preserve word separation
|
||||
.replace(/\x1B\[(\d+)C/g, (_match, n) => ' '.repeat(parseInt(n, 10)))
|
||||
|
||||
@@ -246,7 +246,7 @@ class DevServerService {
|
||||
// No process found on port, which is fine
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Ignore errors - port might not have any process
|
||||
logger.debug(`No process to kill on port ${port}`);
|
||||
}
|
||||
|
||||
@@ -13,12 +13,7 @@
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import {
|
||||
getEventHistoryDir,
|
||||
getEventHistoryIndexPath,
|
||||
getEventPath,
|
||||
ensureEventHistoryDir,
|
||||
} from '@automaker/platform';
|
||||
import { getEventHistoryIndexPath, getEventPath, ensureEventHistoryDir } from '@automaker/platform';
|
||||
import type {
|
||||
StoredEvent,
|
||||
StoredEventIndex,
|
||||
|
||||
@@ -20,7 +20,6 @@ import type { TypedEventBus } from './typed-event-bus.js';
|
||||
import type { ConcurrencyManager, RunningFeature } from './concurrency-manager.js';
|
||||
import type { WorktreeResolver } from './worktree-resolver.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import type { PipelineContext } from './pipeline-orchestrator.js';
|
||||
import { pipelineService } from './pipeline-service.js';
|
||||
|
||||
// Re-export callback types from execution-types.ts for backward compatibility
|
||||
|
||||
@@ -205,7 +205,6 @@ export class FeatureExportService {
|
||||
importData: FeatureImport
|
||||
): Promise<FeatureImportResult> {
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// Extract feature from data (handle both raw Feature and wrapped FeatureExport)
|
||||
|
||||
@@ -195,9 +195,10 @@ export class FeatureLoader {
|
||||
}
|
||||
|
||||
// Read all feature directories
|
||||
// secureFs.readdir returns Dirent[] but typed as generic; cast to access isDirectory()
|
||||
const entries = (await secureFs.readdir(featuresDir, {
|
||||
withFileTypes: true,
|
||||
})) as any[];
|
||||
})) as import('fs').Dirent[];
|
||||
const featureDirs = entries.filter((entry) => entry.isDirectory());
|
||||
|
||||
// Load all features concurrently with automatic recovery from backups
|
||||
|
||||
@@ -13,7 +13,7 @@ import { createLogger } from '@automaker/utils';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { execSync } from 'child_process';
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
const logger = createLogger('GeminiUsage');
|
||||
|
||||
@@ -26,6 +26,12 @@ const CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCode
|
||||
// Google OAuth endpoints for token refresh
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
/** Default timeout for fetch requests in milliseconds */
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** TTL for cached credentials in milliseconds (5 minutes) */
|
||||
const CREDENTIALS_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export interface GeminiQuotaBucket {
|
||||
/** Model ID this quota applies to */
|
||||
modelId: string;
|
||||
@@ -114,8 +120,11 @@ interface QuotaResponse {
|
||||
*/
|
||||
export class GeminiUsageService {
|
||||
private cachedCredentials: OAuthCredentials | null = null;
|
||||
private cachedCredentialsAt: number | null = null;
|
||||
private cachedClientCredentials: OAuthClientCredentials | null = null;
|
||||
private credentialsPath: string;
|
||||
/** The actual path from which credentials were loaded (for write-back) */
|
||||
private loadedCredentialsPath: string | null = null;
|
||||
|
||||
constructor() {
|
||||
// Default credentials path for Gemini CLI
|
||||
@@ -176,6 +185,7 @@ export class GeminiUsageService {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (codeAssistResponse.ok) {
|
||||
@@ -199,6 +209,7 @@ export class GeminiUsageService {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(projectId ? { project: projectId } : {}),
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -338,19 +349,46 @@ export class GeminiUsageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load OAuth credentials from file
|
||||
* Load OAuth credentials from file.
|
||||
* Implements TTL-based cache invalidation and file mtime checks.
|
||||
*/
|
||||
private async loadCredentials(): Promise<OAuthCredentials | null> {
|
||||
if (this.cachedCredentials) {
|
||||
return this.cachedCredentials;
|
||||
// Check if cached credentials are still valid
|
||||
if (this.cachedCredentials && this.cachedCredentialsAt) {
|
||||
const now = Date.now();
|
||||
const cacheAge = now - this.cachedCredentialsAt;
|
||||
|
||||
if (cacheAge < CREDENTIALS_CACHE_TTL_MS) {
|
||||
// Cache is within TTL - also check file mtime
|
||||
const sourcePath = this.loadedCredentialsPath || this.credentialsPath;
|
||||
try {
|
||||
const stat = fs.statSync(sourcePath);
|
||||
if (stat.mtimeMs <= this.cachedCredentialsAt) {
|
||||
// File hasn't been modified since we cached - use cache
|
||||
return this.cachedCredentials;
|
||||
}
|
||||
// File has been modified, fall through to re-read
|
||||
logger.debug('[loadCredentials] File modified since cache, re-reading');
|
||||
} catch {
|
||||
// File doesn't exist or can't stat - use cache
|
||||
return this.cachedCredentials;
|
||||
}
|
||||
} else {
|
||||
// Cache TTL expired, discard
|
||||
logger.debug('[loadCredentials] Cache TTL expired, re-reading');
|
||||
}
|
||||
|
||||
// Invalidate cached credentials
|
||||
this.cachedCredentials = null;
|
||||
this.cachedCredentialsAt = null;
|
||||
}
|
||||
|
||||
// Check multiple possible paths
|
||||
const possiblePaths = [
|
||||
// Build unique possible paths (deduplicate)
|
||||
const rawPaths = [
|
||||
this.credentialsPath,
|
||||
path.join(os.homedir(), '.gemini', 'oauth_creds.json'),
|
||||
path.join(os.homedir(), '.config', 'gemini', 'oauth_creds.json'),
|
||||
];
|
||||
const possiblePaths = [...new Set(rawPaths)];
|
||||
|
||||
for (const credPath of possiblePaths) {
|
||||
try {
|
||||
@@ -361,6 +399,8 @@ export class GeminiUsageService {
|
||||
// Handle different credential formats
|
||||
if (creds.access_token || creds.refresh_token) {
|
||||
this.cachedCredentials = creds;
|
||||
this.cachedCredentialsAt = Date.now();
|
||||
this.loadedCredentialsPath = credPath;
|
||||
logger.info('[loadCredentials] Loaded from:', credPath);
|
||||
return creds;
|
||||
}
|
||||
@@ -372,6 +412,8 @@ export class GeminiUsageService {
|
||||
client_id: clientCreds.client_id,
|
||||
client_secret: clientCreds.client_secret,
|
||||
};
|
||||
this.cachedCredentialsAt = Date.now();
|
||||
this.loadedCredentialsPath = credPath;
|
||||
return this.cachedCredentials;
|
||||
}
|
||||
}
|
||||
@@ -387,14 +429,21 @@ export class GeminiUsageService {
|
||||
* Find the Gemini CLI binary path
|
||||
*/
|
||||
private findGeminiBinaryPath(): string | null {
|
||||
// Try 'which' on Unix-like systems, 'where' on Windows
|
||||
const whichCmd = process.platform === 'win32' ? 'where' : 'which';
|
||||
try {
|
||||
// Try 'which' on Unix-like systems
|
||||
const whichResult = execSync('which gemini 2>/dev/null', { encoding: 'utf8' }).trim();
|
||||
if (whichResult && fs.existsSync(whichResult)) {
|
||||
return whichResult;
|
||||
const whichResult = execFileSync(whichCmd, ['gemini'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
// 'where' on Windows may return multiple lines; take the first
|
||||
const firstLine = whichResult.split('\n')[0]?.trim();
|
||||
if (firstLine && fs.existsSync(firstLine)) {
|
||||
return firstLine;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors from 'which'
|
||||
// Ignore errors from 'which'/'where'
|
||||
}
|
||||
|
||||
// Check common installation paths
|
||||
@@ -554,27 +603,33 @@ export class GeminiUsageService {
|
||||
}
|
||||
}
|
||||
|
||||
// Try finding oauth2.js by searching in node_modules
|
||||
try {
|
||||
const searchResult = execSync(
|
||||
`find ${baseDir}/.. -name "oauth2.js" -path "*gemini*" -path "*code_assist*" 2>/dev/null | head -1`,
|
||||
{ encoding: 'utf8', timeout: 5000 }
|
||||
).trim();
|
||||
// Try finding oauth2.js by searching in node_modules (POSIX only)
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
const searchBase = path.resolve(baseDir, '..');
|
||||
const searchResult = execFileSync(
|
||||
'find',
|
||||
[searchBase, '-name', 'oauth2.js', '-path', '*gemini*', '-path', '*code_assist*'],
|
||||
{ encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
)
|
||||
.trim()
|
||||
.split('\n')[0]; // Take first result
|
||||
|
||||
if (searchResult && fs.existsSync(searchResult)) {
|
||||
logger.debug('[extractOAuthClientCredentials] Found via search:', searchResult);
|
||||
const content = fs.readFileSync(searchResult, 'utf8');
|
||||
const creds = this.parseOAuthCredentialsFromSource(content);
|
||||
if (creds) {
|
||||
this.cachedClientCredentials = creds;
|
||||
logger.info(
|
||||
'[extractOAuthClientCredentials] Extracted credentials from CLI (via search)'
|
||||
);
|
||||
return creds;
|
||||
if (searchResult && fs.existsSync(searchResult)) {
|
||||
logger.debug('[extractOAuthClientCredentials] Found via search:', searchResult);
|
||||
const content = fs.readFileSync(searchResult, 'utf8');
|
||||
const creds = this.parseOAuthCredentialsFromSource(content);
|
||||
if (creds) {
|
||||
this.cachedClientCredentials = creds;
|
||||
logger.info(
|
||||
'[extractOAuthClientCredentials] Extracted credentials from CLI (via search)'
|
||||
);
|
||||
return creds;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore search errors
|
||||
}
|
||||
} catch {
|
||||
// Ignore search errors
|
||||
}
|
||||
|
||||
logger.warn('[extractOAuthClientCredentials] Could not extract credentials from CLI');
|
||||
@@ -669,6 +724,7 @@ export class GeminiUsageService {
|
||||
refresh_token: creds.refresh_token,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -685,13 +741,12 @@ export class GeminiUsageService {
|
||||
access_token: newAccessToken,
|
||||
expiry_date: Date.now() + expiresIn * 1000,
|
||||
};
|
||||
this.cachedCredentialsAt = Date.now();
|
||||
|
||||
// Save back to file
|
||||
// Save back to the file the credentials were loaded from
|
||||
const writePath = this.loadedCredentialsPath || this.credentialsPath;
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
this.credentialsPath,
|
||||
JSON.stringify(this.cachedCredentials, null, 2)
|
||||
);
|
||||
fs.writeFileSync(writePath, JSON.stringify(this.cachedCredentials, null, 2));
|
||||
} catch (e) {
|
||||
logger.debug('[getValidAccessToken] Could not save refreshed token:', e);
|
||||
}
|
||||
@@ -743,6 +798,7 @@ export class GeminiUsageService {
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedCredentials = null;
|
||||
this.cachedCredentialsAt = null;
|
||||
this.cachedClientCredentials = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import type {
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types';
|
||||
import {
|
||||
getIdeationDir,
|
||||
getIdeasDir,
|
||||
getIdeaDir,
|
||||
getIdeaPath,
|
||||
@@ -407,7 +406,9 @@ export class IdeationService {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = (await secureFs.readdir(ideasDir, { withFileTypes: true })) as any[];
|
||||
const entries = (await secureFs.readdir(ideasDir, {
|
||||
withFileTypes: true,
|
||||
})) as import('fs').Dirent[];
|
||||
const ideaDirs = entries.filter((entry) => entry.isDirectory());
|
||||
|
||||
const ideas: Idea[] = [];
|
||||
@@ -855,15 +856,26 @@ ${contextSection}${existingWorkSection}`;
|
||||
}
|
||||
|
||||
return parsed
|
||||
.map((item: any, index: number) => ({
|
||||
id: this.generateId('sug'),
|
||||
category,
|
||||
title: item.title || `Suggestion ${index + 1}`,
|
||||
description: item.description || '',
|
||||
rationale: item.rationale || '',
|
||||
priority: item.priority || 'medium',
|
||||
relatedFiles: item.relatedFiles || [],
|
||||
}))
|
||||
.map(
|
||||
(
|
||||
item: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
rationale?: string;
|
||||
priority?: 'low' | 'medium' | 'high';
|
||||
relatedFiles?: string[];
|
||||
},
|
||||
index: number
|
||||
) => ({
|
||||
id: this.generateId('sug'),
|
||||
category,
|
||||
title: item.title || `Suggestion ${index + 1}`,
|
||||
description: item.description || '',
|
||||
rationale: item.rationale || '',
|
||||
priority: item.priority || ('medium' as const),
|
||||
relatedFiles: item.relatedFiles || [],
|
||||
})
|
||||
)
|
||||
.slice(0, count);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse JSON response:', error);
|
||||
@@ -1705,7 +1717,9 @@ ${contextSection}${existingWorkSection}`;
|
||||
const results: AnalysisFileInfo[] = [];
|
||||
|
||||
try {
|
||||
const entries = (await secureFs.readdir(dirPath, { withFileTypes: true })) as any[];
|
||||
const entries = (await secureFs.readdir(dirPath, {
|
||||
withFileTypes: true,
|
||||
})) as import('fs').Dirent[];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
|
||||
@@ -250,6 +250,14 @@ export class RecoveryService {
|
||||
async resumeInterruptedFeatures(projectPath: string): Promise<void> {
|
||||
const featuresDir = getFeaturesDir(projectPath);
|
||||
try {
|
||||
// Load execution state to find features that were running before restart.
|
||||
// This is critical because reconcileAllFeatureStates() runs at server startup
|
||||
// and resets in_progress/interrupted/pipeline_* features to ready/backlog
|
||||
// BEFORE the UI connects and calls this method. Without checking execution state,
|
||||
// we would find no features to resume since their statuses have already been reset.
|
||||
const executionState = await this.loadExecutionState(projectPath);
|
||||
const previouslyRunningIds = new Set(executionState.runningFeatureIds ?? []);
|
||||
|
||||
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
|
||||
const featuresWithContext: Feature[] = [];
|
||||
const featuresWithoutContext: Feature[] = [];
|
||||
@@ -263,18 +271,37 @@ export class RecoveryService {
|
||||
logRecoveryWarning(result, `Feature ${entry.name}`, logger);
|
||||
const feature = result.data;
|
||||
if (!feature) continue;
|
||||
if (
|
||||
|
||||
// Check if the feature should be resumed:
|
||||
// 1. Features still in active states (in_progress, pipeline_*) - not yet reconciled
|
||||
// 2. Features in interrupted state - explicitly marked for resume
|
||||
// 3. Features that were previously running (from execution state) and are now
|
||||
// in ready/backlog due to reconciliation resetting their status
|
||||
const isActiveState =
|
||||
feature.status === 'in_progress' ||
|
||||
(feature.status && feature.status.startsWith('pipeline_'))
|
||||
) {
|
||||
(await this.contextExists(projectPath, feature.id))
|
||||
? featuresWithContext.push(feature)
|
||||
: featuresWithoutContext.push(feature);
|
||||
feature.status === 'interrupted' ||
|
||||
(feature.status && feature.status.startsWith('pipeline_'));
|
||||
const wasReconciledFromRunning =
|
||||
previouslyRunningIds.has(feature.id) &&
|
||||
(feature.status === 'ready' || feature.status === 'backlog');
|
||||
|
||||
if (isActiveState || wasReconciledFromRunning) {
|
||||
if (await this.contextExists(projectPath, feature.id)) {
|
||||
featuresWithContext.push(feature);
|
||||
} else {
|
||||
featuresWithoutContext.push(feature);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const allInterruptedFeatures = [...featuresWithContext, ...featuresWithoutContext];
|
||||
if (allInterruptedFeatures.length === 0) return;
|
||||
|
||||
logger.info(
|
||||
`[resumeInterruptedFeatures] Found ${allInterruptedFeatures.length} feature(s) to resume ` +
|
||||
`(${previouslyRunningIds.size} from execution state, statuses: ${allInterruptedFeatures.map((f) => `${f.id}=${f.status}`).join(', ')})`
|
||||
);
|
||||
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_resuming_features', {
|
||||
message: `Resuming ${allInterruptedFeatures.length} interrupted feature(s)`,
|
||||
projectPath,
|
||||
@@ -295,6 +322,10 @@ export class RecoveryService {
|
||||
/* continue */
|
||||
}
|
||||
}
|
||||
|
||||
// Clear execution state after successful resume to prevent
|
||||
// re-resuming the same features on subsequent calls
|
||||
await this.clearExecutionState(projectPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { createEventEmitter } from '../lib/events.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
|
||||
const logger = createLogger('ZaiUsage');
|
||||
|
||||
/** Default timeout for fetch requests in milliseconds */
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* z.ai quota limit entry from the API
|
||||
*/
|
||||
@@ -112,6 +117,21 @@ interface ZaiApiResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** Result from configure method */
|
||||
interface ConfigureResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
isAvailable: boolean;
|
||||
}
|
||||
|
||||
/** Result from verifyApiKey method */
|
||||
interface VerifyResult {
|
||||
success: boolean;
|
||||
authenticated: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* z.ai Usage Service
|
||||
*
|
||||
@@ -162,16 +182,163 @@ export class ZaiUsageService {
|
||||
return Boolean(token && token.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure z.ai API token and host.
|
||||
* Persists the token via settingsService and updates in-memory state.
|
||||
*/
|
||||
async configure(
|
||||
options: { apiToken?: string; apiHost?: string },
|
||||
settingsService: SettingsService
|
||||
): Promise<ConfigureResult> {
|
||||
const emitter = createEventEmitter();
|
||||
|
||||
if (options.apiToken !== undefined) {
|
||||
// Set in-memory token
|
||||
this.setApiToken(options.apiToken || '');
|
||||
|
||||
// Persist to credentials
|
||||
try {
|
||||
await settingsService.updateCredentials({
|
||||
apiKeys: { zai: options.apiToken || '' },
|
||||
} as Parameters<typeof settingsService.updateCredentials>[0]);
|
||||
logger.info('[configure] Saved z.ai API key to credentials');
|
||||
} catch (persistError) {
|
||||
logger.error('[configure] Failed to persist z.ai API key:', persistError);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.apiHost) {
|
||||
this.setApiHost(options.apiHost);
|
||||
}
|
||||
|
||||
const result: ConfigureResult = {
|
||||
success: true,
|
||||
message: 'z.ai configuration updated',
|
||||
isAvailable: this.isAvailable(),
|
||||
};
|
||||
|
||||
emitter.emit('notification:created', {
|
||||
type: 'zai.configured',
|
||||
success: result.success,
|
||||
isAvailable: result.isAvailable,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an API key without storing it.
|
||||
* Makes a test request to the z.ai quota URL with the given key.
|
||||
*/
|
||||
async verifyApiKey(apiKey: string | undefined): Promise<VerifyResult> {
|
||||
const emitter = createEventEmitter();
|
||||
|
||||
if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: 'Please provide an API key to test.',
|
||||
};
|
||||
}
|
||||
|
||||
const quotaUrl =
|
||||
process.env.Z_AI_QUOTA_URL ||
|
||||
`${process.env.Z_AI_API_HOST ? `https://${process.env.Z_AI_API_HOST}` : 'https://api.z.ai'}/api/monitor/usage/quota/limit`;
|
||||
|
||||
logger.info(`[verify] Testing API key against: ${quotaUrl}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(quotaUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey.trim()}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
let result: VerifyResult;
|
||||
|
||||
if (response.ok) {
|
||||
result = {
|
||||
success: true,
|
||||
authenticated: true,
|
||||
message: 'Connection successful! z.ai API responded.',
|
||||
};
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
result = {
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: 'Invalid API key. Please check your key and try again.',
|
||||
};
|
||||
} else {
|
||||
result = {
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: `API request failed: ${response.status} ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
emitter.emit('notification:created', {
|
||||
type: 'zai.verify.result',
|
||||
success: result.success,
|
||||
authenticated: result.authenticated,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Handle abort/timeout errors specifically
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
const result: VerifyResult = {
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: 'Request timed out. The z.ai API did not respond in time.',
|
||||
};
|
||||
emitter.emit('notification:created', {
|
||||
type: 'zai.verify.result',
|
||||
success: false,
|
||||
error: 'timeout',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error verifying z.ai API key:', error);
|
||||
|
||||
emitter.emit('notification:created', {
|
||||
type: 'zai.verify.result',
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: `Network error: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data from z.ai API
|
||||
*/
|
||||
async fetchUsageData(): Promise<ZaiUsageData> {
|
||||
logger.info('[fetchUsageData] Starting...');
|
||||
const emitter = createEventEmitter();
|
||||
|
||||
emitter.emit('notification:created', { type: 'zai.usage.start' });
|
||||
|
||||
const token = this.getApiToken();
|
||||
if (!token) {
|
||||
logger.error('[fetchUsageData] No API token configured');
|
||||
throw new Error('z.ai API token not configured. Set Z_AI_API_KEY environment variable.');
|
||||
const error = new Error(
|
||||
'z.ai API token not configured. Set Z_AI_API_KEY environment variable.'
|
||||
);
|
||||
emitter.emit('notification:created', {
|
||||
type: 'zai.usage.error',
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const quotaUrl =
|
||||
@@ -180,31 +347,68 @@ export class ZaiUsageService {
|
||||
logger.info(`[fetchUsageData] Fetching from: ${quotaUrl}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(quotaUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`[fetchUsageData] HTTP ${response.status}: ${response.statusText}`);
|
||||
throw new Error(`z.ai API request failed: ${response.status} ${response.statusText}`);
|
||||
try {
|
||||
const response = await fetch(quotaUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`[fetchUsageData] HTTP ${response.status}: ${response.statusText}`);
|
||||
throw new Error(`z.ai API request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as unknown as ZaiApiResponse;
|
||||
logger.info('[fetchUsageData] Response received:', JSON.stringify(data, null, 2));
|
||||
|
||||
const result = this.parseApiResponse(data);
|
||||
|
||||
emitter.emit('notification:created', {
|
||||
type: 'zai.usage.success',
|
||||
data: result,
|
||||
});
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle abort/timeout errors
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
const timeoutError = new Error(`z.ai API request timed out after ${FETCH_TIMEOUT_MS}ms`);
|
||||
emitter.emit('notification:created', {
|
||||
type: 'zai.usage.error',
|
||||
error: timeoutError.message,
|
||||
});
|
||||
throw timeoutError;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as unknown as ZaiApiResponse;
|
||||
logger.info('[fetchUsageData] Response received:', JSON.stringify(data, null, 2));
|
||||
|
||||
return this.parseApiResponse(data);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('z.ai API')) {
|
||||
emitter.emit('notification:created', {
|
||||
type: 'zai.usage.error',
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error('[fetchUsageData] Failed to fetch:', error);
|
||||
throw new Error(
|
||||
const fetchError = new Error(
|
||||
`Failed to fetch z.ai usage data: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
emitter.emit('notification:created', {
|
||||
type: 'zai.usage.error',
|
||||
error: fetchError.message,
|
||||
});
|
||||
throw fetchError;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user