import * as fs from "node:fs"; import * as path from "node:path"; import { isEnoent } from "@f5-sales-demo/pi-utils"; import { parseCommandArgs } from "../utils/command-args"; import type { ASIData, ASIValue, MetricDirection, NumericMetricMap, PendingRunSummary } from "./types"; export const EXPERIMENT_MAX_LINES = 10; export const EXPERIMENT_MAX_BYTES = 4 * 1024; const AUTORESEARCH_COMMITTABLE_FILES = new Set([ "autoresearch.md", "autoresearch.sh", "autoresearch.checks.sh", "autoresearch.ideas.md", ]); export const DENIED_KEY_NAMES = new Set(["__proto__", "constructor", "prototype"]); const COMPLETION_KEYS = [ "completedAt", "exitCode", "timedOut", "durationSeconds", "checks", "parsedPrimary", "parsedMetrics", "parsedAsi", ] as const; export function finiteOrNull(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } export function parseMetricLines(output: string): Map { return new Map( [...output.matchAll(/^METRIC\s+([\w.µ-]+)=(\S+)\s*$/gm)] .filter(([, name]) => name && !DENIED_KEY_NAMES.has(name)) .map(([, name, raw]) => [name, Number(raw)] as const) .filter(([, v]) => Number.isFinite(v)), ); } export function parseAsiLines(output: string): ASIData | null { const asi: ASIData = {}; for (const [, key, raw] of output.matchAll(/^ASI\s+([\w.-]+)=(.+)\s*$/gm)) { if (key && !DENIED_KEY_NAMES.has(key)) asi[key] = parseAsiValue(raw); } return Object.keys(asi).length > 0 ? asi : null; } function parseAsiValue(raw: string): ASIValue { const value = raw.trim(); if (value === "true") return true; if (value === "false") return false; if (value === "null") return null; const n = /^-?\d+(?:\.\d+)?$/.test(value) ? Number(value) : Number.NaN; if (Number.isFinite(n)) return n; if (value.startsWith("{") || value.startsWith("[") || value.startsWith('"')) { try { return JSON.parse(value) as ASIValue; } catch { return value; } } return value; } export function mergeAsi(base: ASIData | null, override: ASIData | undefined): ASIData | undefined { if (!base && !override) return undefined; return { ...(base ?? {}), ...(override ?? {}), }; } const COMMA_FORMAT = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }); function commas(value: number): string { return COMMA_FORMAT.format(Math.trunc(value)); } export function formatNum(value: number | null, unit: string): string { if (value === null) return "-"; if (Number.isInteger(value)) return `${commas(Math.round(value))}${unit}`; const absolute = Math.abs(value); const whole = Math.floor(absolute); const fraction = (absolute - whole).toFixed(2).slice(1); return `${value < 0 ? "-" : ""}${commas(whole)}${fraction}${unit}`; } export function formatElapsed(milliseconds: number): string { const s = Math.floor(milliseconds / 1000); return s >= 60 ? `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s` : `${s}s`; } export function getAutoresearchRunDirectory(workDir: string, runNumber: number): string { return path.join(workDir, ".autoresearch", "runs", String(runNumber).padStart(4, "0")); } export function getNextAutoresearchRunNumber(workDir: string, lastRunNumber: number | null): number { const runsDir = path.join(workDir, ".autoresearch", "runs"); try { const nums = fs .readdirSync(runsDir, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => Number.parseInt(e.name, 10)) .filter(Number.isFinite); return Math.max(lastRunNumber ?? 0, ...nums) + 1; } catch (error) { if (!isEnoent(error)) throw error; return (lastRunNumber ?? 0) + 1; } } export function normalizeAutoresearchPath(relativePath: string): string { const normalized = relativePath.replaceAll("\\", "/").trim(); if (normalized === "." || normalized === "./") return "."; return normalized.replace(/^\.\/+/, "").replace(/\/+$/, ""); } export function isAutoresearchCommittableFile(relativePath: string): boolean { return AUTORESEARCH_COMMITTABLE_FILES.has(normalizeAutoresearchPath(relativePath)); } export function isAutoresearchLocalStatePath(relativePath: string): boolean { const normalized = normalizeAutoresearchPath(relativePath); return ( normalized === "autoresearch.jsonl" || normalized === ".autoresearch" || normalized.startsWith(".autoresearch/") ); } export function killTree(pid: number, signal: NodeJS.Signals | number = "SIGTERM"): void { for (const target of [-pid, pid]) { try { process.kill(target, signal); return; } catch {} } } export function isAutoresearchShCommand(command: string): boolean { const normalized = command .trim() .replace(/^(?:\w+=\S*\s+)+/, "") .replace(/^(?:(?:env|time|nice|nohup)(?:\s+-\S+(?:\s+\d+)?)?\s+)*/, ""); if (/[;&|<>]/.test(normalized)) return false; const tokens = parseCommandArgs(normalized); if (tokens.length === 0) return false; let index = 0; if (tokens[index] === "bash" || tokens[index] === "sh") { index += 1; while (index < tokens.length && tokens[index]?.startsWith("-")) { if (tokens[index]?.includes("c")) return false; index += 1; } } const scriptToken = tokens[index]; if (!scriptToken || !/^(?:\.\/|\/[\w/.-]*\/)?autoresearch\.sh$/.test(scriptToken)) return false; return !tokens .slice(index + 1) .some(t => t === "&&" || t === "||" || t === ";" || t === "|" || t === ">" || t === "<"); } export function isBetter(current: number, best: number, direction: MetricDirection): boolean { return direction === "lower" ? current < best : current > best; } export function inferMetricUnitFromName(name: string): string { if (name.endsWith("µs") || name.endsWith("_µs")) return "µs"; if (name.endsWith("ms") || name.endsWith("_ms")) return "ms"; if (name.endsWith("_s") || name.endsWith("_sec") || name.endsWith("_secs")) return "s"; if (name.endsWith("_kb") || name.endsWith("kb")) return "kb"; if (name.endsWith("_mb") || name.endsWith("mb")) return "mb"; return ""; } export async function readPendingRunSummary( workDir: string, loggedRunNumbers: ReadonlySet = new Set(), ): Promise { const entries = await readRunDirectoryEntries(workDir); if (!entries) return null; for (const entry of entries.filter(e => e.isDirectory()).sort((a, b) => b.name.localeCompare(a.name))) { const { parsed, runDirectory } = await readRunArtifact(workDir, entry.name); if (!parsed) continue; const pendingRun = parsePendingRunSummary(parsed, runDirectory, entry.name, loggedRunNumbers); if (pendingRun) return pendingRun; } return null; } export async function abandonUnloggedAutoresearchRuns( workDir: string, loggedRunNumbers: ReadonlySet, ): Promise { const entries = await readRunDirectoryEntries(workDir); if (!entries) return 0; let abandoned = 0; const stamp = new Date().toISOString(); for (const entry of entries) { if (!entry.isDirectory()) continue; const { parsed, runDirectory, runJsonPath } = await readRunArtifact(workDir, entry.name); if (!parsed) continue; if (!parsePendingRunSummary(parsed, runDirectory, entry.name, loggedRunNumbers)) continue; await Bun.write( runJsonPath, JSON.stringify({ ...(parsed as Record), abandonedAt: stamp }, null, 2), ); abandoned += 1; } return abandoned; } async function readRunDirectoryEntries(workDir: string): Promise { const runsDir = path.join(workDir, ".autoresearch", "runs"); try { return await fs.promises.readdir(runsDir, { withFileTypes: true }); } catch (error) { if (isEnoent(error)) return null; throw error; } } async function readRunArtifact( workDir: string, directoryName: string, ): Promise<{ parsed: unknown; runDirectory: string; runJsonPath: string }> { const runDirectory = path.join(workDir, ".autoresearch", "runs", directoryName); const runJsonPath = path.join(runDirectory, "run.json"); try { const parsed = await Bun.file(runJsonPath).json(); return { parsed, runDirectory, runJsonPath }; } catch (error) { if (isEnoent(error)) return { parsed: null, runDirectory, runJsonPath }; throw error; } } function readConfig(cwd: string) { try { const o = JSON.parse(fs.readFileSync(path.join(cwd, "autoresearch.config.json"), "utf8")) as unknown; if (typeof o !== "object" || o === null) return {}; const c = o as { maxIterations?: unknown; workingDir?: unknown }; const config: { maxIterations?: number; workingDir?: string } = {}; const maxIter = finiteOrNull(c.maxIterations); if (maxIter !== null) config.maxIterations = maxIter; if (typeof c.workingDir === "string" && c.workingDir.trim().length > 0) config.workingDir = c.workingDir; return config; } catch { return {}; } } export function readMaxExperiments(cwd: string): number | null { const value = readConfig(cwd).maxIterations; return value !== undefined && value > 0 ? Math.floor(value) : null; } export function resolveWorkDir(cwd: string): string { const configured = readConfig(cwd).workingDir; if (!configured) return cwd; return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); } export function validateWorkDir(cwd: string): string | null { const workDir = resolveWorkDir(cwd); try { if (!fs.statSync(workDir).isDirectory()) return `workingDir ${workDir} is not a directory.`; return null; } catch (error) { return isEnoent(error) ? `workingDir ${workDir} does not exist.` : `workingDir ${workDir} is unavailable.`; } } function parsePendingRunSummary( value: unknown, runDirectory: string, directoryName: string, loggedRunNumbers: ReadonlySet, ): PendingRunSummary | null { if (typeof value !== "object" || value === null) return null; const candidate = value as Record; const checks = candidate.checks as Record | undefined; if (candidate.loggedAt !== undefined || candidate.status !== undefined) return null; if (typeof candidate.abandonedAt === "string" && candidate.abandonedAt.trim().length > 0) return null; const runNumber = finiteOrNull(candidate.runNumber) ?? parseInt(directoryName, 10); if (!Number.isFinite(runNumber)) return null; if (loggedRunNumbers.has(runNumber)) return null; if (!COMPLETION_KEYS.some(k => candidate[k] !== undefined)) return null; const checksPass = typeof checks?.passed === "boolean" ? checks.passed : typeof checks?.timedOut === "boolean" && checks.timedOut ? false : null; return { checksDurationSeconds: finiteOrNull(checks?.durationSeconds), checksPass, checksTimedOut: checks?.timedOut === true, command: typeof candidate.command === "string" ? candidate.command : "", durationSeconds: finiteOrNull(candidate.durationSeconds), parsedAsi: cloneAsiData(candidate.parsedAsi), parsedMetrics: cloneNumericMetricMap(candidate.parsedMetrics), parsedPrimary: finiteOrNull(candidate.parsedPrimary), passed: finiteOrNull(candidate.exitCode) === 0 && candidate.timedOut !== true && checksPass !== false, preRunDirtyPaths: Array.isArray(candidate.preRunDirtyPaths) ? candidate.preRunDirtyPaths.filter((item): item is string => typeof item === "string") : [], runDirectory, runNumber, }; } export function cloneNumericMetricMap(value: unknown): NumericMetricMap | null { if (typeof value !== "object" || value === null) return null; const clone: NumericMetricMap = {}; for (const [key, entryValue] of Object.entries(value as Record)) { if (DENIED_KEY_NAMES.has(key)) continue; const num = finiteOrNull(entryValue); if (num !== null) clone[key] = num; } return Object.keys(clone).length > 0 ? clone : null; } function cloneAsiData(value: unknown): ASIData | null { if (typeof value !== "object" || value === null) return null; const result = clonePendingAsiValue(value) as ASIData | undefined; return result && Object.keys(result).length > 0 ? result : null; } function clonePendingAsiValue(value: unknown): ASIValue | undefined { if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value; if (Array.isArray(value)) return value.map(e => clonePendingAsiValue(e)).filter((e): e is NonNullable => e !== undefined); if (typeof value !== "object") return undefined; const clone: { [key: string]: ASIValue } = {}; for (const [key, v] of Object.entries(value as Record)) { if (DENIED_KEY_NAMES.has(key)) continue; const sanitized = clonePendingAsiValue(v); if (sanitized !== undefined) clone[key] = sanitized; } return clone; }