410 lines
12 KiB
JavaScript
410 lines
12 KiB
JavaScript
/**
|
|
* oc-profile-config.cjs — Profile configuration operations for oc_config.json
|
|
*
|
|
* Provides functions for loading, validating, and applying profiles from .planning/oc_config.json.
|
|
* Uses separate oc_config.json file (NOT config.json from Phase 15).
|
|
* Follows validate-then-modify pattern with atomic transactions.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { output, error: outputError, createBackup } = require('./oc-core.cjs');
|
|
const { getModelCatalog } = require('./oc-models.cjs');
|
|
const { applyProfileToOpencode } = require('./oc-config.cjs');
|
|
|
|
/**
|
|
* Error codes for oc_config.json operations
|
|
*/
|
|
const ERROR_CODES = {
|
|
CONFIG_NOT_FOUND: 'CONFIG_NOT_FOUND',
|
|
INVALID_JSON: 'INVALID_JSON',
|
|
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND',
|
|
INVALID_MODELS: 'INVALID_MODELS',
|
|
INCOMPLETE_PROFILE: 'INCOMPLETE_PROFILE',
|
|
WRITE_FAILED: 'WRITE_FAILED',
|
|
APPLY_FAILED: 'APPLY_FAILED',
|
|
ROLLBACK_FAILED: 'ROLLBACK_FAILED'
|
|
};
|
|
|
|
/**
|
|
* Load oc_config.json from .planning directory
|
|
*
|
|
* @param {string} cwd - Current working directory
|
|
* @returns {Object} {success: true, config, configPath} or {success: false, error: {code, message}}
|
|
*/
|
|
function loadOcProfileConfig(cwd) {
|
|
try {
|
|
const configPath = path.join(cwd, '.planning', 'oc_config.json');
|
|
|
|
if (!fs.existsSync(configPath)) {
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: ERROR_CODES.CONFIG_NOT_FOUND,
|
|
message: `.planning/oc_config.json not found at ${configPath}`
|
|
}
|
|
};
|
|
}
|
|
|
|
const content = fs.readFileSync(configPath, 'utf8');
|
|
const config = JSON.parse(content);
|
|
|
|
return {
|
|
success: true,
|
|
config,
|
|
configPath
|
|
};
|
|
} catch (err) {
|
|
if (err instanceof SyntaxError) {
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: ERROR_CODES.INVALID_JSON,
|
|
message: `Invalid JSON in oc_config.json: ${err.message}`
|
|
}
|
|
};
|
|
}
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: ERROR_CODES.CONFIG_NOT_FOUND,
|
|
message: `Failed to read oc_config.json: ${err.message}`
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate a profile definition against model whitelist and completeness requirements
|
|
*
|
|
* @param {Object} config - oc_config.json config object
|
|
* @param {string} profileName - Name of profile to validate
|
|
* @param {string[]} validModels - Array of valid model IDs (from getModelCatalog)
|
|
* @returns {Object} {valid: boolean, errors: [{code, message, field}]}
|
|
*/
|
|
function validateProfile(config, profileName, validModels) {
|
|
const errors = [];
|
|
|
|
// Check if profile exists in presets
|
|
const presets = config.profiles?.presets;
|
|
if (!presets || !presets[profileName]) {
|
|
errors.push({
|
|
code: ERROR_CODES.PROFILE_NOT_FOUND,
|
|
message: `Profile "${profileName}" not found in profiles.presets`,
|
|
field: 'profiles.presets'
|
|
});
|
|
return { valid: false, errors };
|
|
}
|
|
|
|
const profile = presets[profileName];
|
|
|
|
// Check for complete profile definition (all three keys required)
|
|
const requiredKeys = ['planning', 'execution', 'verification'];
|
|
const missingKeys = requiredKeys.filter(key => !profile[key]);
|
|
|
|
if (missingKeys.length > 0) {
|
|
errors.push({
|
|
code: ERROR_CODES.INCOMPLETE_PROFILE,
|
|
message: `Profile "${profileName}" is missing required keys: ${missingKeys.join(', ')}`,
|
|
field: 'profiles.presets.' + profileName,
|
|
missingKeys
|
|
});
|
|
// Return early - can't validate models if profile is incomplete
|
|
return { valid: false, errors };
|
|
}
|
|
|
|
// Validate all models against whitelist
|
|
const invalidModels = [];
|
|
for (const key of requiredKeys) {
|
|
const modelId = profile[key];
|
|
if (!validModels.includes(modelId)) {
|
|
invalidModels.push({
|
|
key,
|
|
model: modelId,
|
|
reason: 'Model ID not found in opencode models catalog'
|
|
});
|
|
}
|
|
}
|
|
|
|
if (invalidModels.length > 0) {
|
|
errors.push({
|
|
code: ERROR_CODES.INVALID_MODELS,
|
|
message: `Profile "${profileName}" contains ${invalidModels.length} invalid model ID(s)`,
|
|
field: 'profiles.presets.' + profileName,
|
|
invalidModels
|
|
});
|
|
}
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Apply profile with full validation, backup, and atomic transaction
|
|
*
|
|
* @param {string} cwd - Current working directory
|
|
* @param {string} profileName - Name of profile to apply
|
|
* @param {Object} options - Options object
|
|
* @param {boolean} options.dryRun - If true, preview changes without modifications
|
|
* @param {boolean} options.verbose - If true, output progress to console.error
|
|
* @param {Object} options.inlineProfile - Optional inline profile definition to create/update
|
|
* @returns {Object} {success: true, data: {profile, models, backup, updated}} or {success: false, error}
|
|
*/
|
|
function applyProfileWithValidation(cwd, profileName, options = {}) {
|
|
const { dryRun = false, verbose = false, inlineProfile = null } = options;
|
|
const log = verbose ? (...args) => console.error('[oc-profile-config]', ...args) : () => {};
|
|
|
|
// Step 1: Load oc_config.json
|
|
const loadResult = loadOcProfileConfig(cwd);
|
|
if (!loadResult.success) {
|
|
return { success: false, error: loadResult.error };
|
|
}
|
|
|
|
const { config, configPath } = loadResult;
|
|
let targetProfileName = profileName;
|
|
let profileToUpdate;
|
|
|
|
// Step 2: Handle inline profile definition (Mode 3)
|
|
if (inlineProfile) {
|
|
log('Processing inline profile definition');
|
|
|
|
// Check if profile already exists
|
|
const presets = config.profiles?.presets || {};
|
|
if (presets[profileName] && !dryRun) {
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: 'PROFILE_EXISTS',
|
|
message: `Profile "${profileName}" already exists. Use a different name or remove --inline flag.`
|
|
}
|
|
};
|
|
}
|
|
|
|
// Validate inline profile has all required keys
|
|
const requiredKeys = ['planning', 'execution', 'verification'];
|
|
const missingKeys = requiredKeys.filter(key => !inlineProfile[key]);
|
|
|
|
if (missingKeys.length > 0) {
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: ERROR_CODES.INCOMPLETE_PROFILE,
|
|
message: `Inline profile is missing required keys: ${missingKeys.join(', ')}`,
|
|
missingKeys
|
|
}
|
|
};
|
|
}
|
|
|
|
profileToUpdate = inlineProfile;
|
|
} else {
|
|
// Step 2: Use existing profile from config
|
|
const presets = config.profiles?.presets;
|
|
if (!presets || !presets[profileName]) {
|
|
const availableProfiles = presets ? Object.keys(presets).join(', ') : 'none';
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: ERROR_CODES.PROFILE_NOT_FOUND,
|
|
message: `Profile "${profileName}" not found in profiles.presets. Available profiles: ${availableProfiles}`
|
|
}
|
|
};
|
|
}
|
|
profileToUpdate = presets[profileName];
|
|
}
|
|
|
|
// Step 3: Get model catalog for validation
|
|
const catalogResult = getModelCatalog();
|
|
if (!catalogResult.success) {
|
|
return { success: false, error: catalogResult.error };
|
|
}
|
|
const validModels = catalogResult.models;
|
|
|
|
// Step 4: Validate profile models
|
|
const validation = validateProfile(
|
|
{ profiles: { presets: { [targetProfileName]: profileToUpdate } } },
|
|
targetProfileName,
|
|
validModels
|
|
);
|
|
|
|
if (!validation.valid) {
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: validation.errors[0].code,
|
|
message: validation.errors[0].message,
|
|
details: validation.errors
|
|
}
|
|
};
|
|
}
|
|
|
|
log('Profile validation passed');
|
|
|
|
// Step 5: Dry-run mode - return preview without modifications
|
|
if (dryRun) {
|
|
const opencodePath = path.join(cwd, 'opencode.json');
|
|
return {
|
|
success: true,
|
|
dryRun: true,
|
|
preview: {
|
|
profile: targetProfileName,
|
|
models: {
|
|
planning: profileToUpdate.planning,
|
|
execution: profileToUpdate.execution,
|
|
verification: profileToUpdate.verification
|
|
},
|
|
changes: {
|
|
oc_config: {
|
|
path: configPath,
|
|
updates: {
|
|
current_oc_profile: targetProfileName,
|
|
...(inlineProfile ? { 'profiles.presets': { [targetProfileName]: profileToUpdate } } : {})
|
|
}
|
|
},
|
|
opencode: {
|
|
path: opencodePath,
|
|
action: fs.existsSync(opencodePath) ? 'update' : 'create',
|
|
agentsToUpdate: getAgentsForProfile(profileToUpdate)
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// Step 6: Create backup of oc_config.json
|
|
log('Creating backup of oc_config.json');
|
|
const backupPath = createBackup(configPath, path.join(cwd, '.planning', 'backups'));
|
|
if (!backupPath) {
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: 'BACKUP_FAILED',
|
|
message: 'Failed to create backup of oc_config.json'
|
|
}
|
|
};
|
|
}
|
|
|
|
// Step 7: Update oc_config.json (atomic transaction start)
|
|
try {
|
|
// Update current_oc_profile
|
|
config.current_oc_profile = targetProfileName;
|
|
|
|
// Add inline profile if provided
|
|
if (inlineProfile) {
|
|
if (!config.profiles) config.profiles = {};
|
|
if (!config.profiles.presets) config.profiles.presets = {};
|
|
config.profiles.presets[targetProfileName] = inlineProfile;
|
|
}
|
|
|
|
// write updated oc_config.json
|
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
log('Updated oc_config.json');
|
|
} catch (err) {
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: ERROR_CODES.WRITE_FAILED,
|
|
message: `Failed to write oc_config.json: ${err.message}`
|
|
}
|
|
};
|
|
}
|
|
|
|
// Step 8: Apply to opencode.json
|
|
const opencodePath = path.join(cwd, 'opencode.json');
|
|
const applyResult = applyProfileToOpencode(opencodePath, configPath, targetProfileName);
|
|
|
|
if (!applyResult.success) {
|
|
// Step 9: Rollback oc_config.json on failure
|
|
log('Applying to opencode.json failed, rolling back');
|
|
try {
|
|
const backupContent = fs.readFileSync(backupPath, 'utf8');
|
|
fs.writeFileSync(configPath, backupContent, 'utf8');
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: ERROR_CODES.APPLY_FAILED,
|
|
message: applyResult.error.message,
|
|
rolledBack: true,
|
|
backupPath
|
|
}
|
|
};
|
|
} catch (rollbackErr) {
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: ERROR_CODES.ROLLBACK_FAILED,
|
|
message: `Failed to apply profile AND failed to rollback: ${rollbackErr.message}`,
|
|
originalError: applyResult.error,
|
|
backupPath
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
log('Successfully applied profile');
|
|
|
|
// Step 10: Return success with details
|
|
return {
|
|
success: true,
|
|
data: {
|
|
profile: targetProfileName,
|
|
models: {
|
|
planning: profileToUpdate.planning,
|
|
execution: profileToUpdate.execution,
|
|
verification: profileToUpdate.verification
|
|
},
|
|
backup: backupPath,
|
|
updated: applyResult.updated,
|
|
configPath
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get list of agent names that should be updated for a profile
|
|
* Helper function for dry-run preview
|
|
*
|
|
* @param {Object} profile - Profile object with planning/execution/verification
|
|
* @returns {Array} Array of {agent, model} objects
|
|
*/
|
|
function getAgentsForProfile(profile) {
|
|
const PROFILE_AGENT_MAPPING = {
|
|
planning: [
|
|
'gsd-planner',
|
|
'gsd-plan-checker',
|
|
'gsd-phase-researcher',
|
|
'gsd-roadmapper',
|
|
'gsd-project-researcher',
|
|
'gsd-research-synthesizer',
|
|
'gsd-codebase-mapper'
|
|
],
|
|
execution: [
|
|
'gsd-executor',
|
|
'gsd-debugger'
|
|
],
|
|
verification: [
|
|
'gsd-verifier',
|
|
'gsd-integration-checker'
|
|
]
|
|
};
|
|
|
|
const agents = [];
|
|
for (const [category, agentNames] of Object.entries(PROFILE_AGENT_MAPPING)) {
|
|
if (profile[category]) {
|
|
for (const agentName of agentNames) {
|
|
agents.push({ agent: agentName, model: profile[category] });
|
|
}
|
|
}
|
|
}
|
|
return agents;
|
|
}
|
|
|
|
module.exports = {
|
|
loadOcProfileConfig,
|
|
validateProfile,
|
|
applyProfileWithValidation,
|
|
getAgentsForProfile,
|
|
ERROR_CODES
|
|
};
|