diff --git a/migrations/024_add_project_archival_fields.sql b/migrations/024_add_project_archival_fields.sql new file mode 100644 index 0000000..16615b9 --- /dev/null +++ b/migrations/024_add_project_archival_fields.sql @@ -0,0 +1,20 @@ +-- Migration 024: Add project archival tracking fields +-- Adds fields to track S3 archival of complete projects + +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP WITH TIME ZONE, + ADD COLUMN IF NOT EXISTS archive_location TEXT, + ADD COLUMN IF NOT EXISTS archive_size BIGINT, + ADD COLUMN IF NOT EXISTS archived_by_session TEXT; + +CREATE INDEX IF NOT EXISTS idx_projects_archived ON projects(archived_at) WHERE archived_at IS NOT NULL; + +COMMENT ON COLUMN projects.archived_at IS 'Timestamp when project was archived to S3'; +COMMENT ON COLUMN projects.archive_location IS 'S3 path to archived tarball (e.g., s3://agiliton-archive/projects/Project-20260127.tar.gz)'; +COMMENT ON COLUMN projects.archive_size IS 'Size of archive in bytes'; +COMMENT ON COLUMN projects.archived_by_session IS 'Session ID that performed the archival'; + +-- Record migration +INSERT INTO schema_migrations (version, applied_at) +VALUES ('024_add_project_archival_fields', NOW()) +ON CONFLICT DO NOTHING; diff --git a/src/index.ts b/src/index.ts index 7046afe..0fc652b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,6 +83,7 @@ import { sessionPatternDetection, } from './tools/session-docs.js'; import { archiveAdd, archiveSearch, archiveList, archiveGet } from './tools/archives.js'; +import { projectArchive } from './tools/project-archive.js'; // Create MCP server const server = new Server( @@ -667,6 +668,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }); break; + // Project archival + case 'project_archive': + result = await projectArchive({ + project_key: a.project_key, + project_path: a.project_path, + delete_local: a.delete_local, + session_id: a.session_id, + }); + break; + default: throw new Error(`Unknown tool: ${name}`); } diff --git a/src/tools/index.ts b/src/tools/index.ts index 1c1ae66..e284e45 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1059,4 +1059,20 @@ export const toolDefinitions = [ required: ['id'], }, }, + + // Project Archival + { + name: 'project_archive', + description: 'Archive complete project to S3 with database tracking. Creates tarball, uploads to s3://agiliton-archive/projects/, updates database, and optionally deletes local copy.', + inputSchema: { + type: 'object', + properties: { + project_key: { type: 'string', description: 'Project key (must exist in database)' }, + project_path: { type: 'string', description: 'Absolute path to project directory' }, + delete_local: { type: 'boolean', description: 'Delete local project after successful archive (default: false)' }, + session_id: { type: 'string', description: 'Session ID performing the archival (optional)' }, + }, + required: ['project_key', 'project_path'], + }, + }, ]; diff --git a/src/tools/project-archive.ts b/src/tools/project-archive.ts new file mode 100644 index 0000000..2101f5a --- /dev/null +++ b/src/tools/project-archive.ts @@ -0,0 +1,217 @@ +// Project archival operations - Complete project archival to S3 +// Coordinates workflow: tar + S3 upload + database tracking + optional local deletion + +import { execute, queryOne } from '../db.js'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { existsSync, statSync } from 'fs'; +import { basename, dirname } from 'path'; + +const execAsync = promisify(exec); + +interface ProjectArchiveArgs { + project_key: string; + project_path: string; + delete_local?: boolean; + session_id?: string; +} + +interface ProjectArchiveResult { + success: boolean; + message: string; + archive_location?: string; + archive_size?: number; +} + +/** + * Verify project exists in database + */ +async function verifyProject(projectKey: string): Promise { + const result = await queryOne<{ key: string }>( + 'SELECT key FROM projects WHERE key = $1', + [projectKey] + ); + return !!result; +} + +/** + * Get vault credentials for S3 access + */ +async function getS3Credentials(): Promise<{ + accessKey: string; + secretKey: string; + endpoint: string; +}> { + try { + const { stdout: accessKey } = await execAsync('vault get hetzner.s3_access_key'); + const { stdout: secretKey } = await execAsync('vault get hetzner.s3_secret_key'); + const { stdout: endpoint } = await execAsync('vault get hetzner.s3_endpoint'); + + return { + accessKey: accessKey.trim(), + secretKey: secretKey.trim(), + endpoint: endpoint.trim() + }; + } catch (error) { + throw new Error(`Failed to get S3 credentials from vault: ${error}`); + } +} + +/** + * Create tarball of project directory + */ +async function createTarball( + projectPath: string, + projectKey: string +): Promise<{ tarballPath: string; size: number }> { + if (!existsSync(projectPath)) { + throw new Error(`Project path not found: ${projectPath}`); + } + + const parentDir = dirname(projectPath); + const projectDir = basename(projectPath); + const date = new Date().toISOString().split('T')[0].replace(/-/g, ''); + const tarballName = `${projectKey}-${date}.tar.gz`; + const tarballPath = `/tmp/${tarballName}`; + + console.log(`Creating tarball: ${tarballPath}`); + await execAsync(`cd "${parentDir}" && tar -czf "${tarballPath}" "${projectDir}"`); + + const stats = statSync(tarballPath); + return { tarballPath, size: stats.size }; +} + +/** + * Upload tarball to S3 + */ +async function uploadToS3( + tarballPath: string, + projectKey: string, + credentials: { accessKey: string; secretKey: string; endpoint: string } +): Promise { + const s3Path = `s3://agiliton-archive/projects/${basename(tarballPath)}`; + + console.log(`Uploading to S3: ${s3Path}`); + + const env = { + ...process.env, + AWS_ACCESS_KEY_ID: credentials.accessKey, + AWS_SECRET_ACCESS_KEY: credentials.secretKey + }; + + try { + await execAsync( + `aws --endpoint-url ${credentials.endpoint} s3 cp "${tarballPath}" "${s3Path}"`, + { env } + ); + + // Verify upload + await execAsync( + `aws --endpoint-url ${credentials.endpoint} s3 ls "${s3Path}"`, + { env } + ); + + return s3Path; + } catch (error) { + throw new Error(`Failed to upload to S3: ${error}`); + } +} + +/** + * Update database with archive metadata + */ +async function updateProjectArchive( + projectKey: string, + archiveLocation: string, + archiveSize: number, + sessionId?: string +): Promise { + await execute( + `UPDATE projects + SET archived_at = NOW(), + archive_location = $1, + archive_size = $2, + archived_by_session = $3, + active = false + WHERE key = $4`, + [archiveLocation, archiveSize, sessionId || null, projectKey] + ); +} + +/** + * Delete local project directory + */ +async function deleteLocalProject(projectPath: string): Promise { + console.log(`Deleting local project: ${projectPath}`); + await execAsync(`rm -rf "${projectPath}"`); +} + +/** + * Clean up temporary tarball + */ +async function cleanupTarball(tarballPath: string): Promise { + try { + await execAsync(`rm -f "${tarballPath}"`); + } catch (error) { + console.warn(`Failed to clean up tarball: ${error}`); + } +} + +/** + * Archive a complete project to S3 + * + * Workflow: + * 1. Verify project exists + * 2. Create tarball of project directory + * 3. Upload tarball to S3 (s3://agiliton-archive/projects/) + * 4. Update database with archive metadata + * 5. Optional: Delete local project directory + * 6. Clean up temporary tarball + * + * @param args - Archive parameters + * @returns Archive result with status and metadata + */ +export async function projectArchive( + args: ProjectArchiveArgs +): Promise { + const { project_key, project_path, delete_local = false, session_id } = args; + + try { + // 1. Verify project exists + const exists = await verifyProject(project_key); + if (!exists) { + return `Error: Project not found in database: ${project_key}`; + } + + // 2. Get S3 credentials + const credentials = await getS3Credentials(); + + // 3. Create tarball + const { tarballPath, size } = await createTarball(project_path, project_key); + + // 4. Upload to S3 + const archiveLocation = await uploadToS3(tarballPath, project_key, credentials); + + // 5. Update database + await updateProjectArchive(project_key, archiveLocation, size, session_id); + + // 6. Optional: Delete local project + if (delete_local) { + await deleteLocalProject(project_path); + } + + // 7. Clean up tarball + await cleanupTarball(tarballPath); + + const sizeStr = `${(size / (1024 * 1024)).toFixed(1)}MB`; + const deletedStr = delete_local ? ' (local copy deleted)' : ''; + + return `✅ Project archived successfully\n` + + `Project: ${project_key}\n` + + `Location: ${archiveLocation}\n` + + `Size: ${sizeStr}${deletedStr}`; + + } catch (error) { + return `❌ Archive failed: ${error}`; + } +}