diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 000000000..5ae555ec4 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,74 @@ +# Relayflows evidence and benchmarks + +This directory separates two claims that must not be blended: + +1. `durability/suite.json` is a product conformance eval. It repeatedly executes + one independent Node black-box SIGKILL/CLI-resume case plus real Rust + crash/resume tests, and captures their literal output and provenance. It + shows which Relayflows invariants held for the tested commit and host. +2. `workflow-reliability/protocol.json` is a vendor-neutral competitive protocol. + It defines the same workloads and success predicates for Relayflows, a + build-it-yourself reference, Temporal, and Inngest. No comparative claim is + valid until the required participants have run the same pinned protocol. + +In this repository, **deterministic** means deterministic orchestration and +exactly-once declared effects under the tested faults. It does not mean an LLM +will emit identical text twice. Quality is measured separately by deterministic +gates and task-specific evals, matching RFC-0001 settled decision 11. + +The black-box case models loss of an execution environment by killing both the +daemon and its in-flight child process before resume. Killing only the daemon +while deliberately leaving its child alive is not environment loss: the old +attempt can continue beside its replacement, so that is a different workload +with different ownership requirements. + +## Run the durability eval + +From the repository root: + +```bash +node scripts/run-evals.mjs \ + --suite benchmarks/durability/suite.json \ + --output /tmp/relayflows-durability-v1.json +``` + +The runner refuses a dirty tree by default and requires report output to live +outside the source tree, so one result cannot silently dirty the next run. +`--allow-dirty` exists for local development, but permanently marks the artifact +`publicationStatus: ineligible` and exits nonzero. A publishable result also +requires the suite's minimum repetition count, zero failed, timed-out, invalid, +or inconclusive trials, and explicit output witnesses proving each selected +test actually ran. Every trial records +its argv, working directory, duration, outcome, exit code, stdout, and stderr; +the report records the suite hash, source commit, toolchain, and host class. +Missing Rust or Cargo version provenance blocks publication. + +Trial processes receive and record a small allowlist of ambient variables +(`HOME`, locale, `PATH`, temporary-directory variables, shell, and Relayflows +toolchain controls), plus any explicit per-case environment. Credentials and +unrelated ambient state are not inherited from the runner. Explicit per-case +environment values are recorded verbatim for reproducibility and therefore +must never contain credentials or other secrets. A command timeout is a product +failure blocker (`trial_timeouts`), not an environmental excuse. + +The black-box crash case stays on the supported CLI boundary. It obtains an +opaque run receipt from `run --stop-after 1`, resumes that run, kills the +workflow while the next step is in flight, then resumes again using the receipt. +It does not inspect journal files or storage layout. Its fixed, named assertion +set is emitted in the report, so the witness cannot pass by merely agreeing +with its own dynamic count. + +## Claim discipline + +- A passing durability artifact supports only its seven named claims. It is not a + general proof that every execution path is deterministic. +- A build-vs-buy claim requires both `relayflows` and `diy-reference` results. +- A “best in class” claim requires all participants named by the protocol. +- Latency, cost, and implementation-burden metrics stay separate. A composite + score requires preregistered weights for a named customer profile. +- Environment failures are inconclusive and must remain in the raw results. + +The next benchmark rung is implementing the platform-neutral scenario adapters, +starting with Relayflows and the DIY reference. The internal durability suite is +not reused as a competitor workload because doing so would privilege Relayflows' +implementation rather than test the shared behavioral contract. diff --git a/benchmarks/durability/black-box-crash.mjs b/benchmarks/durability/black-box-crash.mjs new file mode 100644 index 000000000..530279ee7 --- /dev/null +++ b/benchmarks/durability/black-box-crash.mjs @@ -0,0 +1,305 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { spawn, spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const EXPECTED_ASSERTIONS = 16; + +class EnvironmentError extends Error {} + +export async function runBlackBoxCrash(options = {}) { + if (process.platform === 'win32') { + throw new EnvironmentError('SIGKILL process semantics are required'); + } + const repoRoot = resolve(options.repoRoot ?? fileURLToPath(new URL('../..', import.meta.url))); + const binary = options.binary ?? resolveRelayflowd(repoRoot); + const fixture = mkdtempSync(join(tmpdir(), 'relayflows-black-box-')); + const dataDir = join(fixture, 'data'); + const effects = join(fixture, 'effects.txt'); + const attempts = join(fixture, 'attempts.txt'); + const gate = join(fixture, 'gate'); + const stepPid = join(fixture, 'step.pid'); + const specPath = join(fixture, 'flow.json'); + const assertions = []; + let child = null; + let inFlightPid = null; + + try { + writeFileSync( + specPath, + `${JSON.stringify(flowSpec({ effects, attempts, gate, stepPid }), null, 2)}\n`, + ); + const initial = spawnSync( + binary, + ['--data-dir', dataDir, 'run', specPath, '--stop-after', '1'], + { encoding: 'utf8', timeout: 30000 }, + ); + if (initial.error) throw new EnvironmentError(initial.error.message); + check(assertions, initial.status, 0, `initial run exited zero: ${initial.stderr}`); + const receipt = parseOutcome(initial.stdout); + check(assertions, receipt.status, 'interrupted', 'public run receipt reports interruption'); + check( + assertions, + receipt.completion_reason, + null, + 'interrupted receipt has no completion reason', + ); + check(assertions, receipt.completed_steps, 1, 'public run receipt reports one completed step'); + check( + assertions, + typeof receipt.run_id === 'string' && receipt.run_id.length > 0, + true, + 'public run receipt includes a run id', + ); + check(assertions, readLines(effects), ['first'], 'only step one completed before resume'); + + child = spawn( + binary, + ['--data-dir', dataDir, 'resume', receipt.run_id], + { detached: true, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let stdout = ''; + let stderr = ''; + let spawnError = null; + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', (error) => { + spawnError = error; + stderr += String(error); + }); + + await waitUntil('the resumed second step to be in flight', () => { + if (spawnError) throw new EnvironmentError(spawnError.message); + if (child.exitCode !== null) { + throw new Error(`relayflowd exited before the kill boundary: ${stdout}${stderr}`); + } + return readLines(attempts).join(',') === 'attempt' && readPid(stepPid) !== null; + }); + check(assertions, readLines(attempts), ['attempt'], 'step two was in flight before SIGKILL'); + inFlightPid = readPid(stepPid); + + const exited = once(child, 'exit'); + process.kill(-child.pid, 'SIGKILL'); + const [exitCode, signal] = await exited; + child = null; + check(assertions, exitCode, null, 'killed resume process has no numeric exit code'); + check(assertions, signal, 'SIGKILL', 'resume process observed SIGKILL'); + await killAndWait(inFlightPid); + check(assertions, isProcessAlive(inFlightPid), false, 'in-flight step process was killed'); + inFlightPid = null; + writeFileSync(gate, 'open\n'); + + const resumed = spawnSync(binary, ['--data-dir', dataDir, 'resume', receipt.run_id], { + encoding: 'utf8', + timeout: 30000, + }); + if (resumed.error) throw new EnvironmentError(resumed.error.message); + check(assertions, resumed.status, 0, `final resume exited zero: ${resumed.stderr}`); + const outcome = parseOutcome(resumed.stdout); + check(assertions, outcome.status, 'completed', 'final resume completed the run'); + check( + assertions, + outcome.completion_reason, + 'success', + 'completion reason is declared success', + ); + check(assertions, outcome.completed_steps, 3, 'all three steps completed'); + check( + assertions, + readLines(attempts), + ['attempt', 'attempt'], + 'the interrupted unfinished step executed a replacement attempt', + ); + check( + assertions, + readLines(effects), + ['first', 'second', 'third'], + 'the completed step was not replayed and final effects occurred once in dependency order', + ); + assert.equal(assertions.length, EXPECTED_ASSERTIONS, 'harness assertion count changed'); + + return { + assertionsPassed: assertions.length, + assertionsExpected: EXPECTED_ASSERTIONS, + assertionNames: assertions, + runStatus: outcome.status, + completionReason: outcome.completion_reason, + attempts: readLines(attempts).length, + effects: readLines(effects), + }; + } finally { + try { + await cleanupExecution(child, inFlightPid ?? readPid(stepPid)); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } + } +} + +export async function cleanupExecution(child, stepProcessId) { + await terminateProcessTree(child); + if (isProcessAlive(stepProcessId)) await killAndWait(stepProcessId); +} + +export async function terminateProcessTree(child) { + if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, 'exit'); + try { + process.kill(-child.pid, 'SIGKILL'); + } catch (error) { + if (error.code === 'ESRCH') return; + throw error; + } + await exited; +} + +export function parseOutcome(stdout) { + const lines = String(stdout).split('\n').map((line) => line.trim()).filter(Boolean).reverse(); + for (const line of lines) { + try { + const outcome = JSON.parse(line); + if (outcome && typeof outcome === 'object' && typeof outcome.run_id === 'string') { + return outcome; + } + } catch { + // Non-JSON CLI diagnostics are allowed; the last valid receipt is authoritative. + } + } + throw new Error(`relayflowd stdout did not contain a JSON run receipt: ${stdout}`); +} + +function resolveRelayflowd(repoRoot) { + const kernel = join(repoRoot, 'kernel'); + const cargo = join(repoRoot, 'ops', 'cargo.sh'); + runEnvironmentCommand(cargo, ['build', '-p', 'relayflowd'], kernel); + const metadata = runEnvironmentCommand( + cargo, + ['metadata', '--format-version', '1', '--no-deps'], + kernel, + ); + const targetDirectory = JSON.parse(metadata.stdout).target_directory; + return join(targetDirectory, 'debug', 'relayflowd'); +} + +function runEnvironmentCommand(file, args, cwd) { + const result = spawnSync(file, args, { cwd, encoding: 'utf8', timeout: 120000 }); + if (result.error || result.status !== 0) { + throw new EnvironmentError( + `${file} ${args.join(' ')} failed: ${result.error?.message ?? result.stderr ?? result.stdout}`, + ); + } + return result; +} + +function flowSpec({ effects, attempts, gate, stepPid }) { + const append = "require('node:fs').appendFileSync(process.argv[1], process.argv[2])"; + const step = (id, value, dependsOn) => ({ + id, + type: 'deterministic', + ...(dependsOn ? { depends_on: [dependsOn] } : {}), + command: [process.execPath, '-e', append, effects, `${value}\n`], + }); + const blockingSecond = [ + process.execPath, + '-e', + [ + "const fs=require('node:fs')", + "fs.appendFileSync(process.argv[3], 'attempt\\n')", + 'fs.writeFileSync(process.argv[4], String(process.pid))', + 'const wait=new Int32Array(new SharedArrayBuffer(4))', + 'while(!fs.existsSync(process.argv[2])) Atomics.wait(wait,0,0,20)', + "fs.appendFileSync(process.argv[1], 'second\\n')", + ].join(';'), + effects, + gate, + attempts, + stepPid, + ]; + return { + name: 'black-box-crash-resume', + steps: [ + step('first', 'first'), + { id: 'second', type: 'deterministic', depends_on: ['first'], command: blockingSecond }, + step('third', 'third', 'second'), + ], + }; +} + +function readLines(path) { + try { + return readFileSync(path, 'utf8').trimEnd().split('\n').filter(Boolean); + } catch { + return []; + } +} + +function readPid(path) { + try { + const pid = Number(readFileSync(path, 'utf8')); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +async function killAndWait(pid) { + if (!pid) throw new Error('in-flight step PID is missing'); + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + await waitUntil('in-flight step process exit', () => !isProcessAlive(pid)); +} + +function isProcessAlive(pid) { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error.code === 'ESRCH') return false; + throw error; + } +} + +async function waitUntil(description, predicate) { + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolvePromise) => setTimeout(resolvePromise, 20)); + } + throw new Error(`timed out waiting for ${description}`); +} + +function check(assertions, actual, expected, description) { + assert.deepEqual(actual, expected, description); + assertions.push(description); +} + +async function main() { + try { + const result = await runBlackBoxCrash(); + process.stdout.write( + `BLACK_BOX_ASSERTIONS: ${result.assertionsPassed}/${result.assertionsExpected}\n`, + ); + process.stdout.write(`${JSON.stringify(result)}\n`); + } catch (error) { + const environment = error instanceof EnvironmentError; + process.stderr.write( + `${environment ? 'BENCHMARK_ENVIRONMENT_FAILURE' : 'BLACK_BOX_ASSERTION_FAILURE'}: ${error.stack ?? error}\n`, + ); + process.exitCode = environment ? 2 : 1; + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) await main(); diff --git a/benchmarks/durability/suite.json b/benchmarks/durability/suite.json new file mode 100644 index 000000000..a65f28c62 --- /dev/null +++ b/benchmarks/durability/suite.json @@ -0,0 +1,117 @@ +{ + "schemaVersion": 1, + "kind": "relayflows-eval", + "id": "relayflows-durability-v1", + "defaultRepetitions": 30, + "minimumPublishableRepetitions": 30, + "environmentFailurePatterns": [ + "CARGO_NOT_FOUND", + "CARGO_BOOTSTRAP_FAILED", + "BENCHMARK_ENVIRONMENT_FAILURE", + "failed to download", + "Could not resolve host", + "No space left on device" + ], + "cases": [ + { + "id": "black-box-cli-sigkill-resume", + "claim": "An external caller can SIGKILL the CLI after one completed step while the next is in flight, then resume without replaying completed work while replacing the unfinished attempt.", + "command": { + "file": "node", + "args": ["black-box-crash.mjs"], + "cwd": ".", + "timeoutMs": 150000 + }, + "successWitnesses": [ + {"stream": "stdout", "contains": "BLACK_BOX_ASSERTIONS: 16/16"}, + {"stream": "stdout", "contains": "\"completionReason\":\"success\""}, + {"stream": "stdout", "contains": "\"attempts\":2"}, + {"stream": "stdout", "contains": "\"effects\":[\"first\",\"second\",\"third\"]"} + ] + }, + { + "id": "deterministic-boundary-resume", + "claim": "A deterministic three-step flow survives a kill at every boundary without replaying a completed effect.", + "command": { + "file": "../ops/cargo.sh", + "args": ["test", "-p", "relayflowd", "--test", "crash_resume", "sigkill_sweep_covers_every_hello_step_boundary", "--", "--exact", "--nocapture", "--test-threads=1"], + "cwd": "../../kernel", + "timeoutMs": 120000 + }, + "successWitnesses": [ + {"stream": "stdout", "contains": "test sigkill_sweep_covers_every_hello_step_boundary ... ok"}, + {"stream": "stdout", "contains": "test result: ok. 1 passed; 0 failed"} + ] + }, + { + "id": "llm-result-memoization", + "claim": "A completed llm result and its budget are reused after the server dies during the following step.", + "command": { + "file": "../ops/cargo.sh", + "args": ["test", "-p", "relayflowd", "--test", "crash_resume", "llm::completed_llm_output_is_memoized_when_serve_dies_during_the_next_step", "--", "--exact", "--nocapture", "--test-threads=1"], + "cwd": "../../kernel", + "timeoutMs": 120000 + }, + "successWitnesses": [ + {"stream": "stdout", "contains": "test llm::completed_llm_output_is_memoized_when_serve_dies_during_the_next_step ... ok"}, + {"stream": "stdout", "contains": "test result: ok. 1 passed; 0 failed"} + ] + }, + { + "id": "agent-effect-exactly-once", + "claim": "A crash between effect election and provider invocation produces exactly one declared external effect.", + "command": { + "file": "../ops/cargo.sh", + "args": ["test", "-p", "relayflowd", "--test", "crash_resume", "agent::rung_c_crash_between_effect_election_and_the_provider_call_performs_it_exactly_once", "--", "--exact", "--nocapture", "--test-threads=1"], + "cwd": "../../kernel", + "timeoutMs": 120000 + }, + "successWitnesses": [ + {"stream": "stdout", "contains": "test agent::rung_c_crash_between_effect_election_and_the_provider_call_performs_it_exactly_once ... ok"}, + {"stream": "stdout", "contains": "test result: ok. 1 passed; 0 failed"} + ] + }, + { + "id": "durable-channel-redelivery", + "claim": "An unacknowledged channel message is redelivered after SIGKILL while its external effect remains exactly once.", + "command": { + "file": "../ops/cargo.sh", + "args": ["test", "-p", "relayflowd", "--test", "crash_resume", "channels::channels_sigkill_resume_redelivers_unacked_messages_with_exactly_once_effects", "--", "--exact", "--nocapture", "--test-threads=1"], + "cwd": "../../kernel", + "timeoutMs": 120000 + }, + "successWitnesses": [ + {"stream": "stdout", "contains": "test channels::channels_sigkill_resume_redelivers_unacked_messages_with_exactly_once_effects ... ok"}, + {"stream": "stdout", "contains": "test result: ok. 1 passed; 0 failed"} + ] + }, + { + "id": "concurrent-resume-single-lease", + "claim": "Concurrent resume requests lease exactly one replacement attempt.", + "command": { + "file": "../ops/cargo.sh", + "args": ["test", "-p", "relayflowd", "--test", "crash_resume", "concurrency::concurrent_resumes_lease_exactly_one_attempt", "--", "--exact", "--nocapture", "--test-threads=1"], + "cwd": "../../kernel", + "timeoutMs": 120000 + }, + "successWitnesses": [ + {"stream": "stdout", "contains": "test concurrency::concurrent_resumes_lease_exactly_one_attempt ... ok"}, + {"stream": "stdout", "contains": "test result: ok. 1 passed; 0 failed"} + ] + }, + { + "id": "memory-budget-once", + "claim": "A crash after memory injection reuses the journaled pack and charges its tokens exactly once.", + "command": { + "file": "../ops/cargo.sh", + "args": ["test", "-p", "relayflowd", "--test", "crash_resume", "memory::memory_sigkill_after_injection_replays_pack_and_charges_it_once", "--", "--exact", "--nocapture", "--test-threads=1"], + "cwd": "../../kernel", + "timeoutMs": 120000 + }, + "successWitnesses": [ + {"stream": "stdout", "contains": "test memory::memory_sigkill_after_injection_replays_pack_and_charges_it_once ... ok"}, + {"stream": "stdout", "contains": "test result: ok. 1 passed; 0 failed"} + ] + } + ] +} diff --git a/benchmarks/workflow-reliability/protocol.json b/benchmarks/workflow-reliability/protocol.json new file mode 100644 index 000000000..c5c20d8b6 --- /dev/null +++ b/benchmarks/workflow-reliability/protocol.json @@ -0,0 +1,77 @@ +{ + "schemaVersion": 1, + "kind": "competitive-benchmark-protocol", + "id": "workflow-reliability-v1", + "claims": { + "buildVsBuy": { + "requiredParticipants": ["relayflows", "diy-reference"] + }, + "bestInClass": { + "requiredParticipants": ["relayflows", "diy-reference", "temporal", "inngest"] + } + }, + "fairnessControls": [ + "Pin every participant, runtime, model, prompt, tool set, and dependency version before the first measured run.", + "Use the same immutable workload fixtures and success predicates for every participant.", + "Run on the same isolated host class, randomize participant order, and separate cold-start from warm-run results.", + "Use at least 30 measured trials per scenario after five unmeasured warmups.", + "Capture raw event history, stdout, stderr, wall time, token use, provider cost, and operator interventions for every trial.", + "Publish failures and exclusions; an environment failure is inconclusive, never a product failure or pass.", + "Do not form a composite score until metric weights are preregistered for a named customer profile." + ], + "scenarios": [ + { + "id": "crash-between-steps", + "workload": "Complete step A, kill the workflow process before step B starts, restart, and finish the run.", + "successPredicates": ["step A external effect count equals 1", "step B external effect count equals 1", "run terminates with a declared completion reason"] + }, + { + "id": "crash-during-effect", + "workload": "Kill execution after an effect is durably elected but before the provider acknowledgement is recorded.", + "successPredicates": ["provider-observed effect count equals 1", "attempt history explains the interrupted attempt", "run completes without manual repair"] + }, + { + "id": "semantic-repair", + "workload": "Return a syntactically valid but quality-gate-failing agent result, then permit bounded repair attempts.", + "successPredicates": ["bad result is not promoted", "repair history is durable", "terminal state is either verified success or a declared verification failure"] + }, + { + "id": "durable-human-await", + "workload": "Reach a human approval, stop all processes for ten minutes, restart, deliver the approval, and finish.", + "successPredicates": ["no compute is required while waiting", "one approval resumes one continuation", "prior completed work is not repeated"] + }, + { + "id": "concurrent-resume-race", + "workload": "Issue 20 concurrent resume requests for one interrupted run.", + "successPredicates": ["one replacement attempt receives the lease", "all other requests are no-ops or typed conflicts", "external effects remain exactly once"] + }, + { + "id": "budget-accounting-after-crash", + "workload": "Crash after a model or memory result is committed and resume through completion.", + "successPredicates": ["resumed token total equals one execution per committed step", "resumed provider cost equals one execution per committed step", "journal attributes every charge to one step"] + } + ], + "metrics": { + "primary": [ + "scenario_success_rate", + "duplicate_external_effect_rate", + "undeclared_terminal_failure_rate", + "manual_interventions_per_100_runs" + ], + "performance": [ + "p50_wall_time_ms", + "p95_wall_time_ms", + "p50_recovery_time_ms", + "p95_recovery_time_ms", + "tokens_per_successful_run", + "provider_cost_per_successful_run" + ], + "buildBurden": [ + "preregistered_engineering_minutes", + "custom_orchestration_source_lines", + "custom_persistence_retry_and_dedupe_source_lines", + "runtime_dependencies", + "operator_minutes_per_100_runs" + ] + } +} diff --git a/scripts/benchmark-protocol.test.mjs b/scripts/benchmark-protocol.test.mjs new file mode 100644 index 000000000..dbd8e4909 --- /dev/null +++ b/scripts/benchmark-protocol.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +const protocol = JSON.parse( + readFileSync(new URL('../benchmarks/workflow-reliability/protocol.json', import.meta.url)), +); + +test('competitive claims name the participants required to support them', () => { + assert.deepEqual(protocol.claims.buildVsBuy.requiredParticipants, [ + 'relayflows', + 'diy-reference', + ]); + assert.deepEqual(protocol.claims.bestInClass.requiredParticipants, [ + 'relayflows', + 'diy-reference', + 'temporal', + 'inngest', + ]); +}); + +test('every workload has multiple implementation-neutral success predicates', () => { + assert.ok(protocol.scenarios.length >= 6); + for (const scenario of protocol.scenarios) { + assert.match(scenario.id, /^[a-z0-9]+(?:-[a-z0-9]+)*$/u); + assert.ok(scenario.workload.length > 20, scenario.id); + assert.ok(scenario.successPredicates.length >= 3, scenario.id); + assert.ok( + scenario.successPredicates.every( + (predicate) => typeof predicate === 'string' && predicate.length > 0, + ), + scenario.id, + ); + } +}); + +test('the protocol keeps correctness, performance, and build burden separate', () => { + assert.ok(protocol.metrics.primary.includes('duplicate_external_effect_rate')); + assert.ok(protocol.metrics.performance.includes('provider_cost_per_successful_run')); + assert.ok(protocol.metrics.buildBurden.includes('custom_persistence_retry_and_dedupe_source_lines')); + assert.ok( + protocol.fairnessControls.some((control) => control.includes('Do not form a composite score')), + ); +}); diff --git a/scripts/eval-provenance.mjs b/scripts/eval-provenance.mjs new file mode 100644 index 000000000..2e4e673d5 --- /dev/null +++ b/scripts/eval-provenance.mjs @@ -0,0 +1,51 @@ +import { spawnSync } from 'node:child_process'; +import { cpus, platform, release } from 'node:os'; +import { resolve } from 'node:path'; + +export function collectProvenance(rootDir, execute = spawnSync) { + const gitCommit = commandResult(execute, 'git', ['rev-parse', 'HEAD'], rootDir); + const gitStatus = commandResult(execute, 'git', ['status', '--porcelain=v1'], rootDir); + const gitRoot = commandResult(execute, 'git', ['rev-parse', '--show-toplevel'], rootDir); + const cpu = cpus(); + return { + source: { + commit: gitCommit.ok ? gitCommit.stdout : null, + root: gitRoot.ok ? resolve(gitRoot.stdout) : rootDir, + dirty: !gitCommit.ok || !gitStatus.ok || !gitRoot.ok || gitStatus.stdout.length > 0, + statusAvailable: gitStatus.ok, + statusPorcelain: gitStatus.stdout, + }, + runtime: { + node: process.version, + nodePath: process.execPath, + rustc: commandText(execute, 'rustc', ['--version'], rootDir) || null, + cargo: commandText(execute, resolve(rootDir, 'ops/cargo.sh'), ['--version'], rootDir) || null, + }, + host: { + platform: platform(), + release: release(), + arch: process.arch, + cpuModel: cpu[0]?.model ?? null, + logicalCpus: cpu.length, + }, + }; +} + +function commandText(execute, file, args, cwd) { + const result = execute(file, args, { cwd, encoding: 'utf8' }); + if (result.status !== 0) return ''; + return text(result.stdout).trim(); +} + +function commandResult(execute, file, args, cwd) { + const result = execute(file, args, { cwd, encoding: 'utf8' }); + return { + ok: result.status === 0, + stdout: result.status === 0 ? text(result.stdout).trim() : '', + }; +} + +function text(value) { + if (value === undefined || value === null) return ''; + return Buffer.isBuffer(value) ? value.toString('utf8') : String(value); +} diff --git a/scripts/eval-suite.mjs b/scripts/eval-suite.mjs new file mode 100644 index 000000000..5a946cad8 --- /dev/null +++ b/scripts/eval-suite.mjs @@ -0,0 +1,326 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync, realpathSync } from 'node:fs'; +import { dirname, isAbsolute, relative, resolve } from 'node:path'; +import { performance } from 'node:perf_hooks'; + +import { collectProvenance } from './eval-provenance.mjs'; + +const RESULT_SCHEMA_VERSION = 1; +const INHERITED_ENV_KEYS = Object.freeze([ + 'CARGO_HOME', + 'CARGO_TARGET_DIR', + 'HOME', + 'LANG', + 'LC_ALL', + 'PATH', + 'RELAYFLOWS_NO_TOOLCHAIN_INSTALL', + 'RELAYFLOWS_TOOLCHAIN_HOME', + 'RUSTUP_HOME', + 'RUSTUP_TOOLCHAIN', + 'SHELL', + 'TEMP', + 'TMP', + 'TMPDIR', +]); + +export function validateEvalSuite(value) { + if (!value || typeof value !== 'object') throw new Error('eval suite must be an object'); + if (value.schemaVersion !== 1) throw new Error('eval suite schemaVersion must be 1'); + if (value.kind !== 'relayflows-eval') throw new Error('eval suite kind must be relayflows-eval'); + requireNonEmptyString(value.id, 'eval suite id'); + requirePositiveInteger(value.defaultRepetitions, 'defaultRepetitions'); + requirePositiveInteger(value.minimumPublishableRepetitions, 'minimumPublishableRepetitions'); + if (!Array.isArray(value.environmentFailurePatterns)) { + throw new Error('environmentFailurePatterns must be an array'); + } + value.environmentFailurePatterns.forEach((pattern, index) => + requireNonEmptyString(pattern, `environmentFailurePatterns[${index}]`), + ); + if (!Array.isArray(value.cases) || value.cases.length === 0) { + throw new Error('eval suite cases must be a non-empty array'); + } + + const ids = new Set(); + for (const [caseIndex, testCase] of value.cases.entries()) { + validateCase(testCase, caseIndex, ids); + ids.add(testCase.id); + } + return value; +} + +function validateCase(testCase, caseIndex, ids) { + if (!testCase || typeof testCase !== 'object') { + throw new Error(`cases[${caseIndex}] must be an object`); + } + requireNonEmptyString(testCase.id, `cases[${caseIndex}].id`); + if (ids.has(testCase.id)) throw new Error(`duplicate eval case id: ${testCase.id}`); + requireNonEmptyString(testCase.claim, `cases[${caseIndex}].claim`); + if (!testCase.command || typeof testCase.command !== 'object') { + throw new Error(`cases[${caseIndex}].command must be an object`); + } + requireNonEmptyString(testCase.command.file, `cases[${caseIndex}].command.file`); + if (!Array.isArray(testCase.command.args) || !testCase.command.args.every(isString)) { + throw new Error(`cases[${caseIndex}].command.args must be an array of strings`); + } + requireNonEmptyString(testCase.command.cwd, `cases[${caseIndex}].command.cwd`); + requirePositiveInteger(testCase.command.timeoutMs, `cases[${caseIndex}].command.timeoutMs`); + if (testCase.command.env !== undefined) validateCommandEnv(testCase.command.env, caseIndex); + if (!Array.isArray(testCase.successWitnesses) || testCase.successWitnesses.length === 0) { + throw new Error(`cases[${caseIndex}].successWitnesses must be a non-empty array`); + } + for (const [witnessIndex, witness] of testCase.successWitnesses.entries()) { + if (!witness || !['stdout', 'stderr'].includes(witness.stream)) { + throw new Error( + `cases[${caseIndex}].successWitnesses[${witnessIndex}].stream must be stdout or stderr`, + ); + } + requireNonEmptyString( + witness.contains, + `cases[${caseIndex}].successWitnesses[${witnessIndex}].contains`, + ); + } +} + +function validateCommandEnv(env, caseIndex) { + if (!env || typeof env !== 'object' || Array.isArray(env)) { + throw new Error(`cases[${caseIndex}].command.env must be an object`); + } + for (const [key, value] of Object.entries(env)) { + requireNonEmptyString(key, `cases[${caseIndex}].command.env key`); + if (typeof value !== 'string') { + throw new Error(`cases[${caseIndex}].command.env.${key} must be a string`); + } + } +} + +export function runEvalSuite(options) { + const rootDir = realpathSync(resolve(options.rootDir ?? process.cwd())); + const suitePath = realpathSync(resolve(rootDir, options.suitePath)); + if (!isWithin(rootDir, suitePath)) { + throw new Error('eval suite path escapes the provenance root'); + } + const suiteBytes = readFileSync(suitePath); + const suite = validateEvalSuite(JSON.parse(suiteBytes.toString('utf8'))); + const repetitions = options.repetitions ?? suite.defaultRepetitions; + if (!Number.isInteger(repetitions) || repetitions < 1) { + throw new Error('repetitions must be a positive integer'); + } + + const execute = options.execute ?? spawnSync; + const provenance = options.provenance ?? collectProvenance(rootDir, execute); + validateProvenance(provenance, rootDir, options.allowDirty); + const environment = trialEnvironment(options.parentEnv ?? process.env); + const startedAt = new Date().toISOString(); + const cases = suite.cases.map((testCase) => + runCase({ testCase, suite, suitePath, rootDir, repetitions, execute, environment }), + ); + return buildReport({ options, suite, suiteBytes, cases, repetitions, provenance, startedAt }); +} + +function validateProvenance(provenance, rootDir, allowDirty) { + if (realpathSync(resolve(provenance.source.root)) !== rootDir) { + throw new Error( + `provenance root ${provenance.source.root} does not match requested root ${rootDir}`, + ); + } + if (provenance.source.dirty && !allowDirty) { + throw new Error( + 'refusing to run a publishable eval from a dirty tree; commit it or pass --allow-dirty', + ); + } +} + +function runCase({ testCase, suite, suitePath, rootDir, repetitions, execute, environment }) { + const commandCwd = realpathSync(resolve(dirname(suitePath), testCase.command.cwd)); + if (!isWithin(rootDir, commandCwd)) { + throw new Error(`case ${testCase.id} command cwd escapes the provenance root`); + } + const commandEnvironment = trialEnvironment(environment, testCase.command.env); + const trials = Array.from({ length: repetitions }, (_, index) => + runTrial({ + testCase, + repetition: index + 1, + commandCwd, + execute, + environment: commandEnvironment, + environmentFailurePatterns: suite.environmentFailurePatterns, + }), + ); + return summarizeCase(testCase, trials); +} + +function runTrial({ + testCase, + repetition, + commandCwd, + execute, + environment, + environmentFailurePatterns, +}) { + const before = performance.now(); + const result = execute(testCase.command.file, testCase.command.args, { + cwd: commandCwd, + encoding: 'utf8', + timeout: testCase.command.timeoutMs, + maxBuffer: 64 * 1024 * 1024, + env: environment, + }); + const durationMs = Math.round((performance.now() - before) * 1000) / 1000; + const stdout = text(result.stdout); + const stderr = text(result.stderr); + const missingWitnesses = testCase.successWitnesses.filter( + (witness) => !{ stdout, stderr }[witness.stream].includes(witness.contains), + ); + const outcome = classifyTrial({ + result, + stdout, + stderr, + missingWitnesses, + environmentFailurePatterns, + }); + return { + repetition, + outcome, + passed: outcome === 'passed', + exitCode: result.status, + signal: result.signal ?? null, + durationMs, + command: { + file: testCase.command.file, + args: testCase.command.args, + cwd: testCase.command.cwd, + resolvedCwd: commandCwd, + environment, + }, + stdout, + stderr, + missingWitnesses, + error: result.error ? String(result.error.message ?? result.error) : null, + }; +} + +function classifyTrial({ result, stdout, stderr, missingWitnesses, environmentFailurePatterns }) { + if (result.status === 0) return missingWitnesses.length === 0 ? 'passed' : 'invalid'; + if (result.error?.code === 'ETIMEDOUT') return 'timed_out'; + if (result.error) return 'inconclusive'; + const combined = `${stdout}\n${stderr}\n${result.error?.message ?? ''}`; + if ( + result.status === 127 || + environmentFailurePatterns.some((pattern) => combined.includes(pattern)) + ) { + return 'inconclusive'; + } + return 'failed'; +} + +function summarizeCase(testCase, trials) { + const durations = trials.filter((trial) => trial.passed).map((trial) => trial.durationMs); + const count = (outcome) => trials.filter((trial) => trial.outcome === outcome).length; + const passedTrials = count('passed'); + return { + id: testCase.id, + claim: testCase.claim, + passedTrials, + failedTrials: count('failed'), + timedOutTrials: count('timed_out'), + invalidTrials: count('invalid'), + inconclusiveTrials: count('inconclusive'), + totalTrials: trials.length, + passRate: passedTrials / trials.length, + durationMs: { + sampleSize: durations.length, + p50: durations.length > 0 ? percentile(durations, 0.5) : null, + p95: durations.length >= 20 ? percentile(durations, 0.95) : null, + }, + trials, + }; +} + +function buildReport({ options, suite, suiteBytes, cases, repetitions, provenance, startedAt }) { + const count = (field) => cases.reduce((sum, testCase) => sum + testCase[field], 0); + const summary = { + passedTrials: count('passedTrials'), + failedTrials: count('failedTrials'), + timedOutTrials: count('timedOutTrials'), + invalidTrials: count('invalidTrials'), + inconclusiveTrials: count('inconclusiveTrials'), + totalTrials: count('totalTrials'), + }; + const blockers = publicationBlockers({ ...summary, repetitions, suite, provenance }); + return { + schemaVersion: RESULT_SCHEMA_VERSION, + kind: 'relayflows-eval-result', + suite: { + id: suite.id, + path: options.suitePath, + sha256: createHash('sha256').update(suiteBytes).digest('hex'), + }, + implementation: options.implementation ?? 'relayflows', + startedAt, + completedAt: new Date().toISOString(), + repetitions, + provenance, + summary: { + ...summary, + passRate: summary.passedTrials / summary.totalTrials, + publicationStatus: blockers.length === 0 ? 'eligible' : 'ineligible', + blockers, + }, + cases, + }; +} + +function publicationBlockers(summary) { + const blockers = []; + if (summary.provenance.source.dirty) blockers.push('source_tree_dirty'); + if (summary.repetitions < summary.suite.minimumPublishableRepetitions) { + blockers.push('insufficient_repetitions'); + } + if (!summary.provenance.runtime.rustc || !summary.provenance.runtime.cargo) { + blockers.push('toolchain_provenance_unavailable'); + } + if (summary.failedTrials > 0) blockers.push('trial_failures'); + if (summary.timedOutTrials > 0) blockers.push('trial_timeouts'); + if (summary.invalidTrials > 0) blockers.push('execution_witness_missing'); + if (summary.inconclusiveTrials > 0) blockers.push('environment_failures'); + return blockers; +} + +export function trialEnvironment(parentEnv, commandEnv = {}) { + const environment = {}; + for (const key of INHERITED_ENV_KEYS) { + if (parentEnv[key] !== undefined) environment[key] = String(parentEnv[key]); + } + return { ...environment, ...commandEnv, CARGO_TERM_COLOR: 'never' }; +} + +function percentile(values, quantile) { + const sorted = [...values].sort((left, right) => left - right); + const index = Math.max(0, Math.ceil(sorted.length * quantile) - 1); + return sorted[index]; +} + +export function isWithin(parent, child) { + const path = relative(parent, child); + return path === '' || (!path.startsWith('..') && !isAbsolute(path)); +} + +function requireNonEmptyString(value, field) { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${field} must be a non-empty string`); + } +} + +function requirePositiveInteger(value, field) { + if (!Number.isInteger(value) || value < 1) throw new Error(`${field} must be a positive integer`); +} + +function isString(value) { + return typeof value === 'string'; +} + +function text(value) { + if (value === undefined || value === null) return ''; + return Buffer.isBuffer(value) ? value.toString('utf8') : String(value); +} diff --git a/scripts/run-evals.mjs b/scripts/run-evals.mjs new file mode 100644 index 000000000..c29977ff3 --- /dev/null +++ b/scripts/run-evals.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node + +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isWithin, runEvalSuite } from './eval-suite.mjs'; + +export function parseArgs(argv) { + const options = { rootDir: process.cwd(), allowDirty: false }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--allow-dirty') { + options.allowDirty = true; + continue; + } + if (!['--suite', '--output', '--implementation', '--repeat', '--root'].includes(arg)) { + throw new Error(`unknown option: ${arg}`); + } + const value = argv[index + 1]; + if (value === undefined || value.length === 0) throw new Error(`missing value for ${arg}`); + index += 1; + if (arg === '--suite') options.suitePath = value; + if (arg === '--output') options.output = value; + if (arg === '--implementation') options.implementation = value; + if (arg === '--repeat') options.repetitions = Number(value); + if (arg === '--root') options.rootDir = value; + } + if (!options.suitePath) throw new Error('missing required option --suite'); + if ( + options.repetitions !== undefined && + (!Number.isInteger(options.repetitions) || options.repetitions < 1) + ) { + throw new Error('--repeat must be a positive integer'); + } + return options; +} + +export function reportExitCode(report) { + return report.summary.publicationStatus === 'eligible' ? 0 : 1; +} + +export function validateOutputPath(rootDir, output) { + if (output && isWithin(resolve(rootDir), resolve(rootDir, output))) { + throw new Error('report output must be outside the source tree so it cannot dirty later runs'); + } +} + +function main() { + try { + const options = parseArgs(process.argv.slice(2)); + validateOutputPath(options.rootDir, options.output); + const report = runEvalSuite(options); + const json = `${JSON.stringify(report, null, 2)}\n`; + if (options.output) writeFileSync(resolve(options.rootDir, options.output), json); + else process.stdout.write(json); + process.stderr.write( + `${report.suite.id}: ${report.summary.passedTrials}/${report.summary.totalTrials} trials passed; ` + + `publication ${report.summary.publicationStatus}\n`, + ); + process.exitCode = reportExitCode(report); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) main(); diff --git a/scripts/run-evals.test.mjs b/scripts/run-evals.test.mjs new file mode 100644 index 000000000..2dffcc6d9 --- /dev/null +++ b/scripts/run-evals.test.mjs @@ -0,0 +1,403 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { + mkdtempSync, + mkdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { + parseOutcome, + terminateProcessTree, +} from '../benchmarks/durability/black-box-crash.mjs'; +import { collectProvenance } from './eval-provenance.mjs'; +import { runEvalSuite, trialEnvironment, validateEvalSuite } from './eval-suite.mjs'; +import { parseArgs, reportExitCode, validateOutputPath } from './run-evals.mjs'; + +function suite(overrides = {}) { + return { + schemaVersion: 1, + kind: 'relayflows-eval', + id: 'fixture-v1', + defaultRepetitions: 2, + minimumPublishableRepetitions: 2, + environmentFailurePatterns: ['TOOLCHAIN_UNAVAILABLE'], + cases: [ + { + id: 'resume', + claim: 'completed work is not repeated', + command: { file: 'fixture', args: ['resume'], cwd: '../..', timeoutMs: 1000 }, + successWitnesses: [{ stream: 'stdout', contains: 'executed resume' }], + }, + ], + ...overrides, + }; +} + +function withSuite(value, verify) { + const root = mkdtempSync(join(tmpdir(), 'relayflows-eval-')); + try { + const directory = join(root, 'benchmarks', 'fixture'); + mkdirSync(directory, { recursive: true }); + const path = join(directory, 'suite.json'); + writeFileSync(path, `${JSON.stringify(value)}\n`); + verify({ root, path: 'benchmarks/fixture/suite.json' }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function provenance(root, dirty = false) { + return { + source: { + commit: 'a'.repeat(40), + root, + dirty, + statusAvailable: true, + statusPorcelain: dirty ? ' M file' : '', + }, + runtime: { node: 'v1', rustc: 'rustc 1', cargo: 'cargo 1' }, + host: { platform: 'test', release: '1', arch: 'test', cpuModel: 'test', logicalCpus: 1 }, + }; +} + +test('rejects duplicate case ids', () => { + const duplicate = suite(); + duplicate.cases.push({ ...duplicate.cases[0] }); + assert.throws(() => validateEvalSuite(duplicate), /duplicate eval case id/u); +}); + +test('validation names the malformed field', () => { + const malformed = suite(); + malformed.cases[0].command.timeoutMs = 0; + assert.throws( + () => validateEvalSuite(malformed), + /cases\[0\]\.command\.timeoutMs must be a positive integer/u, + ); +}); + +test('fails closed on a dirty tree by default', () => { + withSuite(suite(), ({ root, path }) => { + assert.throws( + () => runEvalSuite({ rootDir: root, suitePath: path, provenance: provenance(root, true) }), + /refusing to run a publishable eval from a dirty tree/u, + ); + }); +}); + +test('captures literal command output and marks an allowed dirty run ineligible', () => { + withSuite(suite(), ({ root, path }) => { + const report = runEvalSuite({ + rootDir: root, + suitePath: path, + provenance: provenance(root, true), + allowDirty: true, + execute: () => ({ + status: 0, + signal: null, + stdout: 'executed resume\nliteral stdout\n', + stderr: 'literal stderr\n', + }), + }); + assert.equal(report.summary.passRate, 1); + assert.equal(report.summary.publicationStatus, 'ineligible'); + assert.deepEqual(report.summary.blockers, ['source_tree_dirty']); + assert.equal(report.cases[0].trials[0].stdout, 'executed resume\nliteral stdout\n'); + assert.equal(report.cases[0].trials[0].stderr, 'literal stderr\n'); + assert.equal(report.cases[0].trials[0].command.cwd, '../..'); + assert.equal(report.cases[0].trials[0].command.resolvedCwd, realpathSync(root)); + }); +}); + +test('reports a failed trial without erasing its exit evidence', () => { + withSuite(suite({ defaultRepetitions: 1, minimumPublishableRepetitions: 1 }), ({ root, path }) => { + const report = runEvalSuite({ + rootDir: root, + suitePath: path, + provenance: provenance(root, false), + execute: () => ({ status: 9, signal: null, stdout: 'partial\n', stderr: 'failed\n' }), + }); + assert.equal(report.summary.publicationStatus, 'ineligible'); + assert.deepEqual(report.summary.blockers, ['trial_failures']); + assert.equal(report.cases[0].trials[0].exitCode, 9); + assert.equal(report.cases[0].trials[0].stderr, 'failed\n'); + }); +}); + +test('only clean, passing, sufficiently repeated evidence is publication eligible', () => { + withSuite(suite(), ({ root, path }) => { + const report = runEvalSuite({ + rootDir: root, + suitePath: path, + provenance: provenance(root, false), + execute: () => ({ status: 0, signal: null, stdout: 'executed resume\n', stderr: '' }), + }); + assert.equal(report.summary.publicationStatus, 'eligible'); + assert.deepEqual(report.summary.blockers, []); + assert.equal(report.summary.totalTrials, 2); + assert.equal(report.cases[0].durationMs.p95, null); + assert.match(report.suite.sha256, /^[a-f0-9]{64}$/u); + }); +}); + +test('zero exit without the execution witness is invalid, never passing', () => { + withSuite(suite({ defaultRepetitions: 1, minimumPublishableRepetitions: 1 }), ({ root, path }) => { + const report = runEvalSuite({ + rootDir: root, + suitePath: path, + provenance: provenance(root, false), + execute: () => ({ status: 0, signal: null, stdout: 'running 0 tests\n', stderr: '' }), + }); + assert.equal(report.cases[0].trials[0].outcome, 'invalid'); + assert.equal(report.summary.invalidTrials, 1); + assert.deepEqual(report.summary.blockers, ['execution_witness_missing']); + }); +}); + +test('a recognized toolchain failure is inconclusive rather than a product failure', () => { + withSuite(suite({ defaultRepetitions: 1, minimumPublishableRepetitions: 1 }), ({ root, path }) => { + const report = runEvalSuite({ + rootDir: root, + suitePath: path, + provenance: provenance(root, false), + execute: () => ({ + status: 127, + signal: null, + stdout: '', + stderr: 'TOOLCHAIN_UNAVAILABLE\n', + }), + }); + assert.equal(report.cases[0].trials[0].outcome, 'inconclusive'); + assert.deepEqual(report.summary.blockers, ['environment_failures']); + }); +}); + +test('a timed-out command is a product failure blocker, not an environment failure', () => { + withSuite(suite({ defaultRepetitions: 1, minimumPublishableRepetitions: 1 }), ({ root, path }) => { + const error = Object.assign(new Error('spawnSync fixture ETIMEDOUT'), { code: 'ETIMEDOUT' }); + const report = runEvalSuite({ + rootDir: root, + suitePath: path, + provenance: provenance(root, false), + execute: () => ({ status: null, signal: 'SIGTERM', stdout: '', stderr: '', error }), + }); + assert.equal(report.cases[0].trials[0].outcome, 'timed_out'); + assert.equal(report.summary.timedOutTrials, 1); + assert.deepEqual(report.summary.blockers, ['trial_timeouts']); + }); +}); + +test('a command launch error is inconclusive, not a product failure', () => { + withSuite(suite({ defaultRepetitions: 1, minimumPublishableRepetitions: 1 }), ({ root, path }) => { + const error = Object.assign(new Error('spawnSync missing ENOENT'), { code: 'ENOENT' }); + const report = runEvalSuite({ + rootDir: root, + suitePath: path, + provenance: provenance(root, false), + execute: () => ({ status: null, signal: null, stdout: '', stderr: '', error }), + }); + assert.equal(report.cases[0].trials[0].outcome, 'inconclusive'); + assert.equal(report.summary.failedTrials, 0); + assert.deepEqual(report.summary.blockers, ['environment_failures']); + }); +}); + +test('missing toolchain provenance blocks publication', () => { + withSuite(suite({ defaultRepetitions: 1, minimumPublishableRepetitions: 1 }), ({ root, path }) => { + const incomplete = provenance(root, false); + incomplete.runtime.rustc = null; + const report = runEvalSuite({ + rootDir: root, + suitePath: path, + provenance: incomplete, + execute: () => ({ status: 0, signal: null, stdout: 'executed resume\n', stderr: '' }), + }); + assert.equal(report.summary.publicationStatus, 'ineligible'); + assert.deepEqual(report.summary.blockers, ['toolchain_provenance_unavailable']); + }); +}); + +test('trial environments are allowlisted and recorded without ambient secrets', () => { + const environment = trialEnvironment( + { + CARGO_HOME: '/toolchain/cargo', + HOME: '/home/eval', + PATH: '/bin', + RELAY_API_KEY: 'secret', + RUSTUP_TOOLCHAIN: 'stable', + UNKNOWN: 'not-recorded', + }, + { CARGO_TERM_COLOR: 'always', FIXTURE_MODE: 'deterministic' }, + ); + assert.deepEqual(environment, { + CARGO_HOME: '/toolchain/cargo', + CARGO_TERM_COLOR: 'never', + HOME: '/home/eval', + PATH: '/bin', + RUSTUP_TOOLCHAIN: 'stable', + FIXTURE_MODE: 'deterministic', + }); + + withSuite(suite({ defaultRepetitions: 1, minimumPublishableRepetitions: 1 }), ({ root, path }) => { + let observedEnvironment; + const report = runEvalSuite({ + rootDir: root, + suitePath: path, + provenance: provenance(root, false), + parentEnv: { HOME: '/home/eval', PATH: '/bin', RELAY_API_KEY: 'secret' }, + execute: (_file, _args, options) => { + observedEnvironment = options.env; + return { status: 0, signal: null, stdout: 'executed resume\n', stderr: '' }; + }, + }); + assert.deepEqual(observedEnvironment, { + CARGO_TERM_COLOR: 'never', + HOME: '/home/eval', + PATH: '/bin', + }); + assert.deepEqual(report.cases[0].trials[0].command.environment, observedEnvironment); + assert.equal('RELAY_API_KEY' in report.cases[0].trials[0].command.environment, false); + }); +}); + +test('case cwd resolves relative to the suite and cannot escape the provenance root', () => { + withSuite(suite(), ({ root, path }) => { + const observed = []; + runEvalSuite({ + rootDir: root, + suitePath: path, + provenance: provenance(root, false), + execute: (_file, _args, options) => { + observed.push(options.cwd); + return { status: 0, signal: null, stdout: 'executed resume\n', stderr: '' }; + }, + }); + assert.deepEqual(observed, [realpathSync(root), realpathSync(root)]); + + const escaping = suite(); + escaping.cases[0].command.cwd = '../../..'; + writeFileSync(join(root, path), `${JSON.stringify(escaping)}\n`); + assert.throws( + () => + runEvalSuite({ + rootDir: root, + suitePath: path, + provenance: provenance(root, false), + execute: () => ({ status: 0, stdout: 'executed resume\n', stderr: '' }), + }), + /command cwd escapes the provenance root/u, + ); + }); +}); + +test('case cwd cannot escape the provenance root through a symlink', (testContext) => { + const external = mkdtempSync(join(tmpdir(), 'relayflows-eval-external-')); + try { + const value = suite({ defaultRepetitions: 1, minimumPublishableRepetitions: 1 }); + value.cases[0].command.cwd = 'escape'; + withSuite(value, ({ root, path }) => { + try { + symlinkSync(external, join(root, 'benchmarks', 'fixture', 'escape'), 'dir'); + } catch (error) { + if (['EACCES', 'EPERM'].includes(error.code)) { + testContext.skip(`host denied directory symlink creation: ${error.code}`); + return; + } + throw error; + } + assert.throws( + () => + runEvalSuite({ + rootDir: root, + suitePath: path, + provenance: provenance(root, false), + execute: () => ({ status: 0, stdout: 'executed resume\n', stderr: '' }), + }), + /command cwd escapes the provenance root/u, + ); + }); + } finally { + rmSync(external, { recursive: true, force: true }); + } +}); + +test('collectProvenance exercises clean, dirty, and unavailable git state', () => { + const root = '/work/repo'; + const execute = (_file, args) => { + const key = args.join(' '); + if (key === 'rev-parse HEAD') return { status: 0, stdout: `${'b'.repeat(40)}\n` }; + if (key === 'status --porcelain=v1') return { status: 0, stdout: '' }; + if (key === 'rev-parse --show-toplevel') return { status: 0, stdout: `${root}\n` }; + return { status: 0, stdout: 'tool 1\n' }; + }; + const clean = collectProvenance(root, execute); + assert.equal(clean.source.dirty, false); + assert.equal(clean.source.commit, 'b'.repeat(40)); + assert.equal(clean.source.root, root); + + const dirty = collectProvenance(root, (file, args, options) => { + if (args.join(' ') === 'status --porcelain=v1') return { status: 0, stdout: ' M file\n' }; + return execute(file, args, options); + }); + assert.equal(dirty.source.dirty, true); + assert.equal(dirty.source.statusPorcelain, 'M file'); + + const unavailable = collectProvenance(root, () => ({ status: 128, stdout: '', stderr: 'not a repo' })); + assert.equal(unavailable.source.dirty, true); + assert.equal(unavailable.source.commit, null); + assert.equal(unavailable.source.statusAvailable, false); +}); + +test('CLI parsing and exit policy fail closed for nonpublishable evidence', () => { + assert.deepEqual(parseArgs(['--suite', 'suite.json', '--repeat', '30', '--allow-dirty']), { + rootDir: process.cwd(), + allowDirty: true, + suitePath: 'suite.json', + repetitions: 30, + }); + assert.throws(() => parseArgs(['--unknown']), /unknown option/u); + assert.throws( + () => parseArgs(['--suite', 'suite.json', '--implementation', '']), + /missing value for --implementation/u, + ); + assert.throws(() => parseArgs(['--suite', 'suite.json', '--repeat', '0']), /--repeat must be a positive integer/u); + assert.equal(reportExitCode({ summary: { publicationStatus: 'eligible' } }), 0); + assert.equal(reportExitCode({ summary: { publicationStatus: 'ineligible' } }), 1); + assert.throws( + () => validateOutputPath('/work/repo', 'evidence/result.json'), + /output must be outside the source tree/u, + ); + assert.doesNotThrow(() => validateOutputPath('/work/repo', '/tmp/result.json')); +}); + +test('black-box receipt parsing tolerates diagnostics but requires a public run receipt', () => { + assert.deepEqual(parseOutcome('diagnostic\n{"run_id":"run-1","status":"completed"}\n'), { + run_id: 'run-1', + status: 'completed', + }); + assert.throws(() => parseOutcome('diagnostic only\n'), /did not contain a JSON run receipt/u); +}); + +test( + 'black-box cleanup terminates a detached workflow process', + { skip: process.platform === 'win32' }, + async () => { + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + detached: true, + stdio: 'ignore', + }); + await new Promise((resolvePromise, reject) => { + child.once('spawn', resolvePromise); + child.once('error', reject); + }); + await terminateProcessTree(child); + assert.equal(child.exitCode, null); + assert.equal(child.signalCode, 'SIGKILL'); + }, +);