|
| 1 | +import axios from "axios"; |
| 2 | +import pLimit from "p-limit"; |
1 | 3 | import { Logger } from "../logger"; |
| 4 | +import { FileData, ProcessedFile } from "./models"; |
2 | 5 | import { BaseTask } from "./task"; |
3 | 6 |
|
| 7 | +type LimitFunction = ReturnType<typeof pLimit>; |
| 8 | + |
4 | 9 | const LOG_PREFIX = "netra.simulation"; |
| 10 | +const DEFAULT_FILE_DOWNLOAD_TIMEOUT_S = 30; |
| 11 | +const MAX_FILE_DOWNLOAD_WORKERS = 8; |
| 12 | + |
| 13 | +let _cachedFileDownloadTimeoutMs: number | null = null; |
5 | 14 |
|
6 | 15 | /** |
7 | 16 | * Format the trace ID as a 32-digit hexadecimal string. |
@@ -35,26 +44,188 @@ export function validateSimulationInputs( |
35 | 44 | return true; |
36 | 45 | } |
37 | 46 |
|
| 47 | +/** |
| 48 | + * Get the file download timeout in milliseconds. |
| 49 | + * |
| 50 | + * Reads from the `NETRA_SIMULATION_FILE_DOWNLOAD_TIMEOUT` environment |
| 51 | + * variable (value in seconds) on first access and caches the result. |
| 52 | + * Falls back to the default of 30 s. |
| 53 | + * |
| 54 | + * @returns Timeout in milliseconds |
| 55 | + */ |
| 56 | +function _getFileDownloadTimeout(): number { |
| 57 | + if (_cachedFileDownloadTimeoutMs !== null) { |
| 58 | + return _cachedFileDownloadTimeoutMs; |
| 59 | + } |
| 60 | + |
| 61 | + const envVal = process.env.NETRA_SIMULATION_FILE_DOWNLOAD_TIMEOUT; |
| 62 | + if (envVal) { |
| 63 | + const parsed = parseFloat(envVal); |
| 64 | + if (!isNaN(parsed) && parsed > 0) { |
| 65 | + _cachedFileDownloadTimeoutMs = parsed * 1000; |
| 66 | + return _cachedFileDownloadTimeoutMs; |
| 67 | + } |
| 68 | + Logger.warn( |
| 69 | + `${LOG_PREFIX}: Invalid file download timeout '${envVal}', using default ${DEFAULT_FILE_DOWNLOAD_TIMEOUT_S}s`, |
| 70 | + ); |
| 71 | + } |
| 72 | + _cachedFileDownloadTimeoutMs = DEFAULT_FILE_DOWNLOAD_TIMEOUT_S * 1000; |
| 73 | + return _cachedFileDownloadTimeoutMs; |
| 74 | +} |
| 75 | + |
| 76 | +/** |
| 77 | + * Download a single file from its pre-signed URL and base64-encode the content. |
| 78 | + * |
| 79 | + * @param fileData - Metadata with the download URL |
| 80 | + * @param timeoutMs - Request timeout in milliseconds |
| 81 | + * @param signal - AbortSignal to cancel the download if another file in the |
| 82 | + * batch fails |
| 83 | + * @returns ProcessedFile with base64-encoded data |
| 84 | + * @throws Error if the download fails, with contextual information about which |
| 85 | + * file failed and the HTTP status (if available) |
| 86 | + */ |
| 87 | +async function _downloadSingleFile( |
| 88 | + fileData: FileData, |
| 89 | + timeoutMs: number, |
| 90 | + signal?: AbortSignal, |
| 91 | +): Promise<ProcessedFile> { |
| 92 | + try { |
| 93 | + const response = await axios.get(fileData.downloadUrl, { |
| 94 | + responseType: "arraybuffer", |
| 95 | + timeout: timeoutMs, |
| 96 | + signal, |
| 97 | + }); |
| 98 | + |
| 99 | + const encoded = Buffer.from(response.data).toString("base64"); |
| 100 | + return { |
| 101 | + fileName: fileData.fileName, |
| 102 | + contentType: fileData.contentType, |
| 103 | + description: fileData.description, |
| 104 | + data: encoded, |
| 105 | + }; |
| 106 | + } catch (error) { |
| 107 | + if (axios.isCancel(error)) { |
| 108 | + throw new Error( |
| 109 | + `Download of '${fileData.fileName}' was cancelled`, |
| 110 | + ); |
| 111 | + } |
| 112 | + |
| 113 | + const status = axios.isAxiosError(error) ? error.response?.status : undefined; |
| 114 | + const reason = status |
| 115 | + ? `HTTP ${status}` |
| 116 | + : (error instanceof Error ? error.message : String(error)); |
| 117 | + |
| 118 | + Logger.error( |
| 119 | + `${LOG_PREFIX}: Failed to download file '${fileData.fileName}': ${reason}`, |
| 120 | + ); |
| 121 | + |
| 122 | + throw new Error( |
| 123 | + `Failed to download file '${fileData.fileName}': ${reason}`, |
| 124 | + ); |
| 125 | + } |
| 126 | +} |
| 127 | + |
| 128 | +/** |
| 129 | + * Parse raw attachment entries from the backend into typed FileData objects. |
| 130 | + * |
| 131 | + * Entries missing a `fileName` or `downloadUrl` are skipped with a warning. |
| 132 | + * |
| 133 | + * @param rawFiles - Array of attachment objects from the API response |
| 134 | + * @returns Parsed FileData array (empty when input is null/undefined) |
| 135 | + */ |
| 136 | +export function parseFiles( |
| 137 | + rawFiles: Array<Record<string, string>> | null | undefined, |
| 138 | +): FileData[] { |
| 139 | + if (!rawFiles) { |
| 140 | + return []; |
| 141 | + } |
| 142 | + |
| 143 | + const parsed: FileData[] = []; |
| 144 | + for (const entry of rawFiles) { |
| 145 | + const fileName = entry.fileName || ""; |
| 146 | + const downloadUrl = entry.downloadUrl || ""; |
| 147 | + |
| 148 | + if (!fileName || !downloadUrl) { |
| 149 | + Logger.warn( |
| 150 | + `${LOG_PREFIX}: Skipping malformed file attachment (missing fileName or downloadUrl)`, |
| 151 | + ); |
| 152 | + continue; |
| 153 | + } |
| 154 | + |
| 155 | + parsed.push({ |
| 156 | + fileName, |
| 157 | + contentType: entry.contentType || "", |
| 158 | + description: entry.description || undefined, |
| 159 | + downloadUrl, |
| 160 | + }); |
| 161 | + } |
| 162 | + |
| 163 | + return parsed; |
| 164 | +} |
| 165 | + |
| 166 | +/** |
| 167 | + * Download and base64-encode a batch of files concurrently. |
| 168 | + * |
| 169 | + * Uses up to {@link MAX_FILE_DOWNLOAD_WORKERS} parallel downloads. If any |
| 170 | + * single download fails, outstanding downloads are cancelled via |
| 171 | + * AbortController and the error propagates to the caller. |
| 172 | + * |
| 173 | + * @param files - Array of FileData objects to download |
| 174 | + * @returns Array of ProcessedFile objects with base64-encoded data, or null |
| 175 | + * when the input array is empty / undefined |
| 176 | + * @throws Error if any file download fails |
| 177 | + */ |
| 178 | +export async function processFiles( |
| 179 | + files: FileData[] | null | undefined, |
| 180 | +): Promise<ProcessedFile[] | null> { |
| 181 | + if (!files || files.length === 0) { |
| 182 | + return null; |
| 183 | + } |
| 184 | + |
| 185 | + const timeoutMs = _getFileDownloadTimeout(); |
| 186 | + const limit: LimitFunction = pLimit(MAX_FILE_DOWNLOAD_WORKERS); |
| 187 | + const controller = new AbortController(); |
| 188 | + |
| 189 | + const downloadPromises = files.map((file) => |
| 190 | + limit(() => _downloadSingleFile(file, timeoutMs, controller.signal)), |
| 191 | + ); |
| 192 | + |
| 193 | + try { |
| 194 | + return await Promise.all(downloadPromises); |
| 195 | + } catch (error) { |
| 196 | + controller.abort(); |
| 197 | + limit.clearQueue(); |
| 198 | + throw error; |
| 199 | + } |
| 200 | +} |
| 201 | + |
38 | 202 | /** |
39 | 203 | * Execute a task function (sync or async) and extract message and session_id. |
40 | 204 | * |
| 205 | + * When `rawFiles` is provided, the files are downloaded and base64-encoded |
| 206 | + * before being passed to the task's `run` method. |
| 207 | + * |
41 | 208 | * @param task - The task function to execute |
42 | 209 | * @param message - The input message to pass to the task |
43 | 210 | * @param sessionId - The current session identifier |
| 211 | + * @param rawFiles - Optional array of file metadata to download and forward |
44 | 212 | * @returns A tuple of [response_message, session_id] |
45 | | - * @throws Error if the task returns an unsupported type |
| 213 | + * @throws Error if the task returns an unsupported type or file download fails |
46 | 214 | */ |
47 | 215 | export async function executeTask( |
48 | 216 | task: BaseTask, |
49 | 217 | message: string, |
50 | 218 | sessionId: string | null, |
| 219 | + rawFiles?: FileData[] | null, |
51 | 220 | ): Promise<[string, string | null]> { |
52 | | - const result = task.run(message, sessionId); |
| 221 | + const processedFiles = rawFiles && rawFiles.length > 0 |
| 222 | + ? await processFiles(rawFiles) |
| 223 | + : null; |
| 224 | + |
| 225 | + const result = task.run(message, sessionId, processedFiles); |
53 | 226 |
|
54 | | - // Check if result is a Promise (async function) |
55 | 227 | const resolvedResult = result instanceof Promise ? await result : result; |
56 | 228 |
|
57 | | - // Validate that the result is a TaskResult |
58 | 229 | if ( |
59 | 230 | typeof resolvedResult === "object" && |
60 | 231 | resolvedResult !== null && |
|
0 commit comments