cns-mcp-server
Version:
Central Nervous System MCP Server for Autonomous Multi-Agent Orchestration with free local embeddings
429 lines • 16.9 kB
JavaScript
/**
* Workspace Manager - Git worktree management
*/
import { logger } from '../utils/logger.js';
import simpleGit from 'simple-git';
import { join } from 'path';
import { mkdir, access, constants, rmdir, stat, readdir } from 'fs/promises';
import { CNSError } from '../utils/error-handler.js';
export class WorkspaceManager {
workspacesDir;
git;
constructor(config) {
this.workspacesDir = config?.workspaces_dir || '/tmp/cns-workspaces';
// Allow git context to be overridden (for testing)
this.git = config?.git || simpleGit();
}
async create(args) {
logger.info('Creating workspace', args);
// Sanitize agent_id for safe path usage
const sanitizedAgentId = this.sanitizePathComponent(args.agent_id);
const workspacePath = join(this.workspacesDir, sanitizedAgentId);
const baseRef = args.base_ref || 'main';
try {
// Validate we're in a git repository
await this.validateGitRepository();
// Ensure workspaces directory exists
await this.ensureWorkspacesDirectory();
// Check if workspace already exists
try {
await access(workspacePath, constants.F_OK);
logger.warn(`Workspace already exists: ${workspacePath}`);
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'exists',
workspace_path: workspacePath,
agent_id: args.agent_id
}),
}],
};
}
catch {
// Workspace doesn't exist, continue with creation
}
// Validate base reference exists
await this.validateBaseRef(baseRef);
// Create git worktree (detached to avoid branch conflicts)
await this.git.raw(['worktree', 'add', '--detach', workspacePath, baseRef]);
// Verify workspace was created successfully
await this.verifyWorkspaceCreated(workspacePath);
logger.info(`Created worktree: ${workspacePath} from ${baseRef}`);
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'created',
workspace_path: workspacePath,
agent_id: args.agent_id,
base_ref: baseRef
}),
}],
};
}
catch (error) {
logger.error('Failed to create workspace', { agent_id: args.agent_id, baseRef, error });
// Attempt cleanup if creation partially succeeded
try {
await access(workspacePath, constants.F_OK);
logger.info('Attempting cleanup after failed creation');
await this.forceCleanupWorkspace(workspacePath);
}
catch {
// No cleanup needed
}
if (error instanceof Error) {
throw new Error(`Workspace creation failed: ${error.message}`);
}
throw error;
}
}
/**
* Terminate all processes using a specific workspace path
*/
async terminateWorkspaceProcesses(workspacePath, force = false) {
try {
// Find all processes using this workspace path
const { spawn } = await import('child_process');
// Use pgrep to find processes with the workspace path in their command line
const pgrepResult = spawn('pgrep', ['-f', workspacePath], { stdio: ['pipe', 'pipe', 'pipe'] });
let pids = [];
let pgrepOutput = '';
pgrepResult.stdout?.on('data', (data) => {
pgrepOutput += data.toString();
});
await new Promise((resolve, reject) => {
pgrepResult.on('close', (code) => {
if (code === 0) {
pids = pgrepOutput.trim().split('\n').filter(pid => pid.length > 0);
}
resolve();
});
pgrepResult.on('error', reject);
});
if (pids.length === 0) {
logger.info('No processes found using workspace', { workspacePath });
return 0;
}
logger.warn(`Terminating ${pids.length} processes using workspace`, {
workspacePath,
pids,
force
});
// Kill processes
const signal = force ? 'SIGKILL' : 'SIGTERM';
let killedCount = 0;
for (const pid of pids) {
try {
process.kill(parseInt(pid), signal);
killedCount++;
logger.info(`Terminated process ${pid} with ${signal}`);
}
catch (error) {
logger.warn(`Failed to kill process ${pid}:`, error.message);
}
}
// Wait a moment for graceful termination if not force
if (!force && killedCount > 0) {
await new Promise(resolve => setTimeout(resolve, 2000));
}
return killedCount;
}
catch (error) {
logger.error('Failed to terminate workspace processes:', error);
return 0;
}
}
async cleanup(args) {
logger.info('Cleaning up workspace with process termination', args);
// Sanitize agent_id for safe path usage
const sanitizedAgentId = this.sanitizePathComponent(args.agent_id);
const workspacePath = join(this.workspacesDir, sanitizedAgentId);
try {
// Check if workspace exists
try {
await access(workspacePath, constants.F_OK);
}
catch {
logger.warn(`Workspace does not exist: ${workspacePath}`);
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'not_found',
agent_id: args.agent_id,
workspace_path: workspacePath
}),
}],
};
}
// CRITICAL: Terminate all processes using this workspace FIRST
const killedProcesses = await this.terminateWorkspaceProcesses(workspacePath, args.force);
// Remove git worktree
try {
const removeArgs = ['worktree', 'remove', workspacePath];
if (args.force) {
removeArgs.push('--force');
}
await this.git.raw(removeArgs);
}
catch (error) {
// If worktree remove fails but force is requested, try manual cleanup
if (args.force) {
logger.warn(`Git worktree remove failed, attempting manual cleanup: ${error.message}`);
await this.forceCleanupWorkspace(workspacePath);
}
else {
throw new Error(`Failed to remove worktree: ${error.message}`);
}
}
logger.info(`Cleaned up workspace: ${workspacePath}`, {
killed_processes: killedProcesses
});
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'cleaned',
agent_id: args.agent_id,
workspace_path: workspacePath,
force: args.force || false,
killed_processes: killedProcesses
}),
}],
};
}
catch (error) {
logger.error('Failed to cleanup workspace', error);
throw error;
}
}
async getStats() {
try {
// Get active worktrees from git
const worktreeList = await this.git.raw(['worktree', 'list', '--porcelain']);
const activeWorktrees = this.parseWorktreeList(worktreeList);
// Calculate disk usage for workspace directory
const diskUsage = await this.calculateDiskUsage();
// Count workspaces in our workspace directory
let localWorkspaces = 0;
try {
await access(this.workspacesDir, constants.F_OK);
const workspaceEntries = await readdir(this.workspacesDir);
localWorkspaces = workspaceEntries.length;
}
catch {
// Workspaces directory doesn't exist
}
return {
active_worktrees: activeWorktrees.length,
local_workspaces: localWorkspaces,
total_disk_usage: this.formatBytes(diskUsage),
workspaces_dir: this.workspacesDir,
worktrees: activeWorktrees.map(w => ({
path: w.worktree,
branch: w.branch,
commit: w.HEAD
}))
};
}
catch (error) {
logger.error('Failed to get workspace statistics', error);
return {
active_worktrees: 0,
local_workspaces: 0,
total_disk_usage: '0B',
error: 'Failed to collect statistics'
};
}
}
async listAll() {
try {
// Get active worktrees from git
const worktreeList = await this.git.raw(['worktree', 'list', '--porcelain']);
const activeWorktrees = this.parseWorktreeList(worktreeList);
// Get workspace directory contents
let localWorkspaces = [];
try {
await access(this.workspacesDir, constants.F_OK);
localWorkspaces = await readdir(this.workspacesDir);
}
catch {
// Workspaces directory doesn't exist
}
// Calculate disk usage
const diskUsage = await this.calculateDiskUsage();
return {
content: [
{
type: 'text',
text: JSON.stringify({
summary: {
active_worktrees: activeWorktrees.length,
local_workspaces: localWorkspaces.length,
total_disk_usage: this.formatBytes(diskUsage),
workspaces_dir: this.workspacesDir,
},
worktrees: activeWorktrees.map(w => ({
path: w.worktree,
branch: w.branch,
commit: w.HEAD,
bare: w.bare
})),
local_workspace_dirs: localWorkspaces
}, null, 2),
},
],
};
}
catch (error) {
logger.error('Failed to list workspaces', error);
return {
content: [
{
type: 'text',
text: JSON.stringify({
error: 'Failed to list workspaces',
message: error instanceof Error ? error.message : 'Unknown error'
}, null, 2),
},
],
};
}
}
async validateGitRepository() {
try {
await this.git.status();
}
catch (error) {
throw new CNSError('Not in a git repository or git is not available', 'GIT_REPOSITORY_INVALID', { error: error instanceof Error ? error.message : error }, false);
}
}
async ensureWorkspacesDirectory() {
try {
await access(this.workspacesDir, constants.F_OK);
}
catch {
await mkdir(this.workspacesDir, { recursive: true });
logger.info(`Created workspaces directory: ${this.workspacesDir}`);
}
}
async forceCleanupWorkspace(workspacePath) {
try {
// First try to prune worktrees to clean up any stale references
await this.git.raw(['worktree', 'prune']);
// Then manually remove the directory if it still exists
try {
await access(workspacePath, constants.F_OK);
await rmdir(workspacePath, { recursive: true });
logger.info(`Manually removed workspace directory: ${workspacePath}`);
}
catch {
// Directory doesn't exist or already removed
}
}
catch (error) {
logger.error(`Force cleanup failed for ${workspacePath}`, error);
throw error;
}
}
parseWorktreeList(worktreeOutput) {
const worktrees = [];
const lines = worktreeOutput.split('\n').filter(line => line.trim());
let currentWorktree = {};
for (const line of lines) {
if (line.startsWith('worktree ')) {
if (Object.keys(currentWorktree).length > 0) {
worktrees.push(currentWorktree);
}
currentWorktree = { worktree: line.substring(9) };
}
else if (line.startsWith('HEAD ')) {
currentWorktree.HEAD = line.substring(5);
}
else if (line.startsWith('branch ')) {
currentWorktree.branch = line.substring(7);
}
else if (line === 'bare') {
currentWorktree.bare = true;
}
}
if (Object.keys(currentWorktree).length > 0) {
worktrees.push(currentWorktree);
}
return worktrees;
}
async calculateDiskUsage() {
try {
await access(this.workspacesDir, constants.F_OK);
return await this.calculateDirectorySize(this.workspacesDir);
}
catch {
return 0;
}
}
async calculateDirectorySize(dirPath) {
let totalSize = 0;
try {
const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dirPath, entry.name);
if (entry.isDirectory()) {
totalSize += await this.calculateDirectorySize(fullPath);
}
else if (entry.isFile()) {
const stats = await stat(fullPath);
totalSize += stats.size;
}
}
}
catch {
// Skip directories we can't read
}
return totalSize;
}
formatBytes(bytes) {
if (bytes === 0)
return '0B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + sizes[i];
}
sanitizePathComponent(component) {
// Remove dangerous characters and normalize
return component
.replace(/[^a-zA-Z0-9\-_.]/g, '_') // Replace invalid chars with underscore
.replace(/^\.+/, '') // Remove leading dots
.substring(0, 100) // Limit length
.trim();
}
async validateBaseRef(baseRef) {
try {
// Check if the reference exists
await this.git.raw(['show-ref', '--verify', `refs/heads/${baseRef}`]);
}
catch {
// Try as a commit hash or tag
try {
await this.git.raw(['rev-parse', '--verify', baseRef]);
}
catch {
throw new Error(`Base reference '${baseRef}' does not exist`);
}
}
}
async verifyWorkspaceCreated(workspacePath) {
try {
await access(workspacePath, constants.F_OK);
// Verify it's a git repository
const workspaceGit = simpleGit(workspacePath);
await workspaceGit.status();
}
catch {
throw new Error(`Workspace verification failed: ${workspacePath} is not accessible or not a git repository`);
}
}
}
//# sourceMappingURL=index.js.map