Files
PaperClipAI/tests/runner-e2e/testops-chat-reporter.mjs
T
DottaandPaperclip a1845b3b64 fix(evals): capture TestOps registration after watch (#18375)
## Thinking Path

> - Paperclip manages AI agents and verifies their work through Product
E2E tests.
> - The fixed Chat feature workflow prepares a TestOps case and plan
before any model run.
> - Its first zero-result bootstrap completed, but the watched child did
not receive the generated job-run ID.
> - The official watch log contained a job-run ID, so the missing child
variable did not prove that registration was absent.
> - This pull request reads official context after watch and validates
the linked backend records.
> - The workflow retains closed registration metadata and stops on
unknown or foreign context.

## Linked Issues or Issue Description

Refs: #18322.

**What happened?**
The successful zero-result bootstrap retained
`official_job_run_id_missing` from its watched child. It could not prove
the backend job and workflow binding needed for the next setup step.

**Expected behavior**
After successful watch, read the generated context, validate its current
GitHub run and fixed workflow, and retain only closed IDs and parameter
metadata.

**Steps to reproduce**
Run the fixed workflow's `bootstrap` operation on master. Inspect its
setup artifact after watch completes. No evaluation or synthetic passing
result is needed.

**Paperclip version or commit**
The original bootstrap used merged #18322. This follow-up starts from
`76fde891ccc29283b3f5c8dcdce089c5b3d937d1`.

**Deployment mode**
GitHub Actions metadata-only setup in the protected reporting
environment.

## What Changed

- Run an internal context reader after successful bootstrap watch in the
same protected step.
- Parse only numeric official job-run and launch IDs in memory. Do not
log or save raw command output.
- Validate current-run, project, launch, job, workflow, and bounded
parameter metadata before writing an exclusive registration receipt.
- Retain the two known registration receipt paths with the existing
setup artifact.
- Preserve the original inside-watch receipt, case reconciliation,
provider isolation, and evaluation limits.

## Verification

- `node --test tests/runner-e2e/chat-feature-testops-api.test.mjs
tests/runner-e2e/testops-chat-reporter.test.mjs`: 48 pure/mock/FS
controls pass (36 prior and 12 new). The actual local Node version was
26.4.0. Normal Node 24 CI remains required.
- Four MJS syntax checks and `git diff --check` pass. No TypeScript
noEmit claim is made for this MJS-only helper change.
- Actionlint passes for the changed workflow.
- Normal [GitHub Actions
CI](https://github.com/paperclipai/paperclip/actions/runs/41123894542)
passes on exact head `58396de10f0c49294b0bbc0f4709c29990d57d53`: 48 jobs
succeeded and one skipped. Both required `ci / e2e` and `ci / verify`
pass.
- Greptile reviewed that exact head at 5/5 with no actionable findings
or unresolved review threads.
- No local CLI, provider, model, app, database, or build qualification
was run. No replacement bootstrap or paid evaluation was dispatched.

## Risks

The official context or backend DTO shape can differ from the supported
format. The reader then stops with a closed failure receipt. Pure tests
do not qualify the remote CLI or backend. The original zero-result
bootstrap and its missing child-context observation remain part of the
history. The case's actual outcome remains unmeasured.

## Model Used

OpenAI GPT-6.1 Sol (`gpt-6.1-sol`) with reasoning, code editing, tool
use, and pure test review. The runtime does not expose the exact
context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-30 16:07:04 -05:00

132 lines
8.1 KiB
JavaScript

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { createHash } from 'node:crypto';
import { appendFile, lstat, readFile, realpath, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { publicContext, aliases, executionId, scenario, endpoint, testopsClient, bootstrapJobMetadata, failureProjection } from './chat-feature-testops-api.mjs';
import { run } from './chat-feature-testops-cli.mjs';
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
const fail = () => { throw new Error('chat_testops_selection_rejected'); };
async function readVerifiedSelection(file, env) {
const expected = publicContext(env);
if (!/^[a-f0-9]{64}$/.test(env.EXPECTED_SELECTION_SHA256 ?? '') || !/^[1-9][0-9]{0,15}$/.test(env.ALLURE_JOB_RUN_ID ?? '')) fail();
const root = await realpath(env.GITHUB_WORKSPACE ?? process.cwd()), absolute = path.resolve(file);
if (!absolute.startsWith(`${root}${path.sep}`)) fail();
let current = root;
const parts = path.relative(root, absolute).split(path.sep);
for (const [index, part] of parts.entries()) {
if (!/^[A-Za-z0-9._-]+$/.test(part) || ['.', '..'].includes(part)) fail();
current = path.join(current, part);
const stat = await lstat(current);
if (stat.isSymbolicLink() || (index < parts.length - 1 ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1 || stat.size > 16_384)) fail();
}
const bytes = await readFile(absolute);
if (hash(bytes) !== env.EXPECTED_SELECTION_SHA256) fail();
const value = JSON.parse(bytes);
if (value.schema !== 'paperclip.chat-feature-testops-selection/v1' || value.runId !== expected.runId || value.attempt !== '1'
|| value.workflowSha !== expected.workflowSha || value.targetSha !== expected.targetSha || value.allureJobRunId !== env.ALLURE_JOB_RUN_ID
|| value.executionId !== executionId || value.canonicalScenario !== scenario || value.projectId !== 1 || !aliases.includes(value.fullName)
|| ['planId', 'jobId', 'caseId', 'launchId'].some(key => !Number.isSafeInteger(value[key]) || value[key] <= 0)) fail();
return { value, bytes };
}
export async function verifySelectionFile(file, env) {
return (await readVerifiedSelection(file, env)).value;
}
const officialExec = promisify(execFile);
class BootstrapContextFailure extends Error {}
const rejectBootstrapContext = () => { throw new BootstrapContextFailure('chat_testops_bootstrap_context_invalid'); };
export function parseOfficialBootstrapContext(stdout) {
if (typeof stdout !== 'string' || Buffer.byteLength(stdout) > 16_384) rejectBootstrapContext();
const values = new Map();
for (const line of stdout.split(/\r?\n/)) {
for (const name of ['ALLURE_JOB_RUN_ID', 'ALLURE_LAUNCH_ID']) {
if (!line.startsWith(`${name}=`)) continue;
const value = line.slice(name.length + 1);
if (values.has(name) || !/^[1-9][0-9]{0,15}$/.test(value) || !Number.isSafeInteger(Number(value))) rejectBootstrapContext();
values.set(name, value);
}
}
if (values.size !== 2) rejectBootstrapContext();
return { jobRunId: values.get('ALLURE_JOB_RUN_ID'), launchId: Number(values.get('ALLURE_LAUNCH_ID')) };
}
async function captureBootstrapContext(env, fetchImpl, execImpl) {
let stateRoot;
try {
if (env.ALLURE_ENDPOINT !== endpoint || env.ALLURE_PROJECT_ID !== '1' || !env.RUNNER_TEMP
|| !env.GITHUB_WORKSPACE || !path.isAbsolute(env.GITHUB_WORKSPACE) || !env.GITHUB_EVENT_PATH) rejectBootstrapContext();
const eventStat = await lstat(env.GITHUB_EVENT_PATH);
if (!eventStat.isFile() || eventStat.isSymbolicLink() || eventStat.nlink !== 1 || eventStat.size > 65_536) rejectBootstrapContext();
const event = JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8'));
if (!event.inputs || Object.keys(event.inputs).some(key => !['operation', 'ALLURE_JOB_RUN_ID', 'ALLURE_USERNAME'].includes(key))
|| event.inputs.operation !== 'bootstrap' || (event.inputs.ALLURE_JOB_RUN_ID ?? '') !== '') rejectBootstrapContext();
const requested = path.join(await realpath(env.RUNNER_TEMP), 'chat-testops-bootstrap-state');
const stateStat = await lstat(requested);
if (!stateStat.isDirectory() || stateStat.isSymbolicLink() || await realpath(requested) !== requested
|| process.getuid && stateStat.uid !== process.getuid() || stateStat.mode & 0o077) rejectBootstrapContext();
stateRoot = requested;
for (const name of ['registration.json', 'registration-failure.json']) {
try { await lstat(path.join(stateRoot, name)); rejectBootstrapContext(); }
catch (error) { if (error.code !== 'ENOENT') throw error; }
}
let output;
try {
output = await execImpl('allurectl', ['job-run', 'env'], { cwd: await realpath(env.GITHUB_WORKSPACE), env,
shell: false, encoding: 'utf8', timeout: 30_000, maxBuffer: 16_384, windowsHide: true });
} catch { throw new BootstrapContextFailure('chat_testops_bootstrap_command_failed'); }
const captured = parseOfficialBootstrapContext(output.stdout);
const api = await testopsClient(env.ALLURE_TOKEN, fetchImpl);
const registration = await bootstrapJobMetadata(api, { ...env, ALLURE_JOB_RUN_ID: captured.jobRunId }, captured.launchId);
await writeFile(path.join(stateRoot, 'registration.json'), JSON.stringify(registration, null, 2), { flag: 'wx', mode: 0o600 });
return 0;
} catch (error) {
const safe = error instanceof BootstrapContextFailure
? { stage: 'bootstrap_context', code: error.message, httpStatus: null }
: failureProjection(error, 'bootstrap_context');
if (stateRoot) await writeFile(path.join(stateRoot, 'registration-failure.json'), JSON.stringify(safe, null, 2), { flag: 'wx', mode: 0o600 }).catch(() => {});
process.stderr.write(`Chat TestOps bootstrap context stopped: ${JSON.stringify(safe)}\n`);
return 1;
}
}
export async function reporter(command, argument, env = process.env, fetchImpl = fetch, execImpl = officialExec) {
publicContext(env);
if (command === 'bootstrap-context') return captureBootstrapContext(env, fetchImpl, execImpl);
if (command === 'verify-selection') {
if (Object.hasOwn(env, 'ALLURE_TOKEN')) fail();
await verifySelectionFile(argument, env);
return 0;
}
if (!['bootstrap', 'launch-plan', 'selection', 'upload', 'verify-upload'].includes(command) || !env.RUNNER_TEMP) fail();
const stage = command === 'selection' ? 'validate-selection' : command === 'upload' ? 'export' : command;
const stateRoot = path.join(env.RUNNER_TEMP, `chat-testops-${command}-state`);
const selectionPath = path.join(env.GITHUB_WORKSPACE ?? process.cwd(), 'chat-testops-selection', 'testops-selection.json');
let copiedSelectionPath = selectionPath;
if (command === 'upload' || command === 'verify-upload') {
const { bytes } = await readVerifiedSelection(selectionPath, env);
copiedSelectionPath = path.join(env.RUNNER_TEMP, 'chat-testops-authenticated-selection.json');
if (command === 'upload') await writeFile(copiedSelectionPath, bytes, { flag: 'wx', mode: 0o600 });
else if (!(await readFile(copiedSelectionPath)).equals(bytes)) fail();
}
const result = await run({ ...env, CHAT_TESTOPS_OPERATION: stage, CHAT_TESTOPS_STATE_ROOT: stateRoot,
...(['upload', 'verify-upload'].includes(command) ? { CHAT_TESTOPS_EXPORT_ROOT: path.resolve(argument), CHAT_TESTOPS_SELECTION: copiedSelectionPath,
ALLURE_RESULTS: env.ALLURE_RESULTS ?? path.join(env.RUNNER_TEMP, 'chat-testops-allure-results') } : {}) }, fetchImpl);
if (command === 'selection' && result === 0) {
const bytes = await readFile(path.join(stateRoot, 'selection.json'));
await writeFile(path.join(env.GITHUB_WORKSPACE ?? process.cwd(), 'testops-selection.json'), bytes, { flag: 'wx', mode: 0o600 });
if (!env.GITHUB_OUTPUT) fail();
await appendFile(env.GITHUB_OUTPUT, `sha256=${hash(bytes)}\n`);
}
return result;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
reporter(process.argv[2], process.argv[3]).then(code => { process.exitCode = code; })
.catch(() => { process.stderr.write('Chat TestOps reporter stopped: invalid source, custody or selection\n'); process.exitCode = 1; });
}