## Sidebar Enhancement - Change tabs to icon + small label layout for better space utilization - Add Teams tab with team collaboration entry point ## Settings Page Improvements - Connect theme toggle to gatewayStore.saveQuickConfig for persistence - Remove OpenFang backend download section, simplify UI - Add time range filter to UsageStats (7d/30d/all) - Add stat cards with icons (sessions, messages, input/output tokens) - Add token usage overview bar chart - Add 8 ZCLAW system skill definitions with categories ## Bug Fixes - Fix ChannelList duplicate content with deduplication logic - Integrate CreateTriggerModal in TriggersPanel - Add independent SecurityStatusPanel with 12 default enabled layers - Change workflow view to use SchedulerPanel as unified entry ## New Components - CreateTriggerModal: Event trigger creation modal - HandApprovalModal: Hand approval workflow dialog - HandParamsForm: Enhanced Hand parameter form - SecurityLayersPanel: 16-layer security status display ## Architecture - Add TOML config parsing support (toml-utils.ts, config-parser.ts) - Add request timeout and retry mechanism (request-helper.ts) - Add secure token storage (secure-storage.ts, secure_storage.rs) ## Tests - Add unit tests for config-parser, toml-utils, request-helper - Add team-client and teamStore tests ## Documentation - Update SYSTEM_ANALYSIS.md with Phase 8 completion - UI completion: 100% (30/30 components) - API coverage: 93% (63/68 endpoints) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
378 lines
11 KiB
TypeScript
378 lines
11 KiB
TypeScript
/**
|
|
* OpenFang Configuration Parser
|
|
*
|
|
* Provides configuration parsing, validation, and serialization for OpenFang TOML files.
|
|
*
|
|
* @module lib/config-parser
|
|
*/
|
|
|
|
import { tomlUtils, TomlParseError } from './toml-utils';
|
|
import type {
|
|
OpenFangConfig,
|
|
ConfigValidationResult,
|
|
ConfigValidationError,
|
|
ConfigValidationWarning,
|
|
ConfigFileMetadata,
|
|
ServerConfig,
|
|
AgentSectionConfig,
|
|
LLMConfig,
|
|
} from '../types/config';
|
|
|
|
/**
|
|
* Error class for configuration parsing errors
|
|
*/
|
|
export class ConfigParseError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly cause?: unknown
|
|
) {
|
|
super(message);
|
|
this.name = 'ConfigParseError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Error class for configuration validation errors (thrown when validation fails)
|
|
*/
|
|
export class ConfigValidationFailedError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly errors: ConfigValidationError[]
|
|
) {
|
|
super(message);
|
|
this.name = 'ConfigValidationFailedError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Required configuration fields with their paths
|
|
*/
|
|
const REQUIRED_FIELDS: Array<{ path: string; description: string }> = [
|
|
{ path: 'server', description: 'Server configuration' },
|
|
{ path: 'server.host', description: 'Server host address' },
|
|
{ path: 'server.port', description: 'Server port number' },
|
|
{ path: 'agent', description: 'Agent configuration' },
|
|
{ path: 'agent.defaults', description: 'Agent defaults' },
|
|
{ path: 'agent.defaults.workspace', description: 'Default workspace path' },
|
|
{ path: 'agent.defaults.default_model', description: 'Default model name' },
|
|
{ path: 'llm', description: 'LLM configuration' },
|
|
{ path: 'llm.default_provider', description: 'Default LLM provider' },
|
|
{ path: 'llm.default_model', description: 'Default LLM model' },
|
|
];
|
|
|
|
/**
|
|
* Default configuration values
|
|
*/
|
|
const DEFAULT_CONFIG: Partial<OpenFangConfig> = {
|
|
server: {
|
|
host: '127.0.0.1',
|
|
port: 50051,
|
|
websocket_port: 50051,
|
|
websocket_path: '/ws',
|
|
api_version: 'v1',
|
|
},
|
|
agent: {
|
|
defaults: {
|
|
workspace: '~/.openfang/workspace',
|
|
default_model: 'gpt-4',
|
|
},
|
|
},
|
|
llm: {
|
|
default_provider: 'openai',
|
|
default_model: 'gpt-4',
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Configuration parser and validator
|
|
*/
|
|
export const configParser = {
|
|
/**
|
|
* Parse TOML content into an OpenFang configuration object
|
|
*
|
|
* @param content - The TOML content to parse
|
|
* @param envVars - Optional environment variables for resolution
|
|
* @returns The parsed configuration object
|
|
* @throws ConfigParseError if parsing fails
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const config = configParser.parseConfig(tomlContent, { OPENAI_API_KEY: 'sk-...' });
|
|
* ```
|
|
*/
|
|
parseConfig: (content: string, envVars?: Record<string, string | undefined>): OpenFangConfig => {
|
|
try {
|
|
// First resolve environment variables
|
|
const resolved = tomlUtils.resolveEnvVars(content, envVars);
|
|
|
|
// Parse TOML
|
|
const parsed = tomlUtils.parse<OpenFangConfig>(resolved);
|
|
return parsed;
|
|
} catch (error) {
|
|
if (error instanceof TomlParseError) {
|
|
throw new ConfigParseError(`Failed to parse configuration: ${error.message}`, error);
|
|
}
|
|
throw new ConfigParseError(
|
|
`Unexpected error parsing configuration: ${error instanceof Error ? error.message : String(error)}`,
|
|
error
|
|
);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Validate an OpenFang configuration object
|
|
*
|
|
* @param config - The configuration object to validate
|
|
* @returns Validation result with errors and warnings
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const result = configParser.validateConfig(parsedConfig);
|
|
* if (!result.valid) {
|
|
* console.error('Config errors:', result.errors);
|
|
* }
|
|
* ```
|
|
*/
|
|
validateConfig: (config: unknown): ConfigValidationResult => {
|
|
const errors: ConfigValidationError[] = [];
|
|
const warnings: ConfigValidationWarning[] = [];
|
|
// Basic type check
|
|
if (typeof config !== 'object' || config === null) {
|
|
errors.push({
|
|
path: '',
|
|
message: 'Configuration must be a non-null object',
|
|
severity: 'error',
|
|
});
|
|
return { valid: false, errors, warnings };
|
|
}
|
|
const cfg = config as Record<string, unknown>;
|
|
// Check required fields
|
|
for (const { path, description } of REQUIRED_FIELDS) {
|
|
const value = getNestedValue(cfg, path);
|
|
if (value === undefined) {
|
|
errors.push({
|
|
path,
|
|
message: `Missing required field: ${description}`,
|
|
severity: 'error',
|
|
});
|
|
}
|
|
}
|
|
// Validate server configuration
|
|
if (cfg.server && typeof cfg.server === 'object') {
|
|
const server = cfg.server as ServerConfig;
|
|
if (typeof server.port === 'number' && (server.port < 1 || server.port > 65535)) {
|
|
errors.push({
|
|
path: 'server.port',
|
|
message: 'Port must be between 1 and 65535',
|
|
severity: 'error',
|
|
});
|
|
}
|
|
if (typeof server.host === 'string' && server.host.length === 0) {
|
|
errors.push({
|
|
path: 'server.host',
|
|
message: 'Host cannot be empty',
|
|
severity: 'error',
|
|
});
|
|
}
|
|
}
|
|
// Validate agent configuration
|
|
if (cfg.agent && typeof cfg.agent === 'object') {
|
|
const agent = cfg.agent as AgentSectionConfig;
|
|
if (agent.defaults) {
|
|
if (typeof agent.defaults.workspace === 'string' && agent.defaults.workspace.length === 0) {
|
|
warnings.push({
|
|
path: 'agent.defaults.workspace',
|
|
message: 'Workspace path is empty',
|
|
severity: 'warning',
|
|
});
|
|
}
|
|
if (typeof agent.defaults.default_model === 'string' && agent.defaults.default_model.length === 0) {
|
|
errors.push({
|
|
path: 'agent.defaults.default_model',
|
|
message: 'Default model cannot be empty',
|
|
severity: 'error',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
// Validate LLM configuration
|
|
if (cfg.llm && typeof cfg.llm === 'object') {
|
|
const llm = cfg.llm as LLMConfig;
|
|
if (typeof llm.default_provider === 'string' && llm.default_provider.length === 0) {
|
|
errors.push({
|
|
path: 'llm.default_provider',
|
|
message: 'Default provider cannot be empty',
|
|
severity: 'error',
|
|
});
|
|
}
|
|
if (typeof llm.default_model === 'string' && llm.default_model.length === 0) {
|
|
errors.push({
|
|
path: 'llm.default_model',
|
|
message: 'Default model cannot be empty',
|
|
severity: 'error',
|
|
});
|
|
}
|
|
}
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Parse and validate configuration in one step
|
|
*
|
|
* @param content - The TOML content to parse
|
|
* @param envVars - Optional environment variables for resolution
|
|
* @returns The parsed and validated configuration
|
|
* @throws ConfigParseError if parsing fails
|
|
* @throws ConfigValidationFailedError if validation fails
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const config = configParser.parseAndValidate(tomlContent);
|
|
* ```
|
|
*/
|
|
parseAndValidate: (
|
|
content: string,
|
|
envVars?: Record<string, string | undefined>
|
|
): OpenFangConfig => {
|
|
const config = configParser.parseConfig(content, envVars);
|
|
const result = configParser.validateConfig(config);
|
|
if (!result.valid) {
|
|
throw new ConfigValidationFailedError(
|
|
`Configuration validation failed: ${result.errors.map((e) => e.message).join(', ')}`,
|
|
result.errors
|
|
);
|
|
}
|
|
return config;
|
|
},
|
|
|
|
/**
|
|
* Serialize a configuration object to TOML format
|
|
*
|
|
* @param config - The configuration object to serialize
|
|
* @returns The TOML string
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const toml = configParser.stringifyConfig(config);
|
|
* ```
|
|
*/
|
|
stringifyConfig: (config: OpenFangConfig): string => {
|
|
return tomlUtils.stringify(config as unknown as Record<string, unknown>);
|
|
},
|
|
|
|
/**
|
|
* Merge partial configuration with defaults
|
|
*
|
|
* @param config - Partial configuration to merge
|
|
* @returns Complete configuration with defaults applied
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const fullConfig = configParser.mergeWithDefaults(partialConfig);
|
|
* ```
|
|
*/
|
|
mergeWithDefaults: (config: Partial<OpenFangConfig>): OpenFangConfig => {
|
|
return deepMerge(DEFAULT_CONFIG, config) as unknown as OpenFangConfig;
|
|
},
|
|
|
|
/**
|
|
* Extract metadata from a TOML configuration file
|
|
*
|
|
* @param content - The TOML content
|
|
* @param filePath - The file path
|
|
* @returns Configuration file metadata
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const metadata = configParser.extractMetadata(tomlContent, '/path/to/config.toml');
|
|
* console.log('Env vars needed:', metadata.envVars);
|
|
* ```
|
|
*/
|
|
extractMetadata: (content: string, filePath: string): ConfigFileMetadata => {
|
|
const envVars = tomlUtils.extractEnvVarNames(content);
|
|
const hasUnresolvedEnvVars = tomlUtils.hasUnresolvedEnvVars(content);
|
|
return {
|
|
path: filePath,
|
|
name: filePath.split('/').pop() || filePath,
|
|
envVars,
|
|
hasUnresolvedEnvVars,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Get default configuration
|
|
*
|
|
* @returns Default OpenFang configuration
|
|
*/
|
|
getDefaults: (): OpenFangConfig => {
|
|
return JSON.parse(JSON.stringify(DEFAULT_CONFIG)) as OpenFangConfig;
|
|
},
|
|
|
|
/**
|
|
* Check if a configuration object is valid
|
|
*
|
|
* @param config - The configuration to check
|
|
* @returns Type guard for OpenFangConfig
|
|
*/
|
|
isOpenFangConfig: (config: unknown): config is OpenFangConfig => {
|
|
const result = configParser.validateConfig(config);
|
|
return result.valid;
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Helper function to get a nested value from an object using dot-notation path
|
|
*/
|
|
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
|
const parts = path.split('.');
|
|
let current: unknown = obj;
|
|
for (const part of parts) {
|
|
if (current === null || current === undefined) {
|
|
return undefined;
|
|
}
|
|
if (typeof current !== 'object') {
|
|
return undefined;
|
|
}
|
|
current = (current as Record<string, unknown>)[part];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* Helper function to deep merge two objects
|
|
*/
|
|
function deepMerge<T extends Record<string, unknown>>(
|
|
target: Partial<T>,
|
|
source: Partial<T>
|
|
): Partial<T> {
|
|
const result = { ...target };
|
|
for (const key of Object.keys(source) as (keyof T)[]) {
|
|
const sourceValue = source[key];
|
|
const targetValue = target[key];
|
|
if (
|
|
sourceValue !== undefined &&
|
|
typeof sourceValue === 'object' &&
|
|
sourceValue !== null &&
|
|
!Array.isArray(sourceValue) &&
|
|
targetValue !== undefined &&
|
|
typeof targetValue === 'object' &&
|
|
targetValue !== null &&
|
|
!Array.isArray(targetValue)
|
|
) {
|
|
result[key] = deepMerge(
|
|
targetValue as Record<string, unknown>,
|
|
sourceValue as Record<string, unknown>
|
|
) as T[keyof T];
|
|
} else if (sourceValue !== undefined) {
|
|
// Safe assignment: sourceValue is typed as Partial<T>[keyof T], result[key] expects T[keyof T]
|
|
result[key] = sourceValue as T[keyof T];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
export default configParser;
|