blob: 472d031fabf75d5f804eaf783cd1290e1462cc2c [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { createExternalSubjectAdapter } from './external-subject.js';
import {
createHarborExecutor,
createPierExecutor,
type HarnessExecutor,
} from './harness-executor.js';
import { openExperimentDirectory } from './experiment-directory.js';
import type { ExperimentSpec } from './experiment.js';
import { createMakaSubjectAdapter } from './maka-subject.js';
import { runExperiment, type ExperimentExecutor, type SubjectAdapter } from './runner.js';
import { parseExperimentSpec } from './spec.js';
const USAGE = 'usage: maka eval run <spec.json> --out <directory> [--cell <cell-id>]';
export interface EvalCliDependencies {
readonly loadExecutor: (spec: ExperimentSpec, specPath: string) => ExperimentExecutor;
readonly subjects: readonly SubjectAdapter[];
readonly signal?: AbortSignal;
readonly stdout: (text: string) => void;
readonly stderr: (text: string) => void;
}
export async function runMakaEvalCli(
argv: readonly string[],
overrides: Partial<EvalCliDependencies> = {},
): Promise<number> {
const controller = overrides.signal ? undefined : new AbortController();
const signal = overrides.signal ?? controller?.signal;
let interrupted: number | undefined;
const interrupt = () => {
interrupted = 130;
controller?.abort();
};
const terminate = () => {
interrupted = 143;
controller?.abort();
};
if (controller) {
process.once('SIGINT', interrupt);
process.once('SIGTERM', terminate);
}
try {
const command = parseArgs(argv);
if (command.kind === 'help') {
(overrides.stdout ?? process.stdout.write.bind(process.stdout))(`${USAGE}\n`);
return 0;
}
const specPath = resolve(command.specPath);
const spec = parseExperimentSpec(JSON.parse(await readFile(specPath, 'utf8')) as unknown);
const directory = await openExperimentDirectory(resolve(command.outDir), spec);
let executor: ExperimentExecutor;
if (overrides.loadExecutor) {
executor = overrides.loadExecutor(spec, specPath);
} else {
const builtin = builtinExecutor(spec, specPath);
await builtin.preflight({
subjectCredentialNames: [
...new Set(spec.subjects.flatMap((subject) => subject.credentials)),
],
...(signal ? { signal } : {}),
});
executor = builtin;
}
const results = await runExperiment({
spec,
store: directory.attempts,
executor,
subjects: overrides.subjects ?? [createMakaSubjectAdapter(), createExternalSubjectAdapter()],
...(command.cellIds.length > 0 ? { cellIds: command.cellIds } : {}),
...(signal ? { signal } : {}),
});
const incomplete = spec.tasks.length * spec.repetitions * spec.subjects.length - results.size;
(overrides.stdout ?? process.stdout.write.bind(process.stdout))(
`${JSON.stringify({ experimentId: spec.id, cells: results.size, incomplete })}\n`,
);
return interrupted ?? (incomplete === 0 ? 0 : 1);
} catch (error) {
(overrides.stderr ?? process.stderr.write.bind(process.stderr))(
`maka eval: ${error instanceof Error ? error.message : String(error)}\n${USAGE}\n`,
);
return interrupted ?? 2;
} finally {
if (controller) {
process.removeListener('SIGINT', interrupt);
process.removeListener('SIGTERM', terminate);
}
}
}
function builtinExecutor(spec: ExperimentSpec, specPath: string): HarnessExecutor {
if (spec.executor.kind === 'harbor') return createHarborExecutor(spec.executor.config, specPath);
if (spec.executor.kind === 'pier') return createPierExecutor(spec.executor.config, specPath);
throw new Error(`unsupported executor: ${spec.executor.kind}`);
}
function parseArgs(argv: readonly string[]):
| { readonly kind: 'help' }
| {
readonly kind: 'run';
readonly specPath: string;
readonly outDir: string;
readonly cellIds: readonly string[];
} {
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) return { kind: 'help' };
if (argv[0] !== 'run' || !argv[1] || argv[1].startsWith('-')) throw new Error(USAGE);
let outDir: string | undefined;
const cellIds: string[] = [];
for (let index = 2; index < argv.length; index += 2) {
const flag = argv[index];
const value = argv[index + 1];
if (!value || value.startsWith('-')) throw new Error(`${flag} requires a value`);
if (flag === '--out') outDir = value;
else if (flag === '--cell') cellIds.push(value);
else throw new Error(`unexpected argument: ${flag}`);
}
if (!outDir) throw new Error('--out is required');
return { kind: 'run', specPath: argv[1], outDir, cellIds };
}