mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Direct Runner evals retain evidence across model configurations. > - Evalbook already has a grid and a read-only Runner Lab chat viewer. > - Public projection stripped the view and selected a second plain result page. > - This change uses the existing viewer for public and private results. > - The data access differs, but the presentation does not. ## Linked Issues or Issue Description Refs #12931, #12945. Related open runtime-contract PR #11634 does not contain this report-only change. **What happened?** The public direct-eval campaign opened plain result pages. The access-controlled artifact used the chat viewer. Users could not follow the same recorded interaction from the published grid. **Expected behavior** Every newly generated Runner Evalbook opens the existing chat viewer. The grid and durable run history remain. Public evidence has explicit redactions. **Steps to reproduce** Open campaign gha-34062394019-1 from the direct-eval history. Click a result, then compare its plain page with the corresponding Actions artifact. **Paperclip version or commit** Reproduced at83987210d6. **Deployment mode** Static GitHub Actions artifacts and S3/CloudFront publication. Companion site-theme renderer: https://github.com/paperclipai/paperclip-evals/pull/19. This removes the Python light theme and links the same built stylesheet. ## What Changed - Add a closed public chat projection. Require mock isolation evidence before publishing recorded text. Scrub private references and withhold tool payloads, reasoning and provider state. - Validate public HTML against the exact trusted viewer shell and asset bytes. Validate the public DTO and local links. Keep CSP restrictions on outbound requests and forms. - Make the workflow render both data projections with the canonical viewer. Pass a viewer-only artifact to the trusted publisher. Reject an old renderer pin before paid execution. - Fix report-only start position, missing-state inspector, read-only controls and redaction labels. Tool evidence links select and highlight the Evidence tab even when reopened. Runner execution and the full-stack E2E workflow are unchanged. - Add a no-model report refresh command. Preserve original campaign identity, measurements and immutable history; label report revisions. - Document the single presentation and public/private evidence boundary. - Use one Runner Lab stylesheet and local fonts for the grid, Latest, test design, inventory, server gate and S3 history index. Keep static styles scoped away from live chat. - Emit exact published report/history URLs to the Actions summary and job outputs only after successful upload. Set the deployment link from that output. - Switch public Eval and Evidence panes without rendering both at once. - Add all-run history with like-for-like pass-rate and cost timelines, regression/recovery lists, exact commit links, source refs and Actions links. - Record all-attempt costs including retries. Keep provider list cost separate from estimates. Label missing coverage and historical final-only costs. - Retain all run records beyond 200, backfill a separate derived analytics projection, and exclude report refreshes from measurements. ## Verification - Focused report/publishing/projection/workflow/adapter tests: 29 passed. - Workflow Evalbook adapter tests: three passed. - Viewer unit tests: eight passed; Vite viewer build passed. - Companion renderer tests: 59 total, 57 passed, two inventory tests skipped because the expected sibling checkout is absent. - Re-rendered all 375 retained attempts from the completed campaign and validated the public bundle. Zero new model calls; 356/358 selected cells still pass. - Browser walkthrough: grid to failed chat; prompt, named tool calls, correct blocked status, visible assertions, no loading spinner or composer. Public payload redactions are explicit. - Full typecheck and build passed locally. test:run ended with 17 failed files and 19 failed tests in unrelated server/worktree areas (3772 tests passed). Latest-head CI is the final merge gate. The browser sources also typecheck with a temporary TypeScript-7-compatible path configuration; the checked-in browser config still uses removed baseUrl options and is unchanged here. - Real Chromium verification passed for passing, failing and missing-recording attempts in both the full and public bundles. It checks tool expansion, navigation, reload, read-only controls, narrow viewport visibility and the public no-network boundary. Future publications run it automatically. - Fresh live proof: three gpt-5.4-mini native Codex cases passed on the first attempt (get-task-context, create-child-task, workflow-context-document-progress); estimated total $0.00632625. Generated the full canonical report and verified all three file:// pages, all seven DevTools tabs, and evidence cross-links in Chromium. Private screenshots remain local. - Follow-up99697c2c5: viewer build, eight unit tests, browser token gate and browser-source typecheck (existing TS7 configuration workaround) passed. Added repeated tool-to-evidence navigation to the publication browser gate. - Site-theme follow-up: 26 publishing/security tests, 59 Python tests (57 passed, two existing skips), eight viewer unit tests and the viewer build passed. Chromium verified shared colors and grid/test-design/Latest navigation. - Re-rendered all 375 retained attempts with the shared site theme, without new provider calls. - Successful hosted publication: [refreshed Evalbook](https://d1p6rlowie26tp.cloudfront.net/runner-protocol-evals/campaigns/gha-34062394019-1-report-site-theme-v3/index.html) and [themed history](https://d1p6rlowie26tp.cloudfront.net/runner-protocol-evals/index.html). Real browser verified grid → test design → chat → All results and history → run. - Full maintained live suite completed on AWS: [Actions run 34074939112](https://github.com/paperclipai/paperclip/actions/runs/34074939112), Paperclip856813ba3a, evals 34e1846c06a39e641182dadce5de7ea739f657f1. All 358 cells across 11 configurations ran; 355 passed (99.2%), two behavior failures and one infrastructure failure. Nine configurations are entirely green. All 360 retained attempts were rendered using the new design and published as an immutable, zero-provider-call report refresh: [full Evalbook](https://d1p6rlowie26tp.cloudfront.net/runner-protocol-evals/campaigns/gha-34074939112-1-report-history-v1/index.html). The trusted master workflow ran the models; this PR's viewer rendered their results afterward. - Remaining live failures: native Codex mini did not emit the expected discovery event for lazy-unauthorized-undisclosed (no mutation occurred); GLM 5.3 scheduled a wake before approval in workflow-governed-wait; GLM 5.3 timed out on create-task-document on both attempts. No scoring rules were relaxed. - Retry-inclusive recorded estimated model cost is at least $7.067692, with usage for 357/360 attempts. Provider-reported list cost is a separate alternative (at least $14.002806), not an additive cost. Missing usage is unknown, not zero; AWS compute is excluded. - [Production history](https://d1p6rlowie26tp.cloudfront.net/runner-protocol-evals/index.html) now contains nine model runs and two separately labeled report refreshes. Matching full-suite comparison reports three newly failing cells and two recoveries versus the previous run. This is observed run-to-run variation, not proof of a deterministic code regression. Live HTTP checks verify the newest report link, shared theme, exact source SHAs and cost analytics. - The new full report passed Chromium checks for passing, failing and missing-recording chat pages, tool-to-Evidence links, grid/design/Latest navigation, reload, read-only controls and narrow layouts. The Mac was locked during final hosted verification; the new hosted history was checked by HTTP and generated-page browser checks, not a fresh interactive desktop walkthrough. - History follow-up: 31 publishing/security/metrics tests pass. Viewer build passes. Chromium checks desktop and narrow history pages with no document-level horizontal overflow. ### Visual verification Generated from the scrubbed completed campaign; no private provider identities or raw tool payloads are included. Full private pages were also browser-tested, but their private metadata is not published as screenshots.    Latest shared-theme proof:     ## Risks - Public chat text is newly visible, but only for the isolated mock boundary. The producer excludes raw payloads and the publisher fails closed on unknown fields, secrets, shell changes and asset substitutions. - Requires the companion canonical renderer revision and an updated RUNNER_PROTOCOL_EVALS_SHA after merge. Old pins fail before paid execution. - Existing published campaigns remain immutable. A report refresh is a separate history entry, not a new model qualification. - Successfully published the immutable site-theme refresh with the configured report-bucket SSO profile. Original run records and qualification pointers are preserved. ## Model Used OpenAI Codex, GPT-5-based coding agent with reasoning, shell and browser tools. Exact deployment model ID and context-window size are not exposed in this session. ## 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 oncad99dbf04- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups oncad99dbf04- [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
768 lines
25 KiB
JavaScript
768 lines
25 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
appendFile,
|
|
cp,
|
|
lstat,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
rm,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
import { sumAttemptCosts } from "./runner-protocol-eval-metrics.mjs";
|
|
import {
|
|
publicChatView,
|
|
PUBLIC_CHAT_SCHEMA,
|
|
PUBLIC_CHAT_NOTICE,
|
|
} from "./public-eval-chat.mjs";
|
|
|
|
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
|
|
const ATTEMPT_FILES = new Set([
|
|
"request.json",
|
|
"artifact.json",
|
|
"score.json",
|
|
"compiled-fixture.json",
|
|
"case.json",
|
|
"config.json",
|
|
]);
|
|
|
|
function json(value) {
|
|
return `${JSON.stringify(value, null, 2)}\n`;
|
|
}
|
|
|
|
async function loadObject(path) {
|
|
const value = JSON.parse(await readFile(path, "utf8"));
|
|
if (value === null || Array.isArray(value) || typeof value !== "object") {
|
|
throw new Error(`Expected a JSON object: ${path}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function safeId(value, label = "identifier") {
|
|
const result = String(value ?? "");
|
|
if (!SAFE_ID.test(result)) throw new Error(`Unsafe ${label}: ${result}`);
|
|
return result;
|
|
}
|
|
|
|
function inside(root, candidate, label) {
|
|
const rel = relative(resolve(root), resolve(candidate));
|
|
if (!rel || rel === ".." || rel.startsWith(`..${sep}`)) {
|
|
throw new Error(`${label} escapes its declared root`);
|
|
}
|
|
return resolve(candidate);
|
|
}
|
|
|
|
export function credentialForConfig(config) {
|
|
if (config.provider === "opencode") return "OPENROUTER_API_KEY";
|
|
if (config.provider === "claude_managed") return "ANTHROPIC_API_KEY";
|
|
if (config.provider === "aws_agentcore") return "AWS_AGENTCORE_OIDC";
|
|
if (config.provider === "codex" || config.provider === undefined) {
|
|
return "OPENAI_API_KEY";
|
|
}
|
|
if (config.provider === "acpx") {
|
|
if (config.acpxAgent === "pi") return "OPENROUTER_API_KEY";
|
|
if (config.acpxAgent === "claude") return "ANTHROPIC_API_KEY";
|
|
if (config.acpxAgent === "codex") return "OPENAI_API_KEY";
|
|
}
|
|
throw new Error(
|
|
`No credential policy for ${config.provider ?? "codex"}/${config.acpxAgent ?? "default"}`,
|
|
);
|
|
}
|
|
|
|
function parseRosterSelection(value) {
|
|
if (!value?.trim() || value.trim() === "all") return null;
|
|
const selected = value
|
|
.split(",")
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
if (selected.length === 0 || new Set(selected).size !== selected.length) {
|
|
throw new Error("Roster selection must contain unique comma-separated IDs");
|
|
}
|
|
return new Set(selected);
|
|
}
|
|
|
|
async function maintainedRosterSelection(programRoot) {
|
|
const campaignPath = resolve(programRoot, "campaigns/live-direct-full.json");
|
|
const campaign = await loadObject(campaignPath);
|
|
if (
|
|
campaign.schema !== "paperclip-runner/live-campaign/v1" ||
|
|
!Array.isArray(campaign.lanes)
|
|
) {
|
|
throw new Error(`Unsupported live campaign schema in ${campaignPath}`);
|
|
}
|
|
const selected = campaign.lanes
|
|
.filter((lane) => lane.executionClass !== "disabled")
|
|
.map((lane) => {
|
|
const rosterPath = inside(
|
|
programRoot,
|
|
resolve(dirname(campaignPath), String(lane.roster ?? "")),
|
|
"Campaign roster",
|
|
);
|
|
return basename(rosterPath);
|
|
});
|
|
if (selected.length === 0 || new Set(selected).size !== selected.length) {
|
|
throw new Error(
|
|
"Maintained live campaign must contain unique enabled rosters",
|
|
);
|
|
}
|
|
return new Set(selected);
|
|
}
|
|
|
|
export async function buildProtocolEvalCatalog({
|
|
evalsRoot,
|
|
rosterSelection = "all",
|
|
campaignId,
|
|
source = {},
|
|
maxParallel = 100,
|
|
}) {
|
|
safeId(campaignId, "campaign ID");
|
|
if (
|
|
!Number.isSafeInteger(maxParallel) ||
|
|
maxParallel < 2 ||
|
|
maxParallel > 100
|
|
) {
|
|
throw new Error("maxParallel must be an integer from 2 through 100");
|
|
}
|
|
const programRoot = resolve(evalsRoot, "evals/paperclip-runner");
|
|
const rosterRoot = resolve(programRoot, "rosters");
|
|
const requested = parseRosterSelection(rosterSelection);
|
|
const selected = requested ?? (await maintainedRosterSelection(programRoot));
|
|
const rosterFiles = (await readdir(rosterRoot, { withFileTypes: true }))
|
|
.filter(
|
|
(entry) =>
|
|
entry.isFile() &&
|
|
entry.name.startsWith("live-") &&
|
|
entry.name.endsWith(".json"),
|
|
)
|
|
.map((entry) => entry.name)
|
|
.sort();
|
|
const rosters = [];
|
|
for (const rosterFile of rosterFiles) {
|
|
const rosterPath = resolve(rosterRoot, rosterFile);
|
|
const roster = await loadObject(rosterPath);
|
|
const rosterId = safeId(roster.id, "roster ID");
|
|
if (selected && !selected.has(rosterId) && !selected.has(rosterFile)) {
|
|
continue;
|
|
}
|
|
if (roster.schema !== "paperclip-runner/live-roster/v1") {
|
|
throw new Error(`Unsupported live roster schema in ${rosterFile}`);
|
|
}
|
|
if (!Array.isArray(roster.cases) || roster.cases.length === 0) {
|
|
throw new Error(`Live roster ${rosterId} has no cases`);
|
|
}
|
|
const configPath = inside(
|
|
programRoot,
|
|
resolve(rosterRoot, String(roster.config ?? "")),
|
|
`Config for ${rosterId}`,
|
|
);
|
|
const config = await loadObject(configPath);
|
|
const credentialName = credentialForConfig(config);
|
|
const cases = roster.cases.map((caseId) => safeId(caseId, "case ID"));
|
|
if (new Set(cases).size !== cases.length) {
|
|
throw new Error(`Live roster ${rosterId} repeats a case`);
|
|
}
|
|
rosters.push({
|
|
rosterId,
|
|
rosterFile,
|
|
configFile: relative(programRoot, configPath).split(sep).join("/"),
|
|
model: String(roster.model ?? config.model ?? "unknown"),
|
|
provider: String(config.provider ?? "codex"),
|
|
driver: String(config.driver ?? "codex_app_server"),
|
|
credentialName,
|
|
cases,
|
|
});
|
|
}
|
|
if (selected) {
|
|
const found = new Set(
|
|
rosters.flatMap((roster) => [roster.rosterId, roster.rosterFile]),
|
|
);
|
|
const missing = [...selected].filter((entry) => !found.has(entry));
|
|
if (missing.length > 0) {
|
|
throw new Error(`Unknown live roster selection: ${missing.join(", ")}`);
|
|
}
|
|
}
|
|
if (rosters.length === 0) throw new Error("No live rosters were selected");
|
|
|
|
const cells = rosters.flatMap((roster) =>
|
|
roster.cases.map((caseId) => ({
|
|
cellId: safeId(`${roster.rosterId}--${caseId}`, "cell ID"),
|
|
rosterId: roster.rosterId,
|
|
rosterFile: roster.rosterFile,
|
|
caseId,
|
|
model: roster.model,
|
|
provider: roster.provider,
|
|
driver: roster.driver,
|
|
credentialName: roster.credentialName,
|
|
})),
|
|
);
|
|
const shards = [[], []];
|
|
cells.forEach((cell, index) => shards[index % shards.length].push(cell));
|
|
if (shards.some((shard) => shard.length > 256)) {
|
|
throw new Error(
|
|
"Protocol eval catalog exceeds the two-shard GitHub matrix limit",
|
|
);
|
|
}
|
|
return {
|
|
schema: "paperclip.runner-protocol-eval.catalog/v1",
|
|
campaignId,
|
|
source,
|
|
selection: { kind: requested === null ? "maintained_full" : "subset", rosters: rosterSelection },
|
|
rosters,
|
|
cells,
|
|
matrices: shards.map((include) => ({ include })),
|
|
maxParallel,
|
|
maxParallelPerShard: Math.floor(maxParallel / 2),
|
|
};
|
|
}
|
|
|
|
async function writeGithubOutput(entries) {
|
|
const output = process.env.GITHUB_OUTPUT;
|
|
if (!output) return;
|
|
await appendFile(
|
|
output,
|
|
Object.entries(entries)
|
|
.map(([key, value]) => `${key}=${value}\n`)
|
|
.join(""),
|
|
);
|
|
}
|
|
|
|
async function copyAttempt(source, destination) {
|
|
const metadata = await lstat(source);
|
|
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
throw new Error(`Attempt source is not a real directory: ${source}`);
|
|
}
|
|
await mkdir(destination, { recursive: false, mode: 0o700 });
|
|
for (const entry of await readdir(source, { withFileTypes: true })) {
|
|
if (
|
|
!entry.isFile() ||
|
|
entry.isSymbolicLink() ||
|
|
!ATTEMPT_FILES.has(entry.name)
|
|
) {
|
|
throw new Error(`Unexpected attempt path ${join(source, entry.name)}`);
|
|
}
|
|
await cp(join(source, entry.name), join(destination, entry.name), {
|
|
errorOnExist: true,
|
|
});
|
|
}
|
|
for (const required of [
|
|
"artifact.json",
|
|
"score.json",
|
|
"case.json",
|
|
"config.json",
|
|
]) {
|
|
await lstat(join(destination, required));
|
|
}
|
|
}
|
|
|
|
async function findFiles(root, name) {
|
|
const metadata = await lstat(root).catch(() => null);
|
|
if (!metadata) return [];
|
|
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
throw new Error(`Download root must be a real directory: ${root}`);
|
|
}
|
|
const found = [];
|
|
async function visit(directory) {
|
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
const absolute = join(directory, entry.name);
|
|
if (entry.isSymbolicLink())
|
|
throw new Error(`Refusing downloaded symlink: ${absolute}`);
|
|
if (entry.isDirectory()) await visit(absolute);
|
|
else if (entry.isFile() && entry.name === name) found.push(absolute);
|
|
}
|
|
}
|
|
await visit(root);
|
|
return found.sort();
|
|
}
|
|
|
|
function safeUsage(usage) {
|
|
const fields = [
|
|
"agentTurns",
|
|
"providerRequests",
|
|
"inputTokens",
|
|
"outputTokens",
|
|
"cachedInputTokens",
|
|
"reasoningTokens",
|
|
"providerReportedCostNanodollars",
|
|
"estimatedCostNanodollars",
|
|
];
|
|
return Object.fromEntries(
|
|
fields
|
|
.filter((field) => Number.isFinite(usage?.[field]) && usage[field] >= 0)
|
|
.map((field) => [field, usage[field]]),
|
|
);
|
|
}
|
|
|
|
async function syntheticAttempt({ evalsRoot, runsOut, cell, campaignId }) {
|
|
const programRoot = resolve(evalsRoot, "evals/paperclip-runner");
|
|
const roster = await loadObject(
|
|
resolve(programRoot, "rosters", cell.rosterFile),
|
|
);
|
|
const casePath = resolve(programRoot, "cases", `${cell.caseId}.json`);
|
|
const configPath = inside(
|
|
programRoot,
|
|
resolve(programRoot, "rosters", String(roster.config)),
|
|
`Config for ${cell.rosterId}`,
|
|
);
|
|
const [evalCase, config] = await Promise.all([
|
|
loadObject(casePath),
|
|
loadObject(configPath),
|
|
]);
|
|
const attemptId = safeId(
|
|
`${cell.cellId}-${campaignId}-missing`,
|
|
"synthetic attempt ID",
|
|
);
|
|
const directory = resolve(runsOut, attemptId);
|
|
await mkdir(directory, { recursive: false, mode: 0o700 });
|
|
const createdAt = new Date().toISOString();
|
|
const artifact = {
|
|
schema: "paperclip-runner/eval-session-artifact/v1",
|
|
attemptId,
|
|
createdAt,
|
|
requestedModel: config.model,
|
|
provider: config.provider ?? "codex",
|
|
driver: config.driver ?? "codex_app_server",
|
|
providerVersion: config.opencodeVersion ?? null,
|
|
usage: {},
|
|
turn: { status: "failed" },
|
|
snapshot: {
|
|
createdAt,
|
|
providerModel: {
|
|
id: config.model,
|
|
provider: config.modelProvider ?? config.provider ?? "unknown",
|
|
},
|
|
transcript: [],
|
|
evidence: [],
|
|
},
|
|
devtools: { revisions: [] },
|
|
infrastructureFailure: {
|
|
class: "ci_cell_artifact_missing",
|
|
category: "campaign_orchestration",
|
|
retryable: false,
|
|
},
|
|
};
|
|
const score = {
|
|
schema: "paperclip-runner/eval-score/v1",
|
|
attemptId,
|
|
caseId: cell.caseId,
|
|
disposition: "infrastructure_failure",
|
|
passed: false,
|
|
infrastructureErrors: [
|
|
"The matrix cell did not retain a complete attempt artifact.",
|
|
],
|
|
checks: [],
|
|
};
|
|
score.digest = `sha256:${createHash("sha256").update(JSON.stringify(score)).digest("hex")}`;
|
|
await Promise.all([
|
|
writeFile(join(directory, "artifact.json"), json(artifact), {
|
|
mode: 0o600,
|
|
}),
|
|
writeFile(join(directory, "score.json"), json(score), { mode: 0o600 }),
|
|
writeFile(join(directory, "case.json"), json(evalCase), { mode: 0o600 }),
|
|
writeFile(join(directory, "config.json"), json(config), { mode: 0o600 }),
|
|
]);
|
|
return attemptId;
|
|
}
|
|
|
|
export async function aggregateProtocolEvalCampaign({
|
|
catalogPath,
|
|
downloadsRoot,
|
|
evalsRoot,
|
|
runsOut,
|
|
campaignOut,
|
|
source,
|
|
}) {
|
|
const catalog = await loadObject(catalogPath);
|
|
if (catalog.schema !== "paperclip.runner-protocol-eval.catalog/v1") {
|
|
throw new Error("Unsupported protocol eval catalog");
|
|
}
|
|
await rm(runsOut, { recursive: true, force: true });
|
|
await mkdir(runsOut, { recursive: true });
|
|
const retainedByCell = new Map();
|
|
const expectedByCell = new Map(
|
|
catalog.cells.map((cell) => [cell.cellId, cell]),
|
|
);
|
|
const copiedAttemptIds = new Set();
|
|
for (const statusPath of await findFiles(downloadsRoot, "cell.json")) {
|
|
const status = await loadObject(statusPath);
|
|
const cellId = safeId(status.cellId, "recorded cell ID");
|
|
if (retainedByCell.has(cellId))
|
|
throw new Error(`Duplicate cell artifact ${cellId}`);
|
|
const expected = expectedByCell.get(cellId);
|
|
if (!expected)
|
|
throw new Error(`Downloaded artifact names an unexpected cell ${cellId}`);
|
|
if (
|
|
status.caseId !== expected.caseId ||
|
|
status.rosterFile !== expected.rosterFile
|
|
) {
|
|
throw new Error(`Downloaded cell metadata drifted for ${cellId}`);
|
|
}
|
|
const attemptRoot = resolve(dirname(statusPath), "runs");
|
|
const attemptIds = [];
|
|
const attemptMetadata = await lstat(attemptRoot).catch(() => null);
|
|
if (attemptMetadata?.isDirectory() && !attemptMetadata.isSymbolicLink()) {
|
|
for (const entry of (
|
|
await readdir(attemptRoot, { withFileTypes: true })
|
|
).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
throw new Error(
|
|
`Unexpected cell run path ${join(attemptRoot, entry.name)}`,
|
|
);
|
|
}
|
|
const attemptId = safeId(entry.name, "attempt ID");
|
|
if (copiedAttemptIds.has(attemptId))
|
|
throw new Error(`Duplicate attempt artifact ${attemptId}`);
|
|
const [score, artifact, config] = await Promise.all([
|
|
loadObject(join(attemptRoot, entry.name, "score.json")),
|
|
loadObject(join(attemptRoot, entry.name, "artifact.json")),
|
|
loadObject(join(attemptRoot, entry.name, "config.json")),
|
|
]);
|
|
if (
|
|
score.attemptId !== attemptId ||
|
|
artifact.attemptId !== attemptId ||
|
|
score.caseId !== expected.caseId ||
|
|
config.model !== expected.model ||
|
|
(config.provider ?? "codex") !== expected.provider ||
|
|
(config.driver ?? "codex_app_server") !== expected.driver
|
|
) {
|
|
throw new Error(`Downloaded attempt identity drifted for ${cellId}`);
|
|
}
|
|
await copyAttempt(
|
|
join(attemptRoot, entry.name),
|
|
join(runsOut, attemptId),
|
|
);
|
|
copiedAttemptIds.add(attemptId);
|
|
attemptIds.push(attemptId);
|
|
}
|
|
}
|
|
retainedByCell.set(cellId, { status, attemptIds });
|
|
}
|
|
|
|
const results = [];
|
|
const attemptUsages = [];
|
|
for (const cell of catalog.cells) {
|
|
const retained = retainedByCell.get(cell.cellId);
|
|
const attemptIds = retained?.attemptIds?.length
|
|
? retained.attemptIds
|
|
: [
|
|
await syntheticAttempt({
|
|
evalsRoot,
|
|
runsOut,
|
|
cell,
|
|
campaignId: catalog.campaignId,
|
|
}),
|
|
];
|
|
const finalAttemptId = attemptIds.at(-1);
|
|
for (const attemptId of attemptIds) {
|
|
const attempt = await loadObject(join(runsOut, attemptId, "artifact.json"));
|
|
attemptUsages.push(attempt.usage);
|
|
}
|
|
const [score, artifact] = await Promise.all([
|
|
loadObject(join(runsOut, finalAttemptId, "score.json")),
|
|
loadObject(join(runsOut, finalAttemptId, "artifact.json")),
|
|
]);
|
|
if (score.caseId !== cell.caseId || score.attemptId !== finalAttemptId) {
|
|
throw new Error(`Final attempt identity drifted for ${cell.cellId}`);
|
|
}
|
|
results.push({
|
|
cellId: cell.cellId,
|
|
rosterId: cell.rosterId,
|
|
caseId: cell.caseId,
|
|
model: cell.model,
|
|
provider: cell.provider,
|
|
driver: cell.driver,
|
|
attemptIds,
|
|
finalAttemptId,
|
|
disposition: score.disposition,
|
|
passed: score.passed === true,
|
|
usage: safeUsage(artifact.usage),
|
|
cellExitCode: Number.isSafeInteger(retained?.status?.exitCode)
|
|
? retained.status.exitCode
|
|
: null,
|
|
});
|
|
}
|
|
const totals = {
|
|
selected: results.length,
|
|
passed: results.filter((result) => result.passed).length,
|
|
behaviorFailures: results.filter(
|
|
(result) => result.disposition === "behavior_failure",
|
|
).length,
|
|
infrastructureFailures: results.filter(
|
|
(result) => result.disposition === "infrastructure_failure",
|
|
).length,
|
|
};
|
|
const generatedAt = new Date().toISOString();
|
|
const campaign = {
|
|
schema: "paperclip.runner-protocol-eval.campaign/v1",
|
|
campaignId: catalog.campaignId,
|
|
generatedAt,
|
|
selection: catalog.selection,
|
|
costs: sumAttemptCosts(attemptUsages),
|
|
source: {
|
|
paperclip: source.paperclip,
|
|
evals: source.evals,
|
|
workflowRunUrl: source.workflowRunUrl,
|
|
},
|
|
complete: results.length === catalog.cells.length,
|
|
allPassed: totals.passed === totals.selected,
|
|
totals,
|
|
rosters: catalog.rosters.map((roster) => ({
|
|
rosterId: roster.rosterId,
|
|
model: roster.model,
|
|
provider: roster.provider,
|
|
driver: roster.driver,
|
|
selected: roster.cases.length,
|
|
passed: results.filter(
|
|
(result) => result.rosterId === roster.rosterId && result.passed,
|
|
).length,
|
|
})),
|
|
results,
|
|
};
|
|
await mkdir(dirname(campaignOut), { recursive: true });
|
|
await writeFile(campaignOut, json(campaign), { mode: 0o600 });
|
|
return campaign;
|
|
}
|
|
|
|
function publicArtifact(artifact, evalCase) {
|
|
const issueThread = publicChatView(artifact, evalCase);
|
|
const model = artifact.snapshot?.providerModel ?? {};
|
|
const infrastructure = artifact.infrastructureFailure;
|
|
const providerVersion =
|
|
artifact.provider === "claude_managed" ||
|
|
artifact.provider === "aws_agentcore"
|
|
? "remote profile redacted"
|
|
: (artifact.providerVersion ?? null);
|
|
return {
|
|
schema: artifact.schema,
|
|
attemptId: artifact.attemptId,
|
|
createdAt: artifact.createdAt,
|
|
requestedModel: artifact.requestedModel,
|
|
provider: artifact.provider,
|
|
driver: artifact.driver,
|
|
providerVersion,
|
|
retainedSession: null,
|
|
retainedSessionStatus: "redacted from the public report",
|
|
usage: safeUsage(artifact.usage),
|
|
timing: {
|
|
startedAt:
|
|
typeof artifact.timing?.startedAt === "string"
|
|
? artifact.timing.startedAt
|
|
: null,
|
|
finishedAt:
|
|
typeof artifact.timing?.finishedAt === "string"
|
|
? artifact.timing.finishedAt
|
|
: null,
|
|
durationMs: Number.isFinite(artifact.timing?.durationMs)
|
|
? artifact.timing.durationMs
|
|
: null,
|
|
},
|
|
turn: {
|
|
status: artifact.turn?.status ?? "failed",
|
|
turnId: issueThread.turns.at(-1).id,
|
|
},
|
|
snapshot: {
|
|
createdAt: artifact.snapshot?.createdAt ?? artifact.createdAt,
|
|
sessionId: "public-report",
|
|
providerModel: {
|
|
id: model.id ?? artifact.requestedModel,
|
|
provider: model.provider ?? artifact.provider,
|
|
},
|
|
transcript: [],
|
|
evidence: [],
|
|
},
|
|
devtools: { revisions: [] },
|
|
publication: { schema: PUBLIC_CHAT_SCHEMA, notice: PUBLIC_CHAT_NOTICE },
|
|
issueThread,
|
|
...(infrastructure && typeof infrastructure === "object"
|
|
? {
|
|
infrastructureFailure: {
|
|
class: infrastructure.class,
|
|
category: infrastructure.category,
|
|
retryable: infrastructure.retryable === true,
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function publicScore(score) {
|
|
return {
|
|
schema: score.schema,
|
|
attemptId: score.attemptId,
|
|
caseId: score.caseId,
|
|
disposition: score.disposition,
|
|
passed: score.passed === true,
|
|
infrastructureErrors:
|
|
score.disposition === "infrastructure_failure"
|
|
? [
|
|
"Infrastructure failure; diagnostic details remain in the access-controlled artifact.",
|
|
]
|
|
: [],
|
|
checks: Array.isArray(score.checks)
|
|
? score.checks.map((check) => ({
|
|
id: check.id,
|
|
kind: check.kind,
|
|
passed: check.passed === true,
|
|
detail: check.passed === true ? "passed" : "failed",
|
|
evidenceRefs: [],
|
|
}))
|
|
: [],
|
|
digest: score.digest,
|
|
};
|
|
}
|
|
|
|
function publicConfig(config) {
|
|
return Object.fromEntries(
|
|
[
|
|
"schema",
|
|
"id",
|
|
"model",
|
|
"provider",
|
|
"driver",
|
|
"opencodeVersion",
|
|
"acpxAgent",
|
|
"modelProvider",
|
|
]
|
|
.filter((field) => config[field] !== undefined)
|
|
.map((field) => [field, config[field]]),
|
|
);
|
|
}
|
|
|
|
export async function sanitizeProtocolEvalRuns({ runsRoot, publicRunsRoot }) {
|
|
await rm(publicRunsRoot, { recursive: true, force: true });
|
|
await mkdir(publicRunsRoot, { recursive: true });
|
|
const attemptIds = [];
|
|
for (const entry of (await readdir(runsRoot, { withFileTypes: true })).sort(
|
|
(a, b) => a.name.localeCompare(b.name),
|
|
)) {
|
|
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
throw new Error(
|
|
`Unexpected merged run path ${join(runsRoot, entry.name)}`,
|
|
);
|
|
}
|
|
const attemptId = safeId(entry.name, "attempt ID");
|
|
const source = join(runsRoot, attemptId);
|
|
const [artifact, score, evalCase, config] = await Promise.all([
|
|
loadObject(join(source, "artifact.json")),
|
|
loadObject(join(source, "score.json")),
|
|
loadObject(join(source, "case.json")),
|
|
loadObject(join(source, "config.json")),
|
|
]);
|
|
if (artifact.attemptId !== attemptId || score.attemptId !== attemptId) {
|
|
throw new Error(`Attempt identity drifted in ${attemptId}`);
|
|
}
|
|
const destination = join(publicRunsRoot, attemptId);
|
|
await mkdir(destination, { mode: 0o700 });
|
|
await Promise.all([
|
|
writeFile(
|
|
join(destination, "artifact.json"),
|
|
json(publicArtifact(artifact, evalCase)),
|
|
{ mode: 0o600 },
|
|
),
|
|
writeFile(join(destination, "score.json"), json(publicScore(score)), {
|
|
mode: 0o600,
|
|
}),
|
|
writeFile(
|
|
join(destination, "case.json"),
|
|
json({
|
|
id: evalCase.id,
|
|
checks: (evalCase.checks ?? []).map(({ id, kind }) => ({ id, kind })),
|
|
}),
|
|
{
|
|
mode: 0o600,
|
|
},
|
|
),
|
|
writeFile(join(destination, "config.json"), json(publicConfig(config)), {
|
|
mode: 0o600,
|
|
}),
|
|
]);
|
|
attemptIds.push(attemptId);
|
|
}
|
|
return attemptIds;
|
|
}
|
|
|
|
function argument(args, name, fallback) {
|
|
const index = args.indexOf(name);
|
|
return index < 0 ? fallback : args[index + 1];
|
|
}
|
|
|
|
async function main() {
|
|
const [command, ...args] = process.argv.slice(2);
|
|
if (command === "catalog") {
|
|
const output = resolve(
|
|
argument(args, "--output", "runner-protocol-eval-catalog.json"),
|
|
);
|
|
const catalog = await buildProtocolEvalCatalog({
|
|
evalsRoot: resolve(argument(args, "--evals-root", ".paperclip-evals")),
|
|
rosterSelection: argument(args, "--rosters", "all"),
|
|
campaignId: argument(args, "--campaign-id", `local-${Date.now()}`),
|
|
maxParallel: Number(argument(args, "--max-parallel", "100")),
|
|
source: {
|
|
paperclipSha: process.env.PAPERCLIP_PROTOCOL_EVAL_SOURCE_SHA ?? null,
|
|
evalsSha: process.env.PAPERCLIP_PROTOCOL_EVALS_SHA ?? null,
|
|
},
|
|
});
|
|
await mkdir(dirname(output), { recursive: true });
|
|
await writeFile(output, json(catalog), { mode: 0o600 });
|
|
await writeGithubOutput({
|
|
matrix_0: JSON.stringify(catalog.matrices[0]),
|
|
matrix_1: JSON.stringify(catalog.matrices[1]),
|
|
matrix_1_present: String(catalog.matrices[1].include.length > 0),
|
|
max_parallel_per_shard: String(catalog.maxParallelPerShard),
|
|
selected: String(catalog.cells.length),
|
|
});
|
|
console.log(
|
|
json({
|
|
output,
|
|
selected: catalog.cells.length,
|
|
rosters: catalog.rosters.length,
|
|
}).trim(),
|
|
);
|
|
return;
|
|
}
|
|
if (command === "aggregate") {
|
|
const campaign = await aggregateProtocolEvalCampaign({
|
|
catalogPath: resolve(argument(args, "--catalog")),
|
|
downloadsRoot: resolve(argument(args, "--downloads")),
|
|
evalsRoot: resolve(argument(args, "--evals-root")),
|
|
runsOut: resolve(argument(args, "--runs-out")),
|
|
campaignOut: resolve(argument(args, "--campaign-out")),
|
|
source: {
|
|
paperclip: {
|
|
sha: process.env.PAPERCLIP_PROTOCOL_EVAL_SOURCE_SHA,
|
|
ref: process.env.PAPERCLIP_PROTOCOL_EVAL_SOURCE_REF,
|
|
},
|
|
evals: {
|
|
repository: "paperclipai/paperclip-evals",
|
|
sha: process.env.PAPERCLIP_PROTOCOL_EVALS_SHA,
|
|
},
|
|
workflowRunUrl: process.env.PAPERCLIP_PROTOCOL_EVAL_WORKFLOW_URL,
|
|
},
|
|
});
|
|
console.log(json(campaign.totals).trim());
|
|
return;
|
|
}
|
|
if (command === "sanitize") {
|
|
const attemptIds = await sanitizeProtocolEvalRuns({
|
|
runsRoot: resolve(argument(args, "--runs-root")),
|
|
publicRunsRoot: resolve(argument(args, "--output")),
|
|
});
|
|
console.log(json({ attempts: attemptIds.length }).trim());
|
|
return;
|
|
}
|
|
throw new Error(
|
|
"Usage: runner-protocol-eval-campaign.mjs <catalog|aggregate|sanitize> ...",
|
|
);
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
resolve(process.argv[1]) === resolve(import.meta.filename)
|
|
) {
|
|
await main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
});
|
|
}
|