mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
## Thinking Path > - Paperclip is a control plane for AI agents that do work. > - Its evaluation reports separate recorded results from source coverage. > - A case without a main baseline measurement can still have a candidate run. > - A normal campaign card treats an expected execution without a result as missing evidence. > - Catalog planning data must not create those cards or change a recorded grade. > - This change adds a typed metadata view to the existing Evals dashboard. > - Readers can compare main outcomes, candidate execution, allowances and source review separately. ## Linked Issues or Issue Description **What existing behavior does this improve?** The existing Evals dashboard can show a campaign's retained results. It cannot show the full chat catalog as metadata without synthetic result entries. **Current behavior** An unused initial, an unknown allowance and a completed candidate run can all lack a main baseline result. Showing them as missing campaign artifacts hides these differences. **Proposed behavior** Add one validated metadata view in the existing renderer. Show one row per frozen scenario, separate execution and main outcome columns, filters, and the public request and expected outcome from static YAML. Keep normal campaign cards unchanged. **Reason and benefit** Readers can see which cases ran, which have no main measurement, and which still need preparation. Source review and candidate outcomes cannot add baseline pass credit. **Breaking changes** None to existing campaign inputs or outputs. The new metadata mode uses its own strict schema and requires a new private output directory. No matching open or closed PR or issue was found in the duplicate search. ## What Changed - Add a strict display-only catalog schema with closed enums and fixed catalog scope. - Validate the 244 row identities against static YAML without importing executable case callbacks. - Show main results, recorded execution, allowances and source review in separate columns. - Add search and filters, passing examples, qualification notes and expandable public test descriptions. - Add one dated immutable snapshot link in the existing shared report navigation. This does not change history data or update an already-published page. - Add a private metadata preview command to the existing dashboard owner. Keep existing campaign generation on its current path. - Keep allowance certainty independent of measurements and allow an optional later candidate outcome alongside a main outcome, without changing main counters. - Add focused pure, JSDOM and file-loader checks. Document the metadata command. ## Verification - The owning `catalog-overview.test.ts` Vitest suite passes all fourteen tests under the focused source configuration. The valid-main/unknown-allowance and main-fail/later-candidate-pass controls fail against the old renderer and pass with this repair. The never-run/unknown-allowance control fails againste8686bfand passes with the restored consistency guard. - Scoped TypeScript verification of the changed source and its imports has no diagnostics. - The tests cover closed schema fields, duplicate and invented IDs, candidate/main separation, filters, HTML escaping, private output mode, existing output refusal, symlink and oversized input refusal, static descriptions, and the normal campaign branch. Ceiling values below, equal to and above the target are covered. Unsupported navigation is hidden only in metadata mode. - The source was independently reviewed. Actual generated DOM validation also checked 244 rows and all public descriptions. - No visual browser verification or live product journey is claimed. No credentials, private projection, generated report or evidence files are included in this PR. - Repo-wide builds were not run locally for this report-only slice. Normal remote CI passed on repair head `f813c18`, with 55 successful checks, two skipped checks and one neutral review check. Required `ci / verify` and `ci / e2e` both passed. Current-head Greptile is 5/5 and no review threads remain open. Thee8686bfnormal CI had an initial failed attempt and a successful failed-only replay. Both are retained; that replay does not substitute for current-head verification. - The firstf813c18CI attempt passed 373 tests in serialized server shard 4/9 but failed one existing scheduled-retry test: its intermediate cancellation count was 29 rather than 20. The actual CI-tested test, route, worker, scheduler and DB-fixture source matches prior passing CI. The one failed-only replay passed all 374 tests in that shard on the unchanged head. The first failure remains recorded; no assertion or timeout changed. - Rendering the authenticated existing projection through both old and new generators produces byte-identical HTML and both assets. The already-published dated snapshot is unchanged. ## Risks The view depends on an authenticated metadata projection. Shape validation does not qualify source or execution. The view preserves unknown states and keeps candidate outcomes outside main qualification. The preview command does not publish reports or alter history. Existing campaign result behavior remains separate. Shared navigation gains one dated link. Existing published pages need a separate conditional activation; this PR does not upload or rewrite them. ## Model Used OpenAI GPT-6 through Codex, with source editing, tool use and pure/JSDOM checks. The exact serving model ID and context-window size are not exposed to this worker. ## 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>
305 lines
13 KiB
TypeScript
305 lines
13 KiB
TypeScript
import path from "node:path";
|
|
import { mkdir, open, readFile, readdir, writeFile } from "node:fs/promises";
|
|
import { constants } from "node:fs";
|
|
import { isSeq, parseDocument } from "yaml";
|
|
import { parseCatalogOverview, type CatalogScenarioDescription } from "./catalog-overview.js";
|
|
import { stageDashboardBrandAssets } from "./report-assets.js";
|
|
import { renderRunnerE2EDashboard, type RunnerDashboardInput } from "./dashboard.js";
|
|
import { campaignFeedbackReviewText } from "./feedback-dispositions.js";
|
|
import type {
|
|
RunnerE2ECampaign,
|
|
RunnerE2EHistoryIndex,
|
|
RunnerE2EResult,
|
|
} from "./types.js";
|
|
|
|
interface PublishedResult extends RunnerE2EResult {
|
|
evidenceValid?: boolean;
|
|
evidenceErrors?: string[];
|
|
}
|
|
|
|
interface PublishedCampaign {
|
|
schema?: string;
|
|
campaignId?: string;
|
|
source?: RunnerE2ECampaign["source"];
|
|
generatedAt: string;
|
|
expected: string[];
|
|
results: PublishedResult[];
|
|
accounting?: RunnerE2ECampaign["accounting"];
|
|
operations?: RunnerE2ECampaign["operations"];
|
|
feedbackReview?: RunnerE2ECampaign["feedbackReview"];
|
|
braintrustExperiment?: RunnerE2ECampaign["braintrustExperiment"];
|
|
presentation?: RunnerDashboardInput["presentation"];
|
|
}
|
|
|
|
function validatePresentation(value: unknown) {
|
|
if (value === undefined) return;
|
|
const invalid = () => {
|
|
throw new Error("Published bundle has invalid presentation metadata in normalized-results.json");
|
|
};
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) invalid();
|
|
const presentation = value as Record<string, unknown>;
|
|
for (const field of ["title", "description"]) {
|
|
if (presentation[field] !== undefined && typeof presentation[field] !== "string") invalid();
|
|
}
|
|
if (presentation.notes !== undefined &&
|
|
(!Array.isArray(presentation.notes) || presentation.notes.some(note => typeof note !== "string"))) invalid();
|
|
if (presentation.links !== undefined &&
|
|
(!Array.isArray(presentation.links) || presentation.links.some(link =>
|
|
!link || typeof link !== "object" || Array.isArray(link) ||
|
|
typeof link.label !== "string" || typeof link.href !== "string"))) invalid();
|
|
}
|
|
|
|
async function relativeFiles(root: string, current = root): Promise<string[]> {
|
|
const entries = await readdir(current, { withFileTypes: true }).catch(
|
|
() => [],
|
|
);
|
|
const files: string[] = [];
|
|
for (const entry of entries) {
|
|
const absolute = path.join(current, entry.name);
|
|
if (entry.isDirectory())
|
|
files.push(...(await relativeFiles(root, absolute)));
|
|
if (entry.isFile()) {
|
|
files.push(path.relative(root, absolute).split(path.sep).join("/"));
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
async function readOptionalHistory(file: string) {
|
|
return readFile(file, "utf8")
|
|
.then((value) => JSON.parse(value) as RunnerE2EHistoryIndex)
|
|
.then((value) =>
|
|
value.schema === "paperclip.runner-e2e.history/v1" ? value : undefined,
|
|
)
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
export async function regenerateCatalogOverview(input: { metadataFile: string; outputDirectory: string }) {
|
|
const file = await open(input.metadataFile, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
let metadata: unknown;
|
|
try {
|
|
const stat = await file.stat();
|
|
if (!stat.isFile() || stat.size > 256 * 1024) throw new Error("Catalog overview input must be a bounded regular JSON file");
|
|
metadata = JSON.parse(await file.readFile("utf8"));
|
|
} finally {
|
|
await file.close();
|
|
}
|
|
const overview = parseCatalogOverview(metadata);
|
|
const catalogDirectory = new URL("./chat-scenarios/", import.meta.url);
|
|
const catalogIds = new Set<string>();
|
|
const descriptions = new Map<string, CatalogScenarioDescription>();
|
|
for (const name of (await readdir(catalogDirectory)).filter(name => name.endsWith(".yaml") && name !== "defaults.yaml")) {
|
|
const document = parseDocument(await readFile(new URL(name, catalogDirectory), "utf8"), { uniqueKeys: true });
|
|
if (document.errors.length) throw new Error("Invalid static chat catalog");
|
|
const sequence = document.get("scenarios", true);
|
|
if (!isSeq(sequence)) throw new Error("Invalid static chat catalog");
|
|
const scenarios: unknown = sequence.toJSON();
|
|
if (!Array.isArray(scenarios)) throw new Error("Invalid static chat catalog");
|
|
for (const row of scenarios) {
|
|
if (!row || typeof row.id !== "string" || catalogIds.has(row.id)) throw new Error("Invalid static chat catalog identity");
|
|
catalogIds.add(row.id);
|
|
if (typeof row.query !== "string" || typeof row.expected_outcome !== "string") throw new Error("Missing public catalog description");
|
|
descriptions.set(row.id, { query: row.query, expectedOutcome: row.expected_outcome });
|
|
}
|
|
}
|
|
if (catalogIds.size !== 244 || overview.rows.some(row => !catalogIds.has(row.scenarioId))) throw new Error("Catalog overview must match the frozen scenario IDs");
|
|
const dashboard = renderRunnerE2EDashboard({ title: "Chat feature catalog", generatedAt: overview.asOf, expected: [], catalog: [], entries: [], catalogOverview: overview, catalogOverviewDescriptions: descriptions });
|
|
await mkdir(input.outputDirectory, { mode: 0o700 });
|
|
await writeFile(path.join(input.outputDirectory, "index.html"), dashboard, { mode: 0o600, flag: "wx" });
|
|
await stageDashboardBrandAssets(input.outputDirectory);
|
|
}
|
|
|
|
export async function regenerateRunnerDashboard(input: {
|
|
bundle: string;
|
|
historyFile?: string | null;
|
|
outputDirectory?: string;
|
|
evidenceHrefPrefix?: string;
|
|
feedbackReviewFile?: string;
|
|
}) {
|
|
const { runnerMatrix } = await import("./catalog.js");
|
|
const { buildRunnerCampaign, canonicalExecutionId, upgradeRunnerResult } = await import("./history.js");
|
|
const bundle = path.resolve(input.bundle);
|
|
const outputDirectory = path.resolve(input.outputDirectory ?? bundle);
|
|
const evidenceHrefPrefix = input.evidenceHrefPrefix?.replace(
|
|
/^\/+|\/+$/g,
|
|
"",
|
|
);
|
|
if (
|
|
evidenceHrefPrefix &&
|
|
(evidenceHrefPrefix.includes("\\") ||
|
|
evidenceHrefPrefix
|
|
.split("/")
|
|
.some((segment) => !segment || segment === "." || segment === ".."))
|
|
) {
|
|
throw new Error("evidenceHrefPrefix must be a safe relative URL path");
|
|
}
|
|
const normalized = JSON.parse(
|
|
await readFile(path.join(bundle, "normalized-results.json"), "utf8"),
|
|
) as PublishedCampaign;
|
|
if (
|
|
!Array.isArray(normalized.expected) ||
|
|
!Array.isArray(normalized.results) ||
|
|
typeof normalized.generatedAt !== "string"
|
|
) {
|
|
throw new Error("Published bundle has an invalid normalized-results.json");
|
|
}
|
|
validatePresentation(normalized.presentation);
|
|
|
|
const results = normalized.results.map(upgradeRunnerResult);
|
|
const expected = normalized.expected.map(canonicalExecutionId);
|
|
const campaign: RunnerE2ECampaign = buildRunnerCampaign({
|
|
campaignId:
|
|
normalized.campaignId ??
|
|
`legacy-${normalized.generatedAt.replace(/[:.]/g, "-")}`,
|
|
generatedAt: normalized.generatedAt,
|
|
expected,
|
|
results,
|
|
accounting: normalized.accounting,
|
|
operations: normalized.operations,
|
|
braintrustExperiment: normalized.braintrustExperiment,
|
|
feedbackReview: input.feedbackReviewFile
|
|
? JSON.parse(await readFile(input.feedbackReviewFile, "utf8"))
|
|
: normalized.feedbackReview,
|
|
});
|
|
// A rendering refresh must not inherit the renderer's checkout or CI event.
|
|
const retainedSource = results.find((result) =>
|
|
result.source?.sha || result.source?.ref || result.source?.workflowRunUrl,
|
|
)?.source;
|
|
campaign.source = normalized.source ?? {
|
|
sha: retainedSource?.sha ?? null,
|
|
ref: retainedSource?.ref ?? null,
|
|
workflowRunUrl: retainedSource?.workflowRunUrl ?? null,
|
|
eventName: null,
|
|
};
|
|
const history =
|
|
input.historyFile === null
|
|
? undefined
|
|
: await readOptionalHistory(
|
|
input.historyFile ?? path.join(bundle, "history.json"),
|
|
);
|
|
const entries = await Promise.all(
|
|
normalized.results.map(async (publishedResult, index) => {
|
|
const result = results[index]!;
|
|
const originalExecutionId = publishedResult.executionId;
|
|
const evidenceIds = [
|
|
originalExecutionId,
|
|
...(originalExecutionId.startsWith("core-compatibility.")
|
|
? [originalExecutionId.slice("core-compatibility.".length)]
|
|
: []),
|
|
];
|
|
let evidenceBaseHref = "";
|
|
let evidenceFiles: string[] = [];
|
|
for (const evidenceId of evidenceIds) {
|
|
const candidate = [
|
|
"evidence",
|
|
evidenceId,
|
|
`attempt-${result.attempt}`,
|
|
].join("/");
|
|
const files = await relativeFiles(
|
|
path.join(bundle, ...candidate.split("/")),
|
|
);
|
|
if (files.length === 0) continue;
|
|
evidenceBaseHref = [evidenceHrefPrefix, candidate]
|
|
.filter(Boolean)
|
|
.join("/");
|
|
evidenceFiles = files;
|
|
break;
|
|
}
|
|
return {
|
|
result,
|
|
valid:
|
|
result.status === "passed" && result.cleanup === "passed" &&
|
|
publishedResult.evidenceValid !== false,
|
|
errors: publishedResult.evidenceErrors ?? [],
|
|
evidenceBaseHref,
|
|
evidenceFiles,
|
|
};
|
|
}),
|
|
);
|
|
const dashboard = renderRunnerE2EDashboard({
|
|
title: normalized.presentation?.title ?? "E2E tests",
|
|
generatedAt: normalized.generatedAt,
|
|
expected,
|
|
catalog: runnerMatrix,
|
|
entries,
|
|
campaign,
|
|
history,
|
|
presentation: normalized.presentation,
|
|
});
|
|
const upgraded = {
|
|
...campaign,
|
|
...(normalized.presentation ? { presentation: normalized.presentation } : {}),
|
|
results: campaign.results.map((result, index) => ({
|
|
...result,
|
|
evidenceValid:
|
|
normalized.results[index]?.evidenceValid ??
|
|
(result.status === "passed" && result.cleanup === "passed"),
|
|
evidenceErrors: normalized.results[index]?.evidenceErrors ?? [],
|
|
})),
|
|
};
|
|
const summary = await readFile(path.join(bundle, "summary.md"), "utf8").then(text => text.replace(
|
|
/^(Feedback across retained attempts:.*?); [0-9]+ distinct items awaiting review;/m, "$1;",
|
|
)).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === "ENOENT") return undefined;
|
|
throw error;
|
|
});
|
|
const reviewStatus = /^(?:[0-9]+ distinct feedback items await review\.|Feedback reviewed by (?:human|agent) at ).*$/m;
|
|
const refreshedSummary = summary === undefined ? undefined : reviewStatus.test(summary)
|
|
? summary.replace(reviewStatus, () => campaignFeedbackReviewText(campaign))
|
|
: `${summary.trimEnd()}\n\n## Campaign feedback review\n\n${campaignFeedbackReviewText(campaign)}\n`;
|
|
await mkdir(outputDirectory, { recursive: true });
|
|
await Promise.all([
|
|
writeFile(path.join(outputDirectory, "index.html"), dashboard, "utf8"),
|
|
writeFile(path.join(outputDirectory, "dashboard.html"), dashboard, "utf8"),
|
|
...(refreshedSummary === undefined ? [] : [writeFile(path.join(outputDirectory, "summary.md"), refreshedSummary, "utf8")]),
|
|
writeFile(
|
|
path.join(outputDirectory, "normalized-results.json"),
|
|
`${JSON.stringify(upgraded, null, 2)}\n`,
|
|
"utf8",
|
|
),
|
|
]);
|
|
console.log(
|
|
`Regenerated dashboard from ${normalized.results.length} retained result(s) in ${outputDirectory}`,
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const arguments_ = process.argv
|
|
.slice(2)
|
|
.filter((argument) => argument !== "--");
|
|
if (arguments_.includes("--catalog-overview")) {
|
|
const index = arguments_.indexOf("--catalog-overview");
|
|
const output = arguments_.indexOf("--output");
|
|
if (index !== 0 || output !== 2 || arguments_.length !== 4 || !arguments_[1] || !arguments_[3]) throw new Error("Usage: dashboard --catalog-overview <display-metadata.json> --output <new-private-directory>");
|
|
await regenerateCatalogOverview({ metadataFile: arguments_[1], outputDirectory: arguments_[3] });
|
|
return;
|
|
}
|
|
const bundleArgument = arguments_.find(
|
|
(argument) => !argument.startsWith("--"),
|
|
);
|
|
if (!bundleArgument) {
|
|
throw new Error(
|
|
"Usage: pnpm test:e2e:runner:dashboard -- <published-bundle-directory> [--history <history.json>] [--feedback-review <review.json>]",
|
|
);
|
|
}
|
|
const historyIndex = arguments_.indexOf("--history");
|
|
const historyFile =
|
|
historyIndex >= 0 ? arguments_[historyIndex + 1] : undefined;
|
|
if (historyIndex >= 0 && !historyFile)
|
|
throw new Error("--history requires a path");
|
|
const reviewIndex = arguments_.indexOf("--feedback-review");
|
|
const feedbackReviewFile = reviewIndex >= 0 ? arguments_[reviewIndex + 1] : undefined;
|
|
if (reviewIndex >= 0 && !feedbackReviewFile) throw new Error("--feedback-review requires a path");
|
|
await regenerateRunnerDashboard({ bundle: bundleArgument, historyFile, feedbackReviewFile });
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
path.resolve(process.argv[1]) === path.resolve(import.meta.filename)
|
|
) {
|
|
await main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
});
|
|
}
|