UNPKG

@skyramp/mcp

Version:

Skyramp MCP (Model Context Protocol) Server - AI-powered test generation and execution

252 lines (251 loc) 9.04 kB
import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import { logger } from "./logger.js"; /** * File prefix mapping for each state type */ const STATE_FILE_PREFIXES = { analysis: "skyramp-analysis", // For test maintenance workflow recommendation: "skyramp-recommendation", // For test recommendation workflow }; /** * Generic State Manager for persisting workflow data * Reduces token usage by storing intermediate results in filesystem * * Supports two state types: * - analysis: Test maintenance workflow (discovery, drift, execution, health) * - recommendation: Test recommendation workflow (analyze repo, map tests, recommendations) */ export class StateManager { stateFile; sessionId; stateType; /** * Create a new state manager * @param stateType Type of state (analysis, recommendation) * @param sessionId Unique session identifier (defaults to timestamp) * @param stateDir Directory to store state files (defaults to /tmp) */ constructor(stateType = "analysis", sessionId, stateDir) { this.stateType = stateType; this.sessionId = sessionId || Date.now().toString(); const baseDir = stateDir || os.tmpdir(); const prefix = STATE_FILE_PREFIXES[stateType]; this.stateFile = path.join(baseDir, `${prefix}-${this.sessionId}.json`); } /** * Create state manager from existing state file path */ static fromStatePath(stateFilePath) { const basename = path.basename(stateFilePath, ".json"); const stateDir = path.dirname(stateFilePath); // Determine state type from filename let stateType = "analysis"; let sessionId = basename; for (const [type, prefix] of Object.entries(STATE_FILE_PREFIXES)) { if (basename.startsWith(prefix)) { stateType = type; sessionId = basename.replace(`${prefix}-`, ""); break; } } return new StateManager(stateType, sessionId, stateDir); } /** * Read data from state file (excludes metadata) */ async readData() { if (!this.exists()) { logger.debug(`State file does not exist: ${this.stateFile}`); return null; } try { const content = await fs.promises.readFile(this.stateFile, "utf-8"); const { metadata, ...data } = JSON.parse(content); logger.debug(`Read data from state file: ${this.stateFile}`); return data; } catch (error) { logger.error(`Failed to read state file: ${error.message}`); throw new Error(`Failed to read state file: ${error.message}`); } } /** * Read full state including metadata */ async readFullState() { if (!this.exists()) { return null; } try { const content = await fs.promises.readFile(this.stateFile, "utf-8"); return JSON.parse(content); } catch (error) { logger.error(`Failed to read state file: ${error.message}`); throw new Error(`Failed to read state file: ${error.message}`); } } /** * Write data to state file * Data fields are spread at root level alongside metadata */ async writeData(data, options) { try { // Read existing metadata if file exists let existingMetadata; if (this.exists()) { const existing = await this.readFullState(); existingMetadata = existing?.metadata; } const state = { ...data, metadata: { sessionId: this.sessionId, stateType: this.stateType, repositoryPath: options?.repositoryPath || existingMetadata?.repositoryPath, createdAt: existingMetadata?.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), step: options?.step, }, }; await fs.promises.writeFile(this.stateFile, JSON.stringify(state, null, 2), "utf-8"); logger.debug(`Wrote data to state file: ${this.stateFile}`); } catch (error) { logger.error(`Failed to write state file: ${error.message}`); throw new Error(`Failed to write state file: ${error.message}`); } } /** * Get the state file path */ getStatePath() { return this.stateFile; } /** * Get the session ID */ getSessionId() { return this.sessionId; } /** * Get the state type */ getStateType() { return this.stateType; } /** * Check if state file exists */ exists() { return fs.existsSync(this.stateFile); } /** * Delete the state file */ async delete() { if (this.exists()) { await fs.promises.unlink(this.stateFile); logger.debug(`Deleted state file: ${this.stateFile}`); } } /** * Get state file size in bytes */ async getSize() { if (!this.exists()) { return 0; } const stats = await fs.promises.stat(this.stateFile); return stats.size; } /** * Get human-readable state file size */ async getSizeFormatted() { const bytes = await this.getSize(); if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; } /** * Cleanup old state files * @param maxAgeHours Maximum age in hours * @param stateDir Directory to clean (defaults to /tmp) * @param stateTypes Which state types to clean (defaults to all) * @returns Number of files deleted */ static async cleanupOldStateFiles(maxAgeHours = 24, stateDir, stateTypes) { const baseDir = stateDir || os.tmpdir(); const files = await fs.promises.readdir(baseDir); // Get prefixes to clean const prefixesToClean = stateTypes ? stateTypes.map((t) => STATE_FILE_PREFIXES[t]) : Object.values(STATE_FILE_PREFIXES); const stateFiles = files.filter((f) => prefixesToClean.some((prefix) => f.startsWith(prefix))); let deletedCount = 0; const now = Date.now(); const maxAge = maxAgeHours * 60 * 60 * 1000; for (const file of stateFiles) { const filePath = path.join(baseDir, file); try { const stats = await fs.promises.stat(filePath); const age = now - stats.mtimeMs; if (age > maxAge) { await fs.promises.unlink(filePath); deletedCount++; logger.debug(`Deleted old state file: ${filePath}`); } } catch (error) { logger.error(`Failed to delete state file ${filePath}: ${error.message}`); } } if (deletedCount > 0) { logger.info(`Cleaned up ${deletedCount} old state files`); } return deletedCount; } /** * List all state files in directory * @param stateDir Directory to search (defaults to /tmp) * @param stateTypes Which state types to list (defaults to all) */ static async listStateFiles(stateDir, stateTypes) { const baseDir = stateDir || os.tmpdir(); const files = await fs.promises.readdir(baseDir); // Get prefixes to list const prefixesToList = stateTypes ? stateTypes.map((t) => STATE_FILE_PREFIXES[t]) : Object.values(STATE_FILE_PREFIXES); const stateFiles = files.filter((f) => prefixesToList.some((prefix) => f.startsWith(prefix))); const fileInfos = await Promise.all(stateFiles.map(async (file) => { const filePath = path.join(baseDir, file); const stats = await fs.promises.stat(filePath); // Determine state type and session ID let stateType = "analysis"; let sessionId = file.replace(".json", ""); for (const [type, prefix] of Object.entries(STATE_FILE_PREFIXES)) { if (file.startsWith(prefix)) { stateType = type; sessionId = file.replace(`${prefix}-`, "").replace(".json", ""); break; } } return { sessionId, stateType, path: filePath, size: stats.size, createdAt: stats.birthtime, modifiedAt: stats.mtime, }; })); return fileInfos.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime()); } }