mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-20 23:13:07 +00:00
fix: Remove unused vars and improve type safety. Improve task recovery
This commit is contained in:
@@ -303,7 +303,7 @@ app.use(
|
|||||||
callback(null, origin);
|
callback(null, origin);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Ignore URL parsing errors
|
// Ignore URL parsing errors
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,7 +376,7 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
|
|||||||
let globalSettings: Awaited<ReturnType<typeof settingsService.getGlobalSettings>> | null = null;
|
let globalSettings: Awaited<ReturnType<typeof settingsService.getGlobalSettings>> | null = null;
|
||||||
try {
|
try {
|
||||||
globalSettings = await settingsService.getGlobalSettings();
|
globalSettings = await settingsService.getGlobalSettings();
|
||||||
} catch (err) {
|
} catch {
|
||||||
logger.warn('Failed to load global settings, using defaults');
|
logger.warn('Failed to load global settings, using defaults');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +394,7 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
|
|||||||
const enableRequestLog = globalSettings.enableRequestLogging ?? true;
|
const enableRequestLog = globalSettings.enableRequestLogging ?? true;
|
||||||
setRequestLoggingEnabled(enableRequestLog);
|
setRequestLoggingEnabled(enableRequestLog);
|
||||||
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
|
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
|
||||||
} catch (err) {
|
} catch {
|
||||||
logger.warn('Failed to apply logging settings, using defaults');
|
logger.warn('Failed to apply logging settings, using defaults');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,6 +421,22 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
|
|||||||
} else {
|
} else {
|
||||||
logger.info('[STARTUP] Feature state reconciliation complete - no stale states found');
|
logger.info('[STARTUP] Feature state reconciliation complete - no stale states found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resume interrupted features in the background after reconciliation.
|
||||||
|
// This uses the saved execution state to identify features that were running
|
||||||
|
// before the restart (their statuses have been reset to ready/backlog by
|
||||||
|
// reconciliation above). Running in background so it doesn't block startup.
|
||||||
|
if (totalReconciled > 0) {
|
||||||
|
for (const project of globalSettings.projects) {
|
||||||
|
autoModeService.resumeInterruptedFeatures(project.path).catch((err) => {
|
||||||
|
logger.warn(
|
||||||
|
`[STARTUP] Failed to resume interrupted features for ${project.path}:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.info('[STARTUP] Initiated background resume of interrupted features');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('[STARTUP] Failed to reconcile feature states:', err);
|
logger.warn('[STARTUP] Failed to reconcile feature states:', err);
|
||||||
@@ -581,7 +597,7 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
logger.info('Sending event to client:', {
|
logger.info('Sending event to client:', {
|
||||||
type,
|
type,
|
||||||
messageLength: message.length,
|
messageLength: message.length,
|
||||||
sessionId: (payload as any)?.sessionId,
|
sessionId: (payload as Record<string, unknown>)?.sessionId,
|
||||||
});
|
});
|
||||||
ws.send(message);
|
ws.send(message);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ import { spawn, execSync } from 'child_process';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('CliDetection');
|
|
||||||
|
|
||||||
export interface CliInfo {
|
export interface CliInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -86,7 +83,7 @@ export async function detectCli(
|
|||||||
options: CliDetectionOptions = {}
|
options: CliDetectionOptions = {}
|
||||||
): Promise<CliDetectionResult> {
|
): Promise<CliDetectionResult> {
|
||||||
const config = CLI_CONFIGS[provider];
|
const config = CLI_CONFIGS[provider];
|
||||||
const { timeout = 5000, includeWsl = false, wslDistribution } = options;
|
const { timeout = 5000 } = options;
|
||||||
const issues: string[] = [];
|
const issues: string[] = [];
|
||||||
|
|
||||||
const cliInfo: CliInfo = {
|
const cliInfo: CliInfo = {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export interface ErrorClassification {
|
|||||||
suggestedAction?: string;
|
suggestedAction?: string;
|
||||||
retryable: boolean;
|
retryable: boolean;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
context?: Record<string, any>;
|
context?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorPattern {
|
export interface ErrorPattern {
|
||||||
@@ -180,7 +180,7 @@ const ERROR_PATTERNS: ErrorPattern[] = [
|
|||||||
export function classifyError(
|
export function classifyError(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
provider?: string,
|
provider?: string,
|
||||||
context?: Record<string, any>
|
context?: Record<string, unknown>
|
||||||
): ErrorClassification {
|
): ErrorClassification {
|
||||||
const errorText = getErrorText(error);
|
const errorText = getErrorText(error);
|
||||||
|
|
||||||
@@ -281,18 +281,19 @@ function getErrorText(error: unknown): string {
|
|||||||
|
|
||||||
if (typeof error === 'object' && error !== null) {
|
if (typeof error === 'object' && error !== null) {
|
||||||
// Handle structured error objects
|
// Handle structured error objects
|
||||||
const errorObj = error as any;
|
const errorObj = error as Record<string, unknown>;
|
||||||
|
|
||||||
if (errorObj.message) {
|
if (typeof errorObj.message === 'string') {
|
||||||
return errorObj.message;
|
return errorObj.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errorObj.error?.message) {
|
const nestedError = errorObj.error;
|
||||||
return errorObj.error.message;
|
if (typeof nestedError === 'object' && nestedError !== null && 'message' in nestedError) {
|
||||||
|
return String((nestedError as Record<string, unknown>).message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errorObj.error) {
|
if (nestedError) {
|
||||||
return typeof errorObj.error === 'string' ? errorObj.error : JSON.stringify(errorObj.error);
|
return typeof nestedError === 'string' ? nestedError : JSON.stringify(nestedError);
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSON.stringify(error);
|
return JSON.stringify(error);
|
||||||
@@ -307,7 +308,7 @@ function getErrorText(error: unknown): string {
|
|||||||
export function createErrorResponse(
|
export function createErrorResponse(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
provider?: string,
|
provider?: string,
|
||||||
context?: Record<string, any>
|
context?: Record<string, unknown>
|
||||||
): {
|
): {
|
||||||
success: false;
|
success: false;
|
||||||
error: string;
|
error: string;
|
||||||
@@ -335,7 +336,7 @@ export function logError(
|
|||||||
error: unknown,
|
error: unknown,
|
||||||
provider?: string,
|
provider?: string,
|
||||||
operation?: string,
|
operation?: string,
|
||||||
additionalContext?: Record<string, any>
|
additionalContext?: Record<string, unknown>
|
||||||
): void {
|
): void {
|
||||||
const classification = classifyError(error, provider, {
|
const classification = classifyError(error, provider, {
|
||||||
operation,
|
operation,
|
||||||
|
|||||||
@@ -12,11 +12,18 @@ export interface PermissionCheckResult {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minimal shape of a Cursor tool call used for permission checking */
|
||||||
|
interface CursorToolCall {
|
||||||
|
shellToolCall?: { args?: { command: string } };
|
||||||
|
readToolCall?: { args?: { path: string } };
|
||||||
|
writeToolCall?: { args?: { path: string } };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a tool call is allowed based on permissions
|
* Check if a tool call is allowed based on permissions
|
||||||
*/
|
*/
|
||||||
export function checkToolCallPermission(
|
export function checkToolCallPermission(
|
||||||
toolCall: any,
|
toolCall: CursorToolCall,
|
||||||
permissions: CursorCliConfigFile | null
|
permissions: CursorCliConfigFile | null
|
||||||
): PermissionCheckResult {
|
): PermissionCheckResult {
|
||||||
if (!permissions || !permissions.permissions) {
|
if (!permissions || !permissions.permissions) {
|
||||||
@@ -152,7 +159,11 @@ function matchesRule(toolName: string, rule: string): boolean {
|
|||||||
/**
|
/**
|
||||||
* Log permission violations
|
* Log permission violations
|
||||||
*/
|
*/
|
||||||
export function logPermissionViolation(toolCall: any, reason: string, sessionId?: string): void {
|
export function logPermissionViolation(
|
||||||
|
toolCall: CursorToolCall,
|
||||||
|
reason: string,
|
||||||
|
sessionId?: string
|
||||||
|
): void {
|
||||||
const sessionIdStr = sessionId ? ` [${sessionId}]` : '';
|
const sessionIdStr = sessionId ? ` [${sessionId}]` : '';
|
||||||
|
|
||||||
if (toolCall.shellToolCall?.args?.command) {
|
if (toolCall.shellToolCall?.args?.command) {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export async function readWorktreeMetadata(
|
|||||||
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
|
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
|
||||||
const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string;
|
const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string;
|
||||||
return JSON.parse(content) as WorktreeMetadata;
|
return JSON.parse(content) as WorktreeMetadata;
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
// File doesn't exist or can't be read
|
// File doesn't exist or can't be read
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* with the provider architecture.
|
* with the provider architecture.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
|
import { query, type Options, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { BaseProvider } from './base-provider.js';
|
import { BaseProvider } from './base-provider.js';
|
||||||
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
|
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
@@ -32,31 +32,6 @@ import type {
|
|||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
// Explicit allowlist of environment variables to pass to the SDK.
|
|
||||||
// Only these vars are passed - nothing else from process.env leaks through.
|
|
||||||
const ALLOWED_ENV_VARS = [
|
|
||||||
// Authentication
|
|
||||||
'ANTHROPIC_API_KEY',
|
|
||||||
'ANTHROPIC_AUTH_TOKEN',
|
|
||||||
// Endpoint configuration
|
|
||||||
'ANTHROPIC_BASE_URL',
|
|
||||||
'API_TIMEOUT_MS',
|
|
||||||
// Model mappings
|
|
||||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
||||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
||||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
||||||
// Traffic control
|
|
||||||
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
|
||||||
// System vars (always from process.env)
|
|
||||||
'PATH',
|
|
||||||
'HOME',
|
|
||||||
'SHELL',
|
|
||||||
'TERM',
|
|
||||||
'USER',
|
|
||||||
'LANG',
|
|
||||||
'LC_ALL',
|
|
||||||
];
|
|
||||||
|
|
||||||
// System vars are always passed from process.env regardless of profile
|
// System vars are always passed from process.env regardless of profile
|
||||||
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
|
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
|
||||||
|
|
||||||
@@ -258,7 +233,7 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build prompt payload
|
// Build prompt payload
|
||||||
let promptPayload: string | AsyncIterable<any>;
|
let promptPayload: string | AsyncIterable<SDKUserMessage>;
|
||||||
|
|
||||||
if (Array.isArray(prompt)) {
|
if (Array.isArray(prompt)) {
|
||||||
// Multi-part prompt (with images)
|
// Multi-part prompt (with images)
|
||||||
@@ -317,12 +292,16 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
? `${userMessage}\n\nTip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits.`
|
? `${userMessage}\n\nTip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits.`
|
||||||
: userMessage;
|
: userMessage;
|
||||||
|
|
||||||
const enhancedError = new Error(message);
|
const enhancedError = new Error(message) as Error & {
|
||||||
(enhancedError as any).originalError = error;
|
originalError: unknown;
|
||||||
(enhancedError as any).type = errorInfo.type;
|
type: string;
|
||||||
|
retryAfter?: number;
|
||||||
|
};
|
||||||
|
enhancedError.originalError = error;
|
||||||
|
enhancedError.type = errorInfo.type;
|
||||||
|
|
||||||
if (errorInfo.isRateLimit) {
|
if (errorInfo.isRateLimit) {
|
||||||
(enhancedError as any).retryAfter = errorInfo.retryAfter;
|
enhancedError.retryAfter = errorInfo.retryAfter;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw enhancedError;
|
throw enhancedError;
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import type {
|
|||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import {
|
import {
|
||||||
CODEX_MODEL_MAP,
|
|
||||||
supportsReasoningEffort,
|
supportsReasoningEffort,
|
||||||
validateBareModelId,
|
validateBareModelId,
|
||||||
calculateReasoningTimeout,
|
calculateReasoningTimeout,
|
||||||
@@ -56,15 +55,9 @@ const CODEX_EXEC_SUBCOMMAND = 'exec';
|
|||||||
const CODEX_JSON_FLAG = '--json';
|
const CODEX_JSON_FLAG = '--json';
|
||||||
const CODEX_MODEL_FLAG = '--model';
|
const CODEX_MODEL_FLAG = '--model';
|
||||||
const CODEX_VERSION_FLAG = '--version';
|
const CODEX_VERSION_FLAG = '--version';
|
||||||
const CODEX_SANDBOX_FLAG = '--sandbox';
|
|
||||||
const CODEX_APPROVAL_FLAG = '--ask-for-approval';
|
|
||||||
const CODEX_SEARCH_FLAG = '--search';
|
|
||||||
const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema';
|
|
||||||
const CODEX_CONFIG_FLAG = '--config';
|
const CODEX_CONFIG_FLAG = '--config';
|
||||||
const CODEX_IMAGE_FLAG = '--image';
|
|
||||||
const CODEX_ADD_DIR_FLAG = '--add-dir';
|
const CODEX_ADD_DIR_FLAG = '--add-dir';
|
||||||
const CODEX_SKIP_GIT_REPO_CHECK_FLAG = '--skip-git-repo-check';
|
const CODEX_SKIP_GIT_REPO_CHECK_FLAG = '--skip-git-repo-check';
|
||||||
const CODEX_RESUME_FLAG = 'resume';
|
|
||||||
const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort';
|
const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort';
|
||||||
const CODEX_YOLO_FLAG = '--dangerously-bypass-approvals-and-sandbox';
|
const CODEX_YOLO_FLAG = '--dangerously-bypass-approvals-and-sandbox';
|
||||||
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
||||||
@@ -106,9 +99,6 @@ const TEXT_ENCODING = 'utf-8';
|
|||||||
*/
|
*/
|
||||||
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
||||||
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
|
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
|
||||||
const CONTEXT_WINDOW_256K = 256000;
|
|
||||||
const MAX_OUTPUT_32K = 32000;
|
|
||||||
const MAX_OUTPUT_16K = 16000;
|
|
||||||
const SYSTEM_PROMPT_SEPARATOR = '\n\n';
|
const SYSTEM_PROMPT_SEPARATOR = '\n\n';
|
||||||
const CODEX_INSTRUCTIONS_DIR = '.codex';
|
const CODEX_INSTRUCTIONS_DIR = '.codex';
|
||||||
const CODEX_INSTRUCTIONS_SECTION = 'Codex Project Instructions';
|
const CODEX_INSTRUCTIONS_SECTION = 'Codex Project Instructions';
|
||||||
@@ -758,17 +748,14 @@ export class CodexProvider extends BaseProvider {
|
|||||||
options.cwd,
|
options.cwd,
|
||||||
codexSettings.sandboxMode !== 'danger-full-access'
|
codexSettings.sandboxMode !== 'danger-full-access'
|
||||||
);
|
);
|
||||||
const resolvedSandboxMode = sandboxCheck.enabled
|
|
||||||
? codexSettings.sandboxMode
|
|
||||||
: 'danger-full-access';
|
|
||||||
if (!sandboxCheck.enabled && sandboxCheck.message) {
|
if (!sandboxCheck.enabled && sandboxCheck.message) {
|
||||||
console.warn(`[CodexProvider] ${sandboxCheck.message}`);
|
console.warn(`[CodexProvider] ${sandboxCheck.message}`);
|
||||||
}
|
}
|
||||||
const searchEnabled =
|
const searchEnabled =
|
||||||
codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools);
|
codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools);
|
||||||
const outputSchemaPath = await writeOutputSchemaFile(options.cwd, options.outputFormat);
|
await writeOutputSchemaFile(options.cwd, options.outputFormat);
|
||||||
const imageBlocks = codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
|
const imageBlocks = codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
|
||||||
const imagePaths = await writeImageFiles(options.cwd, imageBlocks);
|
await writeImageFiles(options.cwd, imageBlocks);
|
||||||
const approvalPolicy =
|
const approvalPolicy =
|
||||||
hasMcpServers && options.mcpAutoApproveTools !== undefined
|
hasMcpServers && options.mcpAutoApproveTools !== undefined
|
||||||
? options.mcpAutoApproveTools
|
? options.mcpAutoApproveTools
|
||||||
@@ -801,7 +788,7 @@ export class CodexProvider extends BaseProvider {
|
|||||||
overrides.push({ key: 'features.web_search_request', value: true });
|
overrides.push({ key: 'features.web_search_request', value: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const configOverrides = buildConfigOverrides(overrides);
|
buildConfigOverrides(overrides);
|
||||||
const preExecArgs: string[] = [];
|
const preExecArgs: string[] = [];
|
||||||
|
|
||||||
// Add additional directories with write access
|
// Add additional directories with write access
|
||||||
@@ -1033,7 +1020,7 @@ export class CodexProvider extends BaseProvider {
|
|||||||
async detectInstallation(): Promise<InstallationStatus> {
|
async detectInstallation(): Promise<InstallationStatus> {
|
||||||
const cliPath = await findCodexCliPath();
|
const cliPath = await findCodexCliPath();
|
||||||
const hasApiKey = Boolean(await resolveOpenAiApiKey());
|
const hasApiKey = Boolean(await resolveOpenAiApiKey());
|
||||||
const authIndicators = await getCodexAuthIndicators();
|
await getCodexAuthIndicators();
|
||||||
const installed = !!cliPath;
|
const installed = !!cliPath;
|
||||||
|
|
||||||
let version = '';
|
let version = '';
|
||||||
@@ -1045,7 +1032,7 @@ export class CodexProvider extends BaseProvider {
|
|||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
});
|
});
|
||||||
version = result.stdout.trim();
|
version = result.stdout.trim();
|
||||||
} catch (error) {
|
} catch {
|
||||||
version = '';
|
version = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,10 +85,6 @@ interface SdkToolExecutionEndEvent extends SdkEvent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SdkSessionIdleEvent extends SdkEvent {
|
|
||||||
type: 'session.idle';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SdkSessionErrorEvent extends SdkEvent {
|
interface SdkSessionErrorEvent extends SdkEvent {
|
||||||
type: 'session.error';
|
type: 'session.error';
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ interface CursorToolHandler<TArgs = unknown, TResult = unknown> {
|
|||||||
* Registry of Cursor tool handlers
|
* Registry of Cursor tool handlers
|
||||||
* Each handler knows how to normalize its specific tool call type
|
* Each handler knows how to normalize its specific tool call type
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- handler registry stores heterogeneous tool type parameters
|
||||||
const CURSOR_TOOL_HANDLERS: Record<string, CursorToolHandler<any, any>> = {
|
const CURSOR_TOOL_HANDLERS: Record<string, CursorToolHandler<any, any>> = {
|
||||||
readToolCall: {
|
readToolCall: {
|
||||||
name: 'Read',
|
name: 'Read',
|
||||||
@@ -878,7 +879,7 @@ export class CursorProvider extends CliProvider {
|
|||||||
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
|
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
|
||||||
|
|
||||||
// Get effective permissions for this project
|
// Get effective permissions for this project
|
||||||
const effectivePermissions = await getEffectivePermissions(options.cwd || process.cwd());
|
await getEffectivePermissions(options.cwd || process.cwd());
|
||||||
|
|
||||||
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
|
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
|
||||||
const debugRawEvents =
|
const debugRawEvents =
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import type {
|
|||||||
ProviderMessage,
|
ProviderMessage,
|
||||||
InstallationStatus,
|
InstallationStatus,
|
||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
ContentBlock,
|
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { validateBareModelId } from '@automaker/types';
|
import { validateBareModelId } from '@automaker/types';
|
||||||
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
|
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
|
||||||
|
|||||||
@@ -16,8 +16,6 @@
|
|||||||
|
|
||||||
import { ProviderFactory } from './provider-factory.js';
|
import { ProviderFactory } from './provider-factory.js';
|
||||||
import type {
|
import type {
|
||||||
ProviderMessage,
|
|
||||||
ContentBlock,
|
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
ReasoningEffort,
|
ReasoningEffort,
|
||||||
ClaudeApiProfile,
|
ClaudeApiProfile,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
|
|||||||
import { AgentService } from '../../../services/agent-service.js';
|
import { AgentService } from '../../../services/agent-service.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
const logger = createLogger('Agent');
|
const _logger = createLogger('Agent');
|
||||||
|
|
||||||
export function createStartHandler(agentService: AgentService) {
|
export function createStartHandler(agentService: AgentService) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function logAuthStatus(context: string): void {
|
|||||||
*/
|
*/
|
||||||
export function logError(error: unknown, context: string): void {
|
export function logError(error: unknown, context: string): void {
|
||||||
logger.error(`❌ ${context}:`);
|
logger.error(`❌ ${context}:`);
|
||||||
logger.error('Error name:', (error as any)?.name);
|
logger.error('Error name:', (error as Error)?.name);
|
||||||
logger.error('Error message:', (error as Error)?.message);
|
logger.error('Error message:', (error as Error)?.message);
|
||||||
logger.error('Error stack:', (error as Error)?.stack);
|
logger.error('Error stack:', (error as Error)?.stack);
|
||||||
logger.error('Full error object:', JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
|
logger.error('Full error object:', JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const DEFAULT_MAX_FEATURES = 50;
|
|||||||
* Timeout for Codex models when generating features (5 minutes).
|
* Timeout for Codex models when generating features (5 minutes).
|
||||||
* Codex models are slower and need more time to generate 50+ features.
|
* Codex models are slower and need more time to generate 50+ features.
|
||||||
*/
|
*/
|
||||||
const CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
|
const _CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type for extracted features JSON response
|
* Type for extracted features JSON response
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
updateTechnologyStack,
|
updateTechnologyStack,
|
||||||
updateRoadmapPhaseStatus,
|
updateRoadmapPhaseStatus,
|
||||||
type ImplementedFeature,
|
type ImplementedFeature,
|
||||||
type RoadmapPhase,
|
|
||||||
} from '../../lib/xml-extractor.js';
|
} from '../../lib/xml-extractor.js';
|
||||||
import { getNotificationService } from '../../services/notification-service.js';
|
import { getNotificationService } from '../../services/notification-service.js';
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
|
import type { Feature, BacklogPlanResult } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
DEFAULT_PHASE_MODELS,
|
DEFAULT_PHASE_MODELS,
|
||||||
isCursorModel,
|
isCursorModel,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types';
|
import type { BacklogPlanResult } from '@automaker/types';
|
||||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||||
import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
|
import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ interface ExportRequest {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createExportHandler(featureLoader: FeatureLoader) {
|
export function createExportHandler(_featureLoader: FeatureLoader) {
|
||||||
const exportService = getFeatureExportService();
|
const exportService = getFeatureExportService();
|
||||||
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function createGenerateTitleHandler(
|
|||||||
): (req: Request, res: Response) => Promise<void> {
|
): (req: Request, res: Response) => Promise<void> {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { description, projectPath } = req.body as GenerateTitleRequestBody;
|
const { description } = req.body as GenerateTitleRequestBody;
|
||||||
|
|
||||||
if (!description || typeof description !== 'string') {
|
if (!description || typeof description !== 'string') {
|
||||||
const response: GenerateTitleErrorResponse = {
|
const response: GenerateTitleErrorResponse = {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ interface ConflictInfo {
|
|||||||
hasConflict: boolean;
|
hasConflict: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createImportHandler(featureLoader: FeatureLoader) {
|
export function createImportHandler(_featureLoader: FeatureLoader) {
|
||||||
const exportService = getFeatureExportService();
|
const exportService = getFeatureExportService();
|
||||||
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ export function createMkdirHandler() {
|
|||||||
error: 'Path exists and is not a directory',
|
error: 'Path exists and is not a directory',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch (statError: any) {
|
} catch (statError: unknown) {
|
||||||
// ENOENT means path doesn't exist - we should create it
|
// ENOENT means path doesn't exist - we should create it
|
||||||
if (statError.code !== 'ENOENT') {
|
if ((statError as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
// Some other error (could be ELOOP in parent path)
|
// Some other error (could be ELOOP in parent path)
|
||||||
throw statError;
|
throw statError;
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ export function createMkdirHandler() {
|
|||||||
await secureFs.mkdir(resolvedPath, { recursive: true });
|
await secureFs.mkdir(resolvedPath, { recursive: true });
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
// Path not allowed - return 403 Forbidden
|
// Path not allowed - return 403 Forbidden
|
||||||
if (error instanceof PathNotAllowedError) {
|
if (error instanceof PathNotAllowedError) {
|
||||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||||
@@ -55,7 +55,7 @@ export function createMkdirHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle ELOOP specifically
|
// Handle ELOOP specifically
|
||||||
if (error.code === 'ELOOP') {
|
if ((error as NodeJS.ErrnoException).code === 'ELOOP') {
|
||||||
logError(error, 'Create directory failed - symlink loop detected');
|
logError(error, 'Create directory failed - symlink loop detected');
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import { getErrorMessage, logError } from '../common.js';
|
|||||||
export function createResolveDirectoryHandler() {
|
export function createResolveDirectoryHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { directoryName, sampleFiles, fileCount } = req.body as {
|
const {
|
||||||
|
directoryName,
|
||||||
|
sampleFiles,
|
||||||
|
fileCount: _fileCount,
|
||||||
|
} = req.body as {
|
||||||
directoryName: string;
|
directoryName: string;
|
||||||
sampleFiles?: string[];
|
sampleFiles?: string[];
|
||||||
fileCount?: number;
|
fileCount?: number;
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ import { getBoardDir } from '@automaker/platform';
|
|||||||
export function createSaveBoardBackgroundHandler() {
|
export function createSaveBoardBackgroundHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { data, filename, mimeType, projectPath } = req.body as {
|
const { data, filename, projectPath } = req.body as {
|
||||||
data: string;
|
data: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
mimeType: string;
|
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ import { sanitizeFilename } from '@automaker/utils';
|
|||||||
export function createSaveImageHandler() {
|
export function createSaveImageHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { data, filename, mimeType, projectPath } = req.body as {
|
const { data, filename, projectPath } = req.body as {
|
||||||
data: string;
|
data: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
mimeType: string;
|
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import * as secureFs from '../../../lib/secure-fs.js';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
import { isPathAllowed, getAllowedRootDirectory } from '@automaker/platform';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
export function createValidatePathHandler() {
|
export function createValidatePathHandler() {
|
||||||
|
|||||||
@@ -37,9 +37,12 @@ export function createGeminiRoutes(): Router {
|
|||||||
const provider = new GeminiProvider();
|
const provider = new GeminiProvider();
|
||||||
const status = await provider.detectInstallation();
|
const status = await provider.detectInstallation();
|
||||||
|
|
||||||
const authMethod =
|
// Derive authMethod from typed InstallationStatus fields
|
||||||
(status as any).authMethod ||
|
const authMethod = status.authenticated
|
||||||
(status.authenticated ? (status.hasApiKey ? 'api_key' : 'cli_login') : 'none');
|
? status.hasApiKey
|
||||||
|
? 'api_key'
|
||||||
|
: 'cli_login'
|
||||||
|
: 'none';
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -48,7 +51,7 @@ export function createGeminiRoutes(): Router {
|
|||||||
path: status.path || null,
|
path: status.path || null,
|
||||||
authenticated: status.authenticated || false,
|
authenticated: status.authenticated || false,
|
||||||
authMethod,
|
authMethod,
|
||||||
hasCredentialsFile: (status as any).hasCredentialsFile || false,
|
hasCredentialsFile: false,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { Request, Response } from 'express';
|
|||||||
import type { EventEmitter } from '../../../lib/events.js';
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
import type { IssueValidationEvent } from '@automaker/types';
|
import type { IssueValidationEvent } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
isValidationRunning,
|
|
||||||
getValidationStatus,
|
getValidationStatus,
|
||||||
getRunningValidations,
|
getRunningValidations,
|
||||||
abortValidation,
|
abortValidation,
|
||||||
@@ -15,7 +14,6 @@ import {
|
|||||||
logger,
|
logger,
|
||||||
} from './validation-common.js';
|
} from './validation-common.js';
|
||||||
import {
|
import {
|
||||||
readValidation,
|
|
||||||
getAllValidations,
|
getAllValidations,
|
||||||
getValidationWithFreshness,
|
getValidationWithFreshness,
|
||||||
deleteValidation,
|
deleteValidation,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function createProvidersHandler() {
|
|||||||
// Get installation status from all providers
|
// Get installation status from all providers
|
||||||
const statuses = await ProviderFactory.checkAllProviders();
|
const statuses = await ProviderFactory.checkAllProviders();
|
||||||
|
|
||||||
const providers: Record<string, any> = {
|
const providers: Record<string, Record<string, unknown>> = {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
available: statuses.claude?.installed || false,
|
available: statuses.claude?.installed || false,
|
||||||
hasApiKey: !!process.env.ANTHROPIC_API_KEY,
|
hasApiKey: !!process.env.ANTHROPIC_API_KEY,
|
||||||
|
|||||||
@@ -46,16 +46,14 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Minimal debug logging to help diagnose accidental wipes.
|
// Minimal debug logging to help diagnose accidental wipes.
|
||||||
const projectsLen = Array.isArray((updates as any).projects)
|
const projectsLen = Array.isArray(updates.projects) ? updates.projects.length : undefined;
|
||||||
? (updates as any).projects.length
|
const trashedLen = Array.isArray(updates.trashedProjects)
|
||||||
: undefined;
|
? updates.trashedProjects.length
|
||||||
const trashedLen = Array.isArray((updates as any).trashedProjects)
|
|
||||||
? (updates as any).trashedProjects.length
|
|
||||||
: undefined;
|
: undefined;
|
||||||
logger.info(
|
logger.info(
|
||||||
`[SERVER_SETTINGS_UPDATE] Request received: projects=${projectsLen ?? 'n/a'}, trashedProjects=${trashedLen ?? 'n/a'}, theme=${
|
`[SERVER_SETTINGS_UPDATE] Request received: projects=${projectsLen ?? 'n/a'}, trashedProjects=${trashedLen ?? 'n/a'}, theme=${
|
||||||
(updates as any).theme ?? 'n/a'
|
updates.theme ?? 'n/a'
|
||||||
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
|
}, localStorageMigrated=${updates.localStorageMigrated ?? 'n/a'}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get old settings to detect theme changes
|
// Get old settings to detect theme changes
|
||||||
|
|||||||
@@ -4,13 +4,9 @@
|
|||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import { exec } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
export function createAuthClaudeHandler() {
|
export function createAuthClaudeHandler() {
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,13 +4,9 @@
|
|||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { logError, getErrorMessage } from '../common.js';
|
import { logError, getErrorMessage } from '../common.js';
|
||||||
import { exec } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
export function createAuthOpencodeHandler() {
|
export function createAuthOpencodeHandler() {
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ import type { Request, Response } from 'express';
|
|||||||
import { CopilotProvider } from '../../../providers/copilot-provider.js';
|
import { CopilotProvider } from '../../../providers/copilot-provider.js';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import type { ModelDefinition } from '@automaker/types';
|
import type { ModelDefinition } from '@automaker/types';
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('CopilotModelsRoute');
|
|
||||||
|
|
||||||
// Singleton provider instance for caching
|
// Singleton provider instance for caching
|
||||||
let providerInstance: CopilotProvider | null = null;
|
let providerInstance: CopilotProvider | null = null;
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ import {
|
|||||||
} from '../../../providers/opencode-provider.js';
|
} from '../../../providers/opencode-provider.js';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import type { ModelDefinition } from '@automaker/types';
|
import type { ModelDefinition } from '@automaker/types';
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('OpenCodeModelsRoute');
|
|
||||||
|
|
||||||
// Singleton provider instance for caching
|
// Singleton provider instance for caching
|
||||||
let providerInstance: OpencodeProvider | null = null;
|
let providerInstance: OpencodeProvider | null = null;
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
AuthSessionManager.createSession(sessionId, authMethod || 'api_key', apiKey, 'anthropic');
|
AuthSessionManager.createSession(sessionId, authMethod || 'api_key', apiKey, 'anthropic');
|
||||||
|
|
||||||
// Create temporary environment override for SDK call
|
// Create temporary environment override for SDK call
|
||||||
const cleanupEnv = createTempEnvOverride(authEnv);
|
const _cleanupEnv = createTempEnvOverride(authEnv);
|
||||||
|
|
||||||
// Run a minimal query to verify authentication
|
// Run a minimal query to verify authentication
|
||||||
const stream = query({
|
const stream = query({
|
||||||
@@ -194,8 +194,10 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check specifically for assistant messages with text content
|
// Check specifically for assistant messages with text content
|
||||||
if (msg.type === 'assistant' && (msg as any).message?.content) {
|
const msgRecord = msg as Record<string, unknown>;
|
||||||
const content = (msg as any).message.content;
|
const msgMessage = msgRecord.message as Record<string, unknown> | undefined;
|
||||||
|
if (msg.type === 'assistant' && msgMessage?.content) {
|
||||||
|
const content = msgMessage.content;
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
for (const block of content) {
|
for (const block of content) {
|
||||||
if (block.type === 'text' && block.text) {
|
if (block.type === 'text' && block.text) {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import { getTerminalService } from '../../services/terminal-service.js';
|
|
||||||
|
|
||||||
const logger = createLogger('Terminal');
|
const logger = createLogger('Terminal');
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
generateToken,
|
generateToken,
|
||||||
addToken,
|
addToken,
|
||||||
getTokenExpiryMs,
|
getTokenExpiryMs,
|
||||||
getErrorMessage,
|
|
||||||
} from '../common.js';
|
} from '../common.js';
|
||||||
|
|
||||||
export function createAuthHandler() {
|
export function createAuthHandler() {
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export async function getTrackedBranches(projectPath: string): Promise<TrackedBr
|
|||||||
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
|
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
|
||||||
const data: BranchTrackingData = JSON.parse(content);
|
const data: BranchTrackingData = JSON.parse(content);
|
||||||
return data.branches || [];
|
return data.branches || [];
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (error.code === 'ENOENT') {
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
logger.warn('Failed to read tracked branches:', error);
|
logger.warn('Failed to read tracked branches:', error);
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function createCreatePRHandler() {
|
|||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch {
|
||||||
// If push fails, try with --set-upstream
|
// If push fails, try with --set-upstream
|
||||||
try {
|
try {
|
||||||
await execAsync(`git push --set-upstream origin ${branchName}`, {
|
await execAsync(`git push --set-upstream origin ${branchName}`, {
|
||||||
@@ -195,7 +195,7 @@ export function createCreatePRHandler() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Couldn't parse remotes - will try fallback
|
// Couldn't parse remotes - will try fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ export function createCreatePRHandler() {
|
|||||||
originOwner = owner;
|
originOwner = owner;
|
||||||
repoUrl = `https://github.com/${owner}/${repo}`;
|
repoUrl = `https://github.com/${owner}/${repo}`;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Failed to get repo URL from config
|
// Failed to get repo URL from config
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function createDeleteHandler() {
|
|||||||
// Remove the worktree (using array arguments to prevent injection)
|
// Remove the worktree (using array arguments to prevent injection)
|
||||||
try {
|
try {
|
||||||
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Try with prune if remove fails
|
// Try with prune if remove fails
|
||||||
await execGitCommand(['worktree', 'prune'], projectPath);
|
await execGitCommand(['worktree', 'prune'], projectPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,31 +64,8 @@ export function createZaiRoutes(
|
|||||||
router.post('/configure', async (req: Request, res: Response) => {
|
router.post('/configure', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { apiToken, apiHost } = req.body;
|
const { apiToken, apiHost } = req.body;
|
||||||
|
const result = await usageService.configure({ apiToken, apiHost }, settingsService);
|
||||||
if (apiToken !== undefined) {
|
res.json(result);
|
||||||
// Set in-memory token
|
|
||||||
usageService.setApiToken(apiToken || '');
|
|
||||||
|
|
||||||
// Persist to credentials (deep merge happens in updateCredentials)
|
|
||||||
try {
|
|
||||||
await settingsService.updateCredentials({
|
|
||||||
apiKeys: { zai: 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 (apiHost) {
|
|
||||||
usageService.setApiHost(apiHost);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'z.ai configuration updated',
|
|
||||||
isAvailable: usageService.isAvailable(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
logger.error('Error configuring z.ai:', error);
|
logger.error('Error configuring z.ai:', error);
|
||||||
@@ -100,50 +77,8 @@ export function createZaiRoutes(
|
|||||||
router.post('/verify', async (req: Request, res: Response) => {
|
router.post('/verify', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { apiKey } = req.body;
|
const { apiKey } = req.body;
|
||||||
|
const result = await usageService.verifyApiKey(apiKey);
|
||||||
if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) {
|
res.json(result);
|
||||||
res.json({
|
|
||||||
success: false,
|
|
||||||
authenticated: false,
|
|
||||||
error: 'Please provide an API key to test.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test the key by making a request to z.ai API
|
|
||||||
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}`);
|
|
||||||
|
|
||||||
const response = await fetch(quotaUrl, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${apiKey.trim()}`,
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
authenticated: true,
|
|
||||||
message: 'Connection successful! z.ai API responded.',
|
|
||||||
});
|
|
||||||
} else if (response.status === 401 || response.status === 403) {
|
|
||||||
res.json({
|
|
||||||
success: false,
|
|
||||||
authenticated: false,
|
|
||||||
error: 'Invalid API key. Please check your key and try again.',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res.json({
|
|
||||||
success: false,
|
|
||||||
authenticated: false,
|
|
||||||
error: `API request failed: ${response.status} ${response.statusText}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
logger.error('Error verifying z.ai API key:', error);
|
logger.error('Error verifying z.ai API key:', error);
|
||||||
|
|||||||
@@ -444,17 +444,11 @@ export class AgentExecutor {
|
|||||||
callbacks: AgentExecutorCallbacks
|
callbacks: AgentExecutorCallbacks
|
||||||
): Promise<{ responseText: string; tasksCompleted: number }> {
|
): Promise<{ responseText: string; tasksCompleted: number }> {
|
||||||
const {
|
const {
|
||||||
workDir,
|
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
abortController,
|
|
||||||
branchName = null,
|
branchName = null,
|
||||||
planningMode = 'skip',
|
planningMode = 'skip',
|
||||||
provider,
|
provider,
|
||||||
effectiveBareModel,
|
|
||||||
credentials,
|
|
||||||
claudeCompatibleProvider,
|
|
||||||
mcpServers,
|
|
||||||
sdkOptions,
|
sdkOptions,
|
||||||
} = options;
|
} = options;
|
||||||
let responseText = initialResponseText,
|
let responseText = initialResponseText,
|
||||||
|
|||||||
@@ -15,11 +15,9 @@ import {
|
|||||||
loadContextFiles,
|
loadContextFiles,
|
||||||
createLogger,
|
createLogger,
|
||||||
classifyError,
|
classifyError,
|
||||||
getUserFriendlyErrorMessage,
|
|
||||||
} from '@automaker/utils';
|
} from '@automaker/utils';
|
||||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||||
import { PathNotAllowedError } from '@automaker/platform';
|
|
||||||
import type { SettingsService } from './settings-service.js';
|
import type { SettingsService } from './settings-service.js';
|
||||||
import {
|
import {
|
||||||
getAutoLoadClaudeMdSetting,
|
getAutoLoadClaudeMdSetting,
|
||||||
|
|||||||
@@ -158,10 +158,7 @@ export class AutoLoopCoordinator {
|
|||||||
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
||||||
if (!projectState) return;
|
if (!projectState) return;
|
||||||
const { projectPath, branchName } = projectState.config;
|
const { projectPath, branchName } = projectState.config;
|
||||||
let iterationCount = 0;
|
|
||||||
|
|
||||||
while (projectState.isRunning && !projectState.abortController.signal.aborted) {
|
while (projectState.isRunning && !projectState.abortController.signal.aborted) {
|
||||||
iterationCount++;
|
|
||||||
try {
|
try {
|
||||||
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
||||||
if (runningCount >= projectState.config.maxConcurrency) {
|
if (runningCount >= projectState.config.maxConcurrency) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import type { Feature } from '@automaker/types';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import { TypedEventBus } from '../typed-event-bus.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
|
// Don't fail if we have data - return it instead
|
||||||
// Check cleaned output since raw output has ANSI codes between words
|
// Check cleaned output since raw output has ANSI codes between words
|
||||||
// eslint-disable-next-line no-control-regex
|
|
||||||
const cleanedForCheck = output
|
const cleanedForCheck = output
|
||||||
.replace(/\x1B\[(\d+)C/g, (_m: string, n: string) => ' '.repeat(parseInt(n, 10)))
|
.replace(/\x1B\[(\d+)C/g, (_m: string, n: string) => ' '.repeat(parseInt(n, 10)))
|
||||||
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '');
|
.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,
|
// Convert cursor forward (ESC[nC) to spaces first to preserve word boundaries,
|
||||||
// then strip remaining ANSI sequences. Without this, the Claude CLI TUI output
|
// then strip remaining ANSI sequences. Without this, the Claude CLI TUI output
|
||||||
// like "Current week (all models)" becomes "Currentweek(allmodels)".
|
// like "Current week (all models)" becomes "Currentweek(allmodels)".
|
||||||
// eslint-disable-next-line no-control-regex
|
|
||||||
const cleanOutput = output
|
const cleanOutput = output
|
||||||
.replace(/\x1B\[(\d+)C/g, (_match: string, n: string) => ' '.repeat(parseInt(n, 10)))
|
.replace(/\x1B\[(\d+)C/g, (_match: string, n: string) => ' '.repeat(parseInt(n, 10)))
|
||||||
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '');
|
.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.
|
// 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.
|
// 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.
|
// Without this, "Current week (all models)" becomes "Currentweek(allmodels)" after stripping.
|
||||||
// eslint-disable-next-line no-control-regex
|
|
||||||
let clean = text
|
let clean = text
|
||||||
// Cursor forward (CSI n C): replace with n spaces to preserve word separation
|
// Cursor forward (CSI n C): replace with n spaces to preserve word separation
|
||||||
.replace(/\x1B\[(\d+)C/g, (_match, n) => ' '.repeat(parseInt(n, 10)))
|
.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
|
// No process found on port, which is fine
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Ignore errors - port might not have any process
|
// Ignore errors - port might not have any process
|
||||||
logger.debug(`No process to kill on port ${port}`);
|
logger.debug(`No process to kill on port ${port}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,7 @@
|
|||||||
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import * as secureFs from '../lib/secure-fs.js';
|
import * as secureFs from '../lib/secure-fs.js';
|
||||||
import {
|
import { getEventHistoryIndexPath, getEventPath, ensureEventHistoryDir } from '@automaker/platform';
|
||||||
getEventHistoryDir,
|
|
||||||
getEventHistoryIndexPath,
|
|
||||||
getEventPath,
|
|
||||||
ensureEventHistoryDir,
|
|
||||||
} from '@automaker/platform';
|
|
||||||
import type {
|
import type {
|
||||||
StoredEvent,
|
StoredEvent,
|
||||||
StoredEventIndex,
|
StoredEventIndex,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import type { TypedEventBus } from './typed-event-bus.js';
|
|||||||
import type { ConcurrencyManager, RunningFeature } from './concurrency-manager.js';
|
import type { ConcurrencyManager, RunningFeature } from './concurrency-manager.js';
|
||||||
import type { WorktreeResolver } from './worktree-resolver.js';
|
import type { WorktreeResolver } from './worktree-resolver.js';
|
||||||
import type { SettingsService } from './settings-service.js';
|
import type { SettingsService } from './settings-service.js';
|
||||||
import type { PipelineContext } from './pipeline-orchestrator.js';
|
|
||||||
import { pipelineService } from './pipeline-service.js';
|
import { pipelineService } from './pipeline-service.js';
|
||||||
|
|
||||||
// Re-export callback types from execution-types.ts for backward compatibility
|
// Re-export callback types from execution-types.ts for backward compatibility
|
||||||
|
|||||||
@@ -205,7 +205,6 @@ export class FeatureExportService {
|
|||||||
importData: FeatureImport
|
importData: FeatureImport
|
||||||
): Promise<FeatureImportResult> {
|
): Promise<FeatureImportResult> {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Extract feature from data (handle both raw Feature and wrapped FeatureExport)
|
// Extract feature from data (handle both raw Feature and wrapped FeatureExport)
|
||||||
|
|||||||
@@ -195,9 +195,10 @@ export class FeatureLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read all feature directories
|
// Read all feature directories
|
||||||
|
// secureFs.readdir returns Dirent[] but typed as generic; cast to access isDirectory()
|
||||||
const entries = (await secureFs.readdir(featuresDir, {
|
const entries = (await secureFs.readdir(featuresDir, {
|
||||||
withFileTypes: true,
|
withFileTypes: true,
|
||||||
})) as any[];
|
})) as import('fs').Dirent[];
|
||||||
const featureDirs = entries.filter((entry) => entry.isDirectory());
|
const featureDirs = entries.filter((entry) => entry.isDirectory());
|
||||||
|
|
||||||
// Load all features concurrently with automatic recovery from backups
|
// 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 fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { execSync } from 'child_process';
|
import { execFileSync } from 'child_process';
|
||||||
|
|
||||||
const logger = createLogger('GeminiUsage');
|
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
|
// Google OAuth endpoints for token refresh
|
||||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
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 {
|
export interface GeminiQuotaBucket {
|
||||||
/** Model ID this quota applies to */
|
/** Model ID this quota applies to */
|
||||||
modelId: string;
|
modelId: string;
|
||||||
@@ -114,8 +120,11 @@ interface QuotaResponse {
|
|||||||
*/
|
*/
|
||||||
export class GeminiUsageService {
|
export class GeminiUsageService {
|
||||||
private cachedCredentials: OAuthCredentials | null = null;
|
private cachedCredentials: OAuthCredentials | null = null;
|
||||||
|
private cachedCredentialsAt: number | null = null;
|
||||||
private cachedClientCredentials: OAuthClientCredentials | null = null;
|
private cachedClientCredentials: OAuthClientCredentials | null = null;
|
||||||
private credentialsPath: string;
|
private credentialsPath: string;
|
||||||
|
/** The actual path from which credentials were loaded (for write-back) */
|
||||||
|
private loadedCredentialsPath: string | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Default credentials path for Gemini CLI
|
// Default credentials path for Gemini CLI
|
||||||
@@ -176,6 +185,7 @@ export class GeminiUsageService {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (codeAssistResponse.ok) {
|
if (codeAssistResponse.ok) {
|
||||||
@@ -199,6 +209,7 @@ export class GeminiUsageService {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(projectId ? { project: projectId } : {}),
|
body: JSON.stringify(projectId ? { project: projectId } : {}),
|
||||||
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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> {
|
private async loadCredentials(): Promise<OAuthCredentials | null> {
|
||||||
if (this.cachedCredentials) {
|
// Check if cached credentials are still valid
|
||||||
return this.cachedCredentials;
|
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
|
// Build unique possible paths (deduplicate)
|
||||||
const possiblePaths = [
|
const rawPaths = [
|
||||||
this.credentialsPath,
|
this.credentialsPath,
|
||||||
path.join(os.homedir(), '.gemini', 'oauth_creds.json'),
|
|
||||||
path.join(os.homedir(), '.config', 'gemini', 'oauth_creds.json'),
|
path.join(os.homedir(), '.config', 'gemini', 'oauth_creds.json'),
|
||||||
];
|
];
|
||||||
|
const possiblePaths = [...new Set(rawPaths)];
|
||||||
|
|
||||||
for (const credPath of possiblePaths) {
|
for (const credPath of possiblePaths) {
|
||||||
try {
|
try {
|
||||||
@@ -361,6 +399,8 @@ export class GeminiUsageService {
|
|||||||
// Handle different credential formats
|
// Handle different credential formats
|
||||||
if (creds.access_token || creds.refresh_token) {
|
if (creds.access_token || creds.refresh_token) {
|
||||||
this.cachedCredentials = creds;
|
this.cachedCredentials = creds;
|
||||||
|
this.cachedCredentialsAt = Date.now();
|
||||||
|
this.loadedCredentialsPath = credPath;
|
||||||
logger.info('[loadCredentials] Loaded from:', credPath);
|
logger.info('[loadCredentials] Loaded from:', credPath);
|
||||||
return creds;
|
return creds;
|
||||||
}
|
}
|
||||||
@@ -372,6 +412,8 @@ export class GeminiUsageService {
|
|||||||
client_id: clientCreds.client_id,
|
client_id: clientCreds.client_id,
|
||||||
client_secret: clientCreds.client_secret,
|
client_secret: clientCreds.client_secret,
|
||||||
};
|
};
|
||||||
|
this.cachedCredentialsAt = Date.now();
|
||||||
|
this.loadedCredentialsPath = credPath;
|
||||||
return this.cachedCredentials;
|
return this.cachedCredentials;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,14 +429,21 @@ export class GeminiUsageService {
|
|||||||
* Find the Gemini CLI binary path
|
* Find the Gemini CLI binary path
|
||||||
*/
|
*/
|
||||||
private findGeminiBinaryPath(): string | null {
|
private findGeminiBinaryPath(): string | null {
|
||||||
|
// Try 'which' on Unix-like systems, 'where' on Windows
|
||||||
|
const whichCmd = process.platform === 'win32' ? 'where' : 'which';
|
||||||
try {
|
try {
|
||||||
// Try 'which' on Unix-like systems
|
const whichResult = execFileSync(whichCmd, ['gemini'], {
|
||||||
const whichResult = execSync('which gemini 2>/dev/null', { encoding: 'utf8' }).trim();
|
encoding: 'utf8',
|
||||||
if (whichResult && fs.existsSync(whichResult)) {
|
timeout: 5000,
|
||||||
return whichResult;
|
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 {
|
} catch {
|
||||||
// Ignore errors from 'which'
|
// Ignore errors from 'which'/'where'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check common installation paths
|
// Check common installation paths
|
||||||
@@ -554,27 +603,33 @@ export class GeminiUsageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try finding oauth2.js by searching in node_modules
|
// Try finding oauth2.js by searching in node_modules (POSIX only)
|
||||||
try {
|
if (process.platform !== 'win32') {
|
||||||
const searchResult = execSync(
|
try {
|
||||||
`find ${baseDir}/.. -name "oauth2.js" -path "*gemini*" -path "*code_assist*" 2>/dev/null | head -1`,
|
const searchBase = path.resolve(baseDir, '..');
|
||||||
{ encoding: 'utf8', timeout: 5000 }
|
const searchResult = execFileSync(
|
||||||
).trim();
|
'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)) {
|
if (searchResult && fs.existsSync(searchResult)) {
|
||||||
logger.debug('[extractOAuthClientCredentials] Found via search:', searchResult);
|
logger.debug('[extractOAuthClientCredentials] Found via search:', searchResult);
|
||||||
const content = fs.readFileSync(searchResult, 'utf8');
|
const content = fs.readFileSync(searchResult, 'utf8');
|
||||||
const creds = this.parseOAuthCredentialsFromSource(content);
|
const creds = this.parseOAuthCredentialsFromSource(content);
|
||||||
if (creds) {
|
if (creds) {
|
||||||
this.cachedClientCredentials = creds;
|
this.cachedClientCredentials = creds;
|
||||||
logger.info(
|
logger.info(
|
||||||
'[extractOAuthClientCredentials] Extracted credentials from CLI (via search)'
|
'[extractOAuthClientCredentials] Extracted credentials from CLI (via search)'
|
||||||
);
|
);
|
||||||
return creds;
|
return creds;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore search errors
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Ignore search errors
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn('[extractOAuthClientCredentials] Could not extract credentials from CLI');
|
logger.warn('[extractOAuthClientCredentials] Could not extract credentials from CLI');
|
||||||
@@ -669,6 +724,7 @@ export class GeminiUsageService {
|
|||||||
refresh_token: creds.refresh_token,
|
refresh_token: creds.refresh_token,
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
}),
|
}),
|
||||||
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -685,13 +741,12 @@ export class GeminiUsageService {
|
|||||||
access_token: newAccessToken,
|
access_token: newAccessToken,
|
||||||
expiry_date: Date.now() + expiresIn * 1000,
|
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 {
|
try {
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(writePath, JSON.stringify(this.cachedCredentials, null, 2));
|
||||||
this.credentialsPath,
|
|
||||||
JSON.stringify(this.cachedCredentials, null, 2)
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('[getValidAccessToken] Could not save refreshed token:', e);
|
logger.debug('[getValidAccessToken] Could not save refreshed token:', e);
|
||||||
}
|
}
|
||||||
@@ -743,6 +798,7 @@ export class GeminiUsageService {
|
|||||||
*/
|
*/
|
||||||
clearCache(): void {
|
clearCache(): void {
|
||||||
this.cachedCredentials = null;
|
this.cachedCredentials = null;
|
||||||
|
this.cachedCredentialsAt = null;
|
||||||
this.cachedClientCredentials = null;
|
this.cachedClientCredentials = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import type {
|
|||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types';
|
import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
getIdeationDir,
|
|
||||||
getIdeasDir,
|
getIdeasDir,
|
||||||
getIdeaDir,
|
getIdeaDir,
|
||||||
getIdeaPath,
|
getIdeaPath,
|
||||||
@@ -407,7 +406,9 @@ export class IdeationService {
|
|||||||
return [];
|
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 ideaDirs = entries.filter((entry) => entry.isDirectory());
|
||||||
|
|
||||||
const ideas: Idea[] = [];
|
const ideas: Idea[] = [];
|
||||||
@@ -855,15 +856,26 @@ ${contextSection}${existingWorkSection}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
return parsed
|
return parsed
|
||||||
.map((item: any, index: number) => ({
|
.map(
|
||||||
id: this.generateId('sug'),
|
(
|
||||||
category,
|
item: {
|
||||||
title: item.title || `Suggestion ${index + 1}`,
|
title?: string;
|
||||||
description: item.description || '',
|
description?: string;
|
||||||
rationale: item.rationale || '',
|
rationale?: string;
|
||||||
priority: item.priority || 'medium',
|
priority?: 'low' | 'medium' | 'high';
|
||||||
relatedFiles: item.relatedFiles || [],
|
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);
|
.slice(0, count);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to parse JSON response:', error);
|
logger.warn('Failed to parse JSON response:', error);
|
||||||
@@ -1705,7 +1717,9 @@ ${contextSection}${existingWorkSection}`;
|
|||||||
const results: AnalysisFileInfo[] = [];
|
const results: AnalysisFileInfo[] = [];
|
||||||
|
|
||||||
try {
|
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) {
|
for (const entry of entries) {
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
|
|||||||
@@ -250,6 +250,14 @@ export class RecoveryService {
|
|||||||
async resumeInterruptedFeatures(projectPath: string): Promise<void> {
|
async resumeInterruptedFeatures(projectPath: string): Promise<void> {
|
||||||
const featuresDir = getFeaturesDir(projectPath);
|
const featuresDir = getFeaturesDir(projectPath);
|
||||||
try {
|
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 entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
|
||||||
const featuresWithContext: Feature[] = [];
|
const featuresWithContext: Feature[] = [];
|
||||||
const featuresWithoutContext: Feature[] = [];
|
const featuresWithoutContext: Feature[] = [];
|
||||||
@@ -263,18 +271,37 @@ export class RecoveryService {
|
|||||||
logRecoveryWarning(result, `Feature ${entry.name}`, logger);
|
logRecoveryWarning(result, `Feature ${entry.name}`, logger);
|
||||||
const feature = result.data;
|
const feature = result.data;
|
||||||
if (!feature) continue;
|
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 === 'in_progress' ||
|
||||||
(feature.status && feature.status.startsWith('pipeline_'))
|
feature.status === 'interrupted' ||
|
||||||
) {
|
(feature.status && feature.status.startsWith('pipeline_'));
|
||||||
(await this.contextExists(projectPath, feature.id))
|
const wasReconciledFromRunning =
|
||||||
? featuresWithContext.push(feature)
|
previouslyRunningIds.has(feature.id) &&
|
||||||
: featuresWithoutContext.push(feature);
|
(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];
|
const allInterruptedFeatures = [...featuresWithContext, ...featuresWithoutContext];
|
||||||
if (allInterruptedFeatures.length === 0) return;
|
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', {
|
this.eventBus.emitAutoModeEvent('auto_mode_resuming_features', {
|
||||||
message: `Resuming ${allInterruptedFeatures.length} interrupted feature(s)`,
|
message: `Resuming ${allInterruptedFeatures.length} interrupted feature(s)`,
|
||||||
projectPath,
|
projectPath,
|
||||||
@@ -295,6 +322,10 @@ export class RecoveryService {
|
|||||||
/* continue */
|
/* continue */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear execution state after successful resume to prevent
|
||||||
|
// re-resuming the same features on subsequent calls
|
||||||
|
await this.clearExecutionState(projectPath);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import { createEventEmitter } from '../lib/events.js';
|
||||||
|
import type { SettingsService } from './settings-service.js';
|
||||||
|
|
||||||
const logger = createLogger('ZaiUsage');
|
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
|
* z.ai quota limit entry from the API
|
||||||
*/
|
*/
|
||||||
@@ -112,6 +117,21 @@ interface ZaiApiResponse {
|
|||||||
message?: string;
|
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
|
* z.ai Usage Service
|
||||||
*
|
*
|
||||||
@@ -162,16 +182,163 @@ export class ZaiUsageService {
|
|||||||
return Boolean(token && token.length > 0);
|
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
|
* Fetch usage data from z.ai API
|
||||||
*/
|
*/
|
||||||
async fetchUsageData(): Promise<ZaiUsageData> {
|
async fetchUsageData(): Promise<ZaiUsageData> {
|
||||||
logger.info('[fetchUsageData] Starting...');
|
logger.info('[fetchUsageData] Starting...');
|
||||||
|
const emitter = createEventEmitter();
|
||||||
|
|
||||||
|
emitter.emit('notification:created', { type: 'zai.usage.start' });
|
||||||
|
|
||||||
const token = this.getApiToken();
|
const token = this.getApiToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
logger.error('[fetchUsageData] No API token configured');
|
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 =
|
const quotaUrl =
|
||||||
@@ -180,31 +347,68 @@ export class ZaiUsageService {
|
|||||||
logger.info(`[fetchUsageData] Fetching from: ${quotaUrl}`);
|
logger.info(`[fetchUsageData] Fetching from: ${quotaUrl}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(quotaUrl, {
|
const controller = new AbortController();
|
||||||
method: 'GET',
|
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
try {
|
||||||
logger.error(`[fetchUsageData] HTTP ${response.status}: ${response.statusText}`);
|
const response = await fetch(quotaUrl, {
|
||||||
throw new Error(`z.ai API request failed: ${response.status} ${response.statusText}`);
|
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')) {
|
if (error instanceof Error && error.message.includes('z.ai API')) {
|
||||||
|
emitter.emit('notification:created', {
|
||||||
|
type: 'zai.usage.error',
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('[fetchUsageData] Failed to fetch:', 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)}`
|
`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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* across all providers (Claude, Codex, Cursor)
|
* across all providers (Claude, Codex, Cursor)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
detectCli,
|
detectCli,
|
||||||
detectAllCLis,
|
detectAllCLis,
|
||||||
@@ -270,7 +270,7 @@ describe('Error Recovery Tests', () => {
|
|||||||
expect(results).toHaveProperty('cursor');
|
expect(results).toHaveProperty('cursor');
|
||||||
|
|
||||||
// Should provide error information for failures
|
// Should provide error information for failures
|
||||||
Object.entries(results).forEach(([provider, result]) => {
|
Object.entries(results).forEach(([_provider, result]) => {
|
||||||
if (!result.detected && result.issues.length > 0) {
|
if (!result.detected && result.issues.length > 0) {
|
||||||
expect(result.issues.length).toBeGreaterThan(0);
|
expect(result.issues.length).toBeGreaterThan(0);
|
||||||
expect(result.issues[0]).toBeTruthy();
|
expect(result.issues[0]).toBeTruthy();
|
||||||
|
|||||||
@@ -491,6 +491,32 @@ describe('recovery-service.ts', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('finds features with interrupted status', async () => {
|
||||||
|
vi.mocked(secureFs.readdir).mockResolvedValueOnce([
|
||||||
|
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||||
|
]);
|
||||||
|
vi.mocked(utils.readJsonWithRecovery).mockResolvedValueOnce({
|
||||||
|
data: { id: 'feature-1', title: 'Feature 1', status: 'interrupted' },
|
||||||
|
wasRecovered: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockLoadFeature.mockResolvedValue({
|
||||||
|
id: 'feature-1',
|
||||||
|
title: 'Feature 1',
|
||||||
|
status: 'interrupted',
|
||||||
|
description: 'Test',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.resumeInterruptedFeatures('/test/project');
|
||||||
|
|
||||||
|
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
||||||
|
'auto_mode_resuming_features',
|
||||||
|
expect.objectContaining({
|
||||||
|
featureIds: ['feature-1'],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('finds features with pipeline_* status', async () => {
|
it('finds features with pipeline_* status', async () => {
|
||||||
vi.mocked(secureFs.readdir).mockResolvedValueOnce([
|
vi.mocked(secureFs.readdir).mockResolvedValueOnce([
|
||||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||||
@@ -519,6 +545,100 @@ describe('recovery-service.ts', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('finds reconciled features using execution state (ready/backlog from previously running)', async () => {
|
||||||
|
// Simulate execution state with previously running feature IDs
|
||||||
|
const executionState = {
|
||||||
|
version: 1,
|
||||||
|
autoLoopWasRunning: true,
|
||||||
|
maxConcurrency: 2,
|
||||||
|
projectPath: '/test/project',
|
||||||
|
branchName: null,
|
||||||
|
runningFeatureIds: ['feature-1', 'feature-2'],
|
||||||
|
savedAt: '2026-01-27T12:00:00Z',
|
||||||
|
};
|
||||||
|
vi.mocked(secureFs.readFile).mockResolvedValueOnce(JSON.stringify(executionState));
|
||||||
|
|
||||||
|
vi.mocked(secureFs.readdir).mockResolvedValueOnce([
|
||||||
|
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||||
|
{ name: 'feature-2', isDirectory: () => true } as any,
|
||||||
|
{ name: 'feature-3', isDirectory: () => true } as any,
|
||||||
|
]);
|
||||||
|
// feature-1 was reconciled from in_progress to ready
|
||||||
|
// feature-2 was reconciled from in_progress to backlog
|
||||||
|
// feature-3 is in backlog but was NOT previously running
|
||||||
|
vi.mocked(utils.readJsonWithRecovery)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { id: 'feature-1', title: 'Feature 1', status: 'ready' },
|
||||||
|
wasRecovered: false,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { id: 'feature-2', title: 'Feature 2', status: 'backlog' },
|
||||||
|
wasRecovered: false,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { id: 'feature-3', title: 'Feature 3', status: 'backlog' },
|
||||||
|
wasRecovered: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockLoadFeature
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: 'feature-1',
|
||||||
|
title: 'Feature 1',
|
||||||
|
status: 'ready',
|
||||||
|
description: 'Test',
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: 'feature-2',
|
||||||
|
title: 'Feature 2',
|
||||||
|
status: 'backlog',
|
||||||
|
description: 'Test',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.resumeInterruptedFeatures('/test/project');
|
||||||
|
|
||||||
|
// Should resume feature-1 and feature-2 (from execution state) but NOT feature-3
|
||||||
|
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
||||||
|
'auto_mode_resuming_features',
|
||||||
|
expect.objectContaining({
|
||||||
|
featureIds: ['feature-1', 'feature-2'],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears execution state after successful resume', async () => {
|
||||||
|
// Simulate execution state
|
||||||
|
const executionState = {
|
||||||
|
version: 1,
|
||||||
|
autoLoopWasRunning: true,
|
||||||
|
maxConcurrency: 1,
|
||||||
|
projectPath: '/test/project',
|
||||||
|
branchName: null,
|
||||||
|
runningFeatureIds: ['feature-1'],
|
||||||
|
savedAt: '2026-01-27T12:00:00Z',
|
||||||
|
};
|
||||||
|
vi.mocked(secureFs.readFile).mockResolvedValueOnce(JSON.stringify(executionState));
|
||||||
|
|
||||||
|
vi.mocked(secureFs.readdir).mockResolvedValueOnce([
|
||||||
|
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||||
|
]);
|
||||||
|
vi.mocked(utils.readJsonWithRecovery).mockResolvedValueOnce({
|
||||||
|
data: { id: 'feature-1', title: 'Feature 1', status: 'ready' },
|
||||||
|
wasRecovered: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockLoadFeature.mockResolvedValue({
|
||||||
|
id: 'feature-1',
|
||||||
|
title: 'Feature 1',
|
||||||
|
status: 'ready',
|
||||||
|
description: 'Test',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.resumeInterruptedFeatures('/test/project');
|
||||||
|
|
||||||
|
// Should clear execution state after resuming
|
||||||
|
expect(secureFs.unlink).toHaveBeenCalledWith('/test/project/.automaker/execution-state.json');
|
||||||
|
});
|
||||||
|
|
||||||
it('distinguishes features with/without context', async () => {
|
it('distinguishes features with/without context', async () => {
|
||||||
vi.mocked(secureFs.readdir).mockResolvedValueOnce([
|
vi.mocked(secureFs.readdir).mockResolvedValueOnce([
|
||||||
{ name: 'feature-with', isDirectory: () => true } as any,
|
{ name: 'feature-with', isDirectory: () => true } as any,
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ export function UsagePopover() {
|
|||||||
// Calculate max percentage for header button
|
// Calculate max percentage for header button
|
||||||
const claudeSessionPercentage = claudeUsage?.sessionPercentage || 0;
|
const claudeSessionPercentage = claudeUsage?.sessionPercentage || 0;
|
||||||
|
|
||||||
const codexMaxPercentage = codexUsage?.rateLimits
|
const _codexMaxPercentage = codexUsage?.rateLimits
|
||||||
? Math.max(
|
? Math.max(
|
||||||
codexUsage.rateLimits.primary?.usedPercent || 0,
|
codexUsage.rateLimits.primary?.usedPercent || 0,
|
||||||
codexUsage.rateLimits.secondary?.usedPercent || 0
|
codexUsage.rateLimits.secondary?.usedPercent || 0
|
||||||
@@ -369,7 +369,7 @@ export function UsagePopover() {
|
|||||||
codexSecondaryWindowMinutes && codexPrimaryWindowMinutes
|
codexSecondaryWindowMinutes && codexPrimaryWindowMinutes
|
||||||
? Math.min(codexPrimaryWindowMinutes, codexSecondaryWindowMinutes)
|
? Math.min(codexPrimaryWindowMinutes, codexSecondaryWindowMinutes)
|
||||||
: (codexSecondaryWindowMinutes ?? codexPrimaryWindowMinutes);
|
: (codexSecondaryWindowMinutes ?? codexPrimaryWindowMinutes);
|
||||||
const codexWindowLabel = codexWindowMinutes
|
const _codexWindowLabel = codexWindowMinutes
|
||||||
? getCodexWindowLabel(codexWindowMinutes).title
|
? getCodexWindowLabel(codexWindowMinutes).title
|
||||||
: 'Window';
|
: 'Window';
|
||||||
const codexWindowUsage =
|
const codexWindowUsage =
|
||||||
@@ -408,16 +408,16 @@ export function UsagePopover() {
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const statusColor = getStatusInfo(indicatorInfo.percentage).color;
|
const statusColor = indicatorInfo ? getStatusInfo(indicatorInfo.percentage).color : '';
|
||||||
const ProviderIcon = indicatorInfo.icon;
|
const ProviderIcon = indicatorInfo?.icon;
|
||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<Button variant="ghost" size="sm" className="h-9 gap-2 bg-secondary border border-border px-3">
|
<Button variant="ghost" size="sm" className="h-9 gap-2 bg-secondary border border-border px-3">
|
||||||
{(claudeUsage || codexUsage || zaiUsage || geminiUsage) && (
|
{(claudeUsage || codexUsage || zaiUsage || geminiUsage) && ProviderIcon && (
|
||||||
<ProviderIcon className={cn('w-4 h-4', statusColor)} />
|
<ProviderIcon className={cn('w-4 h-4', statusColor)} />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium">Usage</span>
|
<span className="text-sm font-medium">Usage</span>
|
||||||
{(claudeUsage || codexUsage || zaiUsage || geminiUsage) && (
|
{(claudeUsage || codexUsage || zaiUsage || geminiUsage) && indicatorInfo && (
|
||||||
<div
|
<div
|
||||||
title={indicatorInfo.title}
|
title={indicatorInfo.title}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -122,83 +122,130 @@ export const CardActions = memo(function CardActions({
|
|||||||
(feature.status === 'in_progress' ||
|
(feature.status === 'in_progress' ||
|
||||||
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'))) && (
|
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'))) && (
|
||||||
<>
|
<>
|
||||||
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
|
{/* When feature is in_progress with no error and onForceStop is available,
|
||||||
{feature.planSpec?.status === 'generated' && onApprovePlan && (
|
it means the agent is starting/running but hasn't been added to runningAutoTasks yet.
|
||||||
<Button
|
Show Stop button instead of Verify/Resume to avoid confusing UI during this race window. */}
|
||||||
variant="default"
|
{!feature.error && onForceStop ? (
|
||||||
size="sm"
|
<>
|
||||||
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
{onViewOutput && (
|
||||||
onClick={(e) => {
|
<Button
|
||||||
e.stopPropagation();
|
variant="secondary"
|
||||||
onApprovePlan();
|
size="sm"
|
||||||
}}
|
className="flex-1 h-7 text-[11px]"
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
data-testid={`approve-plan-${feature.id}`}
|
e.stopPropagation();
|
||||||
>
|
onViewOutput();
|
||||||
<FileText className="w-3 h-3 mr-1" />
|
}}
|
||||||
Approve Plan
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
</Button>
|
data-testid={`view-output-${feature.id}`}
|
||||||
)}
|
>
|
||||||
{feature.skipTests && onManualVerify ? (
|
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||||
<Button
|
<span className="truncate">Logs</span>
|
||||||
variant="default"
|
{shortcutKey && (
|
||||||
size="sm"
|
<span
|
||||||
className="flex-1 h-7 text-[11px]"
|
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
|
||||||
onClick={(e) => {
|
data-testid={`shortcut-key-${feature.id}`}
|
||||||
e.stopPropagation();
|
>
|
||||||
onManualVerify();
|
{shortcutKey}
|
||||||
}}
|
</span>
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
)}
|
||||||
data-testid={`manual-verify-${feature.id}`}
|
</Button>
|
||||||
>
|
)}
|
||||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
<Button
|
||||||
Verify
|
variant="destructive"
|
||||||
</Button>
|
size="sm"
|
||||||
) : onResume ? (
|
className="h-7 text-[11px] px-2 shrink-0"
|
||||||
<Button
|
onClick={(e) => {
|
||||||
variant="default"
|
e.stopPropagation();
|
||||||
size="sm"
|
onForceStop();
|
||||||
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
}}
|
||||||
onClick={(e) => {
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
e.stopPropagation();
|
data-testid={`force-stop-${feature.id}`}
|
||||||
onResume();
|
>
|
||||||
}}
|
<StopCircle className="w-3 h-3" />
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
</Button>
|
||||||
data-testid={`resume-feature-${feature.id}`}
|
</>
|
||||||
>
|
) : (
|
||||||
<RotateCcw className="w-3 h-3 mr-1" />
|
<>
|
||||||
Resume
|
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
|
||||||
</Button>
|
{feature.planSpec?.status === 'generated' && onApprovePlan && (
|
||||||
) : onVerify ? (
|
<Button
|
||||||
<Button
|
variant="default"
|
||||||
variant="default"
|
size="sm"
|
||||||
size="sm"
|
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
||||||
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
onApprovePlan();
|
||||||
onVerify();
|
}}
|
||||||
}}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
data-testid={`approve-plan-${feature.id}`}
|
||||||
data-testid={`verify-feature-${feature.id}`}
|
>
|
||||||
>
|
<FileText className="w-3 h-3 mr-1" />
|
||||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
Approve Plan
|
||||||
Verify
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
) : null}
|
{feature.skipTests && onManualVerify ? (
|
||||||
{onViewOutput && !feature.skipTests && (
|
<Button
|
||||||
<Button
|
variant="default"
|
||||||
variant="secondary"
|
size="sm"
|
||||||
size="sm"
|
className="flex-1 h-7 text-[11px]"
|
||||||
className="h-7 text-[11px] px-2"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
onManualVerify();
|
||||||
onViewOutput();
|
}}
|
||||||
}}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
data-testid={`manual-verify-${feature.id}`}
|
||||||
data-testid={`view-output-inprogress-${feature.id}`}
|
>
|
||||||
>
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
<FileText className="w-3 h-3" />
|
Verify
|
||||||
</Button>
|
</Button>
|
||||||
|
) : onResume ? (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onResume();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`resume-feature-${feature.id}`}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3 mr-1" />
|
||||||
|
Resume
|
||||||
|
</Button>
|
||||||
|
) : onVerify ? (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onVerify();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`verify-feature-${feature.id}`}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
|
Verify
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{onViewOutput && !feature.skipTests && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[11px] px-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewOutput();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`view-output-inprogress-${feature.id}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -112,9 +112,15 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
currentProject: state.currentProject,
|
currentProject: state.currentProject,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
// A card in waiting_approval should not display as "actively running" even if
|
// A card should only display as "actively running" if it's both in the
|
||||||
// it's still in the runningAutoTasks list. The waiting_approval UI takes precedence.
|
// runningAutoTasks list AND in an execution-compatible status. Cards in resting
|
||||||
const isActivelyRunning = !!isCurrentAutoTask && feature.status !== 'waiting_approval';
|
// states (backlog, ready, waiting_approval, verified, completed) should never
|
||||||
|
// show running controls, even if they appear in runningAutoTasks due to stale
|
||||||
|
// state (e.g., after a server restart that reconciled features back to backlog).
|
||||||
|
const isInExecutionState =
|
||||||
|
feature.status === 'in_progress' ||
|
||||||
|
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'));
|
||||||
|
const isActivelyRunning = !!isCurrentAutoTask && isInExecutionState;
|
||||||
const [isLifted, setIsLifted] = useState(false);
|
const [isLifted, setIsLifted] = useState(false);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
|||||||
@@ -209,9 +209,15 @@ export const ListRow = memo(function ListRow({
|
|||||||
blockingDependencies = [],
|
blockingDependencies = [],
|
||||||
className,
|
className,
|
||||||
}: ListRowProps) {
|
}: ListRowProps) {
|
||||||
// A card in waiting_approval should not display as "actively running" even if
|
// A row should only display as "actively running" if it's both in the
|
||||||
// it's still in the runningAutoTasks list. The waiting_approval UI takes precedence.
|
// runningAutoTasks list AND in an execution-compatible status. Features in resting
|
||||||
const isActivelyRunning = isCurrentAutoTask && feature.status !== 'waiting_approval';
|
// states (backlog, ready, waiting_approval, verified, completed) should never
|
||||||
|
// show running controls, even if they appear in runningAutoTasks due to stale
|
||||||
|
// state (e.g., after a server restart that reconciled features back to backlog).
|
||||||
|
const isInExecutionState =
|
||||||
|
feature.status === 'in_progress' ||
|
||||||
|
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'));
|
||||||
|
const isActivelyRunning = isCurrentAutoTask && isInExecutionState;
|
||||||
|
|
||||||
const handleRowClick = useCallback(
|
const handleRowClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
|||||||
@@ -143,6 +143,17 @@ function getPrimaryAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In progress with no error - agent is starting/running but not yet in runningAutoTasks.
|
||||||
|
// Show Stop button immediately instead of Verify/Resume during this race window.
|
||||||
|
if (feature.status === 'in_progress' && !feature.error && handlers.onForceStop) {
|
||||||
|
return {
|
||||||
|
icon: StopCircle,
|
||||||
|
label: 'Stop',
|
||||||
|
onClick: handlers.onForceStop,
|
||||||
|
variant: 'destructive',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// In progress with plan approval pending
|
// In progress with plan approval pending
|
||||||
if (
|
if (
|
||||||
feature.status === 'in_progress' &&
|
feature.status === 'in_progress' &&
|
||||||
@@ -446,81 +457,126 @@ export const RowActions = memo(function RowActions({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* In Progress actions */}
|
{/* In Progress actions - starting/running (no error, force stop available) - mirrors running task actions */}
|
||||||
{!isCurrentAutoTask && feature.status === 'in_progress' && (
|
{!isCurrentAutoTask &&
|
||||||
<>
|
feature.status === 'in_progress' &&
|
||||||
{handlers.onViewOutput && (
|
!feature.error &&
|
||||||
<MenuItem
|
handlers.onForceStop && (
|
||||||
icon={FileText}
|
<>
|
||||||
label="View Logs"
|
{handlers.onViewOutput && (
|
||||||
onClick={withClose(handlers.onViewOutput)}
|
<MenuItem
|
||||||
/>
|
icon={FileText}
|
||||||
)}
|
label="View Logs"
|
||||||
{feature.planSpec?.status === 'generated' && handlers.onApprovePlan && (
|
onClick={withClose(handlers.onViewOutput)}
|
||||||
<MenuItem
|
/>
|
||||||
icon={FileText}
|
)}
|
||||||
label="Approve Plan"
|
{feature.planSpec?.status === 'generated' && handlers.onApprovePlan && (
|
||||||
onClick={withClose(handlers.onApprovePlan)}
|
<MenuItem
|
||||||
variant="warning"
|
icon={FileText}
|
||||||
/>
|
label="Approve Plan"
|
||||||
)}
|
onClick={withClose(handlers.onApprovePlan)}
|
||||||
{feature.skipTests && handlers.onManualVerify ? (
|
variant="warning"
|
||||||
<MenuItem
|
/>
|
||||||
icon={CheckCircle2}
|
)}
|
||||||
label="Verify"
|
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
|
||||||
onClick={withClose(handlers.onManualVerify)}
|
{handlers.onSpawnTask && (
|
||||||
variant="success"
|
<MenuItem
|
||||||
/>
|
icon={GitFork}
|
||||||
) : handlers.onResume ? (
|
label="Spawn Sub-Task"
|
||||||
<MenuItem
|
onClick={withClose(handlers.onSpawnTask)}
|
||||||
icon={RotateCcw}
|
/>
|
||||||
label="Resume"
|
)}
|
||||||
onClick={withClose(handlers.onResume)}
|
{handlers.onForceStop && (
|
||||||
variant="success"
|
<>
|
||||||
/>
|
<DropdownMenuSeparator />
|
||||||
) : null}
|
<MenuItem
|
||||||
<DropdownMenuSeparator />
|
icon={StopCircle}
|
||||||
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
|
label="Force Stop"
|
||||||
{handlers.onSpawnTask && (
|
onClick={withClose(handlers.onForceStop)}
|
||||||
<MenuItem
|
variant="destructive"
|
||||||
icon={GitFork}
|
/>
|
||||||
label="Spawn Sub-Task"
|
</>
|
||||||
onClick={withClose(handlers.onSpawnTask)}
|
)}
|
||||||
/>
|
</>
|
||||||
)}
|
)}
|
||||||
{handlers.onDuplicate && (
|
|
||||||
<DropdownMenuSub>
|
{/* In Progress actions - interrupted/error state */}
|
||||||
<div className="flex items-center">
|
{!isCurrentAutoTask &&
|
||||||
<DropdownMenuItem
|
feature.status === 'in_progress' &&
|
||||||
onClick={withClose(handlers.onDuplicate)}
|
!(!feature.error && handlers.onForceStop) && (
|
||||||
className="flex-1 pr-0 rounded-r-none"
|
<>
|
||||||
>
|
{handlers.onViewOutput && (
|
||||||
<Copy className="w-4 h-4 mr-2" />
|
<MenuItem
|
||||||
Duplicate
|
icon={FileText}
|
||||||
</DropdownMenuItem>
|
label="View Logs"
|
||||||
|
onClick={withClose(handlers.onViewOutput)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{feature.planSpec?.status === 'generated' && handlers.onApprovePlan && (
|
||||||
|
<MenuItem
|
||||||
|
icon={FileText}
|
||||||
|
label="Approve Plan"
|
||||||
|
onClick={withClose(handlers.onApprovePlan)}
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{feature.skipTests && handlers.onManualVerify ? (
|
||||||
|
<MenuItem
|
||||||
|
icon={CheckCircle2}
|
||||||
|
label="Verify"
|
||||||
|
onClick={withClose(handlers.onManualVerify)}
|
||||||
|
variant="success"
|
||||||
|
/>
|
||||||
|
) : handlers.onResume ? (
|
||||||
|
<MenuItem
|
||||||
|
icon={RotateCcw}
|
||||||
|
label="Resume"
|
||||||
|
onClick={withClose(handlers.onResume)}
|
||||||
|
variant="success"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
|
||||||
|
{handlers.onSpawnTask && (
|
||||||
|
<MenuItem
|
||||||
|
icon={GitFork}
|
||||||
|
label="Spawn Sub-Task"
|
||||||
|
onClick={withClose(handlers.onSpawnTask)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.onDuplicate && (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={withClose(handlers.onDuplicate)}
|
||||||
|
className="flex-1 pr-0 rounded-r-none"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{handlers.onDuplicateAsChild && (
|
||||||
|
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{handlers.onDuplicateAsChild && (
|
{handlers.onDuplicateAsChild && (
|
||||||
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8" />
|
<DropdownMenuSubContent>
|
||||||
|
<MenuItem
|
||||||
|
icon={GitFork}
|
||||||
|
label="Duplicate as Child"
|
||||||
|
onClick={withClose(handlers.onDuplicateAsChild)}
|
||||||
|
/>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
)}
|
)}
|
||||||
</div>
|
</DropdownMenuSub>
|
||||||
{handlers.onDuplicateAsChild && (
|
)}
|
||||||
<DropdownMenuSubContent>
|
<MenuItem
|
||||||
<MenuItem
|
icon={Trash2}
|
||||||
icon={GitFork}
|
label="Delete"
|
||||||
label="Duplicate as Child"
|
onClick={withClose(handlers.onDelete)}
|
||||||
onClick={withClose(handlers.onDuplicateAsChild)}
|
variant="destructive"
|
||||||
/>
|
/>
|
||||||
</DropdownMenuSubContent>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuSub>
|
|
||||||
)}
|
|
||||||
<MenuItem
|
|
||||||
icon={Trash2}
|
|
||||||
label="Delete"
|
|
||||||
onClick={withClose(handlers.onDelete)}
|
|
||||||
variant="destructive"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Waiting Approval actions */}
|
{/* Waiting Approval actions */}
|
||||||
{!isCurrentAutoTask && feature.status === 'waiting_approval' && (
|
{!isCurrentAutoTask && feature.status === 'waiting_approval' && (
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Spinner } from '@/components/ui/spinner';
|
|||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon';
|
import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon';
|
||||||
import type { GeminiUsage } from '@/store/app-store';
|
|
||||||
import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils';
|
import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils';
|
||||||
|
|
||||||
interface MobileUsageBarProps {
|
interface MobileUsageBarProps {
|
||||||
@@ -42,6 +41,11 @@ function formatResetTime(unixTimestamp: number, isMilliseconds = false): string
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = date.getTime() - now.getTime();
|
const diff = date.getTime() - now.getTime();
|
||||||
|
|
||||||
|
// Handle past timestamps (negative diff)
|
||||||
|
if (diff <= 0) {
|
||||||
|
return 'Resetting soon';
|
||||||
|
}
|
||||||
|
|
||||||
if (diff < 3600000) {
|
if (diff < 3600000) {
|
||||||
const mins = Math.ceil(diff / 60000);
|
const mins = Math.ceil(diff / 60000);
|
||||||
return `Resets in ${mins}m`;
|
return `Resets in ${mins}m`;
|
||||||
@@ -184,12 +188,11 @@ export function MobileUsageBar({
|
|||||||
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
||||||
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
||||||
const { zaiUsage, zaiUsageLastUpdated, setZaiUsage } = useAppStore();
|
const { zaiUsage, zaiUsageLastUpdated, setZaiUsage } = useAppStore();
|
||||||
|
const { geminiUsage, geminiUsageLastUpdated, setGeminiUsage } = useAppStore();
|
||||||
const [isClaudeLoading, setIsClaudeLoading] = useState(false);
|
const [isClaudeLoading, setIsClaudeLoading] = useState(false);
|
||||||
const [isCodexLoading, setIsCodexLoading] = useState(false);
|
const [isCodexLoading, setIsCodexLoading] = useState(false);
|
||||||
const [isZaiLoading, setIsZaiLoading] = useState(false);
|
const [isZaiLoading, setIsZaiLoading] = useState(false);
|
||||||
const [isGeminiLoading, setIsGeminiLoading] = useState(false);
|
const [isGeminiLoading, setIsGeminiLoading] = useState(false);
|
||||||
const [geminiUsage, setGeminiUsage] = useState<GeminiUsage | null>(null);
|
|
||||||
const [geminiUsageLastUpdated, setGeminiUsageLastUpdated] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// Check if data is stale (older than 2 minutes)
|
// Check if data is stale (older than 2 minutes)
|
||||||
const isClaudeStale =
|
const isClaudeStale =
|
||||||
@@ -254,15 +257,14 @@ export function MobileUsageBar({
|
|||||||
if (!api.gemini) return;
|
if (!api.gemini) return;
|
||||||
const data = await api.gemini.getUsage();
|
const data = await api.gemini.getUsage();
|
||||||
if (!('error' in data)) {
|
if (!('error' in data)) {
|
||||||
setGeminiUsage(data);
|
setGeminiUsage(data, Date.now());
|
||||||
setGeminiUsageLastUpdated(Date.now());
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail - usage display is optional
|
// Silently fail - usage display is optional
|
||||||
} finally {
|
} finally {
|
||||||
setIsGeminiLoading(false);
|
setIsGeminiLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setGeminiUsage]);
|
||||||
|
|
||||||
const getCodexWindowLabel = (durationMins: number) => {
|
const getCodexWindowLabel = (durationMins: number) => {
|
||||||
if (durationMins < 60) return `${durationMins}m Window`;
|
if (durationMins < 60) return `${durationMins}m Window`;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck - API key management state with validation and persistence
|
// API key management state with validation and persistence
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
@@ -23,20 +23,44 @@ interface ApiKeyStatus {
|
|||||||
hasZaiKey: boolean;
|
hasZaiKey: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shape of the configure API response */
|
||||||
|
interface ConfigureResponse {
|
||||||
|
success?: boolean;
|
||||||
|
isAvailable?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape of a verify API response */
|
||||||
|
interface VerifyResponse {
|
||||||
|
success?: boolean;
|
||||||
|
authenticated?: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape of an API key status response from the env check */
|
||||||
|
interface ApiKeyStatusResponse {
|
||||||
|
success: boolean;
|
||||||
|
hasAnthropicKey: boolean;
|
||||||
|
hasGoogleKey: boolean;
|
||||||
|
hasOpenaiKey: boolean;
|
||||||
|
hasZaiKey?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for managing API key state and operations
|
* Custom hook for managing API key state and operations
|
||||||
* Handles input values, visibility toggles, connection testing, and saving
|
* Handles input values, visibility toggles, connection testing, and saving
|
||||||
*/
|
*/
|
||||||
export function useApiKeyManagement() {
|
export function useApiKeyManagement() {
|
||||||
const { apiKeys, setApiKeys } = useAppStore();
|
const { apiKeys, setApiKeys } = useAppStore();
|
||||||
const { setZaiAuthStatus } = useSetupStore();
|
const { setZaiAuthStatus, zaiAuthStatus } = useSetupStore();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// API key values
|
// API key values
|
||||||
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
|
const [anthropicKey, setAnthropicKey] = useState<string>(apiKeys.anthropic);
|
||||||
const [googleKey, setGoogleKey] = useState(apiKeys.google);
|
const [googleKey, setGoogleKey] = useState<string>(apiKeys.google);
|
||||||
const [openaiKey, setOpenaiKey] = useState(apiKeys.openai);
|
const [openaiKey, setOpenaiKey] = useState<string>(apiKeys.openai);
|
||||||
const [zaiKey, setZaiKey] = useState(apiKeys.zai);
|
const [zaiKey, setZaiKey] = useState<string>(apiKeys.zai);
|
||||||
|
|
||||||
// Visibility toggles
|
// Visibility toggles
|
||||||
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
|
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
|
||||||
@@ -74,7 +98,7 @@ export function useApiKeyManagement() {
|
|||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (api?.setup?.getApiKeys) {
|
if (api?.setup?.getApiKeys) {
|
||||||
try {
|
try {
|
||||||
const status = await api.setup.getApiKeys();
|
const status: ApiKeyStatusResponse = await api.setup.getApiKeys();
|
||||||
if (status.success) {
|
if (status.success) {
|
||||||
setApiKeyStatus({
|
setApiKeyStatus({
|
||||||
hasAnthropicKey: status.hasAnthropicKey,
|
hasAnthropicKey: status.hasAnthropicKey,
|
||||||
@@ -92,7 +116,7 @@ export function useApiKeyManagement() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Test Anthropic/Claude connection
|
// Test Anthropic/Claude connection
|
||||||
const handleTestAnthropicConnection = async () => {
|
const handleTestAnthropicConnection = async (): Promise<void> => {
|
||||||
// Validate input first
|
// Validate input first
|
||||||
if (!anthropicKey || anthropicKey.trim().length === 0) {
|
if (!anthropicKey || anthropicKey.trim().length === 0) {
|
||||||
setTestResult({
|
setTestResult({
|
||||||
@@ -106,7 +130,7 @@ export function useApiKeyManagement() {
|
|||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getHttpApiClient();
|
||||||
// Pass the current input value to test unsaved keys
|
// Pass the current input value to test unsaved keys
|
||||||
const data = await api.setup.verifyClaudeAuth('api_key', anthropicKey);
|
const data = await api.setup.verifyClaudeAuth('api_key', anthropicKey);
|
||||||
|
|
||||||
@@ -133,7 +157,7 @@ export function useApiKeyManagement() {
|
|||||||
|
|
||||||
// Test Google/Gemini connection
|
// Test Google/Gemini connection
|
||||||
// TODO: Add backend endpoint for Gemini API key verification
|
// TODO: Add backend endpoint for Gemini API key verification
|
||||||
const handleTestGeminiConnection = async () => {
|
const handleTestGeminiConnection = async (): Promise<void> => {
|
||||||
setTestingGeminiConnection(true);
|
setTestingGeminiConnection(true);
|
||||||
setGeminiTestResult(null);
|
setGeminiTestResult(null);
|
||||||
|
|
||||||
@@ -157,12 +181,12 @@ export function useApiKeyManagement() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Test OpenAI/Codex connection
|
// Test OpenAI/Codex connection
|
||||||
const handleTestOpenaiConnection = async () => {
|
const handleTestOpenaiConnection = async (): Promise<void> => {
|
||||||
setTestingOpenaiConnection(true);
|
setTestingOpenaiConnection(true);
|
||||||
setOpenaiTestResult(null);
|
setOpenaiTestResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getHttpApiClient();
|
||||||
const data = await api.setup.verifyCodexAuth('api_key', openaiKey);
|
const data = await api.setup.verifyCodexAuth('api_key', openaiKey);
|
||||||
|
|
||||||
if (data.success && data.authenticated) {
|
if (data.success && data.authenticated) {
|
||||||
@@ -187,7 +211,7 @@ export function useApiKeyManagement() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Test z.ai connection
|
// Test z.ai connection
|
||||||
const handleTestZaiConnection = async () => {
|
const handleTestZaiConnection = async (): Promise<void> => {
|
||||||
setTestingZaiConnection(true);
|
setTestingZaiConnection(true);
|
||||||
setZaiTestResult(null);
|
setZaiTestResult(null);
|
||||||
|
|
||||||
@@ -204,7 +228,7 @@ export function useApiKeyManagement() {
|
|||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
// Use the verify endpoint to test the key without storing it
|
// Use the verify endpoint to test the key without storing it
|
||||||
const response = await api.zai?.verify(zaiKey);
|
const response: VerifyResponse | undefined = await api.zai?.verify(zaiKey);
|
||||||
|
|
||||||
if (response?.success && response?.authenticated) {
|
if (response?.success && response?.authenticated) {
|
||||||
setZaiTestResult({
|
setZaiTestResult({
|
||||||
@@ -228,42 +252,70 @@ export function useApiKeyManagement() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Save API keys
|
// Save API keys
|
||||||
const handleSave = async () => {
|
const handleSave = async (): Promise<void> => {
|
||||||
setApiKeys({
|
|
||||||
anthropic: anthropicKey,
|
|
||||||
google: googleKey,
|
|
||||||
openai: openaiKey,
|
|
||||||
zai: zaiKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure z.ai service on the server with the new key
|
// Configure z.ai service on the server with the new key
|
||||||
if (zaiKey && zaiKey.trim().length > 0) {
|
if (zaiKey && zaiKey.trim().length > 0) {
|
||||||
try {
|
try {
|
||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
const result = await api.zai.configure(zaiKey.trim());
|
const result: ConfigureResponse = await api.zai.configure(zaiKey.trim());
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Only persist to local store after server confirms success
|
||||||
|
setApiKeys({
|
||||||
|
anthropic: anthropicKey,
|
||||||
|
google: googleKey,
|
||||||
|
openai: openaiKey,
|
||||||
|
zai: zaiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preserve the existing hasEnvApiKey flag from current auth status
|
||||||
|
const currentHasEnvApiKey = zaiAuthStatus?.hasEnvApiKey ?? false;
|
||||||
|
|
||||||
if (result.success || result.isAvailable) {
|
|
||||||
// Update z.ai auth status in the store
|
// Update z.ai auth status in the store
|
||||||
setZaiAuthStatus({
|
setZaiAuthStatus({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
method: 'api_key' as ZaiAuthMethod,
|
method: 'api_key' as ZaiAuthMethod,
|
||||||
hasApiKey: true,
|
hasApiKey: true,
|
||||||
hasEnvApiKey: false,
|
hasEnvApiKey: currentHasEnvApiKey,
|
||||||
});
|
});
|
||||||
// Invalidate the z.ai usage query so it refetches with the new key
|
// Invalidate the z.ai usage query so it refetches with the new key
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() });
|
||||||
logger.info('z.ai API key configured successfully');
|
logger.info('z.ai API key configured successfully');
|
||||||
|
} else {
|
||||||
|
// Server config failed - still save other keys but log the issue
|
||||||
|
logger.error('z.ai API key configuration failed on server');
|
||||||
|
setApiKeys({
|
||||||
|
anthropic: anthropicKey,
|
||||||
|
google: googleKey,
|
||||||
|
openai: openaiKey,
|
||||||
|
zai: zaiKey,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to configure z.ai API key:', error);
|
logger.error('Failed to configure z.ai API key:', error);
|
||||||
|
// Still save other keys even if z.ai config fails
|
||||||
|
setApiKeys({
|
||||||
|
anthropic: anthropicKey,
|
||||||
|
google: googleKey,
|
||||||
|
openai: openaiKey,
|
||||||
|
zai: zaiKey,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Save keys (z.ai key is empty/removed)
|
||||||
|
setApiKeys({
|
||||||
|
anthropic: anthropicKey,
|
||||||
|
google: googleKey,
|
||||||
|
openai: openaiKey,
|
||||||
|
zai: zaiKey,
|
||||||
|
});
|
||||||
|
|
||||||
// Clear z.ai auth status if key is removed
|
// Clear z.ai auth status if key is removed
|
||||||
setZaiAuthStatus({
|
setZaiAuthStatus({
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
method: 'none' as ZaiAuthMethod,
|
method: 'none' as ZaiAuthMethod,
|
||||||
hasApiKey: false,
|
hasApiKey: false,
|
||||||
hasEnvApiKey: false,
|
hasEnvApiKey: zaiAuthStatus?.hasEnvApiKey ?? false,
|
||||||
});
|
});
|
||||||
// Invalidate the query to clear any cached data
|
// Invalidate the query to clear any cached data
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() });
|
||||||
|
|||||||
@@ -172,7 +172,10 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
|||||||
(backendIsRunning &&
|
(backendIsRunning &&
|
||||||
Array.isArray(backendRunningFeatures) &&
|
Array.isArray(backendRunningFeatures) &&
|
||||||
backendRunningFeatures.length > 0 &&
|
backendRunningFeatures.length > 0 &&
|
||||||
!arraysEqual(backendRunningFeatures, runningAutoTasks));
|
!arraysEqual(backendRunningFeatures, runningAutoTasks)) ||
|
||||||
|
// Also sync when UI has stale running tasks but backend has none
|
||||||
|
// (handles server restart where features were reconciled to backlog/ready)
|
||||||
|
(!backendIsRunning && runningAutoTasks.length > 0 && backendRunningFeatures.length === 0);
|
||||||
|
|
||||||
if (needsSync) {
|
if (needsSync) {
|
||||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||||
|
|||||||
@@ -108,22 +108,41 @@ export function useProviderAuthInit() {
|
|||||||
try {
|
try {
|
||||||
const result = await api.zai.getStatus();
|
const result = await api.zai.getStatus();
|
||||||
if (result.success || result.available !== undefined) {
|
if (result.success || result.available !== undefined) {
|
||||||
|
const available = !!result.available;
|
||||||
|
const hasApiKey = !!(result.hasApiKey ?? result.available);
|
||||||
|
const hasEnvApiKey = !!(result.hasEnvApiKey ?? false);
|
||||||
|
|
||||||
let method: ZaiAuthMethod = 'none';
|
let method: ZaiAuthMethod = 'none';
|
||||||
if (result.hasEnvApiKey) {
|
if (hasEnvApiKey) {
|
||||||
method = 'api_key_env';
|
method = 'api_key_env';
|
||||||
} else if (result.hasApiKey || result.available) {
|
} else if (hasApiKey || available) {
|
||||||
method = 'api_key';
|
method = 'api_key';
|
||||||
}
|
}
|
||||||
|
|
||||||
setZaiAuthStatus({
|
setZaiAuthStatus({
|
||||||
authenticated: result.available,
|
authenticated: available,
|
||||||
method,
|
method,
|
||||||
hasApiKey: result.hasApiKey ?? result.available,
|
hasApiKey,
|
||||||
hasEnvApiKey: result.hasEnvApiKey ?? false,
|
hasEnvApiKey,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Non-success path - set default unauthenticated status
|
||||||
|
setZaiAuthStatus({
|
||||||
|
authenticated: false,
|
||||||
|
method: 'none',
|
||||||
|
hasApiKey: false,
|
||||||
|
hasEnvApiKey: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to init z.ai auth status:', error);
|
logger.error('Failed to init z.ai auth status:', error);
|
||||||
|
// Set default status on error to prevent stale state
|
||||||
|
setZaiAuthStatus({
|
||||||
|
authenticated: false,
|
||||||
|
method: 'none',
|
||||||
|
hasApiKey: false,
|
||||||
|
hasEnvApiKey: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Gemini Auth Status
|
// 4. Gemini Auth Status
|
||||||
@@ -134,7 +153,7 @@ export function useProviderAuthInit() {
|
|||||||
setGeminiCliStatus({
|
setGeminiCliStatus({
|
||||||
installed: result.installed ?? false,
|
installed: result.installed ?? false,
|
||||||
version: result.version,
|
version: result.version,
|
||||||
path: result.status,
|
path: result.path,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set Auth status - always set a status to mark initialization as complete
|
// Set Auth status - always set a status to mark initialization as complete
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ import type {
|
|||||||
Notification,
|
Notification,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import type { Message, SessionListItem } from '@/types/electron';
|
import type { Message, SessionListItem } from '@/types/electron';
|
||||||
import type { ClaudeUsageResponse, CodexUsageResponse, GeminiUsage } from '@/store/app-store';
|
import type {
|
||||||
|
ClaudeUsageResponse,
|
||||||
|
CodexUsageResponse,
|
||||||
|
GeminiUsage,
|
||||||
|
ZaiUsageResponse,
|
||||||
|
} from '@/store/app-store';
|
||||||
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
|
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
|
||||||
import type { ModelId, ThinkingLevel, ReasoningEffort, Feature } from '@automaker/types';
|
import type { ModelId, ThinkingLevel, ReasoningEffort, Feature } from '@automaker/types';
|
||||||
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
|
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
|
||||||
@@ -1748,35 +1753,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> => this.get('/api/zai/status'),
|
}> => this.get('/api/zai/status'),
|
||||||
|
|
||||||
getUsage: (): Promise<{
|
getUsage: (): Promise<ZaiUsageResponse> => this.get('/api/zai/usage'),
|
||||||
quotaLimits?: {
|
|
||||||
tokens?: {
|
|
||||||
limitType: string;
|
|
||||||
limit: number;
|
|
||||||
used: number;
|
|
||||||
remaining: number;
|
|
||||||
usedPercent: number;
|
|
||||||
nextResetTime: number;
|
|
||||||
};
|
|
||||||
time?: {
|
|
||||||
limitType: string;
|
|
||||||
limit: number;
|
|
||||||
used: number;
|
|
||||||
remaining: number;
|
|
||||||
usedPercent: number;
|
|
||||||
nextResetTime: number;
|
|
||||||
};
|
|
||||||
planType: string;
|
|
||||||
} | null;
|
|
||||||
usageDetails?: Array<{
|
|
||||||
modelId: string;
|
|
||||||
used: number;
|
|
||||||
limit: number;
|
|
||||||
}>;
|
|
||||||
lastUpdated: string;
|
|
||||||
error?: string;
|
|
||||||
message?: string;
|
|
||||||
}> => this.get('/api/zai/usage'),
|
|
||||||
|
|
||||||
configure: (
|
configure: (
|
||||||
apiToken?: string,
|
apiToken?: string,
|
||||||
|
|||||||
@@ -321,6 +321,8 @@ const initialState: AppState = {
|
|||||||
codexUsageLastUpdated: null,
|
codexUsageLastUpdated: null,
|
||||||
zaiUsage: null,
|
zaiUsage: null,
|
||||||
zaiUsageLastUpdated: null,
|
zaiUsageLastUpdated: null,
|
||||||
|
geminiUsage: null,
|
||||||
|
geminiUsageLastUpdated: null,
|
||||||
codexModels: [],
|
codexModels: [],
|
||||||
codexModelsLoading: false,
|
codexModelsLoading: false,
|
||||||
codexModelsError: null,
|
codexModelsError: null,
|
||||||
@@ -2410,6 +2412,13 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// z.ai Usage Tracking actions
|
// z.ai Usage Tracking actions
|
||||||
setZaiUsage: (usage) => set({ zaiUsage: usage, zaiUsageLastUpdated: usage ? Date.now() : null }),
|
setZaiUsage: (usage) => set({ zaiUsage: usage, zaiUsageLastUpdated: usage ? Date.now() : null }),
|
||||||
|
|
||||||
|
// Gemini Usage Tracking actions
|
||||||
|
setGeminiUsage: (usage, lastUpdated) =>
|
||||||
|
set({
|
||||||
|
geminiUsage: usage,
|
||||||
|
geminiUsageLastUpdated: lastUpdated ?? (usage ? Date.now() : null),
|
||||||
|
}),
|
||||||
|
|
||||||
// Codex Models actions
|
// Codex Models actions
|
||||||
fetchCodexModels: async (forceRefresh = false) => {
|
fetchCodexModels: async (forceRefresh = false) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import type { ApiKeys } from './settings-types';
|
|||||||
import type { ChatMessage, ChatSession } from './chat-types';
|
import type { ChatMessage, ChatSession } from './chat-types';
|
||||||
import type { TerminalState, TerminalPanelContent, PersistedTerminalState } from './terminal-types';
|
import type { TerminalState, TerminalPanelContent, PersistedTerminalState } from './terminal-types';
|
||||||
import type { Feature, ProjectAnalysis } from './project-types';
|
import type { Feature, ProjectAnalysis } from './project-types';
|
||||||
import type { ClaudeUsage, CodexUsage, ZaiUsage } from './usage-types';
|
import type { ClaudeUsage, CodexUsage, ZaiUsage, GeminiUsage } from './usage-types';
|
||||||
|
|
||||||
/** State for worktree init script execution */
|
/** State for worktree init script execution */
|
||||||
export interface InitScriptState {
|
export interface InitScriptState {
|
||||||
@@ -299,6 +299,10 @@ export interface AppState {
|
|||||||
zaiUsage: ZaiUsage | null;
|
zaiUsage: ZaiUsage | null;
|
||||||
zaiUsageLastUpdated: number | null;
|
zaiUsageLastUpdated: number | null;
|
||||||
|
|
||||||
|
// Gemini Usage Tracking
|
||||||
|
geminiUsage: GeminiUsage | null;
|
||||||
|
geminiUsageLastUpdated: number | null;
|
||||||
|
|
||||||
// Codex Models (dynamically fetched)
|
// Codex Models (dynamically fetched)
|
||||||
codexModels: Array<{
|
codexModels: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -769,6 +773,9 @@ export interface AppActions {
|
|||||||
// z.ai Usage Tracking actions
|
// z.ai Usage Tracking actions
|
||||||
setZaiUsage: (usage: ZaiUsage | null) => void;
|
setZaiUsage: (usage: ZaiUsage | null) => void;
|
||||||
|
|
||||||
|
// Gemini Usage Tracking actions
|
||||||
|
setGeminiUsage: (usage: GeminiUsage | null, lastUpdated?: number) => void;
|
||||||
|
|
||||||
// Codex Models actions
|
// Codex Models actions
|
||||||
fetchCodexModels: (forceRefresh?: boolean) => Promise<void>;
|
fetchCodexModels: (forceRefresh?: boolean) => Promise<void>;
|
||||||
setCodexModels: (
|
setCodexModels: (
|
||||||
|
|||||||
Reference in New Issue
Block a user