Files
automaker/libs/utils/src/atomic-writer.ts
Stefan de Vogelaere a1f234c7e2 feat: Claude Compatible Providers System (#629)
* feat: refactor Claude API Profiles to Claude Compatible Providers

- Rename ClaudeApiProfile to ClaudeCompatibleProvider with models[] array
- Each ProviderModel has mapsToClaudeModel field for Claude tier mapping
- Add providerType field for provider-specific icons (glm, minimax, openrouter)
- Add thinking level support for provider models in phase selectors
- Show all mapped Claude models per provider model (e.g., "Maps to Haiku, Sonnet, Opus")
- Add Bulk Replace feature to switch all phases to a provider at once
- Hide Bulk Replace button when no providers are enabled
- Fix project-level phaseModelOverrides not persisting after refresh
- Fix deleting last provider not persisting (remove empty array guard)
- Add getProviderByModelId() helper for all SDK routes
- Update all routes to pass provider config for provider models
- Update terminology from "profiles" to "providers" throughout UI
- Update documentation to reflect new provider system

* fix: atomic writer race condition and bulk replace reset to defaults

1. AtomicWriter Race Condition Fix (libs/utils/src/atomic-writer.ts):
   - Changed temp file naming from Date.now() to Date.now() + random hex
   - Uses crypto.randomBytes(4).toString('hex') for uniqueness
   - Prevents ENOENT errors when multiple concurrent writes happen
     within the same millisecond

2. Bulk Replace "Anthropic Direct" Reset (both dialogs):
   - When selecting "Anthropic Direct", now uses DEFAULT_PHASE_MODELS
   - Properly resets thinking levels and other settings to defaults
   - Added thinkingLevel to the change detection comparison
   - Affects both global and project-level bulk replace dialogs

* fix: update tests for new model resolver passthrough behavior

1. model-resolver tests:
   - Unknown models now pass through unchanged (provider model support)
   - Removed expectations for warnings on unknown models
   - Updated case sensitivity and edge case tests accordingly
   - Added tests for provider-like model names (GLM-4.7, MiniMax-M2.1)

2. atomic-writer tests:
   - Updated regex to match new temp file format with random suffix
   - Format changed from .tmp.{timestamp} to .tmp.{timestamp}.{hex}

* refactor: simplify getPhaseModelWithOverrides calls per code review

Address code review feedback on PR #629:
- Make settingsService parameter optional in getPhaseModelWithOverrides
- Function now handles undefined settingsService gracefully by returning defaults
- Remove redundant ternary checks in 4 call sites:
  - apps/server/src/routes/context/routes/describe-file.ts
  - apps/server/src/routes/context/routes/describe-image.ts
  - apps/server/src/routes/worktree/routes/generate-commit-message.ts
  - apps/server/src/services/auto-mode-service.ts
- Remove unused DEFAULT_PHASE_MODELS imports where applicable

* test: fix server tests for provider model passthrough behavior

- Update model-resolver.test.ts to expect unknown models to pass through
  unchanged (supports ClaudeCompatibleProvider models like GLM-4.7)
- Remove warning expectations for unknown models (valid for providers)
- Add missing getCredentials and getGlobalSettings mocks to
  ideation-service.test.ts for settingsService

* fix: address code review feedback for model providers

- Honor thinkingLevel in generate-commit-message.ts
- Pass claudeCompatibleProvider in ideation-service.ts for provider models
- Resolve provider configuration for model overrides in generate-suggestions.ts
- Update "Active Profile" to "Active Provider" label in project-claude-section
- Use substring instead of deprecated substr in api-profiles-section
- Preserve provider enabled state when editing in api-profiles-section

* fix: address CodeRabbit review issues for Claude Compatible Providers

- Fix TypeScript TS2339 error in generate-suggestions.ts where
  settingsService was narrowed to 'never' type in else branch
- Use DEFAULT_PHASE_MODELS per-phase defaults instead of hardcoded
  'sonnet' in settings-helpers.ts
- Remove duplicate eventHooks key in use-settings-migration.ts
- Add claudeCompatibleProviders to localStorage migration parsing
  and merging functions
- Handle canonical claude-* model IDs (claude-haiku, claude-sonnet,
  claude-opus) in project-models-section display names

This resolves the CI build failures and addresses code review feedback.

* fix: skip broken list-view-priority E2E test and add Priority column label

- Skip list-view-priority.spec.ts with TODO explaining the infrastructure
  issue: setupRealProject only sets localStorage but server settings
  take precedence with localStorageMigrated: true
- Add 'Priority' label to list-header.tsx for the priority column
  (was empty string, now shows proper header text)
- Increase column width to accommodate the label

The E2E test issue is that tests create features in a temp directory,
but the server loads from the E2E Test Project fixture path set in
setup-e2e-fixtures.mjs. Needs infrastructure fix to properly switch
projects or create features through UI instead of on disk.
2026-01-20 20:57:23 +01:00

366 lines
12 KiB
TypeScript

/**
* Atomic file writing utilities for JSON data
*
* Provides atomic write operations using temp-file + rename pattern,
* ensuring data integrity even during crashes or power failures.
*/
import { secureFs } from '@automaker/platform';
import path from 'path';
import crypto from 'crypto';
import { createLogger } from './logger.js';
import { mkdirSafe } from './fs-utils.js';
const logger = createLogger('AtomicWriter');
/** Default maximum number of backup files to keep for crash recovery */
export const DEFAULT_BACKUP_COUNT = 3;
/**
* Options for atomic write operations
*/
export interface AtomicWriteOptions {
/** Number of spaces for JSON indentation (default: 2) */
indent?: number;
/** Create parent directories if they don't exist (default: false) */
createDirs?: boolean;
/** Number of backup files to keep (0 = no backups, default: 0). When > 0, rotates .bak1, .bak2, etc. */
backupCount?: number;
}
/**
* Rotate backup files (.bak1 -> .bak2 -> .bak3, oldest is deleted)
* and create a new backup from the current file.
*
* @param filePath - Absolute path to the file being backed up
* @param maxBackups - Maximum number of backup files to keep
*/
export async function rotateBackups(
filePath: string,
maxBackups: number = DEFAULT_BACKUP_COUNT
): Promise<void> {
// Check if the source file exists before attempting backup
try {
await secureFs.access(filePath);
} catch {
// No existing file to backup
return;
}
// Rotate existing backups: .bak3 is deleted, .bak2 -> .bak3, .bak1 -> .bak2
for (let i = maxBackups; i >= 1; i--) {
const currentBackup = `${filePath}.bak${i}`;
const nextBackup = `${filePath}.bak${i + 1}`;
try {
if (i === maxBackups) {
// Delete the oldest backup
await secureFs.unlink(currentBackup);
} else {
// Rename current backup to next slot
await secureFs.rename(currentBackup, nextBackup);
}
} catch {
// Ignore errors - backup file may not exist
}
}
// Copy current file to .bak1
try {
await secureFs.copyFile(filePath, `${filePath}.bak1`);
} catch (error) {
logger.warn(`Failed to create backup of ${filePath}:`, error);
// Continue with write even if backup fails
}
}
/**
* Atomically write JSON data to a file.
*
* Uses the temp-file + rename pattern for atomicity:
* 1. Writes data to a temporary file
* 2. Atomically renames temp file to target path
* 3. Cleans up temp file on error
*
* @param filePath - Absolute path to the target file
* @param data - Data to serialize as JSON
* @param options - Optional write options
* @throws Error if write fails (temp file is cleaned up)
*
* @example
* ```typescript
* await atomicWriteJson('/path/to/config.json', { key: 'value' });
* await atomicWriteJson('/path/to/data.json', data, { indent: 4, createDirs: true });
* ```
*/
export async function atomicWriteJson<T>(
filePath: string,
data: T,
options: AtomicWriteOptions = {}
): Promise<void> {
const { indent = 2, createDirs = false, backupCount = 0 } = options;
const resolvedPath = path.resolve(filePath);
// Use timestamp + random suffix to ensure uniqueness even for concurrent writes
const uniqueSuffix = `${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
const tempPath = `${resolvedPath}.tmp.${uniqueSuffix}`;
// Create parent directories if requested
if (createDirs) {
const dirPath = path.dirname(resolvedPath);
await mkdirSafe(dirPath);
}
const content = JSON.stringify(data, null, indent);
try {
// Rotate backups before writing (if backups are enabled)
if (backupCount > 0) {
await rotateBackups(resolvedPath, backupCount);
}
await secureFs.writeFile(tempPath, content, 'utf-8');
await secureFs.rename(tempPath, resolvedPath);
} catch (error) {
// Clean up temp file if it exists
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors - best effort
}
logger.error(`Failed to atomically write to ${resolvedPath}:`, error);
throw error;
}
}
/**
* Safely read JSON from a file with fallback to default value.
*
* Returns the default value if:
* - File doesn't exist (ENOENT)
* - File content is invalid JSON
*
* @param filePath - Absolute path to the file
* @param defaultValue - Value to return if file doesn't exist or is invalid
* @returns Parsed JSON data or default value
*
* @example
* ```typescript
* const config = await readJsonFile('/path/to/config.json', { version: 1 });
* ```
*/
export async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
const resolvedPath = path.resolve(filePath);
try {
const content = (await secureFs.readFile(resolvedPath, 'utf-8')) as string;
return JSON.parse(content) as T;
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === 'ENOENT') {
return defaultValue;
}
logger.error(`Error reading JSON from ${resolvedPath}:`, error);
return defaultValue;
}
}
/**
* Atomically update a JSON file by reading, transforming, and writing.
*
* Provides a safe read-modify-write pattern:
* 1. Reads existing file (or uses default)
* 2. Applies updater function
* 3. Atomically writes result
*
* @param filePath - Absolute path to the file
* @param defaultValue - Default value if file doesn't exist
* @param updater - Function that transforms the data
* @param options - Optional write options
*
* @example
* ```typescript
* await updateJsonAtomically(
* '/path/to/counter.json',
* { count: 0 },
* (data) => ({ ...data, count: data.count + 1 })
* );
* ```
*/
export async function updateJsonAtomically<T>(
filePath: string,
defaultValue: T,
updater: (current: T) => T | Promise<T>,
options: AtomicWriteOptions = {}
): Promise<void> {
const current = await readJsonFile(filePath, defaultValue);
const updated = await updater(current);
await atomicWriteJson(filePath, updated, options);
}
/**
* Result of a JSON read operation with recovery information
*/
export interface ReadJsonRecoveryResult<T> {
/** The data that was successfully read */
data: T;
/** Whether recovery was needed (main file was corrupted or missing) */
recovered: boolean;
/** Source of the data: 'main', 'backup', 'temp', or 'default' */
source: 'main' | 'backup' | 'temp' | 'default';
/** Error message if the main file had an issue */
error?: string;
}
/**
* Options for readJsonWithRecovery
*/
export interface ReadJsonRecoveryOptions {
/** Maximum number of backup files to check (.bak1, .bak2, etc.) Default: 3 */
maxBackups?: number;
/** Whether to automatically restore main file from backup when corrupted. Default: true */
autoRestore?: boolean;
}
/**
* Log a warning if recovery was needed (from backup or temp file).
*
* Use this helper to reduce duplicate logging code when using readJsonWithRecovery.
*
* @param result - The result from readJsonWithRecovery
* @param identifier - A human-readable identifier for the file being recovered (e.g., "Feature abc123")
* @param loggerInstance - Optional logger instance to use (defaults to AtomicWriter logger)
*
* @example
* ```typescript
* const result = await readJsonWithRecovery(featurePath, null);
* logRecoveryWarning(result, `Feature ${featureId}`);
* ```
*/
export function logRecoveryWarning<T>(
result: ReadJsonRecoveryResult<T>,
identifier: string,
loggerInstance: { warn: (msg: string, ...args: unknown[]) => void } = logger
): void {
if (result.recovered && result.source !== 'default') {
loggerInstance.warn(`${identifier} was recovered from ${result.source}: ${result.error}`);
}
}
/**
* Read JSON file with automatic recovery from backups.
*
* This function attempts to read a JSON file with fallback to backups:
* 1. Try to read the main file
* 2. If corrupted, check for temp files (.tmp.*) that might have valid data
* 3. If no valid temp file, try backup files (.bak1, .bak2, .bak3)
* 4. If all fail, return the default value
*
* Optionally restores the main file from a valid backup (autoRestore: true).
*
* @param filePath - Absolute path to the file
* @param defaultValue - Value to return if no valid data found
* @param options - Recovery options
* @returns Result containing the data and recovery information
*
* @example
* ```typescript
* const result = await readJsonWithRecovery('/path/to/config.json', { version: 1 });
* if (result.recovered) {
* console.log(`Recovered from ${result.source}: ${result.error}`);
* }
* const config = result.data;
* ```
*/
export async function readJsonWithRecovery<T>(
filePath: string,
defaultValue: T,
options: ReadJsonRecoveryOptions = {}
): Promise<ReadJsonRecoveryResult<T>> {
const { maxBackups = 3, autoRestore = true } = options;
const resolvedPath = path.resolve(filePath);
const dirPath = path.dirname(resolvedPath);
const fileName = path.basename(resolvedPath);
// Try to read the main file first
try {
const content = (await secureFs.readFile(resolvedPath, 'utf-8')) as string;
const data = JSON.parse(content) as T;
return { data, recovered: false, source: 'main' };
} catch (mainError) {
const nodeError = mainError as NodeJS.ErrnoException;
const errorMessage =
nodeError.code === 'ENOENT'
? 'File does not exist'
: `Failed to parse: ${mainError instanceof Error ? mainError.message : String(mainError)}`;
// If file doesn't exist, check for temp files or backups
logger.warn(`Main file ${resolvedPath} unavailable: ${errorMessage}`);
// Try to find and recover from temp files first (in case of interrupted write)
try {
const files = (await secureFs.readdir(dirPath)) as string[];
const tempFiles = files
.filter((f: string) => f.startsWith(`${fileName}.tmp.`))
.sort()
.reverse(); // Most recent first
for (const tempFile of tempFiles) {
const tempPath = path.join(dirPath, tempFile);
try {
const content = (await secureFs.readFile(tempPath, 'utf-8')) as string;
const data = JSON.parse(content) as T;
logger.info(`Recovered data from temp file: ${tempPath}`);
// Optionally restore main file from temp
if (autoRestore) {
try {
await secureFs.rename(tempPath, resolvedPath);
logger.info(`Restored main file from temp: ${tempPath}`);
} catch (restoreError) {
logger.warn(`Failed to restore main file from temp: ${restoreError}`);
}
}
return { data, recovered: true, source: 'temp', error: errorMessage };
} catch {
// This temp file is also corrupted, try next
continue;
}
}
} catch {
// Could not read directory, skip temp file check
}
// Try backup files (.bak1, .bak2, .bak3)
for (let i = 1; i <= maxBackups; i++) {
const backupPath = `${resolvedPath}.bak${i}`;
try {
const content = (await secureFs.readFile(backupPath, 'utf-8')) as string;
const data = JSON.parse(content) as T;
logger.info(`Recovered data from backup: ${backupPath}`);
// Optionally restore main file from backup
if (autoRestore) {
try {
await secureFs.copyFile(backupPath, resolvedPath);
logger.info(`Restored main file from backup: ${backupPath}`);
} catch (restoreError) {
logger.warn(`Failed to restore main file from backup: ${restoreError}`);
}
}
return { data, recovered: true, source: 'backup', error: errorMessage };
} catch {
// This backup doesn't exist or is corrupted, try next
continue;
}
}
// All recovery attempts failed, return default
logger.warn(`All recovery attempts failed for ${resolvedPath}, using default value`);
return { data: defaultValue, recovered: true, source: 'default', error: errorMessage };
}
}