@skyramp/mcp
Version:
Skyramp MCP (Model Context Protocol) Server - AI-powered test generation and execution
252 lines (251 loc) • 9.04 kB
JavaScript
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());
}
}