@endlessblink/like-i-said-v2
Version:
Task Management & Memory for Claude - Track tasks, remember context, and maintain continuity across sessions with 27 powerful tools. Works with Claude Desktop and Claude Code.
618 lines (517 loc) âĸ 19.1 kB
JavaScript
import fs from 'fs';
import path from 'path';
import { settingsManager } from './settings-manager.js';
import { PathSettings } from './path-settings.js';
/**
* System Safeguards for Task Management
* Prevents data corruption and ensures system stability
*
* v2.8.5 Updates:
* - Dynamic path support via PathSettings
* - Settings-based backup configuration
* - Enhanced backup manifest with version info
* - Support for data/ directory backups
* - Automatic backup rotation based on settings
*/
export class SystemSafeguards {
constructor() {
this.backupDir = 'data-backups';
// Use PathSettings for dynamic path support
this.pathSettings = new PathSettings();
const paths = this.pathSettings.getEffectivePaths();
this.tasksDir = paths.tasks;
this.memoriesDir = paths.memories;
// Data directory for settings, auth, etc.
this.dataDir = 'data';
// Load backup settings
this.settings = settingsManager.getSettings();
this.maxBackups = this.settings.features?.maxBackups || 10;
this.backupInterval = this.settings.features?.backupInterval || 3600000; // 1 hour
this.autoBackup = this.settings.features?.autoBackup !== false;
this.ensureBackupDirectory();
this.lastBackupTime = null;
this.backupTimer = null;
}
ensureBackupDirectory() {
if (!fs.existsSync(this.backupDir)) {
fs.mkdirSync(this.backupDir, { recursive: true });
}
}
/**
* Create backup before any destructive operation
* Enhanced with v2.8.5 features
*/
async createBackup(operation = 'manual') {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = path.join(this.backupDir, `backup-${timestamp}-${operation}`);
try {
fs.mkdirSync(backupPath, { recursive: true });
// Count existing files for statistics
const stats = {
tasks: 0,
memories: 0,
dataFiles: 0,
totalSize: 0
};
// Backup tasks
if (fs.existsSync(this.tasksDir)) {
const tasksBackupPath = path.join(backupPath, 'tasks');
await this.copyDirectory(this.tasksDir, tasksBackupPath);
stats.tasks = await this.countFiles(this.tasksDir, '.md');
}
// Backup memories
if (fs.existsSync(this.memoriesDir)) {
const memoriesBackupPath = path.join(backupPath, 'memories');
await this.copyDirectory(this.memoriesDir, memoriesBackupPath);
stats.memories = await this.countFiles(this.memoriesDir, '.md');
}
// Backup data directory (settings, auth, etc.)
if (fs.existsSync(this.dataDir)) {
const dataBackupPath = path.join(backupPath, 'data');
await this.copyDirectory(this.dataDir, dataBackupPath);
stats.dataFiles = await this.countFiles(this.dataDir);
}
// Calculate total backup size
stats.totalSize = await this.getDirectorySize(backupPath);
// Create enhanced backup manifest
const manifest = {
timestamp: new Date().toISOString(),
operation,
version: '2.8.5',
paths: {
tasks: this.tasksDir,
memories: this.memoriesDir,
data: this.dataDir
},
contents: {
tasks: fs.existsSync(this.tasksDir),
memories: fs.existsSync(this.memoriesDir),
data: fs.existsSync(this.dataDir)
},
statistics: stats,
settings: {
autoBackup: this.autoBackup,
maxBackups: this.maxBackups,
backupInterval: this.backupInterval
}
};
fs.writeFileSync(
path.join(backupPath, 'backup-manifest.json'),
JSON.stringify(manifest, null, 2)
);
console.error(`â
Backup created: ${backupPath}`);
console.error(`đ Backed up: ${stats.tasks} tasks, ${stats.memories} memories, ${stats.dataFiles} data files (${this.formatBytes(stats.totalSize)})`);
// Update last backup time
this.lastBackupTime = new Date();
// Rotate old backups if needed
await this.rotateBackups();
return backupPath;
} catch (error) {
console.error('â Backup failed:', error);
throw error;
}
}
/**
* Copy directory recursively
*/
async copyDirectory(src, dest) {
fs.mkdirSync(dest, { recursive: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await this.copyDirectory(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* Validate task data before saving
*/
validateTaskData(task) {
const errors = [];
// Required fields
if (!task.id || typeof task.id !== 'string') {
errors.push('Task ID is required and must be a string');
}
if (!task.title || typeof task.title !== 'string' || task.title.trim().length === 0) {
errors.push('Task title is required and must be a non-empty string');
}
if (!task.project || typeof task.project !== 'string') {
errors.push('Task project is required and must be a string');
}
// Status validation
const validStatuses = ['todo', 'in_progress', 'completed', 'blocked', 'archived'];
if (!validStatuses.includes(task.status)) {
errors.push(`Task status must be one of: ${validStatuses.join(', ')}`);
}
// Priority validation
const validPriorities = ['low', 'medium', 'high', 'urgent'];
if (!validPriorities.includes(task.priority)) {
errors.push(`Task priority must be one of: ${validPriorities.join(', ')}`);
}
// Memory connections validation
if (task.memory_connections && !Array.isArray(task.memory_connections)) {
errors.push('Memory connections must be an array');
}
return errors;
}
/**
* Validate memory data before saving
*/
validateMemoryData(memory) {
const errors = [];
if (!memory.id || typeof memory.id !== 'string') {
errors.push('Memory ID is required and must be a string');
}
if (!memory.content || typeof memory.content !== 'string' || memory.content.trim().length === 0) {
errors.push('Memory content is required and must be a non-empty string');
}
return errors;
}
/**
* Check system health and integrity
* Enhanced with backup status and storage metrics
*/
async checkSystemHealth() {
const health = {
timestamp: new Date().toISOString(),
status: 'healthy',
issues: [],
stats: {
tasks: 0,
memories: 0,
projects: 0,
backups: 0,
totalStorageSize: 0
},
backup: {
lastBackup: this.lastBackupTime?.toISOString() || 'never',
nextBackup: this.getNextBackupTime()?.toISOString() || 'not scheduled',
autoBackupEnabled: this.autoBackup
}
};
try {
// Check tasks directory
if (fs.existsSync(this.tasksDir)) {
const taskFiles = await this.countFiles(this.tasksDir, '.md');
health.stats.tasks = taskFiles;
// Count projects
const projects = fs.readdirSync(this.tasksDir, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.length;
health.stats.projects = projects;
} else {
health.issues.push('Tasks directory does not exist');
}
// Check memories directory
if (fs.existsSync(this.memoriesDir)) {
const memoryFiles = await this.countFiles(this.memoriesDir, '.md');
health.stats.memories = memoryFiles;
} else {
health.issues.push('Memories directory does not exist');
}
// Check backup directory
if (fs.existsSync(this.backupDir)) {
const backups = fs.readdirSync(this.backupDir, { withFileTypes: true })
.filter(entry => entry.isDirectory() && entry.name.startsWith('backup-'));
health.stats.backups = backups.length;
// Check if backup rotation is needed
if (backups.length > this.maxBackups) {
health.issues.push(`Backup count (${backups.length}) exceeds maximum (${this.maxBackups})`);
}
}
// Calculate total storage size
health.stats.totalStorageSize = await this.calculateTotalStorageSize();
// Check for orphaned files
const orphanedFiles = await this.findOrphanedFiles();
if (orphanedFiles.length > 0) {
health.issues.push(`Found ${orphanedFiles.length} orphaned files`);
}
// Check backup freshness
if (this.autoBackup && this.lastBackupTime) {
const timeSinceLastBackup = Date.now() - this.lastBackupTime.getTime();
if (timeSinceLastBackup > this.backupInterval * 2) {
health.issues.push('Backup is overdue');
}
}
if (health.issues.length > 0) {
health.status = 'warning';
}
} catch (error) {
health.status = 'error';
health.issues.push(`System health check failed: ${error.message}`);
}
return health;
}
/**
* Count files recursively
*/
async countFiles(dir, extension = '') {
let count = 0;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
count += await this.countFiles(fullPath, extension);
} else if (!extension || entry.name.endsWith(extension)) {
count++;
}
}
return count;
}
/**
* Find files that might be orphaned or corrupted
*/
async findOrphanedFiles() {
const orphaned = [];
// Check for files outside proper directory structure
const checkDirectory = (dir, validExtensions = ['.md']) => {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
checkDirectory(fullPath, validExtensions);
} else {
const ext = path.extname(entry.name);
const fileName = entry.name;
// Ignore common git and system files
const ignoredFiles = ['.gitkeep', '.gitignore', '.DS_Store', 'Thumbs.db'];
if (!validExtensions.includes(ext) && !ignoredFiles.includes(fileName)) {
orphaned.push(fullPath);
}
}
}
};
checkDirectory(this.tasksDir);
checkDirectory(this.memoriesDir);
return orphaned;
}
/**
* Clean up orphaned files
*/
async cleanupOrphanedFiles() {
const orphaned = await this.findOrphanedFiles();
if (orphaned.length > 0) {
await this.createBackup('cleanup');
for (const file of orphaned) {
try {
fs.unlinkSync(file);
console.error(`đī¸ Removed orphaned file: ${file}`);
} catch (error) {
console.error(`â Failed to remove ${file}:`, error);
}
}
}
return orphaned.length;
}
/**
* Recovery from backup
*/
async recoverFromBackup(backupPath) {
if (!fs.existsSync(backupPath)) {
throw new Error(`Backup not found: ${backupPath}`);
}
const manifestPath = path.join(backupPath, 'backup-manifest.json');
if (!fs.existsSync(manifestPath)) {
throw new Error('Invalid backup: manifest not found');
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
// Create backup of current state before recovery
await this.createBackup('pre-recovery');
// Restore tasks
if (manifest.contents.tasks) {
const tasksBackupPath = path.join(backupPath, 'tasks');
if (fs.existsSync(tasksBackupPath)) {
if (fs.existsSync(this.tasksDir)) {
fs.rmSync(this.tasksDir, { recursive: true });
}
await this.copyDirectory(tasksBackupPath, this.tasksDir);
}
}
// Restore memories
if (manifest.contents.memories) {
const memoriesBackupPath = path.join(backupPath, 'memories');
if (fs.existsSync(memoriesBackupPath)) {
if (fs.existsSync(this.memoriesDir)) {
fs.rmSync(this.memoriesDir, { recursive: true });
}
await this.copyDirectory(memoriesBackupPath, this.memoriesDir);
}
}
// Restore data directory (new in v2.8.5)
if (manifest.contents.data) {
const dataBackupPath = path.join(backupPath, 'data');
if (fs.existsSync(dataBackupPath)) {
if (fs.existsSync(this.dataDir)) {
fs.rmSync(this.dataDir, { recursive: true });
}
await this.copyDirectory(dataBackupPath, this.dataDir);
}
}
console.error(`â
Recovery completed from backup: ${backupPath}`);
return manifest;
}
/**
* Get directory size in bytes
*/
async getDirectorySize(dir) {
let size = 0;
if (!fs.existsSync(dir)) return 0;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
size += await this.getDirectorySize(fullPath);
} else {
const stats = fs.statSync(fullPath);
size += stats.size;
}
}
return size;
}
/**
* Format bytes to human-readable string
*/
formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Calculate total storage size
*/
async calculateTotalStorageSize() {
let total = 0;
total += await this.getDirectorySize(this.tasksDir);
total += await this.getDirectorySize(this.memoriesDir);
total += await this.getDirectorySize(this.dataDir);
total += await this.getDirectorySize(this.backupDir);
return total;
}
/**
* Rotate old backups based on maxBackups setting
*/
async rotateBackups() {
if (!fs.existsSync(this.backupDir)) return;
const backups = fs.readdirSync(this.backupDir, { withFileTypes: true })
.filter(entry => entry.isDirectory() && entry.name.startsWith('backup-'))
.map(entry => ({
name: entry.name,
path: path.join(this.backupDir, entry.name),
timestamp: this.extractTimestampFromBackupName(entry.name)
}))
.sort((a, b) => a.timestamp - b.timestamp);
// Keep only the most recent backups
while (backups.length > this.maxBackups) {
const oldest = backups.shift();
try {
fs.rmSync(oldest.path, { recursive: true });
console.error(`đī¸ Rotated old backup: ${oldest.name}`);
} catch (error) {
console.error(`â Failed to rotate backup ${oldest.name}:`, error);
}
}
}
/**
* Extract timestamp from backup directory name
*/
extractTimestampFromBackupName(name) {
// Format: backup-2025-01-19T12-30-45-000Z-operation
const match = name.match(/backup-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/);
if (match) {
const timestamp = match[1].replace(/-(\d{2})-(\d{2})-(\d{3}Z)$/, ':$1:$2.$3');
return new Date(timestamp).getTime();
}
return 0;
}
/**
* Get next scheduled backup time
*/
getNextBackupTime() {
if (!this.autoBackup || !this.lastBackupTime) return null;
return new Date(this.lastBackupTime.getTime() + this.backupInterval);
}
/**
* Start automatic backup timer
*/
startAutoBackup() {
if (!this.autoBackup) return;
// Clear existing timer
if (this.backupTimer) {
clearInterval(this.backupTimer);
}
// Set up periodic backup
this.backupTimer = setInterval(async () => {
try {
await this.createBackup('automatic');
} catch (error) {
console.error('â Automatic backup failed:', error);
}
}, this.backupInterval);
console.error(`â° Automatic backup scheduled every ${this.formatDuration(this.backupInterval)}`);
}
/**
* Stop automatic backup timer
*/
stopAutoBackup() {
if (this.backupTimer) {
clearInterval(this.backupTimer);
this.backupTimer = null;
console.error('âšī¸ Automatic backup stopped');
}
}
/**
* Format duration in milliseconds to human-readable string
*/
formatDuration(ms) {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours} hour${hours > 1 ? 's' : ''}`;
} else if (minutes > 0) {
return `${minutes} minute${minutes > 1 ? 's' : ''}`;
} else {
return `${seconds} second${seconds > 1 ? 's' : ''}`;
}
}
/**
* List available backups
*/
async listBackups() {
if (!fs.existsSync(this.backupDir)) return [];
const backups = fs.readdirSync(this.backupDir, { withFileTypes: true })
.filter(entry => entry.isDirectory() && entry.name.startsWith('backup-'))
.map(entry => {
const manifestPath = path.join(this.backupDir, entry.name, 'backup-manifest.json');
let manifest = null;
try {
if (fs.existsSync(manifestPath)) {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
}
} catch (error) {
console.error(`Failed to read manifest for ${entry.name}:`, error);
}
return {
name: entry.name,
path: path.join(this.backupDir, entry.name),
manifest,
size: 0 // Will be populated if needed
};
})
.filter(backup => backup.manifest)
.sort((a, b) => new Date(b.manifest.timestamp) - new Date(a.manifest.timestamp));
// Optionally calculate sizes
for (const backup of backups) {
backup.size = await this.getDirectorySize(backup.path);
}
return backups;
}
}
export default SystemSafeguards;