mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
Merge branch 'v0.11.0rc' into fix/pipeline-resume-edge-cases
This commit is contained in:
@@ -6,13 +6,16 @@
|
||||
import path from 'path';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import type { ExecuteOptions } from '@automaker/types';
|
||||
import type { ExecuteOptions, ThinkingLevel, ReasoningEffort } from '@automaker/types';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
import {
|
||||
readImageAsBase64,
|
||||
buildPromptWithImages,
|
||||
isAbortError,
|
||||
loadContextFiles,
|
||||
createLogger,
|
||||
classifyError,
|
||||
getUserFriendlyErrorMessage,
|
||||
} from '@automaker/utils';
|
||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||
@@ -20,11 +23,12 @@ import { PathNotAllowedError } from '@automaker/platform';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getEnableSandboxModeSetting,
|
||||
filterClaudeMdFromContext,
|
||||
getMCPServersFromSettings,
|
||||
getMCPPermissionSettings,
|
||||
getPromptCustomization,
|
||||
getSkillsConfiguration,
|
||||
getSubagentsConfiguration,
|
||||
getCustomSubagents,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
interface Message {
|
||||
@@ -45,6 +49,7 @@ interface QueuedPrompt {
|
||||
message: string;
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
@@ -54,6 +59,8 @@ interface Session {
|
||||
abortController: AbortController | null;
|
||||
workingDirectory: string;
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel; // Thinking level for Claude models
|
||||
reasoningEffort?: ReasoningEffort; // Reasoning effort for Codex models
|
||||
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
|
||||
promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task
|
||||
}
|
||||
@@ -142,12 +149,16 @@ export class AgentService {
|
||||
workingDirectory,
|
||||
imagePaths,
|
||||
model,
|
||||
thinkingLevel,
|
||||
reasoningEffort,
|
||||
}: {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
workingDirectory?: string;
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
}) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
@@ -160,11 +171,29 @@ export class AgentService {
|
||||
throw new Error('Agent is already processing a message');
|
||||
}
|
||||
|
||||
// Update session model if provided
|
||||
// Update session model, thinking level, and reasoning effort if provided
|
||||
if (model) {
|
||||
session.model = model;
|
||||
await this.updateSession(sessionId, { model });
|
||||
}
|
||||
if (thinkingLevel !== undefined) {
|
||||
session.thinkingLevel = thinkingLevel;
|
||||
}
|
||||
if (reasoningEffort !== undefined) {
|
||||
session.reasoningEffort = reasoningEffort;
|
||||
}
|
||||
|
||||
// Validate vision support before processing images
|
||||
const effectiveModel = model || session.model;
|
||||
if (imagePaths && imagePaths.length > 0 && effectiveModel) {
|
||||
const supportsVision = ProviderFactory.modelSupportsVision(effectiveModel);
|
||||
if (!supportsVision) {
|
||||
throw new Error(
|
||||
`This model (${effectiveModel}) does not support image input. ` +
|
||||
`Please switch to a model that supports vision, or remove the images and try again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Read images and convert to base64
|
||||
const images: Message['images'] = [];
|
||||
@@ -226,22 +255,34 @@ export class AgentService {
|
||||
'[AgentService]'
|
||||
);
|
||||
|
||||
// Load enableSandboxMode setting (global setting only)
|
||||
const enableSandboxMode = await getEnableSandboxModeSetting(
|
||||
this.settingsService,
|
||||
'[AgentService]'
|
||||
);
|
||||
|
||||
// Load MCP servers from settings (global setting only)
|
||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
|
||||
|
||||
// Load MCP permission settings (global setting only)
|
||||
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AgentService]');
|
||||
// Get Skills configuration from settings
|
||||
const skillsConfig = this.settingsService
|
||||
? await getSkillsConfiguration(this.settingsService)
|
||||
: { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false };
|
||||
|
||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
|
||||
// Get Subagents configuration from settings
|
||||
const subagentsConfig = this.settingsService
|
||||
? await getSubagentsConfiguration(this.settingsService)
|
||||
: { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false };
|
||||
|
||||
// Get custom subagents from settings (merge global + project-level) only if enabled
|
||||
const customSubagents =
|
||||
this.settingsService && subagentsConfig.enabled
|
||||
? await getCustomSubagents(this.settingsService, effectiveWorkDir)
|
||||
: undefined;
|
||||
|
||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
|
||||
// Use the user's message as task context for smart memory selection
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath: effectiveWorkDir,
|
||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||
taskContext: {
|
||||
title: message.substring(0, 200), // Use first 200 chars as title
|
||||
description: message,
|
||||
},
|
||||
});
|
||||
|
||||
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
|
||||
@@ -255,6 +296,9 @@ export class AgentService {
|
||||
: baseSystemPrompt;
|
||||
|
||||
// Build SDK options using centralized configuration
|
||||
// Use thinking level and reasoning effort from request, or fall back to session's stored values
|
||||
const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel;
|
||||
const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort;
|
||||
const sdkOptions = createChatOptions({
|
||||
cwd: effectiveWorkDir,
|
||||
model: model,
|
||||
@@ -262,36 +306,78 @@ export class AgentService {
|
||||
systemPrompt: combinedSystemPrompt,
|
||||
abortController: session.abortController!,
|
||||
autoLoadClaudeMd,
|
||||
enableSandboxMode,
|
||||
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
// Extract model, maxTurns, and allowedTools from SDK options
|
||||
const effectiveModel = sdkOptions.model!;
|
||||
const maxTurns = sdkOptions.maxTurns;
|
||||
const allowedTools = sdkOptions.allowedTools as string[] | undefined;
|
||||
let allowedTools = sdkOptions.allowedTools as string[] | undefined;
|
||||
|
||||
// Get provider for this model
|
||||
// Build merged settingSources array using Set for automatic deduplication
|
||||
const sdkSettingSources = (sdkOptions.settingSources ?? []).filter(
|
||||
(source): source is 'user' | 'project' => source === 'user' || source === 'project'
|
||||
);
|
||||
const skillSettingSources = skillsConfig.enabled ? skillsConfig.sources : [];
|
||||
const settingSources = [...new Set([...sdkSettingSources, ...skillSettingSources])];
|
||||
|
||||
// Enhance allowedTools with Skills and Subagents tools
|
||||
// These tools are not in the provider's default set - they're added dynamically based on settings
|
||||
const needsSkillTool = skillsConfig.shouldIncludeInTools;
|
||||
const needsTaskTool =
|
||||
subagentsConfig.shouldIncludeInTools &&
|
||||
customSubagents &&
|
||||
Object.keys(customSubagents).length > 0;
|
||||
|
||||
// Base tools that match the provider's default set
|
||||
const baseTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||
|
||||
if (allowedTools) {
|
||||
allowedTools = [...allowedTools]; // Create a copy to avoid mutating SDK options
|
||||
// Add Skill tool if skills are enabled
|
||||
if (needsSkillTool && !allowedTools.includes('Skill')) {
|
||||
allowedTools.push('Skill');
|
||||
}
|
||||
// Add Task tool if custom subagents are configured
|
||||
if (needsTaskTool && !allowedTools.includes('Task')) {
|
||||
allowedTools.push('Task');
|
||||
}
|
||||
} else if (needsSkillTool || needsTaskTool) {
|
||||
// If no allowedTools specified but we need to add Skill/Task tools,
|
||||
// build the full list including base tools
|
||||
allowedTools = [...baseTools];
|
||||
if (needsSkillTool) {
|
||||
allowedTools.push('Skill');
|
||||
}
|
||||
if (needsTaskTool) {
|
||||
allowedTools.push('Task');
|
||||
}
|
||||
}
|
||||
|
||||
// Get provider for this model (with prefix)
|
||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||
|
||||
// Strip provider prefix - providers should receive bare model IDs
|
||||
const bareModel = stripProviderPrefix(effectiveModel);
|
||||
|
||||
// Build options for provider
|
||||
const options: ExecuteOptions = {
|
||||
prompt: '', // Will be set below based on images
|
||||
model: effectiveModel,
|
||||
model: bareModel, // Bare model ID (e.g., "gpt-5.1-codex-max", "composer-1")
|
||||
originalModel: effectiveModel, // Original with prefix for logging (e.g., "codex-gpt-5.1-codex-max")
|
||||
cwd: effectiveWorkDir,
|
||||
systemPrompt: sdkOptions.systemPrompt,
|
||||
maxTurns: maxTurns,
|
||||
allowedTools: allowedTools,
|
||||
abortController: session.abortController!,
|
||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||
settingSources: sdkOptions.settingSources,
|
||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||
settingSources: settingSources.length > 0 ? settingSources : undefined,
|
||||
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
|
||||
agents: customSubagents, // Pass custom subagents for task delegation
|
||||
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
||||
reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models
|
||||
};
|
||||
|
||||
// Build prompt content with images
|
||||
@@ -372,6 +458,53 @@ export class AgentService {
|
||||
content: responseText,
|
||||
toolUses,
|
||||
});
|
||||
} else if (msg.type === 'error') {
|
||||
// Some providers (like Codex CLI/SaaS or Cursor CLI) surface failures as
|
||||
// streamed error messages instead of throwing. Handle these here so the
|
||||
// Agent Runner UX matches the Claude/Cursor behavior without changing
|
||||
// their provider implementations.
|
||||
const rawErrorText =
|
||||
(typeof msg.error === 'string' && msg.error.trim()) ||
|
||||
'Unexpected error from provider during agent execution.';
|
||||
|
||||
const errorInfo = classifyError(new Error(rawErrorText));
|
||||
|
||||
// Keep the provider-supplied text intact (Codex already includes helpful tips),
|
||||
// only add a small rate-limit hint when we can detect it.
|
||||
const enhancedText = errorInfo.isRateLimit
|
||||
? `${rawErrorText}\n\nTip: It looks like you hit a rate limit. Try waiting a bit or reducing concurrent Agent Runner / Auto Mode tasks.`
|
||||
: rawErrorText;
|
||||
|
||||
this.logger.error('Provider error during agent execution:', {
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
});
|
||||
|
||||
// Mark session as no longer running so the UI and queue stay in sync
|
||||
session.isRunning = false;
|
||||
session.abortController = null;
|
||||
|
||||
const errorMessage: Message = {
|
||||
id: this.generateId(),
|
||||
role: 'assistant',
|
||||
content: `Error: ${enhancedText}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
isError: true,
|
||||
};
|
||||
|
||||
session.messages.push(errorMessage);
|
||||
await this.saveSession(sessionId, session.messages);
|
||||
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: 'error',
|
||||
error: enhancedText,
|
||||
message: errorMessage,
|
||||
});
|
||||
|
||||
// Don't continue streaming after an error message
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,7 +761,12 @@ export class AgentService {
|
||||
*/
|
||||
async addToQueue(
|
||||
sessionId: string,
|
||||
prompt: { message: string; imagePaths?: string[]; model?: string }
|
||||
prompt: {
|
||||
message: string;
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}
|
||||
): Promise<{ success: boolean; queuedPrompt?: QueuedPrompt; error?: string }> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
@@ -640,6 +778,7 @@ export class AgentService {
|
||||
message: prompt.message,
|
||||
imagePaths: prompt.imagePaths,
|
||||
model: prompt.model,
|
||||
thinkingLevel: prompt.thinkingLevel,
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -769,6 +908,7 @@ export class AgentService {
|
||||
message: nextPrompt.message,
|
||||
imagePaths: nextPrompt.imagePaths,
|
||||
model: nextPrompt.model,
|
||||
thinkingLevel: nextPrompt.thinkingLevel,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to process queued prompt:', error);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
|
||||
import * as os from 'os';
|
||||
import * as pty from 'node-pty';
|
||||
import { ClaudeUsage } from '../routes/claude/types.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
/**
|
||||
* Claude Usage Service
|
||||
@@ -14,6 +15,8 @@ import { ClaudeUsage } from '../routes/claude/types.js';
|
||||
* - macOS: Uses 'expect' command for PTY
|
||||
* - Windows/Linux: Uses node-pty for PTY
|
||||
*/
|
||||
const logger = createLogger('ClaudeUsage');
|
||||
|
||||
export class ClaudeUsageService {
|
||||
private claudeBinary = 'claude';
|
||||
private timeout = 30000; // 30 second timeout
|
||||
@@ -164,21 +167,40 @@ export class ClaudeUsageService {
|
||||
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
||||
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
||||
|
||||
const ptyProcess = pty.spawn(shell, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
} as Record<string, string>,
|
||||
});
|
||||
let ptyProcess: any = null;
|
||||
|
||||
try {
|
||||
ptyProcess = pty.spawn(shell, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
} as Record<string, string>,
|
||||
});
|
||||
} catch (spawnError) {
|
||||
// pty.spawn() can throw synchronously if the native module fails to load
|
||||
// or if PTY is not available in the current environment (e.g., containers without /dev/pts)
|
||||
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
||||
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
||||
|
||||
// Return a user-friendly error instead of crashing
|
||||
reject(
|
||||
new Error(
|
||||
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
ptyProcess.kill();
|
||||
if (ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.kill();
|
||||
}
|
||||
// Don't fail if we have data - return it instead
|
||||
if (output.includes('Current session')) {
|
||||
resolve(output);
|
||||
@@ -188,7 +210,7 @@ export class ClaudeUsageService {
|
||||
}
|
||||
}, this.timeout);
|
||||
|
||||
ptyProcess.onData((data) => {
|
||||
ptyProcess.onData((data: string) => {
|
||||
output += data;
|
||||
|
||||
// Check if we've seen the usage data (look for "Current session")
|
||||
@@ -196,12 +218,12 @@ export class ClaudeUsageService {
|
||||
hasSeenUsageData = true;
|
||||
// Wait for full output, then send escape to exit
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.write('\x1b'); // Send escape key
|
||||
|
||||
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.kill('SIGTERM');
|
||||
}
|
||||
}, 2000);
|
||||
@@ -212,14 +234,14 @@ export class ClaudeUsageService {
|
||||
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
|
||||
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.write('\x1b'); // Send escape key
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
||||
212
apps/server/src/services/codex-app-server-service.ts
Normal file
212
apps/server/src/services/codex-app-server-service.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import readline from 'readline';
|
||||
import { findCodexCliPath } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type {
|
||||
AppServerModelResponse,
|
||||
AppServerAccountResponse,
|
||||
AppServerRateLimitsResponse,
|
||||
JsonRpcRequest,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('CodexAppServer');
|
||||
|
||||
/**
|
||||
* CodexAppServerService
|
||||
*
|
||||
* Centralized service for communicating with Codex CLI's app-server via JSON-RPC protocol.
|
||||
* Handles process spawning, JSON-RPC messaging, and cleanup.
|
||||
*
|
||||
* Connection strategy: Spawn on-demand (new process for each method call)
|
||||
*/
|
||||
export class CodexAppServerService {
|
||||
private cachedCliPath: string | null = null;
|
||||
|
||||
/**
|
||||
* Check if Codex CLI is available on the system
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
this.cachedCliPath = await findCodexCliPath();
|
||||
return Boolean(this.cachedCliPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models from app-server
|
||||
*/
|
||||
async getModels(): Promise<AppServerModelResponse | null> {
|
||||
const result = await this.executeJsonRpc<AppServerModelResponse>((sendRequest) => {
|
||||
return sendRequest('model/list', {});
|
||||
});
|
||||
|
||||
if (result) {
|
||||
logger.info(`[getModels] ✓ Fetched ${result.data.length} models`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch account information from app-server
|
||||
*/
|
||||
async getAccount(): Promise<AppServerAccountResponse | null> {
|
||||
return this.executeJsonRpc<AppServerAccountResponse>((sendRequest) => {
|
||||
return sendRequest('account/read', { refreshToken: false });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch rate limits from app-server
|
||||
*/
|
||||
async getRateLimits(): Promise<AppServerRateLimitsResponse | null> {
|
||||
return this.executeJsonRpc<AppServerRateLimitsResponse>((sendRequest) => {
|
||||
return sendRequest('account/rateLimits/read', {});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute JSON-RPC requests via Codex app-server
|
||||
*
|
||||
* This method:
|
||||
* 1. Spawns a new `codex app-server` process
|
||||
* 2. Handles JSON-RPC initialization handshake
|
||||
* 3. Executes user-provided requests
|
||||
* 4. Cleans up the process
|
||||
*
|
||||
* @param requestFn - Function that receives sendRequest helper and returns a promise
|
||||
* @returns Result of the JSON-RPC request or null on failure
|
||||
*/
|
||||
private async executeJsonRpc<T>(
|
||||
requestFn: (sendRequest: <R>(method: string, params?: unknown) => Promise<R>) => Promise<T>
|
||||
): Promise<T | null> {
|
||||
let childProcess: ChildProcess | null = null;
|
||||
|
||||
try {
|
||||
const cliPath = this.cachedCliPath || (await findCodexCliPath());
|
||||
|
||||
if (!cliPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// On Windows, .cmd files must be run through shell
|
||||
const needsShell = process.platform === 'win32' && cliPath.toLowerCase().endsWith('.cmd');
|
||||
|
||||
childProcess = spawn(cliPath, ['app-server'], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'dumb',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: needsShell,
|
||||
});
|
||||
|
||||
if (!childProcess.stdin || !childProcess.stdout) {
|
||||
throw new Error('Failed to create stdio pipes');
|
||||
}
|
||||
|
||||
// Setup readline for reading JSONL responses
|
||||
const rl = readline.createInterface({
|
||||
input: childProcess.stdout,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
// Message ID counter for JSON-RPC
|
||||
let messageId = 0;
|
||||
const pendingRequests = new Map<
|
||||
number,
|
||||
{
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
>();
|
||||
|
||||
// Process incoming messages
|
||||
rl.on('line', (line) => {
|
||||
if (!line.trim()) return;
|
||||
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
|
||||
// Handle response to our request
|
||||
if ('id' in message && message.id !== undefined) {
|
||||
const pending = pendingRequests.get(message.id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
pendingRequests.delete(message.id);
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error.message || 'Unknown error'));
|
||||
} else {
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ignore notifications (no id field)
|
||||
} catch {
|
||||
// Ignore parse errors for non-JSON lines
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to send JSON-RPC request and wait for response
|
||||
const sendRequest = <R>(method: string, params?: unknown): Promise<R> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++messageId;
|
||||
const request: JsonRpcRequest = {
|
||||
method,
|
||||
id,
|
||||
params: params ?? {},
|
||||
};
|
||||
|
||||
// Set timeout for request (10 seconds)
|
||||
const timeout = setTimeout(() => {
|
||||
pendingRequests.delete(id);
|
||||
reject(new Error(`Request timeout: ${method}`));
|
||||
}, 10000);
|
||||
|
||||
pendingRequests.set(id, {
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
|
||||
childProcess!.stdin!.write(JSON.stringify(request) + '\n');
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to send notification (no response expected)
|
||||
const sendNotification = (method: string, params?: unknown): void => {
|
||||
const notification = params ? { method, params } : { method };
|
||||
childProcess!.stdin!.write(JSON.stringify(notification) + '\n');
|
||||
};
|
||||
|
||||
// 1. Initialize the app-server
|
||||
await sendRequest('initialize', {
|
||||
clientInfo: {
|
||||
name: 'automaker',
|
||||
title: 'AutoMaker',
|
||||
version: '1.0.0',
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Send initialized notification
|
||||
sendNotification('initialized');
|
||||
|
||||
// 3. Execute user-provided requests
|
||||
const result = await requestFn(sendRequest);
|
||||
|
||||
// Clean up
|
||||
rl.close();
|
||||
childProcess.kill('SIGTERM');
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[executeJsonRpc] Failed:', error);
|
||||
return null;
|
||||
} finally {
|
||||
// Ensure process is killed
|
||||
if (childProcess && !childProcess.killed) {
|
||||
childProcess.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
258
apps/server/src/services/codex-model-cache-service.ts
Normal file
258
apps/server/src/services/codex-model-cache-service.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import path from 'path';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { AppServerModel } from '@automaker/types';
|
||||
import type { CodexAppServerService } from './codex-app-server-service.js';
|
||||
|
||||
const logger = createLogger('CodexModelCache');
|
||||
|
||||
/**
|
||||
* Codex model with UI-compatible format
|
||||
*/
|
||||
export interface CodexModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
hasThinking: boolean;
|
||||
supportsVision: boolean;
|
||||
tier: 'premium' | 'standard' | 'basic';
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache structure stored on disk
|
||||
*/
|
||||
interface CodexModelCache {
|
||||
models: CodexModel[];
|
||||
cachedAt: number;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexModelCacheService
|
||||
*
|
||||
* Caches Codex models fetched from app-server with TTL-based invalidation and disk persistence.
|
||||
*
|
||||
* Features:
|
||||
* - 1-hour TTL (configurable)
|
||||
* - Atomic file writes (temp file + rename)
|
||||
* - Thread-safe (deduplicates concurrent refresh requests)
|
||||
* - Auto-bootstrap on service creation
|
||||
* - Graceful fallback (returns empty array on errors)
|
||||
*/
|
||||
export class CodexModelCacheService {
|
||||
private cacheFilePath: string;
|
||||
private ttl: number;
|
||||
private appServerService: CodexAppServerService;
|
||||
private inFlightRefresh: Promise<CodexModel[]> | null = null;
|
||||
|
||||
constructor(
|
||||
dataDir: string,
|
||||
appServerService: CodexAppServerService,
|
||||
ttl: number = 3600000 // 1 hour default
|
||||
) {
|
||||
this.cacheFilePath = path.join(dataDir, 'codex-models-cache.json');
|
||||
this.ttl = ttl;
|
||||
this.appServerService = appServerService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models from cache or fetch if stale
|
||||
*
|
||||
* @param forceRefresh - If true, bypass cache and fetch fresh data
|
||||
* @returns Array of Codex models (empty array if unavailable)
|
||||
*/
|
||||
async getModels(forceRefresh = false): Promise<CodexModel[]> {
|
||||
// If force refresh, skip cache
|
||||
if (forceRefresh) {
|
||||
return this.refreshModels();
|
||||
}
|
||||
|
||||
// Try to load from cache
|
||||
const cached = await this.loadFromCache();
|
||||
if (cached) {
|
||||
const age = Date.now() - cached.cachedAt;
|
||||
const isStale = age > cached.ttl;
|
||||
|
||||
if (!isStale) {
|
||||
logger.info(
|
||||
`[getModels] ✓ Using cached models (${cached.models.length} models, age: ${Math.round(age / 60000)}min)`
|
||||
);
|
||||
return cached.models;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache is stale or missing, refresh
|
||||
return this.refreshModels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models with cache metadata
|
||||
*
|
||||
* @param forceRefresh - If true, bypass cache and fetch fresh data
|
||||
* @returns Object containing models and cache timestamp
|
||||
*/
|
||||
async getModelsWithMetadata(
|
||||
forceRefresh = false
|
||||
): Promise<{ models: CodexModel[]; cachedAt: number }> {
|
||||
const models = await this.getModels(forceRefresh);
|
||||
|
||||
// Try to get the actual cache timestamp
|
||||
const cached = await this.loadFromCache();
|
||||
const cachedAt = cached?.cachedAt ?? Date.now();
|
||||
|
||||
return { models, cachedAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh models from app-server and update cache
|
||||
*
|
||||
* Thread-safe: Deduplicates concurrent refresh requests
|
||||
*/
|
||||
async refreshModels(): Promise<CodexModel[]> {
|
||||
// Deduplicate concurrent refresh requests
|
||||
if (this.inFlightRefresh) {
|
||||
return this.inFlightRefresh;
|
||||
}
|
||||
|
||||
// Start new refresh
|
||||
this.inFlightRefresh = this.doRefresh();
|
||||
|
||||
try {
|
||||
const models = await this.inFlightRefresh;
|
||||
return models;
|
||||
} finally {
|
||||
this.inFlightRefresh = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cache file
|
||||
*/
|
||||
async clearCache(): Promise<void> {
|
||||
logger.info('[clearCache] Clearing cache...');
|
||||
|
||||
try {
|
||||
await secureFs.unlink(this.cacheFilePath);
|
||||
logger.info('[clearCache] Cache cleared');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error('[clearCache] Failed to clear cache:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to perform the actual refresh
|
||||
*/
|
||||
private async doRefresh(): Promise<CodexModel[]> {
|
||||
try {
|
||||
// Check if app-server is available
|
||||
const isAvailable = await this.appServerService.isAvailable();
|
||||
if (!isAvailable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch models from app-server
|
||||
const response = await this.appServerService.getModels();
|
||||
if (!response || !response.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Transform models to UI format
|
||||
const models = response.data.map((model) => this.transformModel(model));
|
||||
|
||||
// Save to cache
|
||||
await this.saveToCache(models);
|
||||
|
||||
logger.info(`[refreshModels] ✓ Fetched fresh models (${models.length} models)`);
|
||||
|
||||
return models;
|
||||
} catch (error) {
|
||||
logger.error('[doRefresh] Refresh failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform app-server model to UI-compatible format
|
||||
*/
|
||||
private transformModel(appServerModel: AppServerModel): CodexModel {
|
||||
return {
|
||||
id: `codex-${appServerModel.id}`, // Add 'codex-' prefix for compatibility
|
||||
label: appServerModel.displayName,
|
||||
description: appServerModel.description,
|
||||
hasThinking: appServerModel.supportedReasoningEfforts.length > 0,
|
||||
supportsVision: true, // All Codex models support vision
|
||||
tier: this.inferTier(appServerModel.id),
|
||||
isDefault: appServerModel.isDefault,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer tier from model ID
|
||||
*/
|
||||
private inferTier(modelId: string): 'premium' | 'standard' | 'basic' {
|
||||
if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) {
|
||||
return 'premium';
|
||||
}
|
||||
if (modelId.includes('mini')) {
|
||||
return 'basic';
|
||||
}
|
||||
return 'standard';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cache from disk
|
||||
*/
|
||||
private async loadFromCache(): Promise<CodexModelCache | null> {
|
||||
try {
|
||||
const content = await secureFs.readFile(this.cacheFilePath, 'utf-8');
|
||||
const cache = JSON.parse(content.toString()) as CodexModelCache;
|
||||
|
||||
// Validate cache structure
|
||||
if (!Array.isArray(cache.models) || typeof cache.cachedAt !== 'number') {
|
||||
logger.warn('[loadFromCache] Invalid cache structure, ignoring');
|
||||
return null;
|
||||
}
|
||||
|
||||
return cache;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.warn('[loadFromCache] Failed to read cache:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save cache to disk (atomic write)
|
||||
*/
|
||||
private async saveToCache(models: CodexModel[]): Promise<void> {
|
||||
const cache: CodexModelCache = {
|
||||
models,
|
||||
cachedAt: Date.now(),
|
||||
ttl: this.ttl,
|
||||
};
|
||||
|
||||
const tempPath = `${this.cacheFilePath}.tmp.${Date.now()}`;
|
||||
|
||||
try {
|
||||
// Write to temp file
|
||||
const content = JSON.stringify(cache, null, 2);
|
||||
await secureFs.writeFile(tempPath, content, 'utf-8');
|
||||
|
||||
// Atomic rename
|
||||
await secureFs.rename(tempPath, this.cacheFilePath);
|
||||
} catch (error) {
|
||||
logger.error('[saveToCache] Failed to save cache:', error);
|
||||
|
||||
// Clean up temp file
|
||||
try {
|
||||
await secureFs.unlink(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
348
apps/server/src/services/codex-usage-service.ts
Normal file
348
apps/server/src/services/codex-usage-service.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import {
|
||||
findCodexCliPath,
|
||||
getCodexAuthPath,
|
||||
systemPathExists,
|
||||
systemPathReadFile,
|
||||
} from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { CodexAppServerService } from './codex-app-server-service.js';
|
||||
|
||||
const logger = createLogger('CodexUsage');
|
||||
|
||||
export interface CodexRateLimitWindow {
|
||||
limit: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
usedPercent: number;
|
||||
windowDurationMins: number;
|
||||
resetsAt: number;
|
||||
}
|
||||
|
||||
export type CodexPlanType = 'free' | 'plus' | 'pro' | 'team' | 'enterprise' | 'edu' | 'unknown';
|
||||
|
||||
export interface CodexUsageData {
|
||||
rateLimits: {
|
||||
primary?: CodexRateLimitWindow;
|
||||
secondary?: CodexRateLimitWindow;
|
||||
planType?: CodexPlanType;
|
||||
} | null;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex Usage Service
|
||||
*
|
||||
* Fetches usage data from Codex CLI using the app-server JSON-RPC API.
|
||||
* Falls back to auth file parsing if app-server is unavailable.
|
||||
*/
|
||||
export class CodexUsageService {
|
||||
private cachedCliPath: string | null = null;
|
||||
private appServerService: CodexAppServerService | null = null;
|
||||
private accountPlanTypeArray: CodexPlanType[] = [
|
||||
'free',
|
||||
'plus',
|
||||
'pro',
|
||||
'team',
|
||||
'enterprise',
|
||||
'edu',
|
||||
];
|
||||
|
||||
constructor(appServerService?: CodexAppServerService) {
|
||||
this.appServerService = appServerService || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Codex CLI is available on the system
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
this.cachedCliPath = await findCodexCliPath();
|
||||
return Boolean(this.cachedCliPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to fetch usage data
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Codex app-server JSON-RPC API (most reliable, provides real-time data)
|
||||
* 2. Auth file JWT parsing (fallback for plan type)
|
||||
*/
|
||||
async fetchUsageData(): Promise<CodexUsageData> {
|
||||
logger.info('[fetchUsageData] Starting...');
|
||||
const cliPath = this.cachedCliPath || (await findCodexCliPath());
|
||||
|
||||
if (!cliPath) {
|
||||
logger.error('[fetchUsageData] Codex CLI not found');
|
||||
throw new Error('Codex CLI not found. Please install it with: npm install -g @openai/codex');
|
||||
}
|
||||
|
||||
logger.info(`[fetchUsageData] Using CLI path: ${cliPath}`);
|
||||
|
||||
// Try to get usage from Codex app-server (most reliable method)
|
||||
const appServerUsage = await this.fetchFromAppServer();
|
||||
if (appServerUsage) {
|
||||
logger.info('[fetchUsageData] ✓ Fetched usage from app-server');
|
||||
return appServerUsage;
|
||||
}
|
||||
|
||||
logger.info('[fetchUsageData] App-server failed, trying auth file fallback...');
|
||||
|
||||
// Fallback: try to parse usage from auth file
|
||||
const authUsage = await this.fetchFromAuthFile();
|
||||
if (authUsage) {
|
||||
logger.info('[fetchUsageData] ✓ Fetched usage from auth file');
|
||||
return authUsage;
|
||||
}
|
||||
|
||||
logger.info('[fetchUsageData] All methods failed, returning unknown');
|
||||
|
||||
// If all else fails, return unknown
|
||||
return {
|
||||
rateLimits: {
|
||||
planType: 'unknown',
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data from Codex app-server using JSON-RPC API
|
||||
* This is the most reliable method as it gets real-time data from OpenAI
|
||||
*/
|
||||
private async fetchFromAppServer(): Promise<CodexUsageData | null> {
|
||||
try {
|
||||
// Use CodexAppServerService if available
|
||||
if (!this.appServerService) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch account and rate limits in parallel
|
||||
const [accountResult, rateLimitsResult] = await Promise.all([
|
||||
this.appServerService.getAccount(),
|
||||
this.appServerService.getRateLimits(),
|
||||
]);
|
||||
|
||||
if (!accountResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build response
|
||||
// Prefer planType from rateLimits (more accurate/current) over account (can be stale)
|
||||
let planType: CodexPlanType = 'unknown';
|
||||
|
||||
// First try rate limits planType (most accurate)
|
||||
const rateLimitsPlanType = rateLimitsResult?.rateLimits?.planType;
|
||||
if (rateLimitsPlanType) {
|
||||
const normalizedType = rateLimitsPlanType.toLowerCase() as CodexPlanType;
|
||||
if (this.accountPlanTypeArray.includes(normalizedType)) {
|
||||
planType = normalizedType;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to account planType if rate limits didn't have it
|
||||
if (planType === 'unknown' && accountResult.account?.planType) {
|
||||
const normalizedType = accountResult.account.planType.toLowerCase() as CodexPlanType;
|
||||
if (this.accountPlanTypeArray.includes(normalizedType)) {
|
||||
planType = normalizedType;
|
||||
}
|
||||
}
|
||||
|
||||
const result: CodexUsageData = {
|
||||
rateLimits: {
|
||||
planType,
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Add rate limit info if available
|
||||
if (rateLimitsResult?.rateLimits?.primary) {
|
||||
const primary = rateLimitsResult.rateLimits.primary;
|
||||
result.rateLimits!.primary = {
|
||||
limit: -1, // Not provided by API
|
||||
used: -1, // Not provided by API
|
||||
remaining: -1, // Not provided by API
|
||||
usedPercent: primary.usedPercent,
|
||||
windowDurationMins: primary.windowDurationMins,
|
||||
resetsAt: primary.resetsAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Add secondary rate limit if available
|
||||
if (rateLimitsResult?.rateLimits?.secondary) {
|
||||
const secondary = rateLimitsResult.rateLimits.secondary;
|
||||
result.rateLimits!.secondary = {
|
||||
limit: -1, // Not provided by API
|
||||
used: -1, // Not provided by API
|
||||
remaining: -1, // Not provided by API
|
||||
usedPercent: secondary.usedPercent,
|
||||
windowDurationMins: secondary.windowDurationMins,
|
||||
resetsAt: secondary.resetsAt,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[fetchFromAppServer] ✓ Plan: ${planType}, Primary: ${result.rateLimits?.primary?.usedPercent || 'N/A'}%, Secondary: ${result.rateLimits?.secondary?.usedPercent || 'N/A'}%`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[fetchFromAppServer] Failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plan type from auth file JWT token
|
||||
* Returns the actual plan type or 'unknown' if not available
|
||||
*/
|
||||
private async getPlanTypeFromAuthFile(): Promise<CodexPlanType> {
|
||||
try {
|
||||
const authFilePath = getCodexAuthPath();
|
||||
logger.info(`[getPlanTypeFromAuthFile] Auth file path: ${authFilePath}`);
|
||||
const exists = systemPathExists(authFilePath);
|
||||
|
||||
if (!exists) {
|
||||
logger.warn('[getPlanTypeFromAuthFile] Auth file does not exist');
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const authContent = await systemPathReadFile(authFilePath);
|
||||
const authData = JSON.parse(authContent);
|
||||
|
||||
if (!authData.tokens?.id_token) {
|
||||
logger.info('[getPlanTypeFromAuthFile] No id_token in auth file');
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const claims = this.parseJwt(authData.tokens.id_token);
|
||||
if (!claims) {
|
||||
logger.info('[getPlanTypeFromAuthFile] Failed to parse JWT');
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
logger.info('[getPlanTypeFromAuthFile] JWT claims keys:', Object.keys(claims));
|
||||
|
||||
// Extract plan type from nested OpenAI auth object with type validation
|
||||
const openaiAuthClaim = claims['https://api.openai.com/auth'];
|
||||
logger.info(
|
||||
'[getPlanTypeFromAuthFile] OpenAI auth claim:',
|
||||
JSON.stringify(openaiAuthClaim, null, 2)
|
||||
);
|
||||
|
||||
let accountType: string | undefined;
|
||||
let isSubscriptionExpired = false;
|
||||
|
||||
if (
|
||||
openaiAuthClaim &&
|
||||
typeof openaiAuthClaim === 'object' &&
|
||||
!Array.isArray(openaiAuthClaim)
|
||||
) {
|
||||
const openaiAuth = openaiAuthClaim as Record<string, unknown>;
|
||||
|
||||
if (typeof openaiAuth.chatgpt_plan_type === 'string') {
|
||||
accountType = openaiAuth.chatgpt_plan_type;
|
||||
}
|
||||
|
||||
// Check if subscription has expired
|
||||
if (typeof openaiAuth.chatgpt_subscription_active_until === 'string') {
|
||||
const expiryDate = new Date(openaiAuth.chatgpt_subscription_active_until);
|
||||
if (!isNaN(expiryDate.getTime())) {
|
||||
isSubscriptionExpired = expiryDate < new Date();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: try top-level claim names
|
||||
const possibleClaimNames = [
|
||||
'https://chatgpt.com/account_type',
|
||||
'account_type',
|
||||
'plan',
|
||||
'plan_type',
|
||||
];
|
||||
|
||||
for (const claimName of possibleClaimNames) {
|
||||
const claimValue = claims[claimName];
|
||||
if (claimValue && typeof claimValue === 'string') {
|
||||
accountType = claimValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If subscription is expired, treat as free plan
|
||||
if (isSubscriptionExpired && accountType && accountType !== 'free') {
|
||||
logger.info(`Subscription expired, using "free" instead of "${accountType}"`);
|
||||
accountType = 'free';
|
||||
}
|
||||
|
||||
if (accountType) {
|
||||
const normalizedType = accountType.toLowerCase() as CodexPlanType;
|
||||
logger.info(
|
||||
`[getPlanTypeFromAuthFile] Account type: "${accountType}", normalized: "${normalizedType}"`
|
||||
);
|
||||
if (this.accountPlanTypeArray.includes(normalizedType)) {
|
||||
logger.info(`[getPlanTypeFromAuthFile] Returning plan type: ${normalizedType}`);
|
||||
return normalizedType;
|
||||
}
|
||||
} else {
|
||||
logger.info('[getPlanTypeFromAuthFile] No account type found in claims');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[getPlanTypeFromAuthFile] Failed to get plan type from auth file:', error);
|
||||
}
|
||||
|
||||
logger.info('[getPlanTypeFromAuthFile] Returning unknown');
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract usage info from the Codex auth file
|
||||
* Reuses getPlanTypeFromAuthFile to avoid code duplication
|
||||
*/
|
||||
private async fetchFromAuthFile(): Promise<CodexUsageData | null> {
|
||||
logger.info('[fetchFromAuthFile] Starting...');
|
||||
try {
|
||||
const planType = await this.getPlanTypeFromAuthFile();
|
||||
logger.info(`[fetchFromAuthFile] Got plan type: ${planType}`);
|
||||
|
||||
if (planType === 'unknown') {
|
||||
logger.info('[fetchFromAuthFile] Plan type unknown, returning null');
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: CodexUsageData = {
|
||||
rateLimits: {
|
||||
planType,
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
logger.info('[fetchFromAuthFile] Returning result:', JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[fetchFromAuthFile] Failed to parse auth file:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JWT token to extract claims
|
||||
*/
|
||||
private parseJwt(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base64Url = parts[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
// Use Buffer for Node.js environment
|
||||
const jsonPayload = Buffer.from(base64, 'base64').toString('utf-8');
|
||||
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
280
apps/server/src/services/cursor-config-service.ts
Normal file
280
apps/server/src/services/cursor-config-service.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Cursor Config Service
|
||||
*
|
||||
* Manages Cursor CLI permissions configuration files:
|
||||
* - Global: ~/.cursor/cli-config.json
|
||||
* - Project: <project>/.cursor/cli.json
|
||||
*
|
||||
* Based on: https://cursor.com/docs/cli/reference/configuration
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type {
|
||||
CursorCliConfigFile,
|
||||
CursorCliPermissions,
|
||||
CursorPermissionProfile,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
CURSOR_STRICT_PROFILE,
|
||||
CURSOR_DEVELOPMENT_PROFILE,
|
||||
CURSOR_PERMISSION_PROFILES,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('CursorConfigService');
|
||||
|
||||
/**
|
||||
* Get the path to the global Cursor CLI config
|
||||
*/
|
||||
export function getGlobalConfigPath(): string {
|
||||
// Windows: $env:USERPROFILE\.cursor\cli-config.json
|
||||
// macOS/Linux: ~/.cursor/cli-config.json
|
||||
// XDG_CONFIG_HOME override on Linux: $XDG_CONFIG_HOME/cursor/cli-config.json
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME;
|
||||
const cursorConfigDir = process.env.CURSOR_CONFIG_DIR;
|
||||
|
||||
if (cursorConfigDir) {
|
||||
return path.join(cursorConfigDir, 'cli-config.json');
|
||||
}
|
||||
|
||||
if (process.platform === 'linux' && xdgConfig) {
|
||||
return path.join(xdgConfig, 'cursor', 'cli-config.json');
|
||||
}
|
||||
|
||||
return path.join(os.homedir(), '.cursor', 'cli-config.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to a project's Cursor CLI config
|
||||
*/
|
||||
export function getProjectConfigPath(projectPath: string): string {
|
||||
return path.join(projectPath, '.cursor', 'cli.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the global Cursor CLI config
|
||||
*/
|
||||
export async function readGlobalConfig(): Promise<CursorCliConfigFile | null> {
|
||||
const configPath = getGlobalConfigPath();
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(configPath, 'utf-8');
|
||||
const config = JSON.parse(content) as CursorCliConfigFile;
|
||||
logger.debug('Read global Cursor config from:', configPath);
|
||||
return config;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.debug('Global Cursor config not found at:', configPath);
|
||||
return null;
|
||||
}
|
||||
logger.error('Failed to read global Cursor config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the global Cursor CLI config
|
||||
*/
|
||||
export async function writeGlobalConfig(config: CursorCliConfigFile): Promise<void> {
|
||||
const configPath = getGlobalConfigPath();
|
||||
const configDir = path.dirname(configPath);
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Write config
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
logger.info('Wrote global Cursor config to:', configPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a project's Cursor CLI config
|
||||
*/
|
||||
export async function readProjectConfig(projectPath: string): Promise<CursorCliConfigFile | null> {
|
||||
const configPath = getProjectConfigPath(projectPath);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(configPath, 'utf-8');
|
||||
const config = JSON.parse(content) as CursorCliConfigFile;
|
||||
logger.debug('Read project Cursor config from:', configPath);
|
||||
return config;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.debug('Project Cursor config not found at:', configPath);
|
||||
return null;
|
||||
}
|
||||
logger.error('Failed to read project Cursor config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a project's Cursor CLI config
|
||||
*
|
||||
* Note: Project-level config ONLY supports permissions.
|
||||
* The version field and other settings are global-only.
|
||||
* See: https://cursor.com/docs/cli/reference/configuration
|
||||
*/
|
||||
export async function writeProjectConfig(
|
||||
projectPath: string,
|
||||
config: CursorCliConfigFile
|
||||
): Promise<void> {
|
||||
const configPath = getProjectConfigPath(projectPath);
|
||||
const configDir = path.dirname(configPath);
|
||||
|
||||
// Ensure .cursor directory exists
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Write config (project config ONLY supports permissions - no version field!)
|
||||
const projectConfig = {
|
||||
permissions: config.permissions,
|
||||
};
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(projectConfig, null, 2));
|
||||
logger.info('Wrote project Cursor config to:', configPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project's Cursor CLI config
|
||||
*/
|
||||
export async function deleteProjectConfig(projectPath: string): Promise<void> {
|
||||
const configPath = getProjectConfigPath(projectPath);
|
||||
|
||||
try {
|
||||
await fs.unlink(configPath);
|
||||
logger.info('Deleted project Cursor config:', configPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective permissions for a project
|
||||
* Project config takes precedence over global config
|
||||
*/
|
||||
export async function getEffectivePermissions(
|
||||
projectPath?: string
|
||||
): Promise<CursorCliPermissions | null> {
|
||||
// Try project config first
|
||||
if (projectPath) {
|
||||
const projectConfig = await readProjectConfig(projectPath);
|
||||
if (projectConfig?.permissions) {
|
||||
return projectConfig.permissions;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to global config
|
||||
const globalConfig = await readGlobalConfig();
|
||||
return globalConfig?.permissions || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a predefined permission profile to a project
|
||||
*/
|
||||
export async function applyProfileToProject(
|
||||
projectPath: string,
|
||||
profileId: CursorPermissionProfile
|
||||
): Promise<void> {
|
||||
const profile = CURSOR_PERMISSION_PROFILES.find((p) => p.id === profileId);
|
||||
|
||||
if (!profile) {
|
||||
throw new Error(`Unknown permission profile: ${profileId}`);
|
||||
}
|
||||
|
||||
await writeProjectConfig(projectPath, {
|
||||
version: 1,
|
||||
permissions: profile.permissions,
|
||||
});
|
||||
|
||||
logger.info(`Applied "${profile.name}" profile to project:`, projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a predefined permission profile globally
|
||||
*/
|
||||
export async function applyProfileGlobally(profileId: CursorPermissionProfile): Promise<void> {
|
||||
const profile = CURSOR_PERMISSION_PROFILES.find((p) => p.id === profileId);
|
||||
|
||||
if (!profile) {
|
||||
throw new Error(`Unknown permission profile: ${profileId}`);
|
||||
}
|
||||
|
||||
// Read existing global config to preserve other settings
|
||||
const existingConfig = await readGlobalConfig();
|
||||
|
||||
await writeGlobalConfig({
|
||||
version: 1,
|
||||
...existingConfig,
|
||||
permissions: profile.permissions,
|
||||
});
|
||||
|
||||
logger.info(`Applied "${profile.name}" profile globally`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which profile matches the current permissions
|
||||
*/
|
||||
export function detectProfile(
|
||||
permissions: CursorCliPermissions | null
|
||||
): CursorPermissionProfile | null {
|
||||
if (!permissions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if permissions match a predefined profile
|
||||
for (const profile of CURSOR_PERMISSION_PROFILES) {
|
||||
const allowMatch =
|
||||
JSON.stringify(profile.permissions.allow.sort()) === JSON.stringify(permissions.allow.sort());
|
||||
const denyMatch =
|
||||
JSON.stringify(profile.permissions.deny.sort()) === JSON.stringify(permissions.deny.sort());
|
||||
|
||||
if (allowMatch && denyMatch) {
|
||||
return profile.id;
|
||||
}
|
||||
}
|
||||
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate example config file content
|
||||
*/
|
||||
export function generateExampleConfig(profileId: CursorPermissionProfile = 'development'): string {
|
||||
const profile =
|
||||
CURSOR_PERMISSION_PROFILES.find((p) => p.id === profileId) || CURSOR_DEVELOPMENT_PROFILE;
|
||||
|
||||
const config: CursorCliConfigFile = {
|
||||
version: 1,
|
||||
permissions: profile.permissions,
|
||||
};
|
||||
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a project has Cursor CLI config
|
||||
*/
|
||||
export async function hasProjectConfig(projectPath: string): Promise<boolean> {
|
||||
const configPath = getProjectConfigPath(projectPath);
|
||||
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available permission profiles
|
||||
*/
|
||||
export function getAvailableProfiles() {
|
||||
return CURSOR_PERMISSION_PROFILES;
|
||||
}
|
||||
|
||||
// Export profile constants for convenience
|
||||
export { CURSOR_STRICT_PROFILE, CURSOR_DEVELOPMENT_PROFILE };
|
||||
@@ -11,6 +11,9 @@ import { spawn, execSync, type ChildProcess } from 'child_process';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import net from 'net';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('DevServerService');
|
||||
|
||||
export interface DevServerInfo {
|
||||
worktreePath: string;
|
||||
@@ -69,7 +72,7 @@ class DevServerService {
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
|
||||
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
|
||||
logger.debug(`Killed process ${pid} on port ${port}`);
|
||||
} catch {
|
||||
// Process may have already exited
|
||||
}
|
||||
@@ -82,7 +85,7 @@ class DevServerService {
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
|
||||
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
|
||||
logger.debug(`Killed process ${pid} on port ${port}`);
|
||||
} catch {
|
||||
// Process may have already exited
|
||||
}
|
||||
@@ -93,7 +96,7 @@ class DevServerService {
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors - port might not have any process
|
||||
console.log(`[DevServerService] No process to kill on port ${port}`);
|
||||
logger.debug(`No process to kill on port ${port}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,11 +254,9 @@ class DevServerService {
|
||||
// Small delay to ensure related ports are freed
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
console.log(`[DevServerService] Starting dev server on port ${port}`);
|
||||
console.log(`[DevServerService] Working directory (cwd): ${worktreePath}`);
|
||||
console.log(
|
||||
`[DevServerService] Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`
|
||||
);
|
||||
logger.info(`Starting dev server on port ${port}`);
|
||||
logger.debug(`Working directory (cwd): ${worktreePath}`);
|
||||
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
|
||||
|
||||
// Spawn the dev process with PORT environment variable
|
||||
const env = {
|
||||
@@ -276,26 +277,26 @@ class DevServerService {
|
||||
// Log output for debugging
|
||||
if (devProcess.stdout) {
|
||||
devProcess.stdout.on('data', (data: Buffer) => {
|
||||
console.log(`[DevServer:${port}] ${data.toString().trim()}`);
|
||||
logger.debug(`[Port${port}] ${data.toString().trim()}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (devProcess.stderr) {
|
||||
devProcess.stderr.on('data', (data: Buffer) => {
|
||||
const msg = data.toString().trim();
|
||||
console.error(`[DevServer:${port}] ${msg}`);
|
||||
logger.debug(`[Port${port}] ${msg}`);
|
||||
});
|
||||
}
|
||||
|
||||
devProcess.on('error', (error) => {
|
||||
console.error(`[DevServerService] Process error:`, error);
|
||||
logger.error(`Process error:`, error);
|
||||
status.error = error.message;
|
||||
this.allocatedPorts.delete(port);
|
||||
this.runningServers.delete(worktreePath);
|
||||
});
|
||||
|
||||
devProcess.on('exit', (code) => {
|
||||
console.log(`[DevServerService] Process for ${worktreePath} exited with code ${code}`);
|
||||
logger.info(`Process for ${worktreePath} exited with code ${code}`);
|
||||
status.exited = true;
|
||||
this.allocatedPorts.delete(port);
|
||||
this.runningServers.delete(worktreePath);
|
||||
@@ -352,9 +353,7 @@ class DevServerService {
|
||||
// If we don't have a record of this server, it may have crashed/exited on its own
|
||||
// Return success so the frontend can clear its state
|
||||
if (!server) {
|
||||
console.log(
|
||||
`[DevServerService] No server record for ${worktreePath}, may have already stopped`
|
||||
);
|
||||
logger.debug(`No server record for ${worktreePath}, may have already stopped`);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
@@ -364,7 +363,7 @@ class DevServerService {
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[DevServerService] Stopping dev server for ${worktreePath}`);
|
||||
logger.info(`Stopping dev server for ${worktreePath}`);
|
||||
|
||||
// Kill the process
|
||||
if (server.process && !server.process.killed) {
|
||||
@@ -434,7 +433,7 @@ class DevServerService {
|
||||
* Stop all running dev servers (for cleanup)
|
||||
*/
|
||||
async stopAll(): Promise<void> {
|
||||
console.log(`[DevServerService] Stopping all ${this.runningServers.size} dev servers`);
|
||||
logger.info(`Stopping all ${this.runningServers.size} dev servers`);
|
||||
|
||||
for (const [worktreePath] of this.runningServers) {
|
||||
await this.stopDevServer(worktreePath);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import type { Feature, DescriptionHistoryEntry } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import {
|
||||
@@ -56,10 +56,10 @@ export class FeatureLoader {
|
||||
try {
|
||||
// Paths are now absolute
|
||||
await secureFs.unlink(oldPath);
|
||||
console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`);
|
||||
logger.info(`Deleted orphaned image: ${oldPath}`);
|
||||
} catch (error) {
|
||||
// Ignore errors when deleting (file may already be gone)
|
||||
logger.warn(`[FeatureLoader] Failed to delete image: ${oldPath}`, error);
|
||||
logger.warn(`Failed to delete image: ${oldPath}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export class FeatureLoader {
|
||||
try {
|
||||
await secureFs.access(fullOriginalPath);
|
||||
} catch {
|
||||
logger.warn(`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`);
|
||||
logger.warn(`Image not found, skipping: ${fullOriginalPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ export class FeatureLoader {
|
||||
|
||||
// Copy the file
|
||||
await secureFs.copyFile(fullOriginalPath, newPath);
|
||||
console.log(`[FeatureLoader] Copied image: ${originalPath} -> ${newPath}`);
|
||||
logger.info(`Copied image: ${originalPath} -> ${newPath}`);
|
||||
|
||||
// Try to delete the original temp file
|
||||
try {
|
||||
@@ -158,6 +158,13 @@ export class FeatureLoader {
|
||||
return path.join(this.getFeatureDir(projectPath, featureId), 'agent-output.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to a feature's raw-output.jsonl file
|
||||
*/
|
||||
getRawOutputPath(projectPath: string, featureId: string): string {
|
||||
return path.join(this.getFeatureDir(projectPath, featureId), 'raw-output.jsonl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new feature ID
|
||||
*/
|
||||
@@ -195,9 +202,7 @@ export class FeatureLoader {
|
||||
const feature = JSON.parse(content);
|
||||
|
||||
if (!feature.id) {
|
||||
logger.warn(
|
||||
`[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping`
|
||||
);
|
||||
logger.warn(`Feature ${featureId} missing required 'id' field, skipping`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -206,14 +211,9 @@ export class FeatureLoader {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
} else if (error instanceof SyntaxError) {
|
||||
logger.warn(
|
||||
`[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}`
|
||||
);
|
||||
logger.warn(`Failed to parse feature.json for ${featureId}: ${error.message}`);
|
||||
} else {
|
||||
logger.error(
|
||||
`[FeatureLoader] Failed to load feature ${featureId}:`,
|
||||
(error as Error).message
|
||||
);
|
||||
logger.error(`Failed to load feature ${featureId}:`, (error as Error).message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -248,7 +248,7 @@ export class FeatureLoader {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
logger.error(`[FeatureLoader] Failed to get feature ${featureId}:`, error);
|
||||
logger.error(`Failed to get feature ${featureId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -274,6 +274,16 @@ export class FeatureLoader {
|
||||
featureData.imagePaths
|
||||
);
|
||||
|
||||
// Initialize description history with the initial description
|
||||
const initialHistory: DescriptionHistoryEntry[] = [];
|
||||
if (featureData.description && featureData.description.trim()) {
|
||||
initialHistory.push({
|
||||
description: featureData.description,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'initial',
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure feature has required fields
|
||||
const feature: Feature = {
|
||||
category: featureData.category || 'Uncategorized',
|
||||
@@ -281,6 +291,7 @@ export class FeatureLoader {
|
||||
...featureData,
|
||||
id: featureId,
|
||||
imagePaths: migratedImagePaths,
|
||||
descriptionHistory: initialHistory,
|
||||
};
|
||||
|
||||
// Write feature.json
|
||||
@@ -292,11 +303,20 @@ export class FeatureLoader {
|
||||
|
||||
/**
|
||||
* Update a feature (partial updates supported)
|
||||
* @param projectPath - Path to the project
|
||||
* @param featureId - ID of the feature to update
|
||||
* @param updates - Partial feature updates
|
||||
* @param descriptionHistorySource - Source of description change ('enhance' or 'edit')
|
||||
* @param enhancementMode - Enhancement mode if source is 'enhance'
|
||||
* @param preEnhancementDescription - Description before enhancement (for restoring original)
|
||||
*/
|
||||
async update(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
updates: Partial<Feature>
|
||||
updates: Partial<Feature>,
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||
preEnhancementDescription?: string
|
||||
): Promise<Feature> {
|
||||
const feature = await this.get(projectPath, featureId);
|
||||
if (!feature) {
|
||||
@@ -313,11 +333,50 @@ export class FeatureLoader {
|
||||
updatedImagePaths = await this.migrateImages(projectPath, featureId, updates.imagePaths);
|
||||
}
|
||||
|
||||
// Track description history if description changed
|
||||
let updatedHistory = feature.descriptionHistory || [];
|
||||
if (
|
||||
updates.description !== undefined &&
|
||||
updates.description !== feature.description &&
|
||||
updates.description.trim()
|
||||
) {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// If this is an enhancement and we have the pre-enhancement description,
|
||||
// add the original text to history first (so user can restore to it)
|
||||
if (
|
||||
descriptionHistorySource === 'enhance' &&
|
||||
preEnhancementDescription &&
|
||||
preEnhancementDescription.trim()
|
||||
) {
|
||||
// Check if this pre-enhancement text is different from the last history entry
|
||||
const lastEntry = updatedHistory[updatedHistory.length - 1];
|
||||
if (!lastEntry || lastEntry.description !== preEnhancementDescription) {
|
||||
const preEnhanceEntry: DescriptionHistoryEntry = {
|
||||
description: preEnhancementDescription,
|
||||
timestamp,
|
||||
source: updatedHistory.length === 0 ? 'initial' : 'edit',
|
||||
};
|
||||
updatedHistory = [...updatedHistory, preEnhanceEntry];
|
||||
}
|
||||
}
|
||||
|
||||
// Add the new/enhanced description to history
|
||||
const historyEntry: DescriptionHistoryEntry = {
|
||||
description: updates.description,
|
||||
timestamp,
|
||||
source: descriptionHistorySource || 'edit',
|
||||
...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}),
|
||||
};
|
||||
updatedHistory = [...updatedHistory, historyEntry];
|
||||
}
|
||||
|
||||
// Merge updates
|
||||
const updatedFeature: Feature = {
|
||||
...feature,
|
||||
...updates,
|
||||
...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}),
|
||||
descriptionHistory: updatedHistory,
|
||||
};
|
||||
|
||||
// Write back to file
|
||||
@@ -335,10 +394,10 @@ export class FeatureLoader {
|
||||
try {
|
||||
const featureDir = this.getFeatureDir(projectPath, featureId);
|
||||
await secureFs.rm(featureDir, { recursive: true, force: true });
|
||||
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
|
||||
logger.info(`Deleted feature ${featureId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error);
|
||||
logger.error(`Failed to delete feature ${featureId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -355,7 +414,24 @@ export class FeatureLoader {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
logger.error(`[FeatureLoader] Failed to get agent output for ${featureId}:`, error);
|
||||
logger.error(`Failed to get agent output for ${featureId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw output for a feature (JSONL format for debugging)
|
||||
*/
|
||||
async getRawOutput(projectPath: string, featureId: string): Promise<string | null> {
|
||||
try {
|
||||
const rawOutputPath = this.getRawOutputPath(projectPath, featureId);
|
||||
const content = (await secureFs.readFile(rawOutputPath, 'utf-8')) as string;
|
||||
return content;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
logger.error(`Failed to get raw output for ${featureId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
1731
apps/server/src/services/ideation-service.ts
Normal file
1731
apps/server/src/services/ideation-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
360
apps/server/src/services/init-script-service.ts
Normal file
360
apps/server/src/services/init-script-service.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Init Script Service - Executes worktree initialization scripts
|
||||
*
|
||||
* Runs the .automaker/worktree-init.sh script after worktree creation.
|
||||
* Uses Git Bash on Windows for cross-platform shell script compatibility.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { systemPathExists, getShellPaths, findGitBashPath } from '@automaker/platform';
|
||||
import { findCommand } from '../lib/cli-detection.js';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import { readWorktreeMetadata, writeWorktreeMetadata } from '../lib/worktree-metadata.js';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
|
||||
const logger = createLogger('InitScript');
|
||||
|
||||
export interface InitScriptOptions {
|
||||
/** Absolute path to the project root */
|
||||
projectPath: string;
|
||||
/** Absolute path to the worktree directory */
|
||||
worktreePath: string;
|
||||
/** Branch name for this worktree */
|
||||
branch: string;
|
||||
/** Event emitter for streaming output */
|
||||
emitter: EventEmitter;
|
||||
}
|
||||
|
||||
interface ShellCommand {
|
||||
shell: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Init Script Service
|
||||
*
|
||||
* Handles execution of worktree initialization scripts with cross-platform
|
||||
* shell detection and proper streaming of output via WebSocket events.
|
||||
*/
|
||||
export class InitScriptService {
|
||||
private cachedShellCommand: ShellCommand | null | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Get the path to the init script for a project
|
||||
*/
|
||||
getInitScriptPath(projectPath: string): string {
|
||||
return path.join(projectPath, '.automaker', 'worktree-init.sh');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the init script has already been run for a worktree
|
||||
*/
|
||||
async hasInitScriptRun(projectPath: string, branch: string): Promise<boolean> {
|
||||
const metadata = await readWorktreeMetadata(projectPath, branch);
|
||||
return metadata?.initScriptRan === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the appropriate shell for running scripts
|
||||
* Uses findGitBashPath() on Windows to avoid WSL bash, then falls back to PATH
|
||||
*/
|
||||
async findShellCommand(): Promise<ShellCommand | null> {
|
||||
// Return cached result if available
|
||||
if (this.cachedShellCommand !== undefined) {
|
||||
return this.cachedShellCommand;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// On Windows, prioritize Git Bash over WSL bash (C:\Windows\System32\bash.exe)
|
||||
// WSL bash may not be properly configured and causes ENOENT errors
|
||||
|
||||
// First try known Git Bash installation paths
|
||||
const gitBashPath = await findGitBashPath();
|
||||
if (gitBashPath) {
|
||||
logger.debug(`Found Git Bash at: ${gitBashPath}`);
|
||||
this.cachedShellCommand = { shell: gitBashPath, args: [] };
|
||||
return this.cachedShellCommand;
|
||||
}
|
||||
|
||||
// Fall back to finding bash in PATH, but skip WSL bash
|
||||
const bashInPath = await findCommand(['bash']);
|
||||
if (bashInPath && !bashInPath.toLowerCase().includes('system32')) {
|
||||
logger.debug(`Found bash in PATH at: ${bashInPath}`);
|
||||
this.cachedShellCommand = { shell: bashInPath, args: [] };
|
||||
return this.cachedShellCommand;
|
||||
}
|
||||
|
||||
logger.warn('Git Bash not found. WSL bash was skipped to avoid compatibility issues.');
|
||||
this.cachedShellCommand = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unix-like systems: use getShellPaths() and check existence
|
||||
const shellPaths = getShellPaths();
|
||||
const posixShells = shellPaths.filter(
|
||||
(p) => p.includes('bash') || p === '/bin/sh' || p === '/usr/bin/sh'
|
||||
);
|
||||
|
||||
for (const shellPath of posixShells) {
|
||||
try {
|
||||
if (systemPathExists(shellPath)) {
|
||||
this.cachedShellCommand = { shell: shellPath, args: [] };
|
||||
return this.cachedShellCommand;
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed or doesn't exist, continue
|
||||
}
|
||||
}
|
||||
|
||||
// Ultimate fallback
|
||||
if (systemPathExists('/bin/sh')) {
|
||||
this.cachedShellCommand = { shell: '/bin/sh', args: [] };
|
||||
return this.cachedShellCommand;
|
||||
}
|
||||
|
||||
this.cachedShellCommand = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the worktree initialization script
|
||||
* Non-blocking - returns immediately after spawning
|
||||
*/
|
||||
async runInitScript(options: InitScriptOptions): Promise<void> {
|
||||
const { projectPath, worktreePath, branch, emitter } = options;
|
||||
|
||||
const scriptPath = this.getInitScriptPath(projectPath);
|
||||
|
||||
// Check if script exists using secureFs (respects ALLOWED_ROOT_DIRECTORY)
|
||||
try {
|
||||
await secureFs.access(scriptPath);
|
||||
} catch {
|
||||
logger.debug(`No init script found at ${scriptPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already run
|
||||
if (await this.hasInitScriptRun(projectPath, branch)) {
|
||||
logger.info(`Init script already ran for branch "${branch}", skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get shell command
|
||||
const shellCmd = await this.findShellCommand();
|
||||
if (!shellCmd) {
|
||||
const error =
|
||||
process.platform === 'win32'
|
||||
? 'Git Bash not found. Please install Git for Windows to run init scripts.'
|
||||
: 'No shell found (/bin/bash or /bin/sh)';
|
||||
logger.error(error);
|
||||
|
||||
// Update metadata with error, preserving existing metadata
|
||||
const existingMetadata = await readWorktreeMetadata(projectPath, branch);
|
||||
await writeWorktreeMetadata(projectPath, branch, {
|
||||
branch,
|
||||
createdAt: existingMetadata?.createdAt || new Date().toISOString(),
|
||||
pr: existingMetadata?.pr,
|
||||
initScriptRan: true,
|
||||
initScriptStatus: 'failed',
|
||||
initScriptError: error,
|
||||
});
|
||||
|
||||
emitter.emit('worktree:init-completed', {
|
||||
projectPath,
|
||||
worktreePath,
|
||||
branch,
|
||||
success: false,
|
||||
error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Running init script for branch "${branch}" in ${worktreePath}`);
|
||||
logger.debug(`Using shell: ${shellCmd.shell}`);
|
||||
|
||||
// Update metadata to mark as running
|
||||
const existingMetadata = await readWorktreeMetadata(projectPath, branch);
|
||||
await writeWorktreeMetadata(projectPath, branch, {
|
||||
branch,
|
||||
createdAt: existingMetadata?.createdAt || new Date().toISOString(),
|
||||
pr: existingMetadata?.pr,
|
||||
initScriptRan: false,
|
||||
initScriptStatus: 'running',
|
||||
});
|
||||
|
||||
// Emit started event
|
||||
emitter.emit('worktree:init-started', {
|
||||
projectPath,
|
||||
worktreePath,
|
||||
branch,
|
||||
});
|
||||
|
||||
// Build safe environment - only pass necessary variables, not all of process.env
|
||||
// This prevents exposure of sensitive credentials like ANTHROPIC_API_KEY
|
||||
const safeEnv: Record<string, string> = {
|
||||
// Automaker-specific variables
|
||||
AUTOMAKER_PROJECT_PATH: projectPath,
|
||||
AUTOMAKER_WORKTREE_PATH: worktreePath,
|
||||
AUTOMAKER_BRANCH: branch,
|
||||
|
||||
// Essential system variables
|
||||
PATH: process.env.PATH || '',
|
||||
HOME: process.env.HOME || '',
|
||||
USER: process.env.USER || '',
|
||||
TMPDIR: process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp',
|
||||
|
||||
// Shell and locale
|
||||
SHELL: process.env.SHELL || '',
|
||||
LANG: process.env.LANG || 'en_US.UTF-8',
|
||||
LC_ALL: process.env.LC_ALL || '',
|
||||
|
||||
// Force color output even though we're not a TTY
|
||||
FORCE_COLOR: '1',
|
||||
npm_config_color: 'always',
|
||||
CLICOLOR_FORCE: '1',
|
||||
|
||||
// Git configuration
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
};
|
||||
|
||||
// Platform-specific additions
|
||||
if (process.platform === 'win32') {
|
||||
safeEnv.USERPROFILE = process.env.USERPROFILE || '';
|
||||
safeEnv.APPDATA = process.env.APPDATA || '';
|
||||
safeEnv.LOCALAPPDATA = process.env.LOCALAPPDATA || '';
|
||||
safeEnv.SystemRoot = process.env.SystemRoot || 'C:\\Windows';
|
||||
safeEnv.TEMP = process.env.TEMP || '';
|
||||
}
|
||||
|
||||
// Spawn the script with safe environment
|
||||
const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], {
|
||||
cwd: worktreePath,
|
||||
env: safeEnv,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Stream stdout
|
||||
child.stdout?.on('data', (data: Buffer) => {
|
||||
const content = data.toString();
|
||||
emitter.emit('worktree:init-output', {
|
||||
projectPath,
|
||||
branch,
|
||||
type: 'stdout',
|
||||
content,
|
||||
});
|
||||
});
|
||||
|
||||
// Stream stderr
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
const content = data.toString();
|
||||
emitter.emit('worktree:init-output', {
|
||||
projectPath,
|
||||
branch,
|
||||
type: 'stderr',
|
||||
content,
|
||||
});
|
||||
});
|
||||
|
||||
// Handle completion
|
||||
child.on('exit', async (code) => {
|
||||
const success = code === 0;
|
||||
const status = success ? 'success' : 'failed';
|
||||
|
||||
logger.info(`Init script for branch "${branch}" ${status} with exit code ${code}`);
|
||||
|
||||
// Update metadata
|
||||
const metadata = await readWorktreeMetadata(projectPath, branch);
|
||||
await writeWorktreeMetadata(projectPath, branch, {
|
||||
branch,
|
||||
createdAt: metadata?.createdAt || new Date().toISOString(),
|
||||
pr: metadata?.pr,
|
||||
initScriptRan: true,
|
||||
initScriptStatus: status,
|
||||
initScriptError: success ? undefined : `Exit code: ${code}`,
|
||||
});
|
||||
|
||||
// Emit completion event
|
||||
emitter.emit('worktree:init-completed', {
|
||||
projectPath,
|
||||
worktreePath,
|
||||
branch,
|
||||
success,
|
||||
exitCode: code,
|
||||
});
|
||||
});
|
||||
|
||||
child.on('error', async (error) => {
|
||||
logger.error(`Init script error for branch "${branch}":`, error);
|
||||
|
||||
// Update metadata
|
||||
const metadata = await readWorktreeMetadata(projectPath, branch);
|
||||
await writeWorktreeMetadata(projectPath, branch, {
|
||||
branch,
|
||||
createdAt: metadata?.createdAt || new Date().toISOString(),
|
||||
pr: metadata?.pr,
|
||||
initScriptRan: true,
|
||||
initScriptStatus: 'failed',
|
||||
initScriptError: error.message,
|
||||
});
|
||||
|
||||
// Emit completion with error
|
||||
emitter.emit('worktree:init-completed', {
|
||||
projectPath,
|
||||
worktreePath,
|
||||
branch,
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Force re-run the worktree initialization script
|
||||
* Ignores the initScriptRan flag - useful for testing or re-setup
|
||||
*/
|
||||
async forceRunInitScript(options: InitScriptOptions): Promise<void> {
|
||||
const { projectPath, branch } = options;
|
||||
|
||||
// Reset the initScriptRan flag so the script will run
|
||||
const metadata = await readWorktreeMetadata(projectPath, branch);
|
||||
if (metadata) {
|
||||
await writeWorktreeMetadata(projectPath, branch, {
|
||||
...metadata,
|
||||
initScriptRan: false,
|
||||
initScriptStatus: undefined,
|
||||
initScriptError: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Now run the script
|
||||
await this.runInitScript(options);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for convenience
|
||||
let initScriptService: InitScriptService | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton InitScriptService instance
|
||||
*/
|
||||
export function getInitScriptService(): InitScriptService {
|
||||
if (!initScriptService) {
|
||||
initScriptService = new InitScriptService();
|
||||
}
|
||||
return initScriptService;
|
||||
}
|
||||
|
||||
// Export convenience functions that use the singleton
|
||||
export const getInitScriptPath = (projectPath: string) =>
|
||||
getInitScriptService().getInitScriptPath(projectPath);
|
||||
|
||||
export const hasInitScriptRun = (projectPath: string, branch: string) =>
|
||||
getInitScriptService().hasInitScriptRun(projectPath, branch);
|
||||
|
||||
export const runInitScript = (options: InitScriptOptions) =>
|
||||
getInitScriptService().runInitScript(options);
|
||||
|
||||
export const forceRunInitScript = (options: InitScriptOptions) =>
|
||||
getInitScriptService().forceRunInitScript(options);
|
||||
@@ -22,16 +22,18 @@ import type {
|
||||
Credentials,
|
||||
ProjectSettings,
|
||||
KeyboardShortcuts,
|
||||
AIProfile,
|
||||
ProjectRef,
|
||||
TrashedProjectRef,
|
||||
BoardBackgroundSettings,
|
||||
WorktreeInfo,
|
||||
PhaseModelConfig,
|
||||
PhaseModelEntry,
|
||||
} from '../types/settings.js';
|
||||
import {
|
||||
DEFAULT_GLOBAL_SETTINGS,
|
||||
DEFAULT_CREDENTIALS,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
DEFAULT_PHASE_MODELS,
|
||||
SETTINGS_VERSION,
|
||||
CREDENTIALS_VERSION,
|
||||
PROJECT_SETTINGS_VERSION,
|
||||
@@ -132,6 +134,9 @@ export class SettingsService {
|
||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
const settings = await readJsonFile<GlobalSettings>(settingsPath, DEFAULT_GLOBAL_SETTINGS);
|
||||
|
||||
// Migrate legacy enhancementModel/validationModel to phaseModels
|
||||
const migratedPhaseModels = this.migratePhaseModels(settings);
|
||||
|
||||
// Apply any missing defaults (for backwards compatibility)
|
||||
let result: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
@@ -140,21 +145,37 @@ export class SettingsService {
|
||||
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
...settings.keyboardShortcuts,
|
||||
},
|
||||
phaseModels: migratedPhaseModels,
|
||||
};
|
||||
|
||||
// Version-based migrations
|
||||
const storedVersion = settings.version || 1;
|
||||
let needsSave = false;
|
||||
|
||||
// Migration v1 -> v2: Force enableSandboxMode to false for existing users
|
||||
// Sandbox mode can cause issues on some systems, so we're disabling it by default
|
||||
if (storedVersion < 2) {
|
||||
logger.info('Migrating settings from v1 to v2: disabling sandbox mode');
|
||||
result.enableSandboxMode = false;
|
||||
result.version = SETTINGS_VERSION;
|
||||
// Migration v2 -> v3: Convert string phase models to PhaseModelEntry objects
|
||||
// Note: migratePhaseModels() handles the actual conversion for both v1 and v2 formats
|
||||
if (storedVersion < 3) {
|
||||
logger.info(
|
||||
`Migrating settings from v${storedVersion} to v3: converting phase models to PhaseModelEntry format`
|
||||
);
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Migration v3 -> v4: Add onboarding/setup wizard state fields
|
||||
// Older settings files never stored setup state in settings.json (it lived in localStorage),
|
||||
// so default to "setup complete" for existing installs to avoid forcing re-onboarding.
|
||||
if (storedVersion < 4) {
|
||||
if (settings.setupComplete === undefined) result.setupComplete = true;
|
||||
if (settings.isFirstRun === undefined) result.isFirstRun = false;
|
||||
if (settings.skipClaudeSetup === undefined) result.skipClaudeSetup = false;
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Update version if any migration occurred
|
||||
if (needsSave) {
|
||||
result.version = SETTINGS_VERSION;
|
||||
}
|
||||
|
||||
// Save migrated settings if needed
|
||||
if (needsSave) {
|
||||
try {
|
||||
@@ -169,6 +190,67 @@ export class SettingsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy enhancementModel/validationModel fields to phaseModels structure
|
||||
*
|
||||
* Handles backwards compatibility for settings created before phaseModels existed.
|
||||
* Also handles migration from string phase models (v2) to PhaseModelEntry objects (v3).
|
||||
* Legacy fields take precedence over defaults but phaseModels takes precedence over legacy.
|
||||
*
|
||||
* @param settings - Raw settings from file
|
||||
* @returns Complete PhaseModelConfig with all fields populated
|
||||
*/
|
||||
private migratePhaseModels(settings: Partial<GlobalSettings>): PhaseModelConfig {
|
||||
// Start with defaults
|
||||
const result: PhaseModelConfig = { ...DEFAULT_PHASE_MODELS };
|
||||
|
||||
// If phaseModels exists, use it (with defaults for any missing fields)
|
||||
if (settings.phaseModels) {
|
||||
// Merge with defaults and convert any string values to PhaseModelEntry
|
||||
const merged: PhaseModelConfig = { ...DEFAULT_PHASE_MODELS };
|
||||
for (const key of Object.keys(settings.phaseModels) as Array<keyof PhaseModelConfig>) {
|
||||
const value = settings.phaseModels[key];
|
||||
if (value !== undefined) {
|
||||
// Convert string to PhaseModelEntry if needed (v2 -> v3 migration)
|
||||
merged[key] = this.toPhaseModelEntry(value);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Migrate legacy fields if phaseModels doesn't exist
|
||||
// These were the only two legacy fields that existed
|
||||
if (settings.enhancementModel) {
|
||||
result.enhancementModel = this.toPhaseModelEntry(settings.enhancementModel);
|
||||
logger.debug(`Migrated legacy enhancementModel: ${settings.enhancementModel}`);
|
||||
}
|
||||
if (settings.validationModel) {
|
||||
result.validationModel = this.toPhaseModelEntry(settings.validationModel);
|
||||
logger.debug(`Migrated legacy validationModel: ${settings.validationModel}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a phase model value to PhaseModelEntry format
|
||||
*
|
||||
* Handles migration from string format (v2) to object format (v3).
|
||||
* - String values like 'sonnet' become { model: 'sonnet' }
|
||||
* - Object values are returned as-is (with type assertion)
|
||||
*
|
||||
* @param value - Phase model value (string or PhaseModelEntry)
|
||||
* @returns PhaseModelEntry object
|
||||
*/
|
||||
private toPhaseModelEntry(value: string | PhaseModelEntry): PhaseModelEntry {
|
||||
if (typeof value === 'string') {
|
||||
// v2 format: just a model string
|
||||
return { model: value as PhaseModelEntry['model'] };
|
||||
}
|
||||
// v3 format: already a PhaseModelEntry object
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update global settings with partial changes
|
||||
*
|
||||
@@ -183,17 +265,78 @@ export class SettingsService {
|
||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
|
||||
const current = await this.getGlobalSettings();
|
||||
|
||||
// Guard against destructive "empty array/object" overwrites.
|
||||
// During auth transitions, the UI can briefly have default/empty state and accidentally
|
||||
// sync it, wiping persisted settings (especially `projects`).
|
||||
const sanitizedUpdates: Partial<GlobalSettings> = { ...updates };
|
||||
let attemptedProjectWipe = false;
|
||||
|
||||
const ignoreEmptyArrayOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
|
||||
const nextVal = sanitizedUpdates[key] as unknown;
|
||||
const curVal = current[key] as unknown;
|
||||
if (
|
||||
Array.isArray(nextVal) &&
|
||||
nextVal.length === 0 &&
|
||||
Array.isArray(curVal) &&
|
||||
curVal.length > 0
|
||||
) {
|
||||
delete sanitizedUpdates[key];
|
||||
}
|
||||
};
|
||||
|
||||
const currentProjectsLen = Array.isArray(current.projects) ? current.projects.length : 0;
|
||||
if (
|
||||
Array.isArray(sanitizedUpdates.projects) &&
|
||||
sanitizedUpdates.projects.length === 0 &&
|
||||
currentProjectsLen > 0
|
||||
) {
|
||||
attemptedProjectWipe = true;
|
||||
delete sanitizedUpdates.projects;
|
||||
}
|
||||
|
||||
ignoreEmptyArrayOverwrite('trashedProjects');
|
||||
ignoreEmptyArrayOverwrite('projectHistory');
|
||||
ignoreEmptyArrayOverwrite('recentFolders');
|
||||
ignoreEmptyArrayOverwrite('mcpServers');
|
||||
ignoreEmptyArrayOverwrite('enabledCursorModels');
|
||||
|
||||
// Empty object overwrite guard
|
||||
if (
|
||||
sanitizedUpdates.lastSelectedSessionByProject &&
|
||||
typeof sanitizedUpdates.lastSelectedSessionByProject === 'object' &&
|
||||
!Array.isArray(sanitizedUpdates.lastSelectedSessionByProject) &&
|
||||
Object.keys(sanitizedUpdates.lastSelectedSessionByProject).length === 0 &&
|
||||
current.lastSelectedSessionByProject &&
|
||||
Object.keys(current.lastSelectedSessionByProject).length > 0
|
||||
) {
|
||||
delete sanitizedUpdates.lastSelectedSessionByProject;
|
||||
}
|
||||
|
||||
// If a request attempted to wipe projects, also ignore theme changes in that same request.
|
||||
if (attemptedProjectWipe) {
|
||||
delete sanitizedUpdates.theme;
|
||||
}
|
||||
|
||||
const updated: GlobalSettings = {
|
||||
...current,
|
||||
...updates,
|
||||
...sanitizedUpdates,
|
||||
version: SETTINGS_VERSION,
|
||||
};
|
||||
|
||||
// Deep merge keyboard shortcuts if provided
|
||||
if (updates.keyboardShortcuts) {
|
||||
if (sanitizedUpdates.keyboardShortcuts) {
|
||||
updated.keyboardShortcuts = {
|
||||
...current.keyboardShortcuts,
|
||||
...updates.keyboardShortcuts,
|
||||
...sanitizedUpdates.keyboardShortcuts,
|
||||
};
|
||||
}
|
||||
|
||||
// Deep merge phaseModels if provided
|
||||
if (sanitizedUpdates.phaseModels) {
|
||||
updated.phaseModels = {
|
||||
...current.phaseModels,
|
||||
...sanitizedUpdates.phaseModels,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -434,13 +577,29 @@ export class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse setup wizard state (previously stored in localStorage)
|
||||
let setupState: Record<string, unknown> = {};
|
||||
if (localStorageData['automaker-setup']) {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorageData['automaker-setup']);
|
||||
setupState = parsed.state || parsed;
|
||||
} catch (e) {
|
||||
errors.push(`Failed to parse automaker-setup: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract global settings
|
||||
const globalSettings: Partial<GlobalSettings> = {
|
||||
setupComplete:
|
||||
setupState.setupComplete !== undefined ? (setupState.setupComplete as boolean) : false,
|
||||
isFirstRun: setupState.isFirstRun !== undefined ? (setupState.isFirstRun as boolean) : true,
|
||||
skipClaudeSetup:
|
||||
setupState.skipClaudeSetup !== undefined
|
||||
? (setupState.skipClaudeSetup as boolean)
|
||||
: false,
|
||||
theme: (appState.theme as GlobalSettings['theme']) || 'dark',
|
||||
sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true,
|
||||
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
|
||||
kanbanCardDetailLevel:
|
||||
(appState.kanbanCardDetailLevel as GlobalSettings['kanbanCardDetailLevel']) || 'standard',
|
||||
maxConcurrency: (appState.maxConcurrency as number) || 3,
|
||||
defaultSkipTests:
|
||||
appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true,
|
||||
@@ -448,19 +607,21 @@ export class SettingsService {
|
||||
appState.enableDependencyBlocking !== undefined
|
||||
? (appState.enableDependencyBlocking as boolean)
|
||||
: true,
|
||||
useWorktrees: (appState.useWorktrees as boolean) || false,
|
||||
showProfilesOnly: (appState.showProfilesOnly as boolean) || false,
|
||||
skipVerificationInAutoMode:
|
||||
appState.skipVerificationInAutoMode !== undefined
|
||||
? (appState.skipVerificationInAutoMode as boolean)
|
||||
: false,
|
||||
useWorktrees:
|
||||
appState.useWorktrees !== undefined ? (appState.useWorktrees as boolean) : true,
|
||||
defaultPlanningMode:
|
||||
(appState.defaultPlanningMode as GlobalSettings['defaultPlanningMode']) || 'skip',
|
||||
defaultRequirePlanApproval: (appState.defaultRequirePlanApproval as boolean) || false,
|
||||
defaultAIProfileId: (appState.defaultAIProfileId as string | null) || null,
|
||||
muteDoneSound: (appState.muteDoneSound as boolean) || false,
|
||||
enhancementModel:
|
||||
(appState.enhancementModel as GlobalSettings['enhancementModel']) || 'sonnet',
|
||||
keyboardShortcuts:
|
||||
(appState.keyboardShortcuts as KeyboardShortcuts) ||
|
||||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
aiProfiles: (appState.aiProfiles as AIProfile[]) || [],
|
||||
projects: (appState.projects as ProjectRef[]) || [],
|
||||
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
|
||||
projectHistory: (appState.projectHistory as string[]) || [],
|
||||
|
||||
@@ -12,6 +12,9 @@ import * as path from 'path';
|
||||
// secureFs is used for user-controllable paths (working directory validation)
|
||||
// to enforce ALLOWED_ROOT_DIRECTORY security boundary
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('Terminal');
|
||||
// System paths module handles shell binary checks and WSL detection
|
||||
// These are system paths outside ALLOWED_ROOT_DIRECTORY, centralized for security auditing
|
||||
import {
|
||||
@@ -219,7 +222,7 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
// Reject paths with null bytes (could bypass path checks)
|
||||
if (cwd.includes('\0')) {
|
||||
console.warn(`[Terminal] Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`);
|
||||
logger.warn(`Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`);
|
||||
return homeDir;
|
||||
}
|
||||
|
||||
@@ -242,12 +245,10 @@ export class TerminalService extends EventEmitter {
|
||||
if (statResult.isDirectory()) {
|
||||
return cwd;
|
||||
}
|
||||
console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`);
|
||||
logger.warn(`Path exists but is not a directory: ${cwd}, falling back to home`);
|
||||
return homeDir;
|
||||
} catch {
|
||||
console.warn(
|
||||
`[Terminal] Working directory does not exist or not allowed: ${cwd}, falling back to home`
|
||||
);
|
||||
logger.warn(`Working directory does not exist or not allowed: ${cwd}, falling back to home`);
|
||||
return homeDir;
|
||||
}
|
||||
}
|
||||
@@ -272,7 +273,7 @@ export class TerminalService extends EventEmitter {
|
||||
setMaxSessions(limit: number): void {
|
||||
if (limit >= MIN_MAX_SESSIONS && limit <= MAX_MAX_SESSIONS) {
|
||||
maxSessions = limit;
|
||||
console.log(`[Terminal] Max sessions limit updated to ${limit}`);
|
||||
logger.info(`Max sessions limit updated to ${limit}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +284,7 @@ export class TerminalService extends EventEmitter {
|
||||
async createSession(options: TerminalOptions = {}): Promise<TerminalSession | null> {
|
||||
// Check session limit
|
||||
if (this.sessions.size >= maxSessions) {
|
||||
console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`);
|
||||
logger.error(`Max sessions (${maxSessions}) reached, refusing new session`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -319,7 +320,7 @@ export class TerminalService extends EventEmitter {
|
||||
...options.env,
|
||||
};
|
||||
|
||||
console.log(`[Terminal] Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||
|
||||
const ptyProcess = pty.spawn(shell, shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
@@ -391,13 +392,13 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
// Handle exit
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
console.log(`[Terminal] Session ${id} exited with code ${exitCode}`);
|
||||
logger.info(`Session ${id} exited with code ${exitCode}`);
|
||||
this.sessions.delete(id);
|
||||
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
|
||||
this.emit('exit', id, exitCode);
|
||||
});
|
||||
|
||||
console.log(`[Terminal] Session ${id} created successfully`);
|
||||
logger.info(`Session ${id} created successfully`);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -407,7 +408,7 @@ export class TerminalService extends EventEmitter {
|
||||
write(sessionId: string, data: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.warn(`[Terminal] Session ${sessionId} not found`);
|
||||
logger.warn(`Session ${sessionId} not found`);
|
||||
return false;
|
||||
}
|
||||
session.pty.write(data);
|
||||
@@ -422,7 +423,7 @@ export class TerminalService extends EventEmitter {
|
||||
resize(sessionId: string, cols: number, rows: number, suppressOutput: boolean = true): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.warn(`[Terminal] Session ${sessionId} not found for resize`);
|
||||
logger.warn(`Session ${sessionId} not found for resize`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
@@ -448,7 +449,7 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Terminal] Error resizing session ${sessionId}:`, error);
|
||||
logger.error(`Error resizing session ${sessionId}:`, error);
|
||||
session.resizeInProgress = false; // Clear flag on error
|
||||
return false;
|
||||
}
|
||||
@@ -476,14 +477,14 @@ export class TerminalService extends EventEmitter {
|
||||
}
|
||||
|
||||
// First try graceful SIGTERM to allow process cleanup
|
||||
console.log(`[Terminal] Session ${sessionId} sending SIGTERM`);
|
||||
logger.info(`Session ${sessionId} sending SIGTERM`);
|
||||
session.pty.kill('SIGTERM');
|
||||
|
||||
// Schedule SIGKILL fallback if process doesn't exit gracefully
|
||||
// The onExit handler will remove session from map when it actually exits
|
||||
setTimeout(() => {
|
||||
if (this.sessions.has(sessionId)) {
|
||||
console.log(`[Terminal] Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
|
||||
logger.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
|
||||
try {
|
||||
session.pty.kill('SIGKILL');
|
||||
} catch {
|
||||
@@ -494,10 +495,10 @@ export class TerminalService extends EventEmitter {
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
console.log(`[Terminal] Session ${sessionId} kill initiated`);
|
||||
logger.info(`Session ${sessionId} kill initiated`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Terminal] Error killing session ${sessionId}:`, error);
|
||||
logger.error(`Error killing session ${sessionId}:`, error);
|
||||
// Still try to remove from map even if kill fails
|
||||
this.sessions.delete(sessionId);
|
||||
return false;
|
||||
@@ -580,7 +581,7 @@ export class TerminalService extends EventEmitter {
|
||||
* Clean up all sessions
|
||||
*/
|
||||
cleanup(): void {
|
||||
console.log(`[Terminal] Cleaning up ${this.sessions.size} sessions`);
|
||||
logger.info(`Cleaning up ${this.sessions.size} sessions`);
|
||||
this.sessions.forEach((session, id) => {
|
||||
try {
|
||||
// Clean up flush timeout
|
||||
|
||||
Reference in New Issue
Block a user