UNPKG

aios-core

Version:

Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework

752 lines (650 loc) 24.5 kB
#!/usr/bin/env node 'use strict'; const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); const lockfile = require('proper-lockfile'); const { RegistryLoader } = require(path.resolve(__dirname, 'registry-loader.js')); const { extractEntityId, extractKeywords, extractPurpose, detectDependencies, computeChecksum, resolveUsedBy, SCAN_CONFIG, ADAPTABILITY_DEFAULTS, REPO_ROOT, REGISTRY_PATH, } = require(path.resolve(__dirname, '../../development/scripts/populate-entity-registry.js')); const { enrichRegistryEntry } = require(path.resolve(__dirname, '../code-intel/helpers/creation-helper')); const { classifyLayer } = require(path.resolve(__dirname, 'layer-classifier')); const LOCK_FILE = path.resolve(REPO_ROOT, '.aios-core/data/.entity-registry.lock'); const BACKUP_DIR = path.resolve(REPO_ROOT, '.aios-core/data/registry-backups'); const AUDIT_LOG_PATH = path.resolve(REPO_ROOT, '.aios-core/data/registry-update-log.jsonl'); const MAX_AUDIT_LOG_SIZE = 5 * 1024 * 1024; // 5MB const DEBOUNCE_MS = 100; const LOCK_TIMEOUT_MS = 5000; const LOCK_RETRY_COUNT = 3; const LOCK_RETRY_DELAY_MS = 100; const LOCK_STALE_MS = 10000; const WATCH_PATHS = SCAN_CONFIG.map((c) => c.basePath); const INCLUDE_EXTENSIONS = new Set(['.md', '.yaml', '.yml', '.js', '.ts', '.mjs']); const EXCLUDE_PATTERNS = [ /node_modules/, /\.test\.js$/, /\.spec\.js$/, /README\.md$/i, /registry-update-log\.jsonl$/, /\.entity-registry\.lock$/, /registry-backups/, /entity-registry\.yaml$/, ]; class RegistryUpdater { constructor(options = {}) { this._registryPath = options.registryPath || REGISTRY_PATH; // Resolve repoRoot to real path to handle macOS /var -> /private/var symlink // This ensures path.relative() works correctly with resolved file paths const rawRepoRoot = options.repoRoot || REPO_ROOT; try { this._repoRoot = fs.realpathSync(rawRepoRoot); } catch { this._repoRoot = rawRepoRoot; } this._debounceMs = options.debounceMs ?? DEBOUNCE_MS; this._auditLogPath = options.auditLogPath || AUDIT_LOG_PATH; this._lockFile = options.lockFile || LOCK_FILE; this._backupDir = options.backupDir || BACKUP_DIR; this._watcher = null; this._pendingUpdates = new Map(); this._debounceTimer = null; this._isProcessing = false; this._loader = new RegistryLoader(this._registryPath); this._updateCount = 0; } /** * Start file system watcher (persistent mode). * Returns the chokidar watcher instance. */ startWatcher() { const chokidar = require('chokidar'); const watchPaths = WATCH_PATHS.map((p) => { const abs = path.resolve(this._repoRoot, p); return abs.replace(/\\/g, '/'); }).filter((p) => fs.existsSync(p)); if (watchPaths.length === 0) { console.warn('[IDS-Updater] No watch paths found. Watcher not started.'); return null; } this._watcher = chokidar.watch(watchPaths, { persistent: true, ignoreInitial: true, ignored: (filePath) => this._isExcluded(filePath), followSymlinks: true, awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 }, }); this._watcher .on('add', (filePath) => this._queueUpdate('add', filePath)) .on('change', (filePath) => this._queueUpdate('change', filePath)) .on('unlink', (filePath) => this._queueUpdate('unlink', filePath)) .on('error', (err) => { console.error(`[IDS-Updater] Watcher error: ${err.message}`); }); console.log(`[IDS-Updater] Watching ${watchPaths.length} paths for changes.`); return this._watcher; } /** * Stop the file system watcher. */ async stopWatcher() { if (this._watcher) { await this._watcher.close(); this._watcher = null; console.log('[IDS-Updater] Watcher stopped.'); } if (this._debounceTimer) { clearTimeout(this._debounceTimer); this._debounceTimer = null; } } /** * Process specific files on-demand (CLI / hook mode). * Resolves symlinks before filtering and processing. * @param {Array<{action: string, filePath: string}>} changes */ async processChanges(changes) { if (!changes || changes.length === 0) return { updated: 0, errors: [] }; const validChanges = changes .filter((c) => c && typeof c.filePath === 'string' && c.filePath && c.action) .map((c) => { const abs = path.isAbsolute(c.filePath) ? c.filePath : path.resolve(this._repoRoot, c.filePath); // For unlink, file doesn't exist so _resolveSymlink returns original path // We need to manually resolve the path to match _repoRoot format on macOS let resolved = this._resolveSymlink(abs); if (c.action === 'unlink' && resolved === abs) { // File doesn't exist - normalize path manually for macOS /var -> /private/var // This ensures path.relative() works correctly with _repoRoot resolved = abs.replace(/^\/var\//, '/private/var/'); } return { action: c.action, filePath: resolved }; }) .filter((c) => { // For unlink (delete), skip file existence checks - the file no longer exists // We only need to verify the path would be in scope and not excluded if (c.action === 'unlink') { if (this._isExcluded(c.filePath)) return false; // For deletions, check if path matches any known category pattern const relPath = path.relative(this._repoRoot, c.filePath).replace(/\\/g, '/'); return this._detectCategory(relPath) !== null; } // For add/change, use standard inclusion check return !this._isExcluded(c.filePath) && this._isIncluded(c.filePath); }); if (validChanges.length === 0) return { updated: 0, errors: [] }; return this._executeBatch(validChanges); } /** * Hook for agent task completion. * @param {object} task - Task metadata * @param {string[]} artifacts - List of affected file paths */ async onAgentTaskComplete(task, artifacts) { if (!artifacts || artifacts.length === 0) return { updated: 0, errors: [] }; const changes = []; for (const filePath of artifacts) { const abs = path.isAbsolute(filePath) ? filePath : path.resolve(this._repoRoot, filePath); if (fs.existsSync(abs)) { changes.push({ action: 'change', filePath: abs }); } else { changes.push({ action: 'unlink', filePath: abs }); } } const result = await this.processChanges(changes); this._logAudit({ trigger: 'agent-task-complete', taskId: task?.id || 'unknown', agent: task?.agent || 'unknown', artifactCount: artifacts.length, updated: result.updated, }); return result; } // ─── Internal: Queuing & Debouncing ────────────────────────────── _queueUpdate(action, filePath) { const normalized = path.resolve(filePath).replace(/\\/g, '/'); this._pendingUpdates.set(normalized, { action, filePath: normalized, timestamp: Date.now() }); if (this._debounceTimer) { clearTimeout(this._debounceTimer); } this._debounceTimer = setTimeout(() => { this._flushPending().catch((err) => { console.error(`[IDS-Updater] Flush failed: ${err.message}`); this._isProcessing = false; }); }, this._debounceMs); } async _flushPending() { if (this._pendingUpdates.size === 0) return; if (this._isProcessing) { // Re-schedule flush — don't drop pending updates if (!this._debounceTimer) { this._debounceTimer = setTimeout(() => { this._flushPending().catch((err) => { console.error(`[IDS-Updater] Deferred flush failed: ${err.message}`); this._isProcessing = false; }); }, this._debounceMs); } return; } const batch = Array.from(this._pendingUpdates.values()); this._pendingUpdates.clear(); this._debounceTimer = null; await this._executeBatch(batch); } // ─── Internal: Batch Execution with Locking ────────────────────── async _executeBatch(batch) { this._isProcessing = true; const errors = []; let updated = 0; try { await this._withLock(async () => { const registry = this._loadRegistry(); for (const { action, filePath } of batch) { try { const abs = path.isAbsolute(filePath) ? filePath : path.resolve(this._repoRoot, filePath); let mutated = false; switch (action) { case 'add': mutated = this._handleFileCreate(registry, abs); break; case 'change': mutated = this._handleFileModify(registry, abs); break; case 'unlink': mutated = this._handleFileDelete(registry, abs); break; default: console.warn(`[IDS-Updater] Unknown action: ${action}`); } if (mutated) updated++; this._logAudit({ action, path: path.relative(this._repoRoot, abs).replace(/\\/g, '/'), trigger: 'watcher' }); } catch (err) { const relPath = path.relative(this._repoRoot, filePath).replace(/\\/g, '/'); errors.push({ path: relPath, error: err.message }); console.error(`[IDS-Updater] Error processing ${relPath}: ${err.message}`); } } if (updated > 0) { this._resolveAllUsedBy(registry); // NOG-8: Apply code intelligence enrichment AFTER resolveAllUsedBy // so that code-intel usedBy data is merged on top of static graph await this._applyCodeIntelEnrichments(registry); registry.metadata.lastUpdated = new Date().toISOString(); registry.metadata.entityCount = this._countEntities(registry); this._writeRegistry(registry); } }); } catch (err) { console.error(`[IDS-Updater] Batch execution failed: ${err.message}`); errors.push({ path: 'batch', error: err.message }); } finally { this._isProcessing = false; } this._updateCount += updated; return { updated, errors }; } // ─── Internal: File Handlers (AC 2, 3, 4) ─────────────────────── _handleFileCreate(registry, absPath) { const relPath = path.relative(this._repoRoot, absPath).replace(/\\/g, '/'); const config = this._detectCategory(relPath); if (!config) return false; let content = ''; try { content = fs.readFileSync(absPath, 'utf8'); } catch (err) { if (err.code === 'EACCES' || err.code === 'EPERM') { console.warn(`[IDS-Updater] Permission denied reading ${relPath} — skipping`); return false; } throw err; } const entityId = extractEntityId(absPath); const category = config.category; if (!registry.entities[category]) { registry.entities[category] = {}; } const keywords = extractKeywords(absPath, content); const purpose = extractPurpose(content, absPath); const dependencies = detectDependencies(content, entityId); const checksum = computeChecksum(absPath); const defaultScore = ADAPTABILITY_DEFAULTS[config.type] || 0.5; registry.entities[category][entityId] = { path: relPath, layer: classifyLayer(relPath), type: config.type, purpose, keywords, usedBy: [], dependencies, adaptability: { score: defaultScore, constraints: [], extensionPoints: [], }, checksum, lastVerified: new Date().toISOString(), }; // NOG-8: Enrich with code intelligence data (advisory, never blocks registration) this._pendingEnrichments = this._pendingEnrichments || []; this._pendingEnrichments.push({ entityId, category, relPath }); return true; } _handleFileModify(registry, absPath) { const relPath = path.relative(this._repoRoot, absPath).replace(/\\/g, '/'); const config = this._detectCategory(relPath); if (!config) return false; const entityId = extractEntityId(absPath); const category = config.category; const existing = registry.entities[category]?.[entityId]; if (!existing) { return this._handleFileCreate(registry, absPath); } let content = ''; try { content = fs.readFileSync(absPath, 'utf8'); } catch (err) { if (err.code === 'EACCES' || err.code === 'EPERM') { console.warn(`[IDS-Updater] Permission denied reading ${relPath} — skipping`); return false; } throw err; } const newChecksum = computeChecksum(absPath); if (newChecksum !== existing.checksum) { existing.checksum = newChecksum; existing.purpose = extractPurpose(content, absPath); existing.keywords = extractKeywords(absPath, content); existing.dependencies = detectDependencies(content, entityId); } existing.lastVerified = new Date().toISOString(); return true; } _handleFileDelete(registry, absPath) { const relPath = path.relative(this._repoRoot, absPath).replace(/\\/g, '/'); const entityId = extractEntityId(absPath); let found = false; for (const [_category, entities] of Object.entries(registry.entities)) { if (entities[entityId] && entities[entityId].path === relPath) { delete entities[entityId]; found = true; for (const catEntities of Object.values(registry.entities)) { for (const entity of Object.values(catEntities)) { if (entity.usedBy) { entity.usedBy = entity.usedBy.filter((id) => id !== entityId); } } } console.log(`[IDS-Updater] Removed entity: ${entityId} (${relPath})`); break; } } if (!found) { for (const [_category, entities] of Object.entries(registry.entities)) { for (const [id, entity] of Object.entries(entities)) { if (entity.path === relPath) { delete entities[id]; for (const catEntities of Object.values(registry.entities)) { for (const e of Object.values(catEntities)) { if (e.usedBy) { e.usedBy = e.usedBy.filter((uid) => uid !== id); } } } console.log(`[IDS-Updater] Removed entity: ${id} (${relPath})`); found = true; break; } } if (found) break; } } return found; } // ─── Internal: Code Intelligence Enrichment (NOG-8) ──────────── /** * Apply code intelligence enrichment to newly created entities. * Advisory only — never blocks registration. Falls back gracefully. * @param {Object} registry - Registry data * @private */ async _applyCodeIntelEnrichments(registry) { const pending = this._pendingEnrichments || []; this._pendingEnrichments = []; for (const { entityId, category, relPath } of pending) { try { const entity = registry.entities[category]?.[entityId]; if (!entity) continue; const enrichment = await enrichRegistryEntry(entityId, relPath); if (!enrichment) continue; // Pre-populate usedBy if code intel found references if (enrichment.usedBy && enrichment.usedBy.length > 0) { entity.usedBy = [...new Set([...(entity.usedBy || []), ...enrichment.usedBy])]; } // Pre-populate dependencies if code intel found them if (enrichment.dependencies && enrichment.dependencies.nodes && enrichment.dependencies.nodes.length > 0) { const existingDeps = Array.isArray(entity.dependencies) ? entity.dependencies : []; const newDeps = enrichment.dependencies.nodes.filter((n) => typeof n === 'string'); entity.dependencies = [...new Set([...existingDeps, ...newDeps])]; } } catch { // NOG-8 AC5: Fallback — enrichment failure never blocks registration } } } // ─── Internal: Registry I/O ────────────────────────────────────── _loadRegistry() { const loader = new RegistryLoader(this._registryPath); return loader.load(); } _writeRegistry(registryData) { const yamlStr = yaml.dump(registryData, { lineWidth: 120, noRefs: true, sortKeys: false, }); const dir = path.dirname(this._registryPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } try { fs.writeFileSync(this._registryPath, yamlStr, 'utf8'); } catch (err) { if (err.code === 'ENOSPC') { console.error('[IDS-Updater] Disk full — registry write failed. Retrying not possible.'); throw err; } throw new Error(`[IDS-Updater] Failed to write registry: ${err.message}`); } } // ─── Internal: File Locking ────────────────────────────────────── async _withLock(operation) { const lockDir = path.dirname(this._lockFile); if (!fs.existsSync(lockDir)) { fs.mkdirSync(lockDir, { recursive: true }); } if (!fs.existsSync(this._registryPath)) { this._writeRegistry(this._loadRegistry()); } let release; try { release = await lockfile.lock(this._registryPath, { stale: LOCK_STALE_MS, retries: { retries: LOCK_RETRY_COUNT, minTimeout: LOCK_RETRY_DELAY_MS, maxTimeout: LOCK_TIMEOUT_MS, }, lockfilePath: this._lockFile, }); } catch (err) { throw new Error(`[IDS-Updater] Could not acquire lock: ${err.message}`); } try { await operation(); } finally { try { await release(); } catch { // Lock already released or stale } } } // ─── Internal: Audit Logging (AC 9) ───────────────────────────── _logAudit(entry) { const logEntry = { timestamp: new Date().toISOString(), ...entry, }; try { this._rotateLogIfNeeded(); fs.appendFileSync(this._auditLogPath, JSON.stringify(logEntry) + '\n', 'utf8'); } catch { // Audit logging should never break the main flow } } _rotateLogIfNeeded() { try { if (!fs.existsSync(this._auditLogPath)) return; const stat = fs.statSync(this._auditLogPath); if (stat.size >= MAX_AUDIT_LOG_SIZE) { if (!fs.existsSync(this._backupDir)) { fs.mkdirSync(this._backupDir, { recursive: true }); } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupPath = path.join(this._backupDir, `registry-update-log-${timestamp}.jsonl`); fs.renameSync(this._auditLogPath, backupPath); } } catch { // Rotation failure should not block operations } } // ─── Internal: Helpers ─────────────────────────────────────────── _detectCategory(relPath) { const normalized = relPath.replace(/\\/g, '/'); for (const config of SCAN_CONFIG) { if (normalized.startsWith(config.basePath)) { return config; } } return null; } _isExcluded(filePath) { const normalized = filePath.replace(/\\/g, '/'); return EXCLUDE_PATTERNS.some((pattern) => pattern.test(normalized)); } _isIncluded(filePath) { const ext = path.extname(filePath).toLowerCase(); if (!INCLUDE_EXTENSIONS.has(ext)) return false; const relPath = path.relative(this._repoRoot, filePath).replace(/\\/g, '/'); return this._detectCategory(relPath) !== null; } _resolveAllUsedBy(registry) { for (const catEntities of Object.values(registry.entities)) { for (const entity of Object.values(catEntities)) { entity.usedBy = []; } } resolveUsedBy(registry.entities); } _countEntities(registry) { let count = 0; for (const catEntities of Object.values(registry.entities)) { count += Object.keys(catEntities).length; } return count; } /** * Resolve symlinks to real path before processing. * Used in processChanges() for on-demand mode. * Watcher mode uses chokidar's followSymlinks: true instead. */ _resolveSymlink(filePath) { try { return fs.realpathSync(filePath); } catch { return filePath; } } /** * Get update statistics. */ getStats() { return { totalUpdates: this._updateCount, isWatching: this._watcher !== null, pendingUpdates: this._pendingUpdates.size, }; } /** * Query audit log entries. * @param {object} filter - { action, path, since, limit } */ queryAuditLog(filter = {}) { if (!fs.existsSync(this._auditLogPath)) return []; const lines = fs.readFileSync(this._auditLogPath, 'utf8').trim().split('\n').filter(Boolean); let entries = lines.map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean); if (filter.action) { entries = entries.filter((e) => e.action === filter.action); } if (filter.path) { entries = entries.filter((e) => e.path && e.path.includes(filter.path)); } if (filter.since) { const since = new Date(filter.since).getTime(); entries = entries.filter((e) => new Date(e.timestamp).getTime() >= since); } if (filter.limit) { entries = entries.slice(-filter.limit); } return entries; } } // ─── CLI Entrypoint ──────────────────────────────────────────────── if (require.main === module) { const args = process.argv.slice(2); if (args.includes('--watch')) { const updater = new RegistryUpdater(); updater.startWatcher(); console.log('[IDS-Updater] Running in watcher mode. Press Ctrl+C to stop.'); process.on('SIGINT', async () => { console.log('\n[IDS-Updater] Shutting down...'); await updater.stopWatcher(); const stats = updater.getStats(); console.log(`[IDS-Updater] Total updates: ${stats.totalUpdates}`); process.exit(0); }); process.on('SIGTERM', async () => { await updater.stopWatcher(); process.exit(0); }); } else if (args.includes('--files')) { const fileIdx = args.indexOf('--files'); const files = args.slice(fileIdx + 1); if (files.length === 0) { console.error('[IDS-Updater] No files specified. Usage: --files file1 file2 ...'); process.exit(1); } const updater = new RegistryUpdater(); const changes = files.map((f) => { const abs = path.isAbsolute(f) ? f : path.resolve(REPO_ROOT, f); const action = fs.existsSync(abs) ? 'change' : 'unlink'; return { action, filePath: abs }; }); updater.processChanges(changes).then((result) => { console.log(`[IDS-Updater] Processed ${result.updated} updates.`); if (result.errors.length > 0) { console.error('[IDS-Updater] Errors:', result.errors); process.exit(1); } process.exit(0); }).catch((err) => { console.error(`[IDS-Updater] Fatal error: ${err.message}`); process.exit(1); }); } else if (args.includes('--log')) { const updater = new RegistryUpdater(); const limitIdx = args.indexOf('--limit'); const limit = limitIdx >= 0 ? parseInt(args[limitIdx + 1], 10) : 20; const entries = updater.queryAuditLog({ limit }); for (const entry of entries) { console.log(`${entry.timestamp} | ${entry.action || entry.trigger} | ${entry.path || ''}`); } } else { console.log('Usage:'); console.log(' --watch Start file watcher (persistent mode)'); console.log(' --files f1 f2 ... Process specific files (on-demand mode)'); console.log(' --log [--limit N] Query audit log'); } } module.exports = { RegistryUpdater, WATCH_PATHS, INCLUDE_EXTENSIONS, EXCLUDE_PATTERNS, AUDIT_LOG_PATH, LOCK_FILE, BACKUP_DIR, };