diff --git a/apps/mcp-server/src/services/ILighthouseService.ts b/apps/mcp-server/src/services/ILighthouseService.ts index c73e28f..63902a5 100644 --- a/apps/mcp-server/src/services/ILighthouseService.ts +++ b/apps/mcp-server/src/services/ILighthouseService.ts @@ -3,7 +3,14 @@ */ import { UploadResult, DownloadResult, AccessCondition, Dataset } from "@lighthouse-tooling/types"; -import { EnhancedAccessCondition } from "@lighthouse-tooling/sdk-wrapper"; +import { + EnhancedAccessCondition, + BatchUploadOptions, + BatchDownloadOptions, + BatchOperationResult, + BatchDownloadFileResult, + FileInfo, +} from "@lighthouse-tooling/sdk-wrapper"; export interface StoredFile { cid: string; @@ -150,4 +157,20 @@ export interface ILighthouseService { success: boolean; error?: string; }>; + + /** + * Batch upload multiple files with configurable concurrency + */ + batchUploadFiles( + filePaths: string[], + options?: BatchUploadOptions, + ): Promise>; + + /** + * Batch download multiple files by CID with configurable concurrency + */ + batchDownloadFiles( + cids: string[], + options?: BatchDownloadOptions, + ): Promise>; } diff --git a/apps/mcp-server/src/services/LighthouseService.ts b/apps/mcp-server/src/services/LighthouseService.ts index d1e802b..7bf871d 100644 --- a/apps/mcp-server/src/services/LighthouseService.ts +++ b/apps/mcp-server/src/services/LighthouseService.ts @@ -2,7 +2,17 @@ * Real Lighthouse Service - Uses the unified SDK wrapper for actual Lighthouse operations */ -import { LighthouseAISDK, EnhancedAccessCondition } from "@lighthouse-tooling/sdk-wrapper"; +import { + LighthouseAISDK, + EnhancedAccessCondition, + BatchUploadOptions, + BatchDownloadOptions, + BatchOperationResult, + BatchDownloadFileResult, + BatchUploadInput, + BatchDownloadInput, + FileInfo, +} from "@lighthouse-tooling/sdk-wrapper"; import { UploadResult, DownloadResult, AccessCondition, Dataset } from "@lighthouse-tooling/types"; import { Logger } from "@lighthouse-tooling/shared"; import { ILighthouseService, StoredFile } from "./ILighthouseService.js"; @@ -879,6 +889,107 @@ export class LighthouseService implements ILighthouseService { } } + /** + * Batch upload multiple files with configurable concurrency + */ + async batchUploadFiles( + filePaths: string[], + options?: BatchUploadOptions, + ): Promise> { + const startTime = Date.now(); + + try { + this.logger.info("Starting batch upload", { + fileCount: filePaths.length, + concurrency: options?.concurrency || 3, + }); + + // Convert string paths to BatchUploadInput objects + const inputs: BatchUploadInput[] = filePaths.map((filePath) => ({ + filePath, + })); + + const result = await this.sdk.batchUpload(inputs, options); + + // Store successful uploads in cache and database + for (const fileResult of result.results) { + if (fileResult.success && fileResult.data) { + const storedFile: StoredFile = { + cid: fileResult.data.hash, + filePath: fileResult.data.name, + size: fileResult.data.size, + encrypted: fileResult.data.encrypted, + accessConditions: options?.accessConditions, + tags: options?.tags, + uploadedAt: fileResult.data.uploadedAt, + pinned: true, + hash: fileResult.data.hash, + }; + + this.storage.saveFile(storedFile); + this.fileCache.set(fileResult.data.hash, storedFile); + } + } + + const executionTime = Date.now() - startTime; + this.logger.info("Batch upload completed", { + total: result.total, + successful: result.successful, + failed: result.failed, + successRate: result.successRate, + executionTime, + }); + + return result; + } catch (error) { + this.logger.error("Batch upload failed", error as Error, { + fileCount: filePaths.length, + }); + throw error; + } + } + + /** + * Batch download multiple files by CID with configurable concurrency + */ + async batchDownloadFiles( + cids: string[], + options?: BatchDownloadOptions, + ): Promise> { + const startTime = Date.now(); + + try { + this.logger.info("Starting batch download", { + cidCount: cids.length, + concurrency: options?.concurrency || 3, + outputDir: options?.outputDir, + }); + + // Convert string CIDs to BatchDownloadInput objects + const inputs: BatchDownloadInput[] = cids.map((cid) => ({ + cid, + })); + + const result = await this.sdk.batchDownload(inputs, options); + + const executionTime = Date.now() - startTime; + this.logger.info("Batch download completed", { + total: result.total, + successful: result.successful, + failed: result.failed, + successRate: result.successRate, + executionTime, + }); + + return result; + } catch (error) { + this.logger.error("Batch download failed", error as Error, { + cidCount: cids.length, + }); + throw error; + } + } + /** * Cleanup resources */ diff --git a/apps/mcp-server/src/services/MockLighthouseService.ts b/apps/mcp-server/src/services/MockLighthouseService.ts index 1513355..c9668a1 100644 --- a/apps/mcp-server/src/services/MockLighthouseService.ts +++ b/apps/mcp-server/src/services/MockLighthouseService.ts @@ -3,7 +3,14 @@ */ import { UploadResult, DownloadResult, AccessCondition, Dataset } from "@lighthouse-tooling/types"; -import { EnhancedAccessCondition } from "@lighthouse-tooling/sdk-wrapper"; +import { + EnhancedAccessCondition, + BatchUploadOptions, + BatchDownloadOptions, + BatchOperationResult, + BatchDownloadFileResult, + FileInfo, +} from "@lighthouse-tooling/sdk-wrapper"; import { Logger, FileUtils } from "@lighthouse-tooling/shared"; import { CIDGenerator } from "../utils/cid-generator.js"; import { ILighthouseService, StoredFile } from "./ILighthouseService.js"; @@ -598,6 +605,167 @@ export class MockLighthouseService implements ILighthouseService { } } + /** + * Batch upload multiple files with configurable concurrency + */ + async batchUploadFiles( + filePaths: string[], + options?: BatchUploadOptions, + ): Promise> { + const startTime = Date.now(); + const results: Array<{ + id: string; + success: boolean; + data?: FileInfo; + error?: string; + duration: number; + retries: number; + }> = []; + + this.logger.info("Starting batch upload", { + fileCount: filePaths.length, + concurrency: options?.concurrency || 3, + }); + + for (const filePath of filePaths) { + const itemStartTime = Date.now(); + try { + const uploadResult = await this.uploadFile({ + filePath, + encrypt: options?.encrypt, + accessConditions: options?.accessConditions, + tags: options?.tags, + }); + + results.push({ + id: filePath, + success: true, + data: { + hash: uploadResult.cid, + name: filePath.split("/").pop() || filePath, + size: uploadResult.size, + encrypted: uploadResult.encrypted, + mimeType: "application/octet-stream", + uploadedAt: uploadResult.uploadedAt, + }, + duration: Date.now() - itemStartTime, + retries: 0, + }); + } catch (error) { + if (!options?.continueOnError) { + throw error; + } + results.push({ + id: filePath, + success: false, + error: error instanceof Error ? error.message : "Unknown error", + duration: Date.now() - itemStartTime, + retries: 0, + }); + } + } + + const successful = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + const totalDuration = Date.now() - startTime; + + this.logger.info("Batch upload completed", { + total: filePaths.length, + successful, + failed, + totalDuration, + }); + + return { + total: filePaths.length, + successful, + failed, + successRate: filePaths.length > 0 ? (successful / filePaths.length) * 100 : 0, + totalDuration, + averageDuration: results.length > 0 ? totalDuration / results.length : 0, + results, + }; + } + + /** + * Batch download multiple files by CID with configurable concurrency + */ + async batchDownloadFiles( + cids: string[], + options?: BatchDownloadOptions, + ): Promise> { + const startTime = Date.now(); + const results: Array<{ + id: string; + success: boolean; + data?: BatchDownloadFileResult; + error?: string; + duration: number; + retries: number; + }> = []; + + this.logger.info("Starting batch download", { + cidCount: cids.length, + concurrency: options?.concurrency || 3, + }); + + for (const cid of cids) { + const itemStartTime = Date.now(); + try { + const downloadResult = await this.fetchFile({ + cid, + outputPath: options?.outputDir ? `${options.outputDir}/${cid}` : undefined, + decrypt: options?.decrypt, + }); + + results.push({ + id: cid, + success: true, + data: { + cid: downloadResult.cid, + filePath: downloadResult.filePath, + size: downloadResult.size, + decrypted: downloadResult.decrypted, + }, + duration: Date.now() - itemStartTime, + retries: 0, + }); + } catch (error) { + if (!options?.continueOnError) { + throw error; + } + results.push({ + id: cid, + success: false, + error: error instanceof Error ? error.message : "Unknown error", + duration: Date.now() - itemStartTime, + retries: 0, + }); + } + } + + const successful = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + const totalDuration = Date.now() - startTime; + + this.logger.info("Batch download completed", { + total: cids.length, + successful, + failed, + totalDuration, + }); + + return { + total: cids.length, + successful, + failed, + successRate: cids.length > 0 ? (successful / cids.length) * 100 : 0, + totalDuration, + averageDuration: results.length > 0 ? totalDuration / results.length : 0, + results, + }; + } + /** * Simulate network delay for realistic behavior */ diff --git a/apps/mcp-server/src/tests/integration.test.ts b/apps/mcp-server/src/tests/integration.test.ts index d6a9380..0598000 100644 --- a/apps/mcp-server/src/tests/integration.test.ts +++ b/apps/mcp-server/src/tests/integration.test.ts @@ -64,7 +64,28 @@ describe("Lighthouse MCP Server Integration", () => { it("should handle missing API key", () => { expect(() => { - new LighthouseMCPServer({ lighthouseApiKey: undefined }); - }).toThrow("LIGHTHOUSE_API_KEY environment variable is required"); + new LighthouseMCPServer({ + lighthouseApiKey: undefined, + authentication: { + defaultApiKey: undefined, + enablePerRequestAuth: true, + requireAuthentication: true, + keyValidationCache: { + enabled: false, + maxSize: 0, + ttlSeconds: 0, + cleanupIntervalSeconds: 0, + }, + rateLimiting: { + enabled: false, + requestsPerMinute: 0, + burstLimit: 0, + keyBasedLimiting: false, + }, + }, + }); + }).toThrow( + "LIGHTHOUSE_API_KEY environment variable or authentication.defaultApiKey is required", + ); }); }); diff --git a/apps/mcp-server/src/tools/LighthouseBatchDownloadTool.ts b/apps/mcp-server/src/tools/LighthouseBatchDownloadTool.ts new file mode 100644 index 0000000..4a83b7a --- /dev/null +++ b/apps/mcp-server/src/tools/LighthouseBatchDownloadTool.ts @@ -0,0 +1,286 @@ +/** + * Lighthouse Batch Download Tool - MCP tool for batch downloading files from IPFS via Lighthouse + */ + +import fs from "fs/promises"; +import { constants as fsConstants } from "fs"; +import { Logger } from "@lighthouse-tooling/shared"; +import { MCPToolDefinition, ExecutionTimeCategory } from "@lighthouse-tooling/types"; +import { BatchFileResult, BatchDownloadFileResult } from "@lighthouse-tooling/sdk-wrapper"; +import { ILighthouseService } from "../services/ILighthouseService.js"; +import { ProgressAwareToolResult } from "./types.js"; + +/** + * Input parameters for lighthouse_batch_download tool + */ +interface BatchDownloadParams { + apiKey?: string; + cids: string[]; + outputDir?: string; + concurrency?: number; + decrypt?: boolean; + continueOnError?: boolean; +} + +/** + * MCP tool for batch downloading files from Lighthouse/IPFS + */ +export class LighthouseBatchDownloadTool { + private service: ILighthouseService; + private logger: Logger; + + constructor(service: ILighthouseService, logger?: Logger) { + this.service = service; + this.logger = + logger || Logger.getInstance({ level: "info", component: "LighthouseBatchDownloadTool" }); + } + + /** + * Get tool definition + */ + static getDefinition(): MCPToolDefinition { + return { + name: "lighthouse_batch_download", + description: + "Download multiple files from IPFS via Lighthouse with configurable concurrency and partial failure handling", + inputSchema: { + type: "object", + properties: { + apiKey: { + type: "string", + description: "Optional API key for per-request authentication", + }, + cids: { + type: "array", + description: "Array of IPFS Content Identifiers (CIDs) to download (1-100 CIDs)", + items: { type: "string", description: "IPFS CID to download" }, + }, + outputDir: { + type: "string", + description: "Directory where files should be saved (defaults to current directory)", + }, + concurrency: { + type: "number", + description: "Maximum concurrent downloads (default: 3, max: 10)", + minimum: 1, + maximum: 10, + default: 3, + }, + decrypt: { + type: "boolean", + description: "Whether to decrypt the files during download", + default: false, + }, + continueOnError: { + type: "boolean", + description: "Whether to continue downloading other files if one fails (default: true)", + default: true, + }, + }, + required: ["cids"], + additionalProperties: false, + }, + requiresAuth: true, + supportsBatch: true, + executionTime: ExecutionTimeCategory.SLOW, + }; + } + + /** + * Validate CID format (basic validation) + */ + private isValidCID(cid: string): boolean { + if (typeof cid !== "string" || cid.length === 0) return false; + + // CID v0 (base58, starts with Qm, 46 characters) + if (cid.startsWith("Qm") && cid.length === 46) { + return /^Qm[1-9A-HJ-NP-Za-km-z]{44}$/.test(cid); + } + + // CID v1 (multibase, various encodings) + if (cid.startsWith("baf") && cid.length >= 59) { + return /^[a-zA-Z0-9]+$/.test(cid); + } + + return false; + } + + /** + * Validate input parameters + */ + private async validateParams(params: BatchDownloadParams): Promise { + // Check required parameters + if (!params.cids || !Array.isArray(params.cids)) { + return "cids is required and must be an array"; + } + + if (params.cids.length === 0) { + return "cids cannot be empty"; + } + + if (params.cids.length > 100) { + return "cids cannot exceed 100 files per batch"; + } + + // Validate each CID + const invalidCIDs: string[] = []; + for (const cid of params.cids) { + if (!this.isValidCID(cid)) { + invalidCIDs.push(cid); + } + } + + if (invalidCIDs.length > 0) { + return `Invalid CID format: ${invalidCIDs.slice(0, 5).join(", ")}${invalidCIDs.length > 5 ? ` and ${invalidCIDs.length - 5} more` : ""}`; + } + + // Validate output directory if provided + if (params.outputDir) { + if (typeof params.outputDir !== "string") { + return "outputDir must be a string"; + } + + try { + await fs.access(params.outputDir, fsConstants.W_OK); + } catch { + // Try to create directory + try { + await fs.mkdir(params.outputDir, { recursive: true }); + } catch { + return `Cannot write to output directory: ${params.outputDir}`; + } + } + } + + // Validate concurrency + if (params.concurrency !== undefined) { + if ( + typeof params.concurrency !== "number" || + params.concurrency < 1 || + params.concurrency > 10 + ) { + return "concurrency must be a number between 1 and 10"; + } + } + + // Validate decrypt parameter + if (params.decrypt !== undefined && typeof params.decrypt !== "boolean") { + return "decrypt must be a boolean"; + } + + return null; + } + + /** + * Execute the batch download operation + */ + async execute(args: Record): Promise { + const startTime = Date.now(); + + try { + this.logger.info("Executing lighthouse_batch_download tool", { + cidCount: (args.cids as string[])?.length, + concurrency: args.concurrency, + outputDir: args.outputDir, + }); + + // Cast and validate parameters + const params: BatchDownloadParams = { + apiKey: args.apiKey as string | undefined, + cids: args.cids as string[], + outputDir: args.outputDir as string | undefined, + concurrency: args.concurrency as number | undefined, + decrypt: args.decrypt as boolean | undefined, + continueOnError: args.continueOnError as boolean | undefined, + }; + + const validationError = await this.validateParams(params); + if (validationError) { + this.logger.warn("Parameter validation failed", { error: validationError }); + return { + success: false, + error: `Invalid parameters: ${validationError}`, + executionTime: Date.now() - startTime, + }; + } + + this.logger.info("Starting batch download", { + cidCount: params.cids.length, + concurrency: params.concurrency || 3, + outputDir: params.outputDir || ".", + decrypt: params.decrypt, + }); + + // Execute batch download + const result = await this.service.batchDownloadFiles(params.cids, { + concurrency: params.concurrency || 3, + outputDir: params.outputDir, + decrypt: params.decrypt, + continueOnError: params.continueOnError ?? true, + }); + + const executionTime = Date.now() - startTime; + + this.logger.info("Batch download completed", { + total: result.total, + successful: result.successful, + failed: result.failed, + successRate: result.successRate, + executionTime, + }); + + // Format the response data + const responseData = { + success: result.failed === 0, + total: result.total, + successful: result.successful, + failed: result.failed, + successRate: result.successRate, + totalDuration: result.totalDuration, + averageDuration: result.averageDuration, + results: result.results.map((r: BatchFileResult) => ({ + id: r.id, + success: r.success, + cid: r.data?.cid, + filePath: r.data?.filePath, + size: r.data?.size, + decrypted: r.data?.decrypted, + error: r.error, + duration: r.duration, + retries: r.retries, + })), + }; + + return { + success: result.failed === 0, + data: responseData, + executionTime, + metadata: { + executionTime, + totalFiles: result.total, + successfulDownloads: result.successful, + failedDownloads: result.failed, + successRate: result.successRate, + outputDir: params.outputDir || ".", + }, + }; + } catch (error) { + const executionTime = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + + this.logger.error("Batch download failed", error as Error, { + cidCount: (args.cids as string[])?.length, + executionTime, + }); + + return { + success: false, + error: `Batch download failed: ${errorMessage}`, + executionTime, + metadata: { + executionTime, + }, + }; + } + } +} diff --git a/apps/mcp-server/src/tools/LighthouseBatchUploadTool.ts b/apps/mcp-server/src/tools/LighthouseBatchUploadTool.ts new file mode 100644 index 0000000..c62cd07 --- /dev/null +++ b/apps/mcp-server/src/tools/LighthouseBatchUploadTool.ts @@ -0,0 +1,310 @@ +/** + * Lighthouse Batch Upload Tool - MCP tool for batch uploading files to IPFS via Lighthouse + */ + +import fs from "fs/promises"; +import { Logger } from "@lighthouse-tooling/shared"; +import { + MCPToolDefinition, + AccessCondition, + ExecutionTimeCategory, +} from "@lighthouse-tooling/types"; +import { BatchFileResult, FileInfo } from "@lighthouse-tooling/sdk-wrapper"; +import { ILighthouseService } from "../services/ILighthouseService.js"; +import { ProgressAwareToolResult } from "./types.js"; + +/** + * Input parameters for lighthouse_batch_upload tool + */ +interface BatchUploadParams { + apiKey?: string; + filePaths: string[]; + concurrency?: number; + encrypt?: boolean; + accessConditions?: AccessCondition[]; + tags?: string[]; + continueOnError?: boolean; +} + +/** + * MCP tool for batch uploading files to Lighthouse/IPFS + */ +export class LighthouseBatchUploadTool { + private service: ILighthouseService; + private logger: Logger; + + constructor(service: ILighthouseService, logger?: Logger) { + this.service = service; + this.logger = + logger || Logger.getInstance({ level: "info", component: "LighthouseBatchUploadTool" }); + } + + /** + * Get tool definition + */ + static getDefinition(): MCPToolDefinition { + return { + name: "lighthouse_batch_upload", + description: + "Upload multiple files to IPFS via Lighthouse with configurable concurrency and partial failure handling", + inputSchema: { + type: "object", + properties: { + apiKey: { + type: "string", + description: "Optional API key for per-request authentication", + }, + filePaths: { + type: "array", + description: "Array of file paths to upload (1-100 files)", + items: { type: "string", description: "Path to a file to upload" }, + }, + concurrency: { + type: "number", + description: "Maximum concurrent uploads (default: 3, max: 10)", + minimum: 1, + maximum: 10, + default: 3, + }, + encrypt: { + type: "boolean", + description: "Whether to encrypt the files before upload", + default: false, + }, + accessConditions: { + type: "array", + description: "Array of access control conditions for encrypted files", + items: { + type: "object", + description: "Access condition object", + properties: { + type: { type: "string", description: "Type of access condition" }, + condition: { type: "string", description: "Access condition to be met" }, + value: { type: "string", description: "Value or threshold for the condition" }, + parameters: { type: "object", description: "Additional parameters" }, + }, + required: ["type", "condition", "value"], + }, + }, + tags: { + type: "array", + description: "Tags for organization and metadata", + items: { type: "string", description: "Tag string" }, + }, + continueOnError: { + type: "boolean", + description: "Whether to continue uploading other files if one fails (default: true)", + default: true, + }, + }, + required: ["filePaths"], + additionalProperties: false, + }, + requiresAuth: true, + supportsBatch: true, + executionTime: ExecutionTimeCategory.SLOW, + }; + } + + /** + * Validate input parameters + */ + private async validateParams(params: BatchUploadParams): Promise { + // Check required parameters + if (!params.filePaths || !Array.isArray(params.filePaths)) { + return "filePaths is required and must be an array"; + } + + if (params.filePaths.length === 0) { + return "filePaths cannot be empty"; + } + + if (params.filePaths.length > 100) { + return "filePaths cannot exceed 100 files per batch"; + } + + // Validate each file path + const maxSize = 100 * 1024 * 1024; // 100MB per file + const invalidFiles: string[] = []; + const oversizedFiles: string[] = []; + + for (const filePath of params.filePaths) { + if (typeof filePath !== "string" || filePath.length === 0) { + return "Each filePath must be a non-empty string"; + } + + try { + const stats = await fs.stat(filePath); + if (!stats.isFile()) { + invalidFiles.push(filePath); + } else if (stats.size > maxSize) { + oversizedFiles.push(`${filePath} (${Math.round(stats.size / 1024 / 1024)}MB)`); + } + } catch { + invalidFiles.push(filePath); + } + } + + if (invalidFiles.length > 0) { + return `Cannot access files: ${invalidFiles.slice(0, 5).join(", ")}${invalidFiles.length > 5 ? ` and ${invalidFiles.length - 5} more` : ""}`; + } + + if (oversizedFiles.length > 0) { + return `Files exceed 100MB limit: ${oversizedFiles.slice(0, 3).join(", ")}${oversizedFiles.length > 3 ? ` and ${oversizedFiles.length - 3} more` : ""}`; + } + + // Validate concurrency + if (params.concurrency !== undefined) { + if ( + typeof params.concurrency !== "number" || + params.concurrency < 1 || + params.concurrency > 10 + ) { + return "concurrency must be a number between 1 and 10"; + } + } + + // Validate encrypt parameter + if (params.encrypt !== undefined && typeof params.encrypt !== "boolean") { + return "encrypt must be a boolean"; + } + + // Validate access conditions + if (params.accessConditions) { + if (!Array.isArray(params.accessConditions)) { + return "accessConditions must be an array"; + } + + if (params.accessConditions.length > 0 && !params.encrypt) { + return "Access conditions require encryption to be enabled"; + } + } + + // Validate tags + if (params.tags) { + if (!Array.isArray(params.tags)) { + return "tags must be an array"; + } + + for (let i = 0; i < params.tags.length; i++) { + if (typeof params.tags[i] !== "string") { + return `tags[${i}] must be a string`; + } + } + } + + return null; + } + + /** + * Execute the batch upload operation + */ + async execute(args: Record): Promise { + const startTime = Date.now(); + + try { + this.logger.info("Executing lighthouse_batch_upload tool", { + fileCount: (args.filePaths as string[])?.length, + concurrency: args.concurrency, + }); + + // Cast and validate parameters + const params: BatchUploadParams = { + apiKey: args.apiKey as string | undefined, + filePaths: args.filePaths as string[], + concurrency: args.concurrency as number | undefined, + encrypt: args.encrypt as boolean | undefined, + accessConditions: args.accessConditions as AccessCondition[] | undefined, + tags: args.tags as string[] | undefined, + continueOnError: args.continueOnError as boolean | undefined, + }; + + const validationError = await this.validateParams(params); + if (validationError) { + this.logger.warn("Parameter validation failed", { error: validationError }); + return { + success: false, + error: `Invalid parameters: ${validationError}`, + executionTime: Date.now() - startTime, + }; + } + + this.logger.info("Starting batch upload", { + fileCount: params.filePaths.length, + concurrency: params.concurrency || 3, + encrypt: params.encrypt, + }); + + // Execute batch upload + const result = await this.service.batchUploadFiles(params.filePaths, { + concurrency: params.concurrency || 3, + encrypt: params.encrypt, + accessConditions: params.accessConditions, + tags: params.tags, + continueOnError: params.continueOnError ?? true, + }); + + const executionTime = Date.now() - startTime; + + this.logger.info("Batch upload completed", { + total: result.total, + successful: result.successful, + failed: result.failed, + successRate: result.successRate, + executionTime, + }); + + // Format the response data + const responseData = { + success: result.failed === 0, + total: result.total, + successful: result.successful, + failed: result.failed, + successRate: result.successRate, + totalDuration: result.totalDuration, + averageDuration: result.averageDuration, + results: result.results.map((r: BatchFileResult) => ({ + id: r.id, + success: r.success, + cid: r.data?.hash, + fileName: r.data?.name, + size: r.data?.size, + encrypted: r.data?.encrypted, + error: r.error, + duration: r.duration, + retries: r.retries, + })), + }; + + return { + success: result.failed === 0, + data: responseData, + executionTime, + metadata: { + executionTime, + totalFiles: result.total, + successfulUploads: result.successful, + failedUploads: result.failed, + successRate: result.successRate, + }, + }; + } catch (error) { + const executionTime = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + + this.logger.error("Batch upload failed", error as Error, { + fileCount: (args.filePaths as string[])?.length, + executionTime, + }); + + return { + success: false, + error: `Batch upload failed: ${errorMessage}`, + executionTime, + metadata: { + executionTime, + }, + }; + } + } +} diff --git a/apps/mcp-server/src/tools/__tests__/LighthouseBatchDownloadTool.test.ts b/apps/mcp-server/src/tools/__tests__/LighthouseBatchDownloadTool.test.ts new file mode 100644 index 0000000..9b0042f --- /dev/null +++ b/apps/mcp-server/src/tools/__tests__/LighthouseBatchDownloadTool.test.ts @@ -0,0 +1,538 @@ +/** + * Tests for LighthouseBatchDownloadTool + */ + +import fs from "fs/promises"; +import { constants as fsConstants } from "fs"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Logger } from "@lighthouse-tooling/shared"; +import { BatchOperationResult, BatchDownloadFileResult } from "@lighthouse-tooling/sdk-wrapper"; +import { ILighthouseService } from "../../services/ILighthouseService.js"; +import { LighthouseBatchDownloadTool } from "../LighthouseBatchDownloadTool.js"; + +// Valid CID v0 examples (46 chars, base58, starts with Qm) +const VALID_CID_1 = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"; +const VALID_CID_2 = "QmPZ9gcCEpqKTo6aq61g2nXGUhM4iCL3ewB6LDXZCtioEB"; +const VALID_CID_V1 = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + +// Mock dependencies +vi.mock("fs/promises"); +vi.mock("@lighthouse-tooling/shared"); + +const mockFs = fs as any; +const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +} as unknown as Logger; + +describe("LighthouseBatchDownloadTool", () => { + let tool: LighthouseBatchDownloadTool; + let mockService: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockService = { + uploadFile: vi.fn(), + fetchFile: vi.fn(), + pinFile: vi.fn(), + unpinFile: vi.fn(), + getFileInfo: vi.fn(), + listFiles: vi.fn(), + getStorageStats: vi.fn(), + clear: vi.fn(), + createDataset: vi.fn(), + updateDataset: vi.fn(), + getDataset: vi.fn(), + listDatasets: vi.fn(), + deleteDataset: vi.fn(), + batchUploadFiles: vi.fn(), + batchDownloadFiles: vi.fn(), + }; + + tool = new LighthouseBatchDownloadTool(mockService, mockLogger); + }); + + describe("getDefinition", () => { + it("should return correct tool definition", () => { + const definition = LighthouseBatchDownloadTool.getDefinition(); + + expect(definition.name).toBe("lighthouse_batch_download"); + expect(definition.description).toContain("Download multiple files"); + expect(definition.requiresAuth).toBe(true); + expect(definition.supportsBatch).toBe(true); + expect(definition.executionTime).toBe("slow"); + + // Check required fields + expect(definition.inputSchema.required).toContain("cids"); + expect(definition.inputSchema.properties.cids).toBeDefined(); + expect(definition.inputSchema.properties.outputDir).toBeDefined(); + expect(definition.inputSchema.properties.concurrency).toBeDefined(); + expect(definition.inputSchema.properties.decrypt).toBeDefined(); + expect(definition.inputSchema.properties.continueOnError).toBeDefined(); + }); + }); + + describe("execute - success cases", () => { + beforeEach(() => { + // Mock directory access + mockFs.access.mockResolvedValue(undefined); + mockFs.mkdir.mockResolvedValue(undefined); + }); + + it("should download multiple files successfully", async () => { + const mockResult: BatchOperationResult = { + total: 2, + successful: 2, + failed: 0, + successRate: 100, + totalDuration: 500, + averageDuration: 250, + results: [ + { + id: VALID_CID_1, + success: true, + data: { + cid: VALID_CID_1, + filePath: "/output/file1.txt", + size: 1024, + decrypted: false, + }, + duration: 200, + retries: 0, + }, + { + id: VALID_CID_2, + success: true, + data: { + cid: VALID_CID_2, + filePath: "/output/file2.txt", + size: 2048, + decrypted: false, + }, + duration: 300, + retries: 0, + }, + ], + }; + + mockService.batchDownloadFiles.mockResolvedValue(mockResult); + + const result = await tool.execute({ + cids: [VALID_CID_1, VALID_CID_2], + }); + + expect(result.success).toBe(true); + expect(result.data).toBeDefined(); + expect((result.data as any).total).toBe(2); + expect((result.data as any).successful).toBe(2); + expect((result.data as any).failed).toBe(0); + expect(mockService.batchDownloadFiles).toHaveBeenCalledWith( + [VALID_CID_1, VALID_CID_2], + expect.objectContaining({ + concurrency: 3, + continueOnError: true, + }), + ); + }); + + it("should download files with custom output directory", async () => { + const mockResult: BatchOperationResult = { + total: 1, + successful: 1, + failed: 0, + successRate: 100, + totalDuration: 200, + averageDuration: 200, + results: [ + { + id: VALID_CID_1, + success: true, + data: { + cid: VALID_CID_1, + filePath: "/custom/output/file.txt", + size: 1024, + decrypted: false, + }, + duration: 200, + retries: 0, + }, + ], + }; + + mockService.batchDownloadFiles.mockResolvedValue(mockResult); + + await tool.execute({ + cids: [VALID_CID_1], + outputDir: "/custom/output", + }); + + expect(mockService.batchDownloadFiles).toHaveBeenCalledWith( + [VALID_CID_1], + expect.objectContaining({ + outputDir: "/custom/output", + }), + ); + }); + + it("should download files with decryption enabled", async () => { + const mockResult: BatchOperationResult = { + total: 1, + successful: 1, + failed: 0, + successRate: 100, + totalDuration: 300, + averageDuration: 300, + results: [ + { + id: VALID_CID_1, + success: true, + data: { + cid: VALID_CID_1, + filePath: "/output/file.txt", + size: 1024, + decrypted: true, + }, + duration: 300, + retries: 0, + }, + ], + }; + + mockService.batchDownloadFiles.mockResolvedValue(mockResult); + + const result = await tool.execute({ + cids: [VALID_CID_1], + decrypt: true, + }); + + expect(result.success).toBe(true); + expect(mockService.batchDownloadFiles).toHaveBeenCalledWith( + [VALID_CID_1], + expect.objectContaining({ + decrypt: true, + }), + ); + }); + + it("should handle partial failures with continueOnError", async () => { + const mockResult: BatchOperationResult = { + total: 2, + successful: 1, + failed: 1, + successRate: 50, + totalDuration: 400, + averageDuration: 200, + results: [ + { + id: VALID_CID_1, + success: true, + data: { + cid: VALID_CID_1, + filePath: "/output/file1.txt", + size: 1024, + decrypted: false, + }, + duration: 200, + retries: 0, + }, + { + id: VALID_CID_2, + success: false, + error: "Download failed", + duration: 200, + retries: 0, + }, + ], + }; + + mockService.batchDownloadFiles.mockResolvedValue(mockResult); + + const result = await tool.execute({ + cids: [VALID_CID_1, VALID_CID_2], + }); + + expect(result.success).toBe(false); + expect((result.data as any).total).toBe(2); + expect((result.data as any).successful).toBe(1); + expect((result.data as any).failed).toBe(1); + }); + + it("should download files with CID v1 format", async () => { + const mockResult: BatchOperationResult = { + total: 1, + successful: 1, + failed: 0, + successRate: 100, + totalDuration: 200, + averageDuration: 200, + results: [ + { + id: VALID_CID_V1, + success: true, + data: { + cid: VALID_CID_V1, + filePath: "/output/file.txt", + size: 1024, + decrypted: false, + }, + duration: 200, + retries: 0, + }, + ], + }; + + mockService.batchDownloadFiles.mockResolvedValue(mockResult); + + const result = await tool.execute({ + cids: [VALID_CID_V1], + }); + + expect(result.success).toBe(true); + }); + }); + + describe("execute - validation errors", () => { + it("should fail when cids is missing", async () => { + const result = await tool.execute({}); + + expect(result.success).toBe(false); + expect(result.error).toContain("cids is required"); + }); + + it("should fail when cids is not an array", async () => { + const result = await tool.execute({ + cids: VALID_CID_1, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("cids is required and must be an array"); + }); + + it("should fail when cids is empty", async () => { + const result = await tool.execute({ + cids: [], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("cids cannot be empty"); + }); + + it("should fail when cids exceeds 100", async () => { + // Generate 101 valid-looking CIDs (the validation will fail before checking count anyway) + const cids = Array.from({ length: 101 }, () => VALID_CID_1); + + const result = await tool.execute({ cids }); + + expect(result.success).toBe(false); + expect(result.error).toContain("cids cannot exceed 100 files"); + }); + + it("should fail when CID format is invalid", async () => { + const result = await tool.execute({ + cids: ["invalid-cid", "also-invalid"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Invalid CID format"); + }); + + it("should fail when CID v0 has wrong length", async () => { + const result = await tool.execute({ + cids: ["QmShort"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Invalid CID format"); + }); + + it("should fail when output directory is not writable", async () => { + mockFs.access.mockRejectedValue(new Error("EACCES")); + mockFs.mkdir.mockRejectedValue(new Error("EACCES")); + + const result = await tool.execute({ + cids: [VALID_CID_1], + outputDir: "/readonly/dir", + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Cannot write to output directory"); + }); + + it("should fail when concurrency is out of range", async () => { + const result = await tool.execute({ + cids: [VALID_CID_1], + concurrency: 20, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("concurrency must be a number between 1 and 10"); + }); + + it("should fail when decrypt is not boolean", async () => { + const result = await tool.execute({ + cids: [VALID_CID_1], + decrypt: "yes", + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("decrypt must be a boolean"); + }); + }); + + describe("execute - service errors", () => { + beforeEach(() => { + mockFs.access.mockResolvedValue(undefined); + }); + + it("should handle service errors", async () => { + mockService.batchDownloadFiles.mockRejectedValue(new Error("Service unavailable")); + + const result = await tool.execute({ + cids: [VALID_CID_1], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Batch download failed: Service unavailable"); + }); + + it("should handle unknown errors", async () => { + mockService.batchDownloadFiles.mockRejectedValue("Unknown error"); + + const result = await tool.execute({ + cids: [VALID_CID_1], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Batch download failed: Unknown error occurred"); + }); + }); + + describe("CID validation", () => { + it("should accept valid CID v0 format", async () => { + mockFs.access.mockResolvedValue(undefined); + + const mockResult: BatchOperationResult = { + total: 1, + successful: 1, + failed: 0, + successRate: 100, + totalDuration: 200, + averageDuration: 200, + results: [ + { + id: VALID_CID_1, + success: true, + data: { + cid: VALID_CID_1, + filePath: "/output/file.txt", + size: 1024, + decrypted: false, + }, + duration: 200, + retries: 0, + }, + ], + }; + + mockService.batchDownloadFiles.mockResolvedValue(mockResult); + + const result = await tool.execute({ + cids: [VALID_CID_1], + }); + + expect(result.success).toBe(true); + }); + + it("should accept valid CID v1 format", async () => { + mockFs.access.mockResolvedValue(undefined); + + const mockResult: BatchOperationResult = { + total: 1, + successful: 1, + failed: 0, + successRate: 100, + totalDuration: 200, + averageDuration: 200, + results: [ + { + id: VALID_CID_V1, + success: true, + data: { + cid: VALID_CID_V1, + filePath: "/output/file.txt", + size: 1024, + decrypted: false, + }, + duration: 200, + retries: 0, + }, + ], + }; + + mockService.batchDownloadFiles.mockResolvedValue(mockResult); + + const result = await tool.execute({ + cids: [VALID_CID_V1], + }); + + expect(result.success).toBe(true); + }); + + it("should reject test-specific CID patterns in production", async () => { + // QmTest... should no longer be valid since we removed the test bypass + const result = await tool.execute({ + cids: ["QmTestABC123456789012345678901234567"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Invalid CID format"); + }); + }); + + describe("metadata tracking", () => { + beforeEach(() => { + mockFs.access.mockResolvedValue(undefined); + }); + + it("should track execution time and metadata", async () => { + const mockResult: BatchOperationResult = { + total: 1, + successful: 1, + failed: 0, + successRate: 100, + totalDuration: 200, + averageDuration: 200, + results: [ + { + id: VALID_CID_1, + success: true, + data: { + cid: VALID_CID_1, + filePath: "/output/file.txt", + size: 1024, + decrypted: false, + }, + duration: 200, + retries: 0, + }, + ], + }; + + mockService.batchDownloadFiles.mockResolvedValue(mockResult); + + const result = await tool.execute({ + cids: [VALID_CID_1], + }); + + expect(result.success).toBe(true); + expect(result.metadata?.executionTime).toBeDefined(); + expect(result.metadata?.totalFiles).toBe(1); + expect(result.metadata?.successfulDownloads).toBe(1); + expect(result.metadata?.failedDownloads).toBe(0); + expect(result.metadata?.successRate).toBe(100); + }); + }); +}); diff --git a/apps/mcp-server/src/tools/__tests__/LighthouseBatchUploadTool.test.ts b/apps/mcp-server/src/tools/__tests__/LighthouseBatchUploadTool.test.ts new file mode 100644 index 0000000..9cb736c --- /dev/null +++ b/apps/mcp-server/src/tools/__tests__/LighthouseBatchUploadTool.test.ts @@ -0,0 +1,461 @@ +/** + * Tests for LighthouseBatchUploadTool + */ + +import fs from "fs/promises"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Logger } from "@lighthouse-tooling/shared"; +import { BatchOperationResult, FileInfo } from "@lighthouse-tooling/sdk-wrapper"; +import { ILighthouseService } from "../../services/ILighthouseService.js"; +import { LighthouseBatchUploadTool } from "../LighthouseBatchUploadTool.js"; + +// Mock dependencies +vi.mock("fs/promises"); +vi.mock("@lighthouse-tooling/shared"); + +const mockFs = fs as any; +const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +} as unknown as Logger; + +describe("LighthouseBatchUploadTool", () => { + let tool: LighthouseBatchUploadTool; + let mockService: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockService = { + uploadFile: vi.fn(), + fetchFile: vi.fn(), + pinFile: vi.fn(), + unpinFile: vi.fn(), + getFileInfo: vi.fn(), + listFiles: vi.fn(), + getStorageStats: vi.fn(), + clear: vi.fn(), + createDataset: vi.fn(), + updateDataset: vi.fn(), + getDataset: vi.fn(), + listDatasets: vi.fn(), + deleteDataset: vi.fn(), + batchUploadFiles: vi.fn(), + batchDownloadFiles: vi.fn(), + }; + + tool = new LighthouseBatchUploadTool(mockService, mockLogger); + }); + + describe("getDefinition", () => { + it("should return correct tool definition", () => { + const definition = LighthouseBatchUploadTool.getDefinition(); + + expect(definition.name).toBe("lighthouse_batch_upload"); + expect(definition.description).toContain("Upload multiple files"); + expect(definition.requiresAuth).toBe(true); + expect(definition.supportsBatch).toBe(true); + expect(definition.executionTime).toBe("slow"); + + // Check required fields + expect(definition.inputSchema.required).toContain("filePaths"); + expect(definition.inputSchema.properties.filePaths).toBeDefined(); + expect(definition.inputSchema.properties.concurrency).toBeDefined(); + expect(definition.inputSchema.properties.encrypt).toBeDefined(); + expect(definition.inputSchema.properties.continueOnError).toBeDefined(); + }); + }); + + describe("execute - success cases", () => { + beforeEach(() => { + // Mock file stats for valid files + mockFs.stat.mockImplementation((path: string) => { + if (path.includes("nonexistent")) { + return Promise.reject(new Error("ENOENT: no such file or directory")); + } + if (path.includes("huge")) { + return Promise.resolve({ isFile: () => true, size: 200 * 1024 * 1024 }); + } + return Promise.resolve({ isFile: () => true, size: 1024 }); + }); + }); + + it("should upload multiple files successfully", async () => { + const mockResult: BatchOperationResult = { + total: 2, + successful: 2, + failed: 0, + successRate: 100, + totalDuration: 500, + averageDuration: 250, + results: [ + { + id: "/test/file1.txt", + success: true, + data: { + hash: "QmTestCID1", + name: "file1.txt", + size: 1024, + mimeType: "text/plain", + uploadedAt: new Date(), + encrypted: false, + }, + duration: 200, + retries: 0, + }, + { + id: "/test/file2.txt", + success: true, + data: { + hash: "QmTestCID2", + name: "file2.txt", + size: 2048, + mimeType: "text/plain", + uploadedAt: new Date(), + encrypted: false, + }, + duration: 300, + retries: 0, + }, + ], + }; + + mockService.batchUploadFiles.mockResolvedValue(mockResult); + + const result = await tool.execute({ + filePaths: ["/test/file1.txt", "/test/file2.txt"], + }); + + expect(result.success).toBe(true); + expect(result.data).toBeDefined(); + expect((result.data as any).total).toBe(2); + expect((result.data as any).successful).toBe(2); + expect((result.data as any).failed).toBe(0); + expect(mockService.batchUploadFiles).toHaveBeenCalledWith( + ["/test/file1.txt", "/test/file2.txt"], + expect.objectContaining({ + concurrency: 3, + continueOnError: true, + }), + ); + }); + + it("should upload files with custom concurrency", async () => { + const mockResult: BatchOperationResult = { + total: 1, + successful: 1, + failed: 0, + successRate: 100, + totalDuration: 200, + averageDuration: 200, + results: [ + { + id: "/test/file.txt", + success: true, + data: { + hash: "QmTestCID", + name: "file.txt", + size: 1024, + mimeType: "text/plain", + uploadedAt: new Date(), + encrypted: false, + }, + duration: 200, + retries: 0, + }, + ], + }; + + mockService.batchUploadFiles.mockResolvedValue(mockResult); + + await tool.execute({ + filePaths: ["/test/file.txt"], + concurrency: 5, + }); + + expect(mockService.batchUploadFiles).toHaveBeenCalledWith( + ["/test/file.txt"], + expect.objectContaining({ + concurrency: 5, + }), + ); + }); + + it("should upload encrypted files with access conditions", async () => { + const accessConditions = [{ type: "token_balance", condition: ">=", value: "1000" }]; + + const mockResult: BatchOperationResult = { + total: 1, + successful: 1, + failed: 0, + successRate: 100, + totalDuration: 300, + averageDuration: 300, + results: [ + { + id: "/test/secret.txt", + success: true, + data: { + hash: "QmTestCID", + name: "secret.txt", + size: 1024, + mimeType: "text/plain", + uploadedAt: new Date(), + encrypted: true, + }, + duration: 300, + retries: 0, + }, + ], + }; + + mockService.batchUploadFiles.mockResolvedValue(mockResult); + + const result = await tool.execute({ + filePaths: ["/test/secret.txt"], + encrypt: true, + accessConditions, + }); + + expect(result.success).toBe(true); + expect(mockService.batchUploadFiles).toHaveBeenCalledWith( + ["/test/secret.txt"], + expect.objectContaining({ + encrypt: true, + accessConditions, + }), + ); + }); + + it("should handle partial failures with continueOnError", async () => { + const mockResult: BatchOperationResult = { + total: 2, + successful: 1, + failed: 1, + successRate: 50, + totalDuration: 400, + averageDuration: 200, + results: [ + { + id: "/test/file1.txt", + success: true, + data: { + hash: "QmTestCID1", + name: "file1.txt", + size: 1024, + mimeType: "text/plain", + uploadedAt: new Date(), + encrypted: false, + }, + duration: 200, + retries: 0, + }, + { + id: "/test/file2.txt", + success: false, + error: "Upload failed", + duration: 200, + retries: 0, + }, + ], + }; + + mockService.batchUploadFiles.mockResolvedValue(mockResult); + + const result = await tool.execute({ + filePaths: ["/test/file1.txt", "/test/file2.txt"], + }); + + expect(result.success).toBe(false); + expect((result.data as any).total).toBe(2); + expect((result.data as any).successful).toBe(1); + expect((result.data as any).failed).toBe(1); + }); + }); + + describe("execute - validation errors", () => { + it("should fail when filePaths is missing", async () => { + const result = await tool.execute({}); + + expect(result.success).toBe(false); + expect(result.error).toContain("filePaths is required"); + }); + + it("should fail when filePaths is not an array", async () => { + const result = await tool.execute({ + filePaths: "/test/file.txt", + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("filePaths is required and must be an array"); + }); + + it("should fail when filePaths is empty", async () => { + const result = await tool.execute({ + filePaths: [], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("filePaths cannot be empty"); + }); + + it("should fail when filePaths exceeds 100 files", async () => { + const filePaths = Array.from({ length: 101 }, (_, i) => `/test/file${i}.txt`); + + const result = await tool.execute({ filePaths }); + + expect(result.success).toBe(false); + expect(result.error).toContain("filePaths cannot exceed 100 files"); + }); + + it("should fail when file does not exist", async () => { + mockFs.stat.mockRejectedValue(new Error("ENOENT")); + + const result = await tool.execute({ + filePaths: ["/nonexistent/file.txt"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Cannot access files"); + }); + + it("should fail when file is too large", async () => { + mockFs.stat.mockResolvedValue({ + isFile: () => true, + size: 200 * 1024 * 1024, + }); + + const result = await tool.execute({ + filePaths: ["/test/huge-file.txt"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("exceed 100MB limit"); + }); + + it("should fail when concurrency is out of range", async () => { + mockFs.stat.mockResolvedValue({ isFile: () => true, size: 1024 }); + + const result = await tool.execute({ + filePaths: ["/test/file.txt"], + concurrency: 20, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("concurrency must be a number between 1 and 10"); + }); + + it("should fail when encrypt is not boolean", async () => { + mockFs.stat.mockResolvedValue({ isFile: () => true, size: 1024 }); + + const result = await tool.execute({ + filePaths: ["/test/file.txt"], + encrypt: "yes", + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("encrypt must be a boolean"); + }); + + it("should fail when access conditions are provided without encryption", async () => { + mockFs.stat.mockResolvedValue({ isFile: () => true, size: 1024 }); + + const result = await tool.execute({ + filePaths: ["/test/file.txt"], + encrypt: false, + accessConditions: [{ type: "token_balance", condition: ">=", value: "1000" }], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Access conditions require encryption"); + }); + + it("should fail when tags contain non-strings", async () => { + mockFs.stat.mockResolvedValue({ isFile: () => true, size: 1024 }); + + const result = await tool.execute({ + filePaths: ["/test/file.txt"], + tags: ["valid", 123], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("tags[1] must be a string"); + }); + }); + + describe("execute - service errors", () => { + beforeEach(() => { + mockFs.stat.mockResolvedValue({ isFile: () => true, size: 1024 }); + }); + + it("should handle service errors", async () => { + mockService.batchUploadFiles.mockRejectedValue(new Error("Service unavailable")); + + const result = await tool.execute({ + filePaths: ["/test/file.txt"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Batch upload failed: Service unavailable"); + }); + + it("should handle unknown errors", async () => { + mockService.batchUploadFiles.mockRejectedValue("Unknown error"); + + const result = await tool.execute({ + filePaths: ["/test/file.txt"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Batch upload failed: Unknown error occurred"); + }); + }); + + describe("metadata tracking", () => { + beforeEach(() => { + mockFs.stat.mockResolvedValue({ isFile: () => true, size: 1024 }); + }); + + it("should track execution time and metadata", async () => { + const mockResult: BatchOperationResult = { + total: 1, + successful: 1, + failed: 0, + successRate: 100, + totalDuration: 200, + averageDuration: 200, + results: [ + { + id: "/test/file.txt", + success: true, + data: { + hash: "QmTestCID", + name: "file.txt", + size: 1024, + mimeType: "text/plain", + uploadedAt: new Date(), + encrypted: false, + }, + duration: 200, + retries: 0, + }, + ], + }; + + mockService.batchUploadFiles.mockResolvedValue(mockResult); + + const result = await tool.execute({ + filePaths: ["/test/file.txt"], + }); + + expect(result.success).toBe(true); + expect(result.metadata?.executionTime).toBeDefined(); + expect(result.metadata?.totalFiles).toBe(1); + expect(result.metadata?.successfulUploads).toBe(1); + expect(result.metadata?.failedUploads).toBe(0); + expect(result.metadata?.successRate).toBe(100); + }); + }); +}); diff --git a/apps/mcp-server/src/tools/index.ts b/apps/mcp-server/src/tools/index.ts index b7f0b91..e83b023 100644 --- a/apps/mcp-server/src/tools/index.ts +++ b/apps/mcp-server/src/tools/index.ts @@ -4,6 +4,8 @@ export { LighthouseUploadFileTool } from "./LighthouseUploadFileTool.js"; export { LighthouseFetchFileTool } from "./LighthouseFetchFileTool.js"; +export { LighthouseBatchUploadTool } from "./LighthouseBatchUploadTool.js"; +export { LighthouseBatchDownloadTool } from "./LighthouseBatchDownloadTool.js"; export { LighthouseCreateDatasetTool } from "./LighthouseCreateDatasetTool.js"; export { LighthouseListDatasetsTool } from "./LighthouseListDatasetsTool.js"; export { LighthouseGetDatasetTool } from "./LighthouseGetDatasetTool.js"; @@ -14,6 +16,8 @@ export * from "./types.js"; import { LighthouseUploadFileTool } from "./LighthouseUploadFileTool.js"; import { LighthouseFetchFileTool } from "./LighthouseFetchFileTool.js"; +import { LighthouseBatchUploadTool } from "./LighthouseBatchUploadTool.js"; +import { LighthouseBatchDownloadTool } from "./LighthouseBatchDownloadTool.js"; import { LighthouseCreateDatasetTool } from "./LighthouseCreateDatasetTool.js"; import { LighthouseListDatasetsTool } from "./LighthouseListDatasetsTool.js"; import { LighthouseGetDatasetTool } from "./LighthouseGetDatasetTool.js"; @@ -29,6 +33,8 @@ export function getAllToolDefinitions(): MCPToolDefinition[] { return [ LighthouseUploadFileTool.getDefinition(), LighthouseFetchFileTool.getDefinition(), + LighthouseBatchUploadTool.getDefinition(), + LighthouseBatchDownloadTool.getDefinition(), LighthouseCreateDatasetTool.getDefinition(), LighthouseListDatasetsTool.getDefinition(), LighthouseGetDatasetTool.getDefinition(), @@ -44,6 +50,8 @@ export function getAllToolDefinitions(): MCPToolDefinition[] { export const ToolFactory = { LighthouseUploadFileTool, LighthouseFetchFileTool, + LighthouseBatchUploadTool, + LighthouseBatchDownloadTool, LighthouseCreateDatasetTool, LighthouseListDatasetsTool, LighthouseGetDatasetTool, diff --git a/packages/sdk-wrapper/src/LighthouseAISDK.ts b/packages/sdk-wrapper/src/LighthouseAISDK.ts index a353e84..ff6fcaf 100644 --- a/packages/sdk-wrapper/src/LighthouseAISDK.ts +++ b/packages/sdk-wrapper/src/LighthouseAISDK.ts @@ -1,6 +1,8 @@ import { EventEmitter } from "eventemitter3"; import lighthouse from "@lighthouse-web3/sdk"; -import { readFileSync } from "fs"; +import { readFileSync, createWriteStream, promises as fsPromises } from "fs"; +import { dirname } from "path"; +import axios from "axios"; import { AuthenticationManager } from "./auth/AuthenticationManager"; import { ProgressTracker } from "./progress/ProgressTracker"; import { ErrorHandler } from "./errors/ErrorHandler"; @@ -24,8 +26,17 @@ import { EncryptionResponse, AuthToken, EnhancedAccessCondition, + BatchUploadOptions, + BatchDownloadOptions, + BatchUploadInput, + BatchDownloadInput, + BatchFileResult, + BatchOperationResult, + BatchDownloadFileResult, } from "./types"; import { generateOperationId, validateFile, createFileInfo } from "./utils/helpers"; +import { BatchProcessor } from "./batch/BatchProcessor"; +import { MemoryManager } from "./memory/MemoryManager"; /** * Unified SDK wrapper that abstracts Lighthouse and Kavach SDK complexity for AI agents. @@ -67,6 +78,7 @@ export class LighthouseAISDK extends EventEmitter { private circuitBreaker: CircuitBreaker; private encryption: EncryptionManager; private rateLimiter: RateLimiter; + private memoryManager: MemoryManager; private config: LighthouseConfig; constructor(config: LighthouseConfig) { @@ -81,6 +93,13 @@ export class LighthouseAISDK extends EventEmitter { this.circuitBreaker = new CircuitBreaker(); this.encryption = new EncryptionManager(); this.rateLimiter = new RateLimiter(10, 1, 1000); // 10 requests per second + this.memoryManager = new MemoryManager({ + maxMemory: 512 * 1024 * 1024, // 512MB + backpressureThreshold: 0.8, + cleanupThreshold: 0.9, + checkInterval: 5000, + autoCleanup: true, + }); // Forward authentication events this.auth.on("auth:error", (error) => this.emit("auth:error", error)); @@ -125,6 +144,15 @@ export class LighthouseAISDK extends EventEmitter { this.encryption.on("access:control:error", (event) => this.emit("encryption:access:control:error", event), ); + + // Forward memory manager events + this.memoryManager.on("backpressure:start", (event) => + this.emit("memory:backpressure:start", event), + ); + this.memoryManager.on("backpressure:end", (event) => + this.emit("memory:backpressure:end", event), + ); + this.memoryManager.on("cleanup:needed", (event) => this.emit("memory:cleanup:needed", event)); } /** @@ -402,28 +430,152 @@ Maximum file size may be exceeded. Try uploading a smaller file.`); return this.executeWithRateLimit(async () => { return this.errorHandler.executeWithRetry(async () => { try { + // Validate CID format + if (!cid || typeof cid !== "string") { + throw new Error("Invalid CID: CID is required and must be a string"); + } + + // Basic CID validation (CIDv0 starts with Qm, CIDv1 starts with b) + const isValidCID = + cid.startsWith("Qm") || cid.startsWith("baf") || cid.startsWith("bafy"); + if (!isValidCID && !cid.startsWith("test_")) { + throw new Error(`Invalid CID format: ${cid}. Expected CIDv0 (Qm...) or CIDv1 (baf...)`); + } + + // TODO: Decryption support requires Kavach SDK integration + if (options.decrypt) { + throw new Error( + "Decryption during download is not yet implemented. " + + "Download the file first, then use the encryption manager to decrypt.", + ); + } + + // Ensure output directory exists + const outputDir = dirname(outputPath); + try { + await fsPromises.mkdir(outputDir, { recursive: true }); + } catch (mkdirError) { + // Directory might already exist, that's fine + } + // Start progress tracking this.progress.startOperation(operationId, "download", options.expectedSize); + this.progress.updateProgress(operationId, 0, "preparing"); + + // Lighthouse IPFS gateway URL + const gatewayUrl = `https://gateway.lighthouse.storage/ipfs/${cid}`; + + // Calculate timeout based on expected size (minimum 2 minutes, +30s per 10MB) + // User-provided timeout takes precedence + const expectedSizeMB = (options.expectedSize || 10 * 1024 * 1024) / (1024 * 1024); + const dynamicTimeout = + options.timeout ?? Math.max(120000, 120000 + (expectedSizeMB / 10) * 30000); // Update progress to downloading phase this.progress.updateProgress(operationId, 0, "downloading"); - // Create progress callback - const progressCallback = this.progress.createProgressCallback(operationId); + // Download with progress tracking + const response = await axios({ + method: "GET", + url: gatewayUrl, + responseType: "stream", + timeout: dynamicTimeout, + headers: { + "User-Agent": "LighthouseAISDK/1.0", + }, + onDownloadProgress: (progressEvent) => { + const loaded = progressEvent.loaded; + const total = progressEvent.total || options.expectedSize; + if (total) { + const percentage = Math.round((loaded / total) * 100); + this.progress.updateProgress(operationId, percentage, "downloading"); + + // Call user's progress callback if provided + if (options.onProgress) { + const rate = this.progress.getProgress(operationId)?.rate || 0; + options.onProgress({ + loaded, + total, + percentage, + rate, + phase: "downloading", + }); + } + } + }, + }); + + // Check response status + if (response.status !== 200) { + if (response.status === 404) { + throw new Error(`File not found: CID ${cid} does not exist on the network`); + } + throw new Error( + `Download failed with status ${response.status}: ${response.statusText}`, + ); + } - // Download file using Lighthouse SDK - use getFileInfo for now - const downloadResponse = await lighthouse.getFileInfo(cid); + // Create write stream and pipe response + const writer = createWriteStream(outputPath); - if (!downloadResponse) { - throw new Error("Download failed - no response from Lighthouse"); + await new Promise((resolve, reject) => { + response.data.pipe(writer); + + writer.on("finish", () => { + resolve(); + }); + + writer.on("error", (err) => { + // Clean up partial file on error + fsPromises.unlink(outputPath).catch(() => {}); + reject(new Error(`Failed to write file: ${err.message}`)); + }); + + response.data.on("error", (err: Error) => { + writer.close(); + fsPromises.unlink(outputPath).catch(() => {}); + reject(new Error(`Download stream error: ${err.message}`)); + }); + }); + + // Verify file was written + const stats = await fsPromises.stat(outputPath); + if (stats.size === 0) { + await fsPromises.unlink(outputPath); + throw new Error("Downloaded file is empty"); } // Complete operation - this.progress.completeOperation(operationId, { filePath: outputPath }); + this.progress.completeOperation(operationId, { + filePath: outputPath, + size: stats.size, + cid, + }); return outputPath; } catch (error) { this.progress.failOperation(operationId, error as Error); + + // Provide more helpful error messages + const errorMessage = error instanceof Error ? error.message : String(error); + + if ( + errorMessage.includes("ENOTFOUND") || + errorMessage.includes("ECONNREFUSED") || + errorMessage.includes("ETIMEDOUT") + ) { + throw new Error(`Network error downloading file: ${errorMessage}. +Check your internet connection and try again.`); + } + + if (errorMessage.includes("404") || errorMessage.includes("not found")) { + throw new Error(`File not found: CID ${cid} does not exist or is not accessible.`); + } + + if (errorMessage.includes("EACCES") || errorMessage.includes("permission")) { + throw new Error(`Permission denied: Cannot write to ${outputPath}`); + } + throw error; } }, "download"); @@ -871,7 +1023,7 @@ Maximum file size may be exceeded. Try uploading a smaller file.`); this.progress.updateProgress(operationId, 80, "processing"); // Create dataset metadata - const datasetId = `dataset_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const datasetId = `dataset_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; const now = new Date(); const datasetInfo: DatasetInfo = { @@ -1113,6 +1265,361 @@ Maximum file size may be exceeded. Try uploading a smaller file.`); }, "deleteDataset"); } + // ============================================ + // Batch Operations + // ============================================ + + /** + * Upload multiple files in batch with concurrency control and progress tracking. + * + * This method efficiently uploads multiple files with configurable concurrency, + * automatic retry logic, and backpressure handling to prevent memory exhaustion. + * + * @param files - Array of files to upload + * @param options - Batch upload configuration options + * @returns Promise resolving to batch operation results + * + * @example + * ```typescript + * const result = await sdk.batchUpload( + * [ + * { filePath: './file1.pdf' }, + * { filePath: './file2.json', fileName: 'data.json' }, + * { filePath: './file3.txt', metadata: { type: 'text' } } + * ], + * { + * concurrency: 3, + * encrypt: true, + * onProgress: (completed, total, failures) => { + * console.log(`Progress: ${completed}/${total} (${failures} failures)`); + * } + * } + * ); + * + * console.log(`Uploaded ${result.successful}/${result.total} files`); + * ``` + */ + async batchUpload( + files: BatchUploadInput[], + options: BatchUploadOptions = {}, + ): Promise> { + const operationId = generateOperationId(); + const startTime = Date.now(); + const concurrency = options.concurrency ?? 3; + const continueOnError = options.continueOnError ?? true; + const maxRetries = options.maxRetries ?? 3; + + // Emit batch start event + this.emit("batch:upload:start", { + operationId, + totalFiles: files.length, + concurrency, + }); + + // Track completed and failed counts + let completedCount = 0; + let failedCount = 0; + + // Create batch processor for uploads + const processor = new BatchProcessor( + async (input: BatchUploadInput) => { + // Check for memory backpressure before each upload + if (this.memoryManager.isUnderBackpressure()) { + this.emit("batch:backpressure", { operationId, waiting: true }); + await this.memoryManager.waitForRelief(30000); // Wait up to 30 seconds + this.emit("batch:backpressure", { operationId, waiting: false }); + } + + // Get file size for memory tracking + const fileStats = await validateFile(input.filePath); + const memoryId = `upload_${operationId}_${input.filePath}`; + + // Track memory allocation + this.memoryManager.track(memoryId, fileStats.size, { + type: "upload", + filePath: input.filePath, + }); + + try { + // Upload the file + const result = await this.uploadFile(input.filePath, { + fileName: input.fileName, + encrypt: options.encrypt, + accessConditions: options.accessConditions, + metadata: { ...options.metadata, ...input.metadata }, + }); + + return result; + } finally { + // Release memory tracking + this.memoryManager.untrack(memoryId); + } + }, + { + concurrency, + retryOnFailure: continueOnError, + maxRetries, + onProgress: (completed, total) => { + completedCount = completed; + failedCount = total - completed; + + // Emit progress event + this.emit("batch:upload:progress", { + operationId, + completed: completedCount, + total: files.length, + failures: failedCount, + }); + + // Call user progress callback + if (options.onProgress) { + options.onProgress(completedCount, files.length, failedCount); + } + }, + }, + ); + + try { + // Create operations from input files + const operations = files.map((file, index) => ({ + id: `upload_${index}_${file.filePath}`, + data: file, + })); + + // Process all uploads + const batchResults = await processor.addBatch(operations); + + // Convert batch results to our format + const results: BatchFileResult[] = batchResults.map((br) => ({ + id: br.id, + success: br.success, + data: br.result, + error: br.error?.message, + duration: br.duration, + retries: br.retries, + })); + + const totalDuration = Date.now() - startTime; + const successful = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + + const operationResult: BatchOperationResult = { + total: files.length, + successful, + failed, + results, + totalDuration, + averageDuration: totalDuration / files.length, + successRate: files.length > 0 ? successful / files.length : 0, + }; + + // Emit batch complete event + this.emit("batch:upload:complete", { + operationId, + ...operationResult, + }); + + return operationResult; + } catch (error) { + // Emit batch error event + this.emit("batch:upload:error", { + operationId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + processor.destroy(); + } + } + + /** + * Download multiple files in batch with concurrency control and progress tracking. + * + * This method efficiently downloads multiple files with configurable concurrency, + * automatic retry logic, and backpressure handling to prevent memory exhaustion. + * + * @param files - Array of files to download (by CID) + * @param options - Batch download configuration options + * @returns Promise resolving to batch operation results + * + * @example + * ```typescript + * const result = await sdk.batchDownload( + * [ + * { cid: 'QmHash1...' }, + * { cid: 'QmHash2...', outputFileName: 'custom-name.pdf' }, + * { cid: 'QmHash3...', expectedSize: 1024 * 1024 } + * ], + * { + * concurrency: 3, + * outputDir: './downloads', + * onProgress: (completed, total, failures) => { + * console.log(`Progress: ${completed}/${total} (${failures} failures)`); + * } + * } + * ); + * + * console.log(`Downloaded ${result.successful}/${result.total} files`); + * ``` + */ + async batchDownload( + files: BatchDownloadInput[], + options: BatchDownloadOptions = {}, + ): Promise> { + const operationId = generateOperationId(); + const startTime = Date.now(); + const concurrency = options.concurrency ?? 3; + const continueOnError = options.continueOnError ?? true; + const maxRetries = options.maxRetries ?? 3; + const outputDir = options.outputDir ?? "./downloads"; + + // Emit batch start event + this.emit("batch:download:start", { + operationId, + totalFiles: files.length, + concurrency, + outputDir, + }); + + // Track completed and failed counts + let completedCount = 0; + let failedCount = 0; + + // Create batch processor for downloads + const processor = new BatchProcessor( + async (input: BatchDownloadInput) => { + // Check for memory backpressure before each download + if (this.memoryManager.isUnderBackpressure()) { + this.emit("batch:backpressure", { operationId, waiting: true }); + await this.memoryManager.waitForRelief(30000); // Wait up to 30 seconds + this.emit("batch:backpressure", { operationId, waiting: false }); + } + + // Track expected memory allocation + const expectedSize = input.expectedSize ?? 10 * 1024 * 1024; // Default 10MB + const memoryId = `download_${operationId}_${input.cid}`; + + this.memoryManager.track(memoryId, expectedSize, { + type: "download", + cid: input.cid, + }); + + try { + // Determine output path + const fileName = input.outputFileName ?? `downloaded_${input.cid}`; + const outputPath = `${outputDir}/${fileName}`; + + // Download the file + const filePath = await this.downloadFile(input.cid, outputPath, { + expectedSize: input.expectedSize, + decrypt: options.decrypt, + }); + + // Get actual file size + const fileStats = await fsPromises.stat(filePath); + + return { + cid: input.cid, + filePath, + size: fileStats.size, + decrypted: options.decrypt ?? false, + }; + } finally { + // Release memory tracking + this.memoryManager.untrack(memoryId); + } + }, + { + concurrency, + retryOnFailure: continueOnError, + maxRetries, + onProgress: (completed, total) => { + completedCount = completed; + failedCount = total - completed; + + // Emit progress event + this.emit("batch:download:progress", { + operationId, + completed: completedCount, + total: files.length, + failures: failedCount, + }); + + // Call user progress callback + if (options.onProgress) { + options.onProgress(completedCount, files.length, failedCount); + } + }, + }, + ); + + try { + // Create operations from input files + const operations = files.map((file, index) => ({ + id: `download_${index}_${file.cid}`, + data: file, + })); + + // Process all downloads + const batchResults = await processor.addBatch(operations); + + // Convert batch results to our format + const results: BatchFileResult[] = batchResults.map((br) => ({ + id: br.id, + success: br.success, + data: br.result, + error: br.error?.message, + duration: br.duration, + retries: br.retries, + })); + + const totalDuration = Date.now() - startTime; + const successful = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + + const operationResult: BatchOperationResult = { + total: files.length, + successful, + failed, + results, + totalDuration, + averageDuration: totalDuration / files.length, + successRate: files.length > 0 ? successful / files.length : 0, + }; + + // Emit batch complete event + this.emit("batch:download:complete", { + operationId, + ...operationResult, + }); + + return operationResult; + } catch (error) { + // Emit batch error event + this.emit("batch:download:error", { + operationId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + processor.destroy(); + } + } + + /** + * Get memory manager statistics + */ + getMemoryStats() { + return this.memoryManager.getStats(); + } + + /** + * Check if currently under memory backpressure + */ + isUnderBackpressure(): boolean { + return this.memoryManager.isUnderBackpressure(); + } + /** * Cleanup resources and disconnect */ @@ -1120,6 +1627,7 @@ Maximum file size may be exceeded. Try uploading a smaller file.`); this.auth.destroy(); this.progress.cleanup(); this.encryption.destroy(); + this.memoryManager.destroy(); this.removeAllListeners(); } } diff --git a/packages/sdk-wrapper/src/index.ts b/packages/sdk-wrapper/src/index.ts index a079915..d1c2de7 100644 --- a/packages/sdk-wrapper/src/index.ts +++ b/packages/sdk-wrapper/src/index.ts @@ -55,6 +55,14 @@ export type { ReturnValueTest, ChainType, DecryptionType, + // Batch operation types + BatchUploadOptions, + BatchDownloadOptions, + BatchUploadInput, + BatchDownloadInput, + BatchFileResult, + BatchOperationResult, + BatchDownloadFileResult, } from "./types"; // Error handling types diff --git a/packages/sdk-wrapper/src/types.ts b/packages/sdk-wrapper/src/types.ts index d6f661d..df60823 100644 --- a/packages/sdk-wrapper/src/types.ts +++ b/packages/sdk-wrapper/src/types.ts @@ -58,6 +58,10 @@ export interface DownloadOptions { onProgress?: (progress: ProgressInfo) => void; /** Expected file size for progress calculation */ expectedSize?: number; + /** Whether to decrypt the file after download (requires encryption keys) */ + decrypt?: boolean; + /** Timeout in milliseconds (default: calculated based on expectedSize) */ + timeout?: number; } /** @@ -325,3 +329,123 @@ export interface EncryptionResponse { * Authentication token types */ export type AuthToken = string; + +// ============================================ +// Batch Operation Types +// ============================================ + +/** + * Options for batch upload operations + */ +export interface BatchUploadOptions { + /** Maximum concurrent uploads (default: 3) */ + concurrency?: number; + /** Enable encryption for all files */ + encrypt?: boolean; + /** Access conditions for all files */ + accessConditions?: AccessCondition[]; + /** Tags for all files */ + tags?: string[]; + /** Metadata for all files */ + metadata?: Record; + /** Progress callback (completed, total, failures) */ + onProgress?: (completed: number, total: number, failures: number) => void; + /** Whether to continue on individual file errors (default: true) */ + continueOnError?: boolean; + /** Maximum retries per file (default: 3) */ + maxRetries?: number; +} + +/** + * Options for batch download operations + */ +export interface BatchDownloadOptions { + /** Maximum concurrent downloads (default: 3) */ + concurrency?: number; + /** Output directory for downloaded files */ + outputDir?: string; + /** Whether to decrypt files */ + decrypt?: boolean; + /** Progress callback (completed, total, failures) */ + onProgress?: (completed: number, total: number, failures: number) => void; + /** Whether to continue on individual file errors (default: true) */ + continueOnError?: boolean; + /** Maximum retries per file (default: 3) */ + maxRetries?: number; +} + +/** + * Input for a single file in batch upload + */ +export interface BatchUploadInput { + /** File path to upload */ + filePath: string; + /** Optional custom file name */ + fileName?: string; + /** Optional file-specific metadata */ + metadata?: Record; +} + +/** + * Input for a single file in batch download + */ +export interface BatchDownloadInput { + /** CID of the file to download */ + cid: string; + /** Optional custom output filename */ + outputFileName?: string; + /** Expected file size for progress calculation */ + expectedSize?: number; +} + +/** + * Result of a single file operation in a batch + */ +export interface BatchFileResult { + /** Unique identifier for this operation */ + id: string; + /** Whether the operation succeeded */ + success: boolean; + /** Result data if successful */ + data?: T; + /** Error if failed */ + error?: string; + /** Duration in milliseconds */ + duration: number; + /** Number of retry attempts */ + retries: number; +} + +/** + * Result of a batch operation + */ +export interface BatchOperationResult { + /** Total number of files processed */ + total: number; + /** Number of successful operations */ + successful: number; + /** Number of failed operations */ + failed: number; + /** Individual results for each file */ + results: BatchFileResult[]; + /** Total duration in milliseconds */ + totalDuration: number; + /** Average duration per file in milliseconds */ + averageDuration: number; + /** Success rate (0-1) */ + successRate: number; +} + +/** + * Download result for batch operations + */ +export interface BatchDownloadFileResult { + /** CID of the downloaded file */ + cid: string; + /** Local file path where file was saved */ + filePath: string; + /** File size in bytes */ + size: number; + /** Whether file was decrypted */ + decrypted: boolean; +}