blob: ab87482e2305e532b447d2e2ba69806b989d4478 [file]
import { createHash } from 'node:crypto';
import { lstat, readdir, readFile, readlink } from 'node:fs/promises';
import { join, relative, resolve } from 'node:path';
import type { FixedPromptTask } from './fixed-prompt-controller.js';
export function resolveFixedPromptRunRoot(
outDir: string,
runId: string,
envName = 'MAKA_PROMPT_RUN_ID',
): string {
if (!/^[A-Za-z0-9._-]+$/.test(runId) || runId === '.' || runId === '..') {
throw new Error(`${envName} must contain only letters, numbers, dot, underscore, or hyphen`);
}
return join(outDir, runId);
}
/** Scan a Harbor task cache (`<root>/<hash>/<task-name>/task.toml`) or exported
* dataset (`<root>/<task-name>/task.toml`) into a deterministic task list. */
export async function discoverCachedHarborTasks(
tasksRoot: string,
taskIds?: ReadonlySet<string>,
options: { duplicatePolicy?: 'error' | 'newest' } = {},
): Promise<FixedPromptTask[]> {
const byId = new Map<string, { task: FixedPromptTask; modifiedAtMs: number }>();
let hashDirs;
try {
hashDirs = await readdir(tasksRoot, { withFileTypes: true });
} catch {
return [];
}
for (const hashDir of hashDirs) {
if (!hashDir.isDirectory()) continue;
const hashPath = join(tasksRoot, hashDir.name);
const exportedTaskToml = await readTaskToml(hashPath);
if (exportedTaskToml !== undefined) {
if (taskIds && !taskIds.has(hashDir.name)) continue;
addDiscoveredTask(byId, hashDir.name, hashPath, exportedTaskToml, options);
continue;
}
let inner;
try {
inner = await readdir(hashPath, { withFileTypes: true });
} catch {
continue;
}
for (const taskDir of inner) {
if (!taskDir.isDirectory()) continue;
if (taskIds && !taskIds.has(taskDir.name)) continue;
const taskPath = join(hashPath, taskDir.name);
const taskToml = await readTaskToml(taskPath);
if (taskToml === undefined) continue;
addDiscoveredTask(byId, taskDir.name, taskPath, taskToml, options);
}
}
return [...byId.values()].map((entry) => entry.task).sort((a, b) => a.id.localeCompare(b.id));
}
/**
* Pick tasks by explicit id, preserving the requested order. Throws on an
* unknown id (and, unless `rejectDuplicates` is false, on a duplicate id that
* would double-weight a task). `label` scopes the unknown-id error message for
* callers that select from more than one named id list.
*/
export function selectTasksByIds<T extends { id: string }>(
allTasks: readonly T[],
ids: readonly string[],
options: { label?: string; rejectDuplicates?: boolean } = {},
): T[] {
const { label, rejectDuplicates = true } = options;
if (rejectDuplicates) {
const duplicates = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))];
if (duplicates.length > 0) throw new Error(`duplicate task id(s): ${duplicates.join(', ')}`);
}
const byId = new Map(allTasks.map((task) => [task.id, task]));
const missing = ids.filter((id) => !byId.has(id));
if (missing.length > 0) {
throw new Error(
`${label ? `${label} contains ` : ''}unknown task id(s): ${missing.join(', ')}`,
);
}
return ids.map((id) => byId.get(id)!);
}
export async function fingerprintFixedPromptTaskTree(
tasks: readonly FixedPromptTask[],
): Promise<string> {
const taskEntries = [];
for (const task of [...tasks].sort((left, right) => left.id.localeCompare(right.id))) {
taskEntries.push({ id: task.id, entries: await taskDirectoryEntries(task.path) });
}
return `sha256:${createHash('sha256')
.update(JSON.stringify({ schemaVersion: 1, tasks: taskEntries }))
.digest('hex')}`;
}
export async function fingerprintFixedPromptTask(task: FixedPromptTask): Promise<string> {
return `sha256:${createHash('sha256')
.update(
JSON.stringify({
schemaVersion: 1,
task: { id: task.id, entries: await taskDirectoryEntries(task.path) },
}),
)
.digest('hex')}`;
}
async function taskDirectoryEntries(
taskPath: string,
): Promise<Array<Record<string, string | boolean>>> {
const root = resolve(taskPath);
const entries: Array<Record<string, string | boolean>> = [];
await walkTaskDirectory(root, root, entries);
return entries;
}
async function walkTaskDirectory(
root: string,
dir: string,
entries: Array<Record<string, string | boolean>>,
): Promise<void> {
const children = await readdir(dir, { withFileTypes: true });
children.sort((left, right) => left.name.localeCompare(right.name));
for (const child of children) {
const path = join(dir, child.name);
const entryPath = relative(root, path).split('\\').join('/');
const stats = await lstat(path);
if (child.isDirectory()) {
entries.push({ path: entryPath, type: 'directory' });
await walkTaskDirectory(root, path, entries);
} else if (child.isSymbolicLink()) {
throw new Error(
`task source symlink is not supported: ${entryPath} -> ${await readlink(path)}`,
);
} else if (child.isFile()) {
entries.push({
path: entryPath,
type: 'file',
executable: (stats.mode & 0o111) !== 0,
hash: createHash('sha256')
.update(await readFile(path))
.digest('hex'),
});
} else {
entries.push({ path: entryPath, type: 'other' });
}
}
}
interface CachedTaskToml {
text: string;
modifiedAtMs: number;
}
async function readTaskToml(taskPath: string): Promise<CachedTaskToml | undefined> {
try {
const path = join(taskPath, 'task.toml');
const [text, stats] = await Promise.all([readFile(path, 'utf8'), lstat(path)]);
return { text, modifiedAtMs: stats.mtimeMs };
} catch {
return undefined;
}
}
function addDiscoveredTask(
byId: Map<string, { task: FixedPromptTask; modifiedAtMs: number }>,
taskId: string,
taskPath: string,
taskToml: CachedTaskToml,
options: { duplicatePolicy?: 'error' | 'newest' },
): void {
// The controller keys events by task id, so two cached versions of the same
// task name would silently collide and pollute scoring. Fail loud instead.
const existing = byId.get(taskId);
if (existing && options.duplicatePolicy !== 'newest') {
throw new Error(`duplicate cached task id "${taskId}": ${existing.task.path} and ${taskPath}`);
}
if (
existing &&
(existing.modifiedAtMs > taskToml.modifiedAtMs ||
(existing.modifiedAtMs === taskToml.modifiedAtMs && existing.task.path > taskPath))
) {
return;
}
byId.set(taskId, {
modifiedAtMs: taskToml.modifiedAtMs,
task: {
id: taskId,
path: taskPath,
...metadataField(parseTaskTomlMetadata(taskToml.text)),
},
});
}
function parseTaskTomlMetadata(text: string): FixedPromptTask['metadata'] {
return {
...stringField('difficulty', sectionField(text, 'metadata', 'difficulty')),
...numberField(
'estimatedDurationSec',
sectionField(text, 'metadata', 'estimated_duration_sec'),
),
...numberField(
'expertTimeEstimateMin',
sectionField(text, 'metadata', 'expert_time_estimate_min'),
),
...numberField(
'juniorTimeEstimateMin',
sectionField(text, 'metadata', 'junior_time_estimate_min'),
),
...numberField('agentTimeoutSec', sectionField(text, 'agent', 'timeout_sec')),
...numberField('verifierTimeoutSec', sectionField(text, 'verifier', 'timeout_sec')),
...numberField('buildTimeoutSec', sectionField(text, 'environment', 'build_timeout_sec')),
};
}
function sectionField(text: string, sectionName: string, fieldName: string): string | undefined {
let inSection = false;
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.replace(/#.*/, '').trim();
if (line.length === 0) continue;
const section = line.match(/^\[([^\]]+)\]$/);
if (section) {
inSection = section[1] === sectionName;
continue;
}
if (!inSection) continue;
const field = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/);
if (field?.[1] !== fieldName) continue;
return field[2]?.trim();
}
return undefined;
}
function stringField(
key: 'difficulty',
raw: string | undefined,
): Pick<NonNullable<FixedPromptTask['metadata']>, 'difficulty'> | {} {
if (raw === undefined) return {};
const value = raw.match(/^"([^"]*)"$/)?.[1] ?? raw;
return value.length > 0 ? { [key]: value } : {};
}
function numberField<
K extends Exclude<keyof NonNullable<FixedPromptTask['metadata']>, 'difficulty'>,
>(key: K, raw: string | undefined): Pick<NonNullable<FixedPromptTask['metadata']>, K> | {} {
if (raw === undefined) return {};
const value = Number(raw);
return Number.isFinite(value)
? ({ [key]: value } as Pick<NonNullable<FixedPromptTask['metadata']>, K>)
: {};
}
function metadataField(
metadata: FixedPromptTask['metadata'],
): Pick<FixedPromptTask, 'metadata'> | {} {
return metadata && Object.keys(metadata).length > 0 ? { metadata } : {};
}