mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## Thinking Path > - Paperclip is the open source app that people use to manage AI agents for work. > - Product E2E campaigns check user journeys across agent profiles and environments. > - Readers use the campaign history and reports to inspect results and retained evidence. > - The reports showed image summaries, many unselected suites, and a long sticky index. > - Individual case cards also showed routine diagnostics before the visual evidence. > - This pull request adds text summaries, feature filters, folding case indexes, and simpler cards. > - A local preview uses retained public data so designers can change reports without another paid campaign. ## Linked Issues or Issue Description **Current behavior** The report is hard to scan as the journey catalog grows. Suite links can point into hidden results. Case indexes do not show whether a case passed across its configurations. Design changes also need a realistic local preview. **Proposed behavior** Show the selected campaign as text. Link journeys to current feature-map capabilities. Add folding case indexes with status indicators, reliable section links, and a back-to-top control. Keep failed outcomes visible and put routine diagnostics under Test details. Render retained public campaign data with local templates. **Reason and benefit** Readers can find a feature or failed case faster. Designers can iterate against real retained data without starting an application instance or using provider credentials. **Breaking changes** No result schema or grading change. The report stops embedding the generated summary PNG. The publisher still produces that artifact for existing consumers. Related work: #12889 added the existing matrix browser. #12846 concerns the public image publication boundary. This PR changes report presentation and local preview behavior. ## What Changed - Replace image summaries with shared HTML suite summaries on the history and campaign pages. - Show selected executions and preserve missing results for selected cells. - Add explicit journey-to-capability associations, feature filters, and a coverage view with unmapped journeys. - Add folding case indexes, section case lists, and red, green, or amber status indicators across matching configurations. - Fix anchor navigation through filters and add a floating back-to-top control. - Simplify case cards. Keep failure reasons visible. Fold routine checks, billing, and context into Test details. - Add an original-image link to the gallery. - Add a local preview utility with public data caching and offline mode. Document the direct Node command. - Test manifest-listed evidence, rejection of unlisted files and encoded traversal, cache reuse, and offline misses with a real loopback server and a stub downloader. ## Verification - Focused report, history, and preview tests: 75 passed in 6 files. - Report Playwright spec: 4 passed in Chrome with a standalone config that does not start the application server. - `node scripts/check-maps.mjs`: passed. - `git diff --check`: passed. - Manually checked retained campaign reports, feature filters, folding indexes, failure links, card details, gallery links, mobile layout, and keyboard navigation. - `pnpm build`: passed. - `pnpm -r typecheck`: passed. - `pnpm test:e2e:runner:typecheck`: passed. - `pnpm test:run`: stopped after 15 native session database cases failed. The local Postgres package first lacked dynamic-library links. After link hydration, the same cases still failed. A direct `initdb` check then reported `could not create semaphores: No space left on device` and identified the macOS semaphore limit. The complete local suite has not passed. - `pnpm test:e2e:runner:unit`: 3,250 passed, 6 failed, 2 skipped. All six failures reproduced against unchanged `origin/master` report sources with the same installed dependencies. They concern Copilot admission and its Daytona image input, plus the launcher test that runs those assertions. No report or history test failed. - No live provider evaluation was run for this presentation change. - CI on `b1a9c06823` first failed browser shard 3/8. The agent-chat split-handoff test reached cleanup, where cancelling a run returned HTTP 500 from a Postgres deadlock. Cleanup did not restore the experiment setting, and a later test failed its initial setting check. This shard does not contain the report spec. One targeted rerun passed, and that full workflow then passed. This shows recovery; no production fix was made for the deadlock. The later preview-test commit requires its own fresh checks. To review the design, run `node --watch --import ./cli/node_modules/tsx/dist/loader.mjs tests/runner-e2e/preview.ts`. Open the history address printed by the command, then open a campaign. The README covers offline mode and cache refresh. ## Risks - Feature associations use the current map. They describe linked scenarios, not proof that a whole capability is covered. Historical grades remain unchanged. - New or unfamiliar cases remain unmapped until their assertions are inspected. - The preview needs the public history host on first access. Offline mode requires cached files. It binds to the loopback interface and serves manifest-listed public evidence. - Hiding routine diagnostics adds one disclosure step. Failed or incomplete outcomes remain visible. This is report polish. It does not duplicate planned core work in `ROADMAP.md`. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, tool use, code execution, and browser automation. The exact serving model ID and context window 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 - [ ] All Paperclip CI gates are green - [ ] 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>
223 lines
10 KiB
TypeScript
223 lines
10 KiB
TypeScript
import { createServer } from "node:http";
|
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { runnerMatrix } from "./catalog.js";
|
|
import { renderRunnerE2EDashboard } from "./dashboard.js";
|
|
import { renderRunnerHistoryIndex } from "./history-index.js";
|
|
import type { RunnerE2ECampaign, RunnerE2EHistoryIndex, RunnerE2EResult } from "./types.js";
|
|
|
|
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
|
|
const publicRoot = "https://d1p6rlowie26tp.cloudfront.net/runner-e2e/";
|
|
const routeRoot = "/runner-e2e/";
|
|
export async function createRunnerReportPreview(options: {
|
|
cacheRoot?: string;
|
|
offline?: boolean;
|
|
refresh?: boolean;
|
|
download?: (relative: string) => Promise<Buffer>;
|
|
} = {}) {
|
|
const cacheRoot = options.cacheRoot ?? path.join(repositoryRoot, "tests/runner-e2e/results/design-preview");
|
|
const offline = options.offline ?? false;
|
|
const downloadFile = options.download ?? (async (relative: string) => {
|
|
const response = await fetch(new URL(relative, publicRoot), {
|
|
signal: AbortSignal.timeout(60_000),
|
|
redirect: "error",
|
|
});
|
|
if (!response.ok) throw new Error(`Public artifact ${relative}: HTTP ${response.status}`);
|
|
return Buffer.from(await response.arrayBuffer());
|
|
});
|
|
const version = String(Date.now());
|
|
const inFlight = new Map<string, Promise<Buffer>>();
|
|
|
|
interface Manifest {
|
|
campaignId: string;
|
|
files: Array<{ path: string; sha256: string; bytes: number }>;
|
|
}
|
|
type PublishedResult = RunnerE2EResult & { evidenceValid?: boolean; evidenceErrors?: string[] };
|
|
type PublishedCampaign = Omit<RunnerE2ECampaign, "results"> & { results: PublishedResult[] };
|
|
|
|
function safePath(relative: string) {
|
|
if (!relative || relative.split("/").some((part) =>
|
|
!part || part === "." || part === ".." || !/^[A-Za-z0-9._-]+$/.test(part),
|
|
)) throw new Error("Invalid public artifact path");
|
|
return relative;
|
|
}
|
|
|
|
async function publicFile(relative: string, refresh = false): Promise<Buffer> {
|
|
safePath(relative);
|
|
const filename = path.join(cacheRoot, relative);
|
|
if (!refresh || offline) {
|
|
try { return await readFile(filename); }
|
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
}
|
|
if (offline) throw new Error(`Not cached: ${relative}. Run once without --offline.`);
|
|
const pending = inFlight.get(relative);
|
|
if (pending) return pending;
|
|
const download = (async () => {
|
|
const bytes = await downloadFile(relative);
|
|
await mkdir(path.dirname(filename), { recursive: true });
|
|
const temporary = `${filename}.${process.pid}.tmp`;
|
|
await writeFile(temporary, bytes);
|
|
await rename(temporary, filename);
|
|
return bytes;
|
|
})();
|
|
inFlight.set(relative, download);
|
|
try { return await download; }
|
|
finally { inFlight.delete(relative); }
|
|
}
|
|
|
|
async function publicJson<T>(relative: string, refresh = false): Promise<T> {
|
|
return JSON.parse((await publicFile(relative, refresh)).toString("utf8")) as T;
|
|
}
|
|
|
|
const retainedHistory = await publicJson<RunnerE2EHistoryIndex>("history.json", options.refresh ?? false);
|
|
if (retainedHistory.schema !== "paperclip.runner-e2e.history/v1") throw new Error("Unsupported public history");
|
|
const campaigns = new Set(retainedHistory.campaigns.map((campaign) => campaign.campaignId));
|
|
const localHistory: RunnerE2EHistoryIndex = {
|
|
...retainedHistory,
|
|
campaigns: retainedHistory.campaigns.map((campaign) => ({
|
|
...campaign,
|
|
publicUrl: `${routeRoot}campaigns/${encodeURIComponent(campaign.campaignId)}/`,
|
|
})),
|
|
};
|
|
const manifests = new Map<string, Promise<Manifest>>();
|
|
function manifestFor(campaignId: string) {
|
|
if (!campaigns.has(campaignId)) throw new Error("Unknown campaign");
|
|
let pending = manifests.get(campaignId);
|
|
if (!pending) {
|
|
pending = publicJson<Manifest>(`campaigns/${campaignId}/bundle-manifest.json`).then((manifest) => {
|
|
if (manifest.campaignId !== campaignId || !Array.isArray(manifest.files)) throw new Error("Invalid campaign manifest");
|
|
for (const file of manifest.files) safePath(file.path);
|
|
return manifest;
|
|
});
|
|
manifests.set(campaignId, pending);
|
|
pending.catch(() => manifests.delete(campaignId));
|
|
}
|
|
return pending;
|
|
}
|
|
|
|
async function campaignHtml(campaignId: string) {
|
|
const [campaign, manifest] = await Promise.all([
|
|
publicJson<PublishedCampaign>(`campaigns/${campaignId}/normalized-results.json`),
|
|
manifestFor(campaignId),
|
|
]);
|
|
if (campaign.campaignId !== campaignId || !Array.isArray(campaign.results)) throw new Error("Invalid retained campaign");
|
|
return renderRunnerE2EDashboard({
|
|
title: "Runner Full-Stack E2E",
|
|
generatedAt: campaign.generatedAt,
|
|
expected: campaign.expected,
|
|
catalog: runnerMatrix,
|
|
campaign,
|
|
history: localHistory,
|
|
entries: campaign.results.map((result) => {
|
|
const base = `evidence/${result.executionId}/attempt-${result.attempt}`;
|
|
return {
|
|
result,
|
|
valid: result.status === "passed" && result.cleanup === "passed" && result.evidenceValid !== false,
|
|
errors: result.evidenceErrors ?? [],
|
|
evidenceBaseHref: base,
|
|
evidenceFiles: manifest.files.filter((file) => file.path.startsWith(`${base}/`))
|
|
.map((file) => file.path.slice(base.length + 1)),
|
|
};
|
|
}),
|
|
});
|
|
}
|
|
|
|
function withReload(html: string) {
|
|
return html.replace("</body>", `<script>
|
|
const previewVersion = ${JSON.stringify(version)};
|
|
setInterval(async () => {
|
|
try {
|
|
const response = await fetch("/__preview/version", { cache: "no-store" });
|
|
if (response.ok && await response.text() !== previewVersion) location.reload();
|
|
} catch {}
|
|
}, 1000);
|
|
</script></body>`);
|
|
}
|
|
|
|
const contentTypes: Record<string, string> = {
|
|
".json": "application/json", ".png": "image/png", ".woff2": "font/woff2",
|
|
".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8",
|
|
".log": "text/plain; charset=utf-8", ".xml": "application/xml",
|
|
};
|
|
const server = createServer(async (request, response) => {
|
|
response.setHeader("Cache-Control", "no-store");
|
|
try {
|
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
response.writeHead(405).end("Read-only preview");
|
|
return;
|
|
}
|
|
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
|
|
if (pathname === "/__preview/version") {
|
|
response.writeHead(200, { "Content-Type": "text/plain" }).end(version);
|
|
return;
|
|
}
|
|
if (pathname === "/" || pathname === "/runner-e2e") {
|
|
response.writeHead(302, { Location: routeRoot }).end();
|
|
return;
|
|
}
|
|
let html: string | undefined;
|
|
let bytes: Buffer | undefined;
|
|
let extension = ".html";
|
|
if (pathname === routeRoot || pathname === `${routeRoot}index.html`) {
|
|
html = renderRunnerHistoryIndex(localHistory);
|
|
} else if (pathname.startsWith(routeRoot)) {
|
|
const relative = safePath(decodeURIComponent(pathname.slice(routeRoot.length)).replace(/\/$/, ""));
|
|
const campaignMatch = /^campaigns\/([A-Za-z0-9._-]+)(?:\/(.*))?$/.exec(relative);
|
|
if (campaignMatch && campaigns.has(campaignMatch[1]!)) {
|
|
const campaignId = campaignMatch[1]!;
|
|
const artifact = campaignMatch[2];
|
|
if (!artifact || artifact === "index.html" || artifact === "dashboard.html") {
|
|
if (!artifact && !pathname.endsWith("/")) {
|
|
response.writeHead(302, { Location: `${pathname}/` }).end();
|
|
return;
|
|
}
|
|
html = await campaignHtml(campaignId);
|
|
} else if ((await manifestFor(campaignId)).files.some((file) => file.path === artifact)) {
|
|
extension = path.extname(artifact);
|
|
if (contentTypes[extension]) bytes = await publicFile(relative);
|
|
}
|
|
} else if (relative === "history.json") {
|
|
extension = ".json";
|
|
bytes = Buffer.from(JSON.stringify(retainedHistory));
|
|
} else if (relative === "assets/InterVariable.woff2" || relative === "assets/favicon-32x32.png") {
|
|
extension = path.extname(relative);
|
|
bytes = await publicFile(relative);
|
|
}
|
|
}
|
|
if (html !== undefined) bytes = Buffer.from(withReload(html));
|
|
if (!bytes) {
|
|
response.writeHead(404, { "Content-Type": "text/plain" }).end("Preview page or public artifact not found");
|
|
return;
|
|
}
|
|
response.writeHead(200, { "Content-Type": contentTypes[extension] ?? "text/html; charset=utf-8" });
|
|
response.end(request.method === "HEAD" ? undefined : bytes);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(message);
|
|
response.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" }).end(message);
|
|
}
|
|
});
|
|
return { server, campaignCount: campaigns.size, cacheRoot };
|
|
}
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2).filter((argument) => argument !== "--");
|
|
const portIndex = args.indexOf("--port");
|
|
const port = portIndex < 0 ? 4317 : Number(args[portIndex + 1]);
|
|
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("Invalid --port");
|
|
const { server, campaignCount, cacheRoot } = await createRunnerReportPreview({
|
|
offline: args.includes("--offline"), refresh: args.includes("--refresh"),
|
|
});
|
|
server.listen(port, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
const origin = `http://127.0.0.1:${typeof address === "object" && address ? address.port : port}`;
|
|
console.log(`History: ${origin}${routeRoot}`);
|
|
console.log(`${campaignCount} real campaigns; reports and public evidence cache on first access.`);
|
|
console.log(`Cached data: ${cacheRoot}`);
|
|
console.log("Edit history-index.ts or dashboard.ts; Node restarts the preview and the browser reloads.");
|
|
});
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
|