SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
1244914 verified
Raw
History Blame Contribute Delete
11.5 kB
#!/usr/bin/env node
// Handle EPIPE errors gracefully (e.g., when piping to `head` or `jq` that closes early)
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[];
};
// ESM compatibility for __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Create logger instance
* - Human-readable CLI output by default
* - Set LOG_JSON=1 for machine-readable JSON output (for piping to jq, log aggregators, etc.)
*/
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() {
// Parse command line arguments
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;
// Check if eval directory and task file exist
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);
}
// Read and parse task.yml
const taskContent = await fs.readFile(taskFile, "utf-8");
const task: Task = parseYaml(taskContent);
// Display header
const displayName = path.relative(__dirname, evalDir) || evalName;
// Create debug directory with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const debugDir = path.join(evalDir, "debug", timestamp);
await fs.mkdir(debugDir, { recursive: true });
// Create a temp directory for setup commands
const setupTmpDir = await createTempDir("forge-setup-");
// Execute before_run commands
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",
);
// Small delay to allow logger to flush before command output
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);
}
}
}
// Load data from sources and create cross product
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);
}
}
// Create cross product of all sources
if (sourcesData.length === 0) {
logger.error("No sources configured");
process.exit(1);
}
// Get contexts from sources using pure function
const data = getContextsFromSources(sourcesData);
const results: TaskResult[] = [];
// Get parallelism setting (default to 1 for sequential execution)
const parallelism = task.parallelism ?? 1;
const limit = pLimit(parallelism);
// Execute run command for each data row
// Create promises for all tasks
const taskPromises = data.map((row, i) => {
return limit(async () => {
// Create a unique temp directory for this task
const taskTmpDir = await createTempDir(`forge-task-${i + 1}-`);
// Create a 'task' subdirectory for running commands
const taskWorkDir = path.join(taskTmpDir.name, 'task');
await fs.mkdir(taskWorkDir, { recursive: true });
const logFile = path.join(taskTmpDir.name, `task.log`);
// Context for command interpolation and validations
const context = { ...row, dir: taskTmpDir.name };
// Support both single command and multiple commands
const commands = Array.isArray(task.run) ? task.run : [task.run];
// Filter out empty or non-string commands
const validCommands = commands.filter(cmd => typeof cmd === 'string' && cmd.trim().length > 0);
// If no valid commands, skip this task
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;
// Log task launch once before executing commands
logger.info(
{
task_id: i + 1,
total_commands: validCommands.length,
log: logFile,
dir: taskTmpDir.name,
work_dir: taskWorkDir,
parameters: context,
},
"Launching task",
);
// Execute commands sequentially
for (let cmdIdx = 0; cmdIdx < validCommands.length; cmdIdx++) {
const commandTemplate = validCommands[cmdIdx]!; // Non-null assertion safe after filter
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, // Use taskWorkDir instead of taskTmpDir.name
task,
context,
cmdIdx > 0, // append if this is not the first command
);
totalDuration += executionResult.duration;
if (executionResult.output) {
combinedOutput += executionResult.output;
}
if (executionResult.earlyExit) {
hasEarlyExit = true;
}
// If execution failed or timed out, stop executing remaining commands
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 any command failed, return failure result
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,
};
}
// Run validations on the combined output
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,
};
});
});
// Wait for all tasks to complete
const taskResults = await Promise.all(taskPromises);
results.push(...taskResults);
// Calculate summary statistics
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);
// Calculate validation statistics
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,
);
// Print summary
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",
);
// Exit with error code if any task failed (excluding timeouts and validation failures)
if (failCount > 0) {
process.exit(1);
}
// Exit successfully - ensures process terminates even with open handles
process.exit(0);
}
main().catch((error) => {
logger.error({ error: error.message }, "Fatal error");
process.exit(1);
});