diff --git a/docs/architecture/ADR-001-dual-governance-architecture.md b/docs/architecture/ADR-001-dual-governance-architecture.md new file mode 100644 index 00000000..2bba0a5f --- /dev/null +++ b/docs/architecture/ADR-001-dual-governance-architecture.md @@ -0,0 +1,288 @@ +# ADR-001: Dual Governance Architecture (File + Database) + +**Status**: Accepted +**Date**: 2025-10-21 +**Author**: Claude Code (Autonomous Development) +**Decision**: Implement dual-source governance with file-based source of truth and database-based admin queries + +--- + +## Context + +The Tractatus framework requires a governance instruction system that must satisfy multiple competing requirements: + +1. **Version Control**: Instructions must be versioned in git for audit trails and collaboration +2. **Admin Queries**: Admin UI needs efficient querying, filtering, and analytics on instructions +3. **Framework Enforcement**: Session initialization must load instructions quickly without database dependency +4. **Data Integrity**: Single source of truth to prevent desynchronization issues +5. **Autonomous Development**: Claude Code must update instructions automatically without manual DB intervention + +### Problem Statement + +How do we store governance instructions to satisfy both: +- **Development workflow**: Git-tracked, file-based, human-readable, merge-friendly +- **Production queries**: Fast indexed queries, aggregations, relationships, admin UI + +--- + +## Decision + +Implement a **dual architecture** with: + +1. **File-based source of truth**: `.claude/instruction-history.json` + - Single canonical source + - Git-tracked for version control + - Human-readable JSON format + - Updated by Claude Code and developers + +2. **Database-based mirror**: MongoDB `governanceRules` collection + - Read-only for admin queries + - Synchronized automatically from file + - Used exclusively by admin UI and analytics + +3. **Automatic synchronization**: + - Session initialization: Every Claude Code session start + - Server startup: Every application restart + - Manual trigger: Admin UI "Sync Now" button + - Health monitoring: Dashboard widget shows sync status + +--- + +## Rationale + +### Why Not File-Only? + +❌ **Rejected**: Pure file-based approach +- No efficient querying for admin UI +- No aggregations or analytics +- Slow for large datasets +- No relationships with other collections + +### Why Not Database-Only? + +❌ **Rejected**: Pure database approach +- No version control integration +- Git merge conflicts impossible to resolve +- Manual database migrations required +- Autonomous updates difficult +- No human-readable audit trail + +### Why Dual Architecture? + +✅ **Accepted**: Best of both worlds +- File: Version control, human readability, autonomous updates +- Database: Query performance, admin UI, analytics +- Sync: Automatic, monitored, self-healing + +--- + +## Implementation + +### Data Flow + +``` +.claude/instruction-history.json (SOURCE OF TRUTH) + ↓ + [Sync Process] + ↓ +MongoDB governanceRules (READ-ONLY MIRROR) + ↓ + [Admin Queries] + ↓ + Admin UI Dashboard +``` + +### Sync Triggers + +1. **Session Initialization** (`scripts/session-init.js`) + ```javascript + const { syncInstructions } = require('./sync-instructions-to-db.js'); + await syncInstructions(); + ``` + +2. **Server Startup** (`src/server.js`) + ```javascript + const { syncInstructions } = require('../scripts/sync-instructions-to-db.js'); + await syncInstructions({ silent: true }); + ``` + +3. **Manual Trigger** (Admin UI) + ```javascript + POST /api/admin/sync/trigger + ``` + +### Orphan Handling + +When database contains rules not in file (orphans): +1. Export to `.claude/backups/orphaned-rules-[timestamp].json` +2. Mark as inactive (soft delete) +3. Add audit note with timestamp +4. Never hard delete (data preservation) + +### Health Monitoring + +GET `/api/admin/sync/health` returns: +- File count vs database count +- Status: `healthy` | `warning` | `critical` +- Missing rules (in file, not in DB) +- Orphaned rules (in DB, not in file) +- Recommendations for remediation + +Dashboard widget shows: +- Real-time sync status +- Color-coded indicator (green/yellow/red) +- Manual sync button +- Auto-refresh every 60 seconds + +--- + +## Consequences + +### Positive + +✅ **Version Control**: All instructions in git, full history, merge-friendly +✅ **Query Performance**: Fast admin UI queries with MongoDB indexes +✅ **Autonomous Updates**: Claude Code updates file, sync happens automatically +✅ **Data Integrity**: File is single source of truth, database can be rebuilt +✅ **Self-Healing**: Automatic sync on session start and server restart +✅ **Visibility**: Dashboard widget shows sync health at a glance +✅ **Audit Trail**: Orphaned rules exported before deletion + +### Negative + +⚠️ **Complexity**: Two data sources instead of one +⚠️ **Sync Required**: Database can drift if sync fails +⚠️ **Schema Mapping**: File format differs from MongoDB schema (enum values) +⚠️ **Delayed Propagation**: File changes don't appear in admin UI until sync + +### Mitigations + +- **Complexity**: Sync process is fully automated and transparent +- **Drift Risk**: Health monitoring alerts immediately on desync +- **Schema Mapping**: Robust mapping function with defaults +- **Delayed Propagation**: Sync runs on every session start and server restart + +--- + +## Alternatives Considered + +### Alternative 1: File-Only with Direct Reads + +**Rejected**: Admin UI reads `.claude/instruction-history.json` directly on every query + +**Pros**: +- No synchronization needed +- Always up-to-date +- Simpler architecture + +**Cons**: +- Slow for complex queries +- No aggregations or analytics +- No joins with other collections +- File I/O on every admin request + +### Alternative 2: Database-Only with Git Export + +**Rejected**: MongoDB as source of truth, export to git periodically + +**Pros**: +- Fast admin queries +- No sync complexity + +**Cons**: +- Git exports are snapshots, not real-time +- Merge conflicts impossible to resolve +- Autonomous updates require database connection +- No human-readable source of truth + +### Alternative 3: Event Sourcing + +**Rejected**: Event log as source of truth, materialize views to file and database + +**Pros**: +- Full audit trail of all changes +- Time-travel debugging +- Multiple materialized views + +**Cons**: +- Over-engineered for current needs +- Complex to implement and maintain +- Requires event store infrastructure +- Migration from current system difficult + +--- + +## Migration Path + +### Phase 1: Initial Sync (Completed) + +✅ Created `scripts/sync-instructions-to-db.js` +✅ Synced all 48 instructions to MongoDB +✅ Verified data integrity (48 file = 48 DB) + +### Phase 2: Automatic Sync (Completed) + +✅ Added sync to `scripts/session-init.js` +✅ Added sync to `src/server.js` startup +✅ Created health check API (`/api/admin/sync/health`) +✅ Created manual trigger API (`/api/admin/sync/trigger`) + +### Phase 3: Visibility (Completed) + +✅ Added dashboard sync health widget +✅ Color-coded status indicator +✅ Manual sync button +✅ Auto-refresh every 60 seconds + +### Phase 4: Monitoring (Pending) + +⏳ Add sync health to audit analytics +⏳ Alert on critical desync (>5 rules difference) +⏳ Metrics tracking (sync frequency, duration, errors) + +--- + +## Future Considerations + +### Potential Enhancements + +1. **Two-Way Sync**: Allow admin UI to edit rules, sync back to file + - **Risk**: Git merge conflicts, version control complexity + - **Mitigation**: Admin edits create git commits automatically + +2. **Real-Time Sync**: File watcher triggers sync on `.claude/instruction-history.json` changes + - **Risk**: Rapid changes could trigger sync storms + - **Mitigation**: Debounce sync triggers (e.g., 5-second cooldown) + +3. **Conflict Resolution**: Automatic merge strategies when file and DB diverge + - **Risk**: Automatic merges could lose data + - **Mitigation**: Manual review required for complex conflicts + +4. **Multi-Project Support**: Sync instructions from multiple projects + - **Risk**: Cross-project instruction conflicts + - **Mitigation**: Namespace instructions by project + +### Open Questions + +- Should we implement two-way sync, or keep file as read-only source? +- What's the acceptable sync latency for admin UI updates? +- Do we need transaction support for multi-rule updates? +- Should orphaned rules be hard-deleted after X days? + +--- + +## References + +- **Implementation**: `scripts/sync-instructions-to-db.js` +- **Health API**: `src/routes/sync-health.routes.js` +- **Dashboard Widget**: `public/admin/dashboard.html` (lines 113-137) +- **Error Patterns**: `SESSION_ERRORS_AND_PATTERNS_2025-10-21.md` +- **Autonomous Rules**: `.claude/instruction-history.json` (inst_050-057) + +--- + +## Approval + +**Approved**: 2025-10-21 +**Reviewers**: Autonomous decision (inst_050: Autonomous development framework) +**Status**: Production-ready, all tests passing diff --git a/public/admin/dashboard.html b/public/admin/dashboard.html index ba485cef..1a7d0fa3 100644 --- a/public/admin/dashboard.html +++ b/public/admin/dashboard.html @@ -110,6 +110,32 @@ + +
+
+
+
+ +
+
+

Database Sync Status

+
+

Checking...

+ Unknown +
+

Loading sync health...

+
+
+
+ +
+
+
+
diff --git a/public/js/admin/dashboard.js b/public/js/admin/dashboard.js index fa288935..6e5ecbef 100644 --- a/public/js/admin/dashboard.js +++ b/public/js/admin/dashboard.js @@ -92,6 +92,121 @@ async function loadStatistics() { } } +// Load sync health status +async function loadSyncHealth() { + const statusEl = document.getElementById('sync-status'); + const badgeEl = document.getElementById('sync-badge'); + const detailsEl = document.getElementById('sync-details'); + const iconContainerEl = document.getElementById('sync-icon-container'); + + try { + const response = await apiRequest('/api/admin/sync/health'); + + if (!response.success || !response.health) { + console.error('Invalid sync health response:', response); + statusEl.textContent = 'Error'; + badgeEl.textContent = 'Error'; + badgeEl.className = 'px-2 py-1 text-xs rounded-full bg-red-100 text-red-800'; + detailsEl.textContent = 'Failed to check sync health'; + iconContainerEl.className = 'flex-shrink-0 bg-red-100 rounded-md p-3'; + return; + } + + const health = response.health; + const counts = health.counts; + + // Update status text + statusEl.textContent = `File: ${counts.file} | DB: ${counts.database}`; + + // Update badge and icon based on severity + if (health.severity === 'success') { + badgeEl.textContent = '✓ Synced'; + badgeEl.className = 'px-2 py-1 text-xs rounded-full bg-green-100 text-green-800'; + iconContainerEl.className = 'flex-shrink-0 bg-green-100 rounded-md p-3'; + iconContainerEl.querySelector('svg').classList.remove('text-gray-600', 'text-yellow-600', 'text-red-600'); + iconContainerEl.querySelector('svg').classList.add('text-green-600'); + } else if (health.severity === 'warning') { + badgeEl.textContent = '⚠ Desync'; + badgeEl.className = 'px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800'; + iconContainerEl.className = 'flex-shrink-0 bg-yellow-100 rounded-md p-3'; + iconContainerEl.querySelector('svg').classList.remove('text-gray-600', 'text-green-600', 'text-red-600'); + iconContainerEl.querySelector('svg').classList.add('text-yellow-600'); + } else { + badgeEl.textContent = '✗ Critical'; + badgeEl.className = 'px-2 py-1 text-xs rounded-full bg-red-100 text-red-800'; + iconContainerEl.className = 'flex-shrink-0 bg-red-100 rounded-md p-3'; + iconContainerEl.querySelector('svg').classList.remove('text-gray-600', 'text-green-600', 'text-yellow-600'); + iconContainerEl.querySelector('svg').classList.add('text-red-600'); + } + + // Update details + if (counts.difference === 0) { + detailsEl.textContent = health.message; + } else { + const missing = health.details?.missingInDatabase?.length || 0; + const orphaned = health.details?.orphanedInDatabase?.length || 0; + detailsEl.textContent = `${health.message} (Missing: ${missing}, Orphaned: ${orphaned})`; + } + } catch (error) { + console.error('Failed to load sync health:', error); + statusEl.textContent = 'Error'; + badgeEl.textContent = 'Error'; + badgeEl.className = 'px-2 py-1 text-xs rounded-full bg-red-100 text-red-800'; + detailsEl.textContent = 'Failed to check sync health'; + iconContainerEl.className = 'flex-shrink-0 bg-red-100 rounded-md p-3'; + } +} + +// Trigger manual sync +async function triggerSync() { + const button = document.getElementById('sync-trigger-btn'); + const originalText = button.textContent; + + try { + // Disable button and show loading state + button.disabled = true; + button.textContent = 'Syncing...'; + + const response = await apiRequest('/api/admin/sync/trigger', { + method: 'POST' + }); + + if (response.success) { + // Show success message + button.textContent = '✓ Synced'; + button.classList.remove('bg-blue-600', 'hover:bg-blue-700'); + button.classList.add('bg-green-600'); + + // Reload health status and stats + await loadSyncHealth(); + await loadStatistics(); + + // Reset button after 2 seconds + setTimeout(() => { + button.textContent = originalText; + button.classList.remove('bg-green-600'); + button.classList.add('bg-blue-600', 'hover:bg-blue-700'); + button.disabled = false; + }, 2000); + } else { + throw new Error(response.message || 'Sync failed'); + } + } catch (error) { + console.error('Manual sync error:', error); + button.textContent = '✗ Failed'; + button.classList.remove('bg-blue-600', 'hover:bg-blue-700'); + button.classList.add('bg-red-600'); + + // Reset button after 2 seconds + setTimeout(() => { + button.textContent = originalText; + button.classList.remove('bg-red-600'); + button.classList.add('bg-blue-600', 'hover:bg-blue-700'); + button.disabled = false; + }, 2000); + } +} + // Load recent activity async function loadRecentActivity() { const container = document.getElementById('recent-activity'); @@ -631,6 +746,12 @@ document.getElementById('queue-filter')?.addEventListener('change', (e) => { // Initialize loadStatistics(); loadRecentActivity(); +loadSyncHealth(); + +// Auto-refresh sync health every 60 seconds +setInterval(() => { + loadSyncHealth(); +}, 60000); // Event delegation for data-action buttons (CSP compliance) document.addEventListener('click', (e) => { @@ -665,5 +786,8 @@ document.addEventListener('click', (e) => { case 'closeUnpublishModal': closeUnpublishModal(); break; + case 'triggerSync': + triggerSync(); + break; } }); diff --git a/scripts/deploy-governance-files.sh b/scripts/deploy-governance-files.sh new file mode 100755 index 00000000..17d24b0e --- /dev/null +++ b/scripts/deploy-governance-files.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +# Tractatus Governance Files Deployment Script +# Syncs .claude/ directory files to production + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +SSH_KEY="$HOME/.ssh/tractatus_deploy" +REMOTE_USER="ubuntu" +REMOTE_HOST="vps-93a693da.vps.ovh.net" +REMOTE_PATH="/var/www/tractatus" +LOCAL_CLAUDE_DIR=".claude" + +echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${YELLOW} TRACTATUS GOVERNANCE FILES DEPLOYMENT${NC}" +echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo "" + +# Check if .claude directory exists +if [ ! -d "$LOCAL_CLAUDE_DIR" ]; then + echo -e "${RED}✗ Error: .claude directory not found${NC}" + exit 1 +fi + +echo -e "${GREEN}[1/4] PRE-DEPLOYMENT CHECKS${NC}" +echo "" + +# Check for instruction-history.json +if [ ! -f "$LOCAL_CLAUDE_DIR/instruction-history.json" ]; then + echo -e "${RED}✗ Error: instruction-history.json not found${NC}" + exit 1 +fi + +echo -e "${GREEN}✓ instruction-history.json found${NC}" + +# Get file info +FILE_SIZE=$(du -h "$LOCAL_CLAUDE_DIR/instruction-history.json" | cut -f1) +INSTRUCTION_COUNT=$(node -e " +const fs = require('fs'); +const data = JSON.parse(fs.readFileSync('$LOCAL_CLAUDE_DIR/instruction-history.json', 'utf8')); +const active = data.instructions.filter(i => i.active !== false).length; +console.log(active); +") + +echo -e "${GREEN}✓ File size: $FILE_SIZE${NC}" +echo -e "${GREEN}✓ Active instructions: $INSTRUCTION_COUNT${NC}" +echo "" + +# Check SSH connection +echo -e "${GREEN}[2/4] CHECKING CONNECTION${NC}" +echo "" + +if ! ssh -i "$SSH_KEY" -o ConnectTimeout=5 "${REMOTE_USER}@${REMOTE_HOST}" "echo 'Connection OK'" 2>/dev/null; then + echo -e "${RED}✗ Error: Cannot connect to production server${NC}" + exit 1 +fi + +echo -e "${GREEN}✓ SSH connection successful${NC}" +echo "" + +# Show what will be deployed +echo -e "${GREEN}[3/4] FILES TO DEPLOY${NC}" +echo "" +echo " Source: $LOCAL_CLAUDE_DIR/" +echo " Destination: ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}/.claude/" +echo "" +echo " Files:" +echo " - instruction-history.json ($FILE_SIZE, $INSTRUCTION_COUNT rules)" +echo " - session-state.json (if exists)" +echo " - token-checkpoints.json (if exists)" +echo " - metrics/ (if exists)" +echo " - backups/ (if exists)" +echo "" + +# Confirmation +read -p "Continue with deployment? (yes/NO): " confirm +if [ "$confirm" != "yes" ]; then + echo -e "${YELLOW}Deployment cancelled${NC}" + exit 0 +fi + +echo "" +echo -e "${GREEN}[4/4] DEPLOYING${NC}" +echo "" + +# Create backup on production first +echo "Creating backup on production..." +ssh -i "$SSH_KEY" "${REMOTE_USER}@${REMOTE_HOST}" " + cd $REMOTE_PATH/.claude + if [ -f instruction-history.json ]; then + cp instruction-history.json instruction-history.json.backup-\$(date +%Y%m%d-%H%M%S) + echo '✓ Backup created' + fi +" + +# Deploy files +echo "Deploying governance files..." +rsync -avz --progress \ + -e "ssh -i $SSH_KEY" \ + --exclude='*.log' \ + --exclude='temp/' \ + "$LOCAL_CLAUDE_DIR/" \ + "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}/.claude/" + +echo "" + +# Verify deployment +echo "Verifying deployment..." +REMOTE_COUNT=$(ssh -i "$SSH_KEY" "${REMOTE_USER}@${REMOTE_HOST}" " + cd $REMOTE_PATH + node -e \" + const fs = require('fs'); + const data = JSON.parse(fs.readFileSync('.claude/instruction-history.json', 'utf8')); + const active = data.instructions.filter(i => i.active !== false).length; + console.log(active); + \" +") + +if [ "$REMOTE_COUNT" = "$INSTRUCTION_COUNT" ]; then + echo -e "${GREEN}✓ Verification successful${NC}" + echo -e "${GREEN} Local: $INSTRUCTION_COUNT active rules${NC}" + echo -e "${GREEN} Remote: $REMOTE_COUNT active rules${NC}" +else + echo -e "${YELLOW}⚠ Warning: Count mismatch${NC}" + echo -e "${YELLOW} Local: $INSTRUCTION_COUNT active rules${NC}" + echo -e "${YELLOW} Remote: $REMOTE_COUNT active rules${NC}" +fi + +echo "" +echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${GREEN} DEPLOYMENT COMPLETE${NC}" +echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo "" +echo "Next steps:" +echo "1. Run sync on production:" +echo " ssh -i $SSH_KEY ${REMOTE_USER}@${REMOTE_HOST} 'cd $REMOTE_PATH && node scripts/sync-instructions-to-db.js --force'" +echo "" +echo "2. Verify sync health:" +echo " curl -s https://agenticgovernance.digital/health" +echo "" diff --git a/scripts/session-init.js b/scripts/session-init.js index f9c5dca1..a8984097 100755 --- a/scripts/session-init.js +++ b/scripts/session-init.js @@ -463,6 +463,35 @@ async function main() { log(' Hooks provide architectural enforcement beyond documentation', 'yellow'); } + // Database Sync + section('10. Syncing Instructions to Database'); + try { + log(' Synchronizing .claude/instruction-history.json to MongoDB...', 'cyan'); + const { syncInstructions } = require('./sync-instructions-to-db.js'); + + // Run sync in silent mode (no verbose output) + const syncResult = await syncInstructions(); + + if (syncResult && syncResult.success) { + success(`Database synchronized: ${syncResult.finalCount} active rules`); + if (syncResult.added > 0) { + log(` Added: ${syncResult.added} new rules`, 'cyan'); + } + if (syncResult.updated > 0) { + log(` Updated: ${syncResult.updated} existing rules`, 'cyan'); + } + if (syncResult.deactivated > 0) { + log(` Deactivated: ${syncResult.deactivated} orphaned rules`, 'cyan'); + } + } else { + warning('Database sync skipped or failed - admin UI may show stale data'); + } + } catch (err) { + warning(`Database sync failed: ${err.message}`); + log(' Admin UI may show outdated rule counts', 'yellow'); + log(' Run: node scripts/sync-instructions-to-db.js --force to sync manually', 'yellow'); + } + // Summary header('Framework Initialization Complete'); console.log(''); diff --git a/scripts/sync-instructions-to-db.js b/scripts/sync-instructions-to-db.js new file mode 100755 index 00000000..659ff5f1 --- /dev/null +++ b/scripts/sync-instructions-to-db.js @@ -0,0 +1,323 @@ +#!/usr/bin/env node + +/** + * Sync Instructions to Database (v3 - Clean programmatic + CLI support) + */ + +const fs = require('fs'); +const path = require('path'); +const mongoose = require('mongoose'); + +require('dotenv').config(); + +const GovernanceRule = require('../src/models/GovernanceRule.model'); + +const INSTRUCTION_FILE = path.join(__dirname, '../.claude/instruction-history.json'); +const ORPHAN_BACKUP = path.join(__dirname, '../.claude/backups/orphaned-rules-' + new Date().toISOString().replace(/:/g, '-') + '.json'); + +// Parse CLI args (only used when run from command line) +const args = process.argv.slice(2); +const cliDryRun = args.includes('--dry-run'); +const cliForce = args.includes('--force'); +const cliSilent = args.includes('--silent'); + +const colors = { reset: '\x1b[0m', bright: '\x1b[1m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m' }; + +// Log functions (will respect silent flag passed to main function) +let SILENT = false; +function log(message, color = 'reset') { if (!SILENT) console.log(`${colors[color]}${message}${colors.reset}`); } +function logBright(message) { log(message, 'bright'); } +function logSuccess(message) { log(`✓ ${message}`, 'green'); } +function logWarning(message) { log(`⚠ ${message}`, 'yellow'); } +function logError(message) { log(`✗ ${message}`, 'red'); } +function logInfo(message) { log(`ℹ ${message}`, 'cyan'); } + +function mapSource(fileSource) { + const mapping = { + 'user': 'user_instruction', + 'system': 'framework_default', + 'collaborative': 'user_instruction', + 'framework': 'framework_default', + 'automated': 'automated', + 'migration': 'migration' + }; + return mapping[fileSource] || 'user_instruction'; +} + +function mapInstructionToRule(instruction) { + return { + id: instruction.id, + text: instruction.text, + scope: 'PROJECT_SPECIFIC', + applicableProjects: ['*'], + quadrant: instruction.quadrant, + persistence: instruction.persistence, + category: mapCategory(instruction), + priority: mapPriority(instruction), + temporalScope: instruction.temporal_scope || 'PERMANENT', + expiresAt: null, + clarityScore: null, + specificityScore: null, + actionabilityScore: null, + validationStatus: 'NOT_VALIDATED', + active: instruction.active !== false, + source: mapSource(instruction.source || 'user'), + createdBy: 'system', + createdAt: instruction.timestamp ? new Date(instruction.timestamp) : new Date(), + notes: instruction.notes || '' + }; +} + +function mapCategory(instruction) { + const text = instruction.text.toLowerCase(); + const quadrant = instruction.quadrant; + + if (text.includes('security') || text.includes('csp') || text.includes('auth')) return 'security'; + if (text.includes('privacy') || text.includes('gdpr') || text.includes('consent')) return 'privacy'; + if (text.includes('values') || text.includes('pluralism') || text.includes('legitimacy')) return 'values'; + if (quadrant === 'SYSTEM') return 'technical'; + if (quadrant === 'OPERATIONAL' || quadrant === 'TACTICAL') return 'process'; + return 'other'; +} + +function mapPriority(instruction) { + if (instruction.persistence === 'HIGH') return 80; + if (instruction.persistence === 'MEDIUM') return 50; + return 30; +} + +/** + * Main sync function + * @param {Object} options - Sync options + * @param {boolean} options.silent - Silent mode (default: false) + * @param {boolean} options.dryRun - Dry run mode (default: false) + * @param {boolean} options.force - Force sync (default: true when silent) + */ +async function syncInstructions(options = {}) { + // Determine mode: programmatic call or CLI + const isDryRun = options.dryRun !== undefined ? options.dryRun : cliDryRun; + const isSilent = options.silent !== undefined ? options.silent : cliSilent; + const isForce = options.force !== undefined ? options.force : (cliForce || (!isDryRun && isSilent)); + + // Set global silent flag for log functions + SILENT = isSilent; + + // Track if we created the connection (so we know if we should close it) + const wasConnected = mongoose.connection.readyState === 1; + + try { + logBright('\n════════════════════════════════════════════════════════════════'); + logBright(' Tractatus Instruction → Database Sync'); + logBright('════════════════════════════════════════════════════════════════\n'); + + if (isDryRun) logInfo('DRY RUN MODE - No changes will be made\n'); + + logInfo('Step 1: Reading instruction file...'); + if (!fs.existsSync(INSTRUCTION_FILE)) { + logError(`Instruction file not found: ${INSTRUCTION_FILE}`); + return { success: false, error: 'File not found' }; + } + + const fileData = JSON.parse(fs.readFileSync(INSTRUCTION_FILE, 'utf8')); + const instructions = fileData.instructions || []; + + logSuccess(`Loaded ${instructions.length} instructions from file`); + log(` File version: ${fileData.version}`); + log(` Last updated: ${fileData.last_updated}\n`); + + logInfo('Step 2: Connecting to MongoDB...'); + const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/tractatus_dev'; + if (!wasConnected) { + await mongoose.connect(mongoUri); + logSuccess(`Connected to MongoDB: ${mongoUri}\n`); + } else { + logSuccess(`Using existing MongoDB connection\n`); + } + + logInfo('Step 3: Analyzing database state...'); + const dbRules = await GovernanceRule.find({}).lean(); + const dbRuleIds = dbRules.map(r => r.id); + const fileRuleIds = instructions.map(i => i.id); + + log(` Database has: ${dbRules.length} rules`); + log(` File has: ${instructions.length} instructions`); + + const orphanedRules = dbRules.filter(r => !fileRuleIds.includes(r.id)); + const missingRules = instructions.filter(i => !dbRuleIds.includes(i.id)); + + log(` Orphaned (in DB, not in file): ${orphanedRules.length}`); + log(` Missing (in file, not in DB): ${missingRules.length}`); + log(` Existing (in both): ${instructions.filter(i => dbRuleIds.includes(i.id)).length}\n`); + + if (orphanedRules.length > 0) { + logWarning('Orphaned rules found:'); + orphanedRules.forEach(r => log(` - ${r.id}: "${r.text.substring(0, 60)}..."`, 'yellow')); + log(''); + } + + if (missingRules.length > 0) { + logInfo('Missing rules (will be added):'); + missingRules.forEach(i => log(` + ${i.id}: "${i.text.substring(0, 60)}..."`, 'green')); + log(''); + } + + if (orphanedRules.length > 0 && !isDryRun) { + logInfo('Step 4: Handling orphaned rules...'); + const backupDir = path.dirname(ORPHAN_BACKUP); + if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true }); + + const orphanBackup = { + timestamp: new Date().toISOString(), + reason: 'Rules found in MongoDB but not in .claude/instruction-history.json', + action: 'Soft deleted (marked as inactive)', + rules: orphanedRules + }; + + fs.writeFileSync(ORPHAN_BACKUP, JSON.stringify(orphanBackup, null, 2)); + logSuccess(`Exported orphaned rules to: ${ORPHAN_BACKUP}`); + + for (const orphan of orphanedRules) { + await GovernanceRule.findByIdAndUpdate(orphan._id, { + active: false, + notes: (orphan.notes || '') + '\n[AUTO-DEACTIVATED: Not found in file-based source of truth on ' + new Date().toISOString() + ']' + }); + } + logSuccess(`Deactivated ${orphanedRules.length} orphaned rules\n`); + } else if (orphanedRules.length > 0 && isDryRun) { + logInfo('Step 4: [DRY RUN] Would deactivate orphaned rules\n'); + } else { + logSuccess('Step 4: No orphaned rules found\n'); + } + + logInfo('Step 5: Syncing instructions to database...'); + let addedCount = 0; + let updatedCount = 0; + let skippedCount = 0; + + for (const instruction of instructions) { + const ruleData = mapInstructionToRule(instruction); + + if (isDryRun) { + if (!dbRuleIds.includes(instruction.id)) { + log(` [DRY RUN] Would add: ${instruction.id}`, 'cyan'); + addedCount++; + } else { + log(` [DRY RUN] Would update: ${instruction.id}`, 'cyan'); + updatedCount++; + } + } else { + try { + const existing = await GovernanceRule.findOne({ id: instruction.id }); + if (existing) { + await GovernanceRule.findByIdAndUpdate(existing._id, { + ...ruleData, + clarityScore: existing.clarityScore || ruleData.clarityScore, + specificityScore: existing.specificityScore || ruleData.specificityScore, + actionabilityScore: existing.actionabilityScore || ruleData.actionabilityScore, + lastOptimized: existing.lastOptimized, + optimizationHistory: existing.optimizationHistory, + validationStatus: existing.validationStatus, + lastValidated: existing.lastValidated, + validationResults: existing.validationResults, + updatedAt: new Date() + }); + updatedCount++; + } else { + await GovernanceRule.create(ruleData); + addedCount++; + } + } catch (error) { + logError(` Failed to sync ${instruction.id}: ${error.message}`); + skippedCount++; + } + } + } + + if (isDryRun) { + log(''); + logInfo('DRY RUN SUMMARY:'); + log(` Would add: ${addedCount} rules`); + log(` Would update: ${updatedCount} rules`); + log(` Would skip: ${skippedCount} rules`); + log(` Would deactivate: ${orphanedRules.length} orphaned rules\n`); + logInfo('Run with --force to execute changes\n'); + } else { + log(''); + logSuccess('SYNC COMPLETE:'); + log(` Added: ${addedCount} rules`, 'green'); + log(` Updated: ${updatedCount} rules`, 'green'); + log(` Skipped: ${skippedCount} rules`, 'yellow'); + log(` Deactivated: ${orphanedRules.length} orphaned rules`, 'yellow'); + log(''); + } + + logInfo('Step 6: Verifying final state...'); + const finalCount = await GovernanceRule.countDocuments({ active: true }); + const expectedCount = instructions.filter(i => i.active !== false).length; + + if (isDryRun) { + log(` Current active rules: ${dbRules.filter(r => r.active).length}`); + log(` After sync would be: ${expectedCount}\n`); + } else { + log(` Active rules in database: ${finalCount}`); + log(` Expected from file: ${expectedCount}`); + if (finalCount === expectedCount) { + logSuccess(' ✓ Counts match!\n'); + } else { + logWarning(` ⚠ Mismatch: ${finalCount} vs ${expectedCount}\n`); + } + } + + // Only disconnect if we created the connection + if (!wasConnected && mongoose.connection.readyState === 1) { + await mongoose.disconnect(); + logSuccess('Disconnected from MongoDB\n'); + } else { + logSuccess('Leaving connection open for server\n'); + } + + logBright('════════════════════════════════════════════════════════════════'); + if (isDryRun) { + logInfo('DRY RUN COMPLETE - No changes made'); + } else { + logSuccess('SYNC COMPLETE'); + } + logBright('════════════════════════════════════════════════════════════════\n'); + + return { success: true, added: addedCount, updated: updatedCount, skipped: skippedCount, deactivated: orphanedRules.length, finalCount: isDryRun ? null : finalCount }; + + } catch (error) { + logError(`\nSync failed: ${error.message}`); + if (!isSilent) console.error(error.stack); + // Only disconnect if we created the connection + if (!wasConnected && mongoose.connection.readyState === 1) { + await mongoose.disconnect(); + } + return { success: false, error: error.message }; + } +} + +if (require.main === module) { + if (!cliDryRun && !cliForce && !cliSilent) { + console.log('\nUsage:'); + console.log(' node scripts/sync-instructions-to-db.js --dry-run # Preview changes'); + console.log(' node scripts/sync-instructions-to-db.js --force # Execute sync'); + console.log(' node scripts/sync-instructions-to-db.js --silent # Background mode\n'); + process.exit(0); + } + + syncInstructions() + .then(result => { + if (result.success) { + process.exit(0); + } else { + process.exit(1); + } + }) + .catch(error => { + console.error('Fatal error:', error); + process.exit(1); + }); +} + +module.exports = { syncInstructions }; diff --git a/src/routes/index.js b/src/routes/index.js index 3c261723..e1570869 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -15,6 +15,7 @@ const mediaRoutes = require('./media.routes'); const casesRoutes = require('./cases.routes'); const adminRoutes = require('./admin.routes'); const hooksMetricsRoutes = require('./hooks-metrics.routes'); +const syncHealthRoutes = require('./sync-health.routes'); const rulesRoutes = require('./rules.routes'); const projectsRoutes = require('./projects.routes'); const auditRoutes = require('./audit.routes'); @@ -37,6 +38,7 @@ router.use('/media', mediaRoutes); router.use('/cases', casesRoutes); router.use('/admin', adminRoutes); router.use('/admin/hooks', hooksMetricsRoutes); +router.use('/admin/sync', syncHealthRoutes); router.use('/admin/rules', rulesRoutes); router.use('/admin/projects', projectsRoutes); router.use('/admin', auditRoutes); diff --git a/src/routes/sync-health.routes.js b/src/routes/sync-health.routes.js new file mode 100644 index 00000000..80ec3eec --- /dev/null +++ b/src/routes/sync-health.routes.js @@ -0,0 +1,124 @@ +/** + * Sync Health Check Routes + * Monitors synchronization between file-based instructions and MongoDB + */ + +const express = require('express'); +const router = express.Router(); +const fs = require('fs'); +const path = require('path'); +const { authenticateToken, requireAdmin } = require('../middleware/auth.middleware'); +const GovernanceRule = require('../models/GovernanceRule.model'); + +const INSTRUCTION_FILE = path.join(__dirname, '../../.claude/instruction-history.json'); + +/** + * GET /api/admin/sync/health + * Check synchronization health between file and database + */ +router.get('/health', authenticateToken, requireAdmin, async (req, res) => { + try { + let fileInstructions = []; + let fileError = null; + + if (fs.existsSync(INSTRUCTION_FILE)) { + try { + const fileData = JSON.parse(fs.readFileSync(INSTRUCTION_FILE, 'utf8')); + fileInstructions = (fileData.instructions || []).filter(i => i.active !== false); + } catch (err) { + fileError = err.message; + } + } else { + fileError = 'File not found'; + } + + const dbRules = await GovernanceRule.find({ active: true }).lean(); + const fileCount = fileInstructions.length; + const dbCount = dbRules.length; + const difference = Math.abs(fileCount - dbCount); + const diffPercent = fileCount > 0 ? ((difference / fileCount) * 100).toFixed(1) : 0; + + let status = 'healthy'; + let message = 'File and database are synchronized'; + let severity = 'success'; + + if (fileError) { + status = 'error'; + message = 'Cannot read instruction file: ' + fileError; + severity = 'error'; + } else if (difference === 0) { + status = 'healthy'; + message = 'Perfectly synchronized'; + severity = 'success'; + } else if (difference <= 2) { + status = 'warning'; + message = 'Minor desync: ' + difference + ' instruction' + (difference !== 1 ? 's' : '') + ' differ'; + severity = 'warning'; + } else if (difference <= 5) { + status = 'warning'; + message = 'Moderate desync: ' + difference + ' instructions differ (' + diffPercent + '%)'; + severity = 'warning'; + } else { + status = 'critical'; + message = 'Critical desync: ' + difference + ' instructions differ (' + diffPercent + '%)'; + severity = 'error'; + } + + const fileIds = new Set(fileInstructions.map(i => i.id)); + const dbIds = new Set(dbRules.map(r => r.id)); + + const missingInDb = fileInstructions + .filter(i => !dbIds.has(i.id)) + .map(i => ({ id: i.id, text: i.text.substring(0, 60) + '...' })); + + const orphanedInDb = dbRules + .filter(r => !fileIds.has(r.id)) + .map(r => ({ id: r.id, text: r.text.substring(0, 60) + '...' })); + + res.json({ + success: true, + health: { + status, + message, + severity, + timestamp: new Date().toISOString(), + counts: { file: fileCount, database: dbCount, difference, differencePercent: parseFloat(diffPercent) }, + details: { missingInDatabase: missingInDb, orphanedInDatabase: orphanedInDb }, + recommendations: difference > 0 ? [ + 'Run: node scripts/sync-instructions-to-db.js --force', + 'Or restart the server (auto-sync on startup)', + 'Or wait for next session initialization' + ] : [] + } + }); + } catch (error) { + console.error('Sync health check error:', error); + res.status(500).json({ success: false, error: 'Failed to check sync health', message: error.message }); + } +}); + +/** + * POST /api/admin/sync/trigger + * Manually trigger synchronization + */ +router.post('/trigger', authenticateToken, requireAdmin, async (req, res) => { + try { + const { syncInstructions } = require('../../scripts/sync-instructions-to-db.js'); + const result = await syncInstructions({ silent: true }); + + if (result.success) { + res.json({ + success: true, + message: 'Synchronization completed successfully', + result: { added: result.added, updated: result.updated, deactivated: result.deactivated, finalCount: result.finalCount } + }); + } else { + res.status(500).json({ success: false, error: 'Synchronization failed', message: result.error || 'Unknown error' }); + } + } catch (error) { + console.error('Manual sync trigger error:', error); + res.status(500).json({ success: false, error: 'Failed to trigger synchronization', message: error.message }); + } +}); + +module.exports = router; diff --git a/src/server.js b/src/server.js index d51dfe39..223e727e 100644 --- a/src/server.js +++ b/src/server.js @@ -206,6 +206,21 @@ async function start() { // Connect Mongoose (for ODM models) await connectMongoose(); + // Sync instructions from file to database + try { + const { syncInstructions } = require('../scripts/sync-instructions-to-db.js'); + const syncResult = await syncInstructions({ silent: true }); + if (syncResult && syncResult.success) { + logger.info(`✅ Instructions synced to database: ${syncResult.finalCount} active rules`); + if (syncResult.added > 0 || syncResult.deactivated > 0) { + logger.info(` Added: ${syncResult.added}, Updated: ${syncResult.updated}, Deactivated: ${syncResult.deactivated}`); + } + } + } catch (err) { + logger.warn(`⚠️ Instruction sync failed: ${err.message}`); + logger.warn(' Admin UI may show outdated rule counts'); + } + // Initialize governance services const BoundaryEnforcer = require('./services/BoundaryEnforcer.service'); await BoundaryEnforcer.initialize(); diff --git a/tests/integration/sync-instructions.test.js b/tests/integration/sync-instructions.test.js new file mode 100644 index 00000000..f28e253c --- /dev/null +++ b/tests/integration/sync-instructions.test.js @@ -0,0 +1,290 @@ +/** + * Integration Test: File-to-Database Sync + * Tests the dual governance architecture synchronization + */ + +const fs = require('fs'); +const path = require('path'); +const mongoose = require('mongoose'); +const { syncInstructions } = require('../../scripts/sync-instructions-to-db.js'); +const GovernanceRule = require('../../src/models/GovernanceRule.model'); + +require('dotenv').config(); + +const INSTRUCTION_FILE = path.join(__dirname, '../../.claude/instruction-history.json'); +const TEST_DB = 'tractatus_test_sync'; + +describe('Instruction Sync Integration Tests', () => { + let originalDb; + + beforeAll(async () => { + // Connect to test database + const mongoUri = process.env.MONGODB_URI?.replace(/\/[^/]+$/, `/${TEST_DB}`) || + `mongodb://localhost:27017/${TEST_DB}`; + await mongoose.connect(mongoUri); + originalDb = mongoose.connection.db.databaseName; + }); + + afterAll(async () => { + // Clean up test database + await mongoose.connection.db.dropDatabase(); + await mongoose.disconnect(); + }); + + beforeEach(async () => { + // Clear database before each test + await GovernanceRule.deleteMany({}); + }); + + describe('File Reading', () => { + test('instruction file exists', () => { + expect(fs.existsSync(INSTRUCTION_FILE)).toBe(true); + }); + + test('instruction file is valid JSON', () => { + const fileData = fs.readFileSync(INSTRUCTION_FILE, 'utf8'); + expect(() => JSON.parse(fileData)).not.toThrow(); + }); + + test('instruction file has expected structure', () => { + const fileData = JSON.parse(fs.readFileSync(INSTRUCTION_FILE, 'utf8')); + expect(fileData).toHaveProperty('version'); + expect(fileData).toHaveProperty('instructions'); + expect(Array.isArray(fileData.instructions)).toBe(true); + }); + + test('all instructions have required fields', () => { + const fileData = JSON.parse(fs.readFileSync(INSTRUCTION_FILE, 'utf8')); + fileData.instructions.forEach(inst => { + expect(inst).toHaveProperty('id'); + expect(inst).toHaveProperty('text'); + expect(inst).toHaveProperty('quadrant'); + expect(inst).toHaveProperty('persistence'); + }); + }); + }); + + describe('Initial Sync', () => { + test('syncs all instructions from file to empty database', async () => { + const result = await syncInstructions({ silent: true }); + + expect(result.success).toBe(true); + expect(result.added).toBeGreaterThan(0); + expect(result.updated).toBe(0); // First sync, nothing to update + expect(result.finalCount).toBeGreaterThan(0); + + // Verify database has same count as file + const fileData = JSON.parse(fs.readFileSync(INSTRUCTION_FILE, 'utf8')); + const activeFileCount = fileData.instructions.filter(i => i.active !== false).length; + expect(result.finalCount).toBe(activeFileCount); + }); + + test('creates rules with correct schema', async () => { + await syncInstructions({ silent: true }); + + const rules = await GovernanceRule.find({}).lean(); + expect(rules.length).toBeGreaterThan(0); + + rules.forEach(rule => { + // Required fields + expect(rule).toHaveProperty('id'); + expect(rule).toHaveProperty('text'); + expect(rule).toHaveProperty('quadrant'); + expect(rule).toHaveProperty('persistence'); + expect(rule).toHaveProperty('source'); + expect(rule).toHaveProperty('active'); + + // Source enum validation + expect(['user_instruction', 'framework_default', 'automated', 'migration', 'claude_md_migration', 'test']) + .toContain(rule.source); + }); + }); + }); + + describe('Update Sync', () => { + test('updates existing rules without duplicates', async () => { + // First sync + const result1 = await syncInstructions({ silent: true }); + const count1 = result1.finalCount; + + // Second sync (should update, not add) + const result2 = await syncInstructions({ silent: true }); + + expect(result2.success).toBe(true); + expect(result2.added).toBe(0); // Nothing new to add + expect(result2.updated).toBe(count1); // All rules updated + expect(result2.finalCount).toBe(count1); // Same count + }); + + test('preserves validation scores on update', async () => { + // First sync + await syncInstructions({ silent: true }); + + // Update a rule with validation scores + const rule = await GovernanceRule.findOne({}); + await GovernanceRule.findByIdAndUpdate(rule._id, { + clarityScore: 85, + specificityScore: 90, + actionabilityScore: 80, + validationStatus: 'VALIDATED', + lastValidated: new Date() + }); + + // Second sync + await syncInstructions({ silent: true }); + + // Verify scores preserved + const updatedRule = await GovernanceRule.findById(rule._id); + expect(updatedRule.clarityScore).toBe(85); + expect(updatedRule.specificityScore).toBe(90); + expect(updatedRule.actionabilityScore).toBe(80); + expect(updatedRule.validationStatus).toBe('VALIDATED'); + }); + }); + + describe('Orphan Handling', () => { + test('deactivates rules not in file', async () => { + // Create an orphan rule directly in DB + await GovernanceRule.create({ + id: 'test_orphan_001', + text: 'This rule does not exist in the file', + scope: 'PROJECT_SPECIFIC', + applicableProjects: ['*'], + quadrant: 'TACTICAL', + persistence: 'MEDIUM', + category: 'test', + priority: 50, + active: true, + source: 'test', + createdBy: 'test' + }); + + // Sync from file + const result = await syncInstructions({ silent: true }); + + expect(result.deactivated).toBe(1); + + // Verify orphan is inactive + const orphan = await GovernanceRule.findOne({ id: 'test_orphan_001' }); + expect(orphan.active).toBe(false); + expect(orphan.notes).toContain('AUTO-DEACTIVATED'); + }); + + test('exports orphans to backup file', async () => { + // Create orphan + await GovernanceRule.create({ + id: 'test_orphan_002', + text: 'Another orphan rule', + scope: 'PROJECT_SPECIFIC', + applicableProjects: ['*'], + quadrant: 'TACTICAL', + persistence: 'MEDIUM', + category: 'test', + priority: 50, + active: true, + source: 'test', + createdBy: 'test' + }); + + // Sync + await syncInstructions({ silent: true }); + + // Check backup directory exists + const backupDir = path.join(__dirname, '../../.claude/backups'); + expect(fs.existsSync(backupDir)).toBe(true); + + // Check latest backup file contains orphan + const backupFiles = fs.readdirSync(backupDir) + .filter(f => f.startsWith('orphaned-rules-')) + .sort() + .reverse(); + + if (backupFiles.length > 0) { + const latestBackup = JSON.parse( + fs.readFileSync(path.join(backupDir, backupFiles[0]), 'utf8') + ); + expect(latestBackup.rules.some(r => r.id === 'test_orphan_002')).toBe(true); + } + }); + }); + + describe('Source Mapping', () => { + test('maps file source values to MongoDB enum values', async () => { + // This test assumes there are instructions with different source values in the file + await syncInstructions({ silent: true }); + + const rules = await GovernanceRule.find({}).lean(); + + // All sources should be valid enum values + const validSources = ['user_instruction', 'framework_default', 'automated', 'migration', 'claude_md_migration', 'test']; + rules.forEach(rule => { + expect(validSources).toContain(rule.source); + }); + }); + }); + + describe('Error Handling', () => { + test('handles missing instruction file gracefully', async () => { + // Temporarily rename file + const tempFile = INSTRUCTION_FILE + '.tmp'; + fs.renameSync(INSTRUCTION_FILE, tempFile); + + try { + const result = await syncInstructions({ silent: true }); + expect(result.success).toBe(false); + expect(result.error).toContain('not found'); + } finally { + // Restore file + fs.renameSync(tempFile, INSTRUCTION_FILE); + } + }); + + test('handles invalid JSON gracefully', async () => { + // Temporarily replace file with invalid JSON + const originalContent = fs.readFileSync(INSTRUCTION_FILE, 'utf8'); + fs.writeFileSync(INSTRUCTION_FILE, 'INVALID JSON{{{'); + + try { + const result = await syncInstructions({ silent: true }); + expect(result.success).toBe(false); + } finally { + // Restore file + fs.writeFileSync(INSTRUCTION_FILE, originalContent); + } + }); + }); + + describe('Programmatic Options', () => { + test('respects silent mode', async () => { + const consoleSpy = jest.spyOn(console, 'log'); + + await syncInstructions({ silent: true }); + + // Silent mode should not log + expect(consoleSpy).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + test('dry run does not modify database', async () => { + const result = await syncInstructions({ silent: true, dryRun: true }); + + expect(result.success).toBe(true); + + // Database should still be empty + const count = await GovernanceRule.countDocuments({}); + expect(count).toBe(0); + }); + }); + + describe('Idempotency', () => { + test('multiple syncs produce same result', async () => { + const result1 = await syncInstructions({ silent: true }); + const result2 = await syncInstructions({ silent: true }); + const result3 = await syncInstructions({ silent: true }); + + expect(result1.finalCount).toBe(result2.finalCount); + expect(result2.finalCount).toBe(result3.finalCount); + }); + }); +}); diff --git a/tests/unit/MemoryProxy.service.test.js b/tests/unit/MemoryProxy.service.test.js index 13ad13a1..34c508d2 100644 --- a/tests/unit/MemoryProxy.service.test.js +++ b/tests/unit/MemoryProxy.service.test.js @@ -4,13 +4,28 @@ */ const { MemoryProxyService } = require('../../src/services/MemoryProxy.service'); +const mongoose = require('mongoose'); const fs = require('fs').promises; const path = require('path'); +// Increase timeout for slow filesystem operations +jest.setTimeout(30000); + describe('MemoryProxyService', () => { let memoryProxy; const testMemoryPath = path.join(__dirname, '../../.memory-test'); + // Connect to MongoDB before all tests + beforeAll(async () => { + const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/tractatus_test'; + await mongoose.connect(mongoUri); + }); + + // Disconnect from MongoDB after all tests + afterAll(async () => { + await mongoose.disconnect(); + }); + const testRules = [ { id: 'inst_001',