| #!/usr/bin/env node |
|
|
| |
| process.stdout.on("error", (error: NodeJS.ErrnoException) => { |
| if (error.code === "EPIPE") { |
| process.exit(0); |
| } |
| throw error; |
| }); |
|
|
| import * as fs from "fs/promises"; |
| import * as path from "path"; |
| import { fileURLToPath } from "url"; |
| import { parse as parseYaml } from "yaml"; |
| import { exec } from "child_process"; |
| import { promisify } from "util"; |
| import pLimit from "p-limit"; |
| import pino from "pino"; |
| import { TaskStatus, type Task } from "./model.js"; |
| import { |
| getContextsFromSources, |
| generateCommand, |
| } from "./command-generator.js"; |
| import { parseCliArgs } from "./parse.js"; |
| import { executeTask, type TaskExecutionResult } from "./task-executor.js"; |
| import { processValidations, type ValidationResult } from "./verification.js"; |
| import { createTempDir, parseCsvAsync } from "./utils.js"; |
|
|
| const execAsync = promisify(exec); |
|
|
| export type TaskResult = { |
| index: number; |
| status: TaskStatus; |
| command: string; |
| duration: number; |
| validationResults: ValidationResult[]; |
| }; |
|
|
| |
| const __filename = fileURLToPath(import.meta.url); |
| const __dirname = path.dirname(__filename); |
|
|
| |
| |
| |
| |
| |
| const logger = |
| process.env.LOG_JSON === "1" |
| ? pino({ |
| level: process.env.LOG_LEVEL || "info", |
| formatters: { |
| level: (label) => ({ level: label }), |
| }, |
| timestamp: pino.stdTimeFunctions.isoTime, |
| }) |
| : pino({ |
| level: process.env.LOG_LEVEL || "info", |
| transport: { |
| target: "pino-pretty", |
| options: { |
| colorize: true, |
| translateTime: "HH:MM:ss", |
| ignore: "pid,hostname", |
| messageFormat: "{msg}", |
| }, |
| }, |
| formatters: { |
| level: (label) => ({ level: label }), |
| }, |
| timestamp: pino.stdTimeFunctions.isoTime, |
| }); |
|
|
| async function main() { |
| |
| let args; |
| try { |
| args = await parseCliArgs(__dirname); |
| } catch (error) { |
| const message = error instanceof Error ? error.message : "Unknown error"; |
| logger.error({ error: message }, "Failed to parse CLI arguments"); |
| process.exit(1); |
| } |
|
|
| const { evalName, evalDir, taskFile } = args; |
|
|
| |
| try { |
| await fs.access(evalDir); |
| } catch { |
| logger.error({ evalDir }, "Eval directory not found"); |
| process.exit(1); |
| } |
|
|
| try { |
| await fs.access(taskFile); |
| } catch { |
| logger.error({ evalDir }, "task.yml not found"); |
| process.exit(1); |
| } |
|
|
| |
| const taskContent = await fs.readFile(taskFile, "utf-8"); |
| const task: Task = parseYaml(taskContent); |
|
|
| |
| const displayName = path.relative(__dirname, evalDir) || evalName; |
|
|
| |
| const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); |
| const debugDir = path.join(evalDir, "debug", timestamp); |
| await fs.mkdir(debugDir, { recursive: true }); |
|
|
| |
| const setupTmpDir = await createTempDir("forge-setup-"); |
|
|
| |
| if (task.before_run && task.before_run.length > 0) { |
| for (const cmd of task.before_run) { |
| try { |
| logger.info( |
| { dir: setupTmpDir.name, command: cmd }, |
| "Running setup command", |
| ); |
| |
| await new Promise((resolve) => setTimeout(resolve, 0)); |
| await execAsync(cmd, { |
| cwd: setupTmpDir.name, |
| }); |
| } catch (error) { |
| logger.error({ command: cmd }, "Setup command failed"); |
| process.exit(1); |
| } |
| } |
| } |
|
|
| |
| const sourcesData: Record<string, string>[][] = []; |
|
|
| for (const source of task.sources) { |
| if ("csv" in source) { |
| const csvPath = path.join(evalDir, source.csv); |
| try { |
| await fs.access(csvPath); |
| } catch { |
| logger.error({ csvPath }, "CSV file not found"); |
| process.exit(1); |
| } |
|
|
| const csvContent = await fs.readFile(csvPath, "utf-8"); |
| const csvData = await parseCsvAsync(csvContent, { |
| columns: true, |
| skip_empty_lines: true, |
| }); |
| sourcesData.push(csvData); |
| } else if ("cmd" in source) { |
| logger.error("cmd source type not yet implemented"); |
| process.exit(1); |
| } else if ("value" in source) { |
| sourcesData.push(source.value); |
| } |
| } |
|
|
| |
| if (sourcesData.length === 0) { |
| logger.error("No sources configured"); |
| process.exit(1); |
| } |
|
|
| |
| const data = getContextsFromSources(sourcesData); |
|
|
| const results: TaskResult[] = []; |
|
|
| |
| const parallelism = task.parallelism ?? 1; |
| const limit = pLimit(parallelism); |
|
|
| |
| |
| const taskPromises = data.map((row, i) => { |
| return limit(async () => { |
| |
| const taskTmpDir = await createTempDir(`forge-task-${i + 1}-`); |
|
|
| |
| const taskWorkDir = path.join(taskTmpDir.name, 'task'); |
| await fs.mkdir(taskWorkDir, { recursive: true }); |
|
|
| const logFile = path.join(taskTmpDir.name, `task.log`); |
|
|
| |
| const context = { ...row, dir: taskTmpDir.name }; |
|
|
| |
| const commands = Array.isArray(task.run) ? task.run : [task.run]; |
|
|
| |
| const validCommands = commands.filter(cmd => typeof cmd === 'string' && cmd.trim().length > 0); |
|
|
| |
| if (validCommands.length === 0) { |
| logger.warn({ task_id: i + 1 }, "No valid commands found, skipping task"); |
| return { |
| index: i + 1, |
| status: TaskStatus.Failed, |
| command: "No valid commands", |
| duration: 0, |
| validationResults: [], |
| }; |
| } |
|
|
| let combinedOutput = ""; |
| let totalDuration = 0; |
| let lastError: string | undefined; |
| let hasTimeout = false; |
| let hasEarlyExit = false; |
|
|
| |
| logger.info( |
| { |
| task_id: i + 1, |
| total_commands: validCommands.length, |
| log: logFile, |
| dir: taskTmpDir.name, |
| work_dir: taskWorkDir, |
| parameters: context, |
| }, |
| "Launching task", |
| ); |
|
|
| |
| for (let cmdIdx = 0; cmdIdx < validCommands.length; cmdIdx++) { |
| const commandTemplate = validCommands[cmdIdx]!; |
|
|
| const command = generateCommand(commandTemplate, context); |
|
|
| logger.info( |
| { |
| command, |
| task_id: i + 1, |
| command_id: cmdIdx + 1, |
| total_commands: validCommands.length, |
| }, |
| "Executing command", |
| ); |
|
|
| const executionResult = await executeTask( |
| command, |
| i + 1, |
| logFile, |
| taskWorkDir, |
| task, |
| context, |
| cmdIdx > 0, |
| ); |
|
|
| totalDuration += executionResult.duration; |
|
|
| if (executionResult.output) { |
| combinedOutput += executionResult.output; |
| } |
|
|
| if (executionResult.earlyExit) { |
| hasEarlyExit = true; |
| } |
|
|
| |
| if (executionResult.error) { |
| lastError = executionResult.error; |
| hasTimeout = executionResult.isTimeout; |
|
|
| logger.warn( |
| { |
| task_id: executionResult.index, |
| command: executionResult.command, |
| command_id: cmdIdx + 1, |
| duration: executionResult.duration, |
| error: executionResult.error, |
| is_timeout: executionResult.isTimeout, |
| }, |
| executionResult.isTimeout ? "Task timed out" : "Task failed", |
| ); |
| break; |
| } |
| } |
|
|
| |
| if (lastError) { |
| const { validationResults } = await processValidations( |
| combinedOutput, |
| task, |
| logger, |
| i + 1, |
| totalDuration, |
| logFile, |
| context, |
| ); |
|
|
| return { |
| index: i + 1, |
| status: hasTimeout ? TaskStatus.Timeout : TaskStatus.Failed, |
| command: validCommands.length === 1 ? validCommands[0]! : `${validCommands.length} commands`, |
| duration: totalDuration, |
| validationResults, |
| }; |
| } |
|
|
| |
| const { validationResults, status: validationStatus } = |
| await processValidations( |
| combinedOutput, |
| task, |
| logger, |
| i + 1, |
| totalDuration, |
| logFile, |
| context, |
| ); |
|
|
| return { |
| index: i + 1, |
| status: |
| validationStatus === "passed" |
| ? TaskStatus.Passed |
| : TaskStatus.ValidationFailed, |
| command: validCommands.length === 1 ? validCommands[0]! : `${validCommands.length} commands`, |
| duration: totalDuration, |
| validationResults, |
| }; |
| }); |
| }); |
|
|
| |
| const taskResults = await Promise.all(taskPromises); |
| results.push(...taskResults); |
|
|
| |
| const successCount = results.filter( |
| (r) => r.status === TaskStatus.Passed, |
| ).length; |
| const warningCount = results.filter( |
| (r) => r.status === TaskStatus.ValidationFailed, |
| ).length; |
| const timeoutCount = results.filter( |
| (r) => r.status === TaskStatus.Timeout, |
| ).length; |
| const failCount = results.filter( |
| (r) => r.status === TaskStatus.Failed, |
| ).length; |
| const totalDuration = results.reduce((sum, r) => sum + r.duration, 0); |
|
|
| |
| const totalValidations = results.reduce( |
| (sum, r) => sum + r.validationResults.length, |
| 0, |
| ); |
| const passedValidations = results.reduce( |
| (sum, r) => sum + r.validationResults.filter((v) => v.passed).length, |
| 0, |
| ); |
|
|
| |
| logger.info( |
| { |
| total: results.length, |
| passed: successCount, |
| validation_failed: warningCount, |
| timeout: timeoutCount, |
| failed: failCount, |
| total_duration: totalDuration, |
| validations: { |
| total: totalValidations, |
| passed: passedValidations, |
| failed: totalValidations - passedValidations, |
| }, |
| dir: setupTmpDir.name, |
| }, |
| "Evaluation completed", |
| ); |
|
|
| |
| if (failCount > 0) { |
| process.exit(1); |
| } |
| |
| |
| process.exit(0); |
| } |
|
|
| main().catch((error) => { |
| logger.error({ error: error.message }, "Fatal error"); |
| process.exit(1); |
| }); |
|
|