mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip supports direct adapters and an experimental native Runner path. > - These paths need one stable compatibility matrix. > - The matrix must not launch providers or load credentials during normal tests. > - Result handling must reject incomplete output and sensitive values. > - This pull request adds a credential-free acceptance foundation. > - The benefit is a reviewable contract for later end-to-end executors. ## Linked Issues or Issue Description **What existing behavior does this improve?** This improves verification for direct adapters and Paperclip Runner providers. **Subsystem affected** Cross-cutting test infrastructure for adapters, the server runtime, and the task thread. **Current behavior** The repository has subsystem tests. It does not have one declarative matrix for direct and native compatibility. **Proposed behavior** Add a pure acceptance catalog, result validator, redaction helpers, and failure classification. Keep all execution authority outside this change. **Reason and benefit** The matrix makes legacy isolation and native recovery requirements explicit. The helpers let later executors report safe and complete results. **Breaking changes** None. This change does not alter production runtime selection or start any provider. ## What Changed - Add a catalog for built-in direct adapters and qualified native provider profiles. - Add compatibility cases for runtime selection, task threads, questions, and flag-change recovery. - Add pure redaction and transient-failure classification helpers. - Add fail-closed Markdown and JUnit report aggregation. - Add isolated test and type-check commands. - Document the credential-free boundary and deferred live execution work. ## Verification GitHub Actions must run: - `pnpm test:runner-acceptance` - `pnpm test:runner-acceptance:typecheck` - The repository test, type-check, build, policy, and security gates. No local test command was run. The repository owner requested GitHub-only verification. ## Risks Low production risk. The change adds test-only files and root scripts. The catalog can drift when a built-in adapter changes. Its validation fails closed on that drift. ## Model Used OpenAI Codex with the GPT-5 agent model. The work used high reasoning, repository inspection, tool use, and parallel code review. ## 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 - [ ] 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
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
import { runnerAcceptanceMatrix } from "./catalog.js";
|
|
import { findSensitiveJsonValue, redactText } from "./redaction.js";
|
|
import type {
|
|
AggregatedAcceptanceResult,
|
|
FailureClass,
|
|
RunnerAcceptanceCell,
|
|
RunnerAcceptanceReport,
|
|
RunnerAcceptanceResult,
|
|
} from "./types.js";
|
|
|
|
const RESULT_SCHEMA = "paperclip.runner-acceptance.result/v1" as const;
|
|
const ALLOWED_FAILURE_CLASSES = new Set<FailureClass>([
|
|
"candidate_failure",
|
|
"transient_infrastructure",
|
|
"permanent_infrastructure",
|
|
"secret_leak",
|
|
"cleanup_failure",
|
|
]);
|
|
|
|
function record(value: unknown): Record<string, unknown> | null {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: null;
|
|
}
|
|
|
|
function field(value: Record<string, unknown>, key: string): unknown {
|
|
try {
|
|
return value[key];
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function textField(value: Record<string, unknown>, key: string): string | null {
|
|
const candidate = field(value, key);
|
|
return typeof candidate === "string" ? candidate : null;
|
|
}
|
|
|
|
function numericField(value: Record<string, unknown>, key: string): number | null {
|
|
const candidate = field(value, key);
|
|
return typeof candidate === "number" && Number.isFinite(candidate)
|
|
? candidate
|
|
: null;
|
|
}
|
|
|
|
function safeSensitivePayloadError(value: unknown): string | null {
|
|
try {
|
|
const leak = findSensitiveJsonValue(value);
|
|
return leak ? `unsafe result payload: ${leak}` : null;
|
|
} catch {
|
|
return "result payload could not be inspected safely";
|
|
}
|
|
}
|
|
|
|
function validationErrors(
|
|
value: unknown,
|
|
cell: RunnerAcceptanceCell,
|
|
) {
|
|
const errors: string[] = [];
|
|
const result = record(value);
|
|
if (!result) return ["result must be an object"];
|
|
|
|
if (field(result, "schema") !== RESULT_SCHEMA) {
|
|
errors.push("unsupported result schema");
|
|
}
|
|
if (textField(result, "cellId") !== cell.id) {
|
|
errors.push("result cellId does not match the acceptance cell");
|
|
}
|
|
const attempt = numericField(result, "attempt");
|
|
if (attempt === null || !Number.isSafeInteger(attempt) || attempt < 1) {
|
|
errors.push("attempt must be a positive safe integer");
|
|
}
|
|
const durationMs = numericField(result, "durationMs");
|
|
if (durationMs === null || durationMs < 0) {
|
|
errors.push("duration must be a non-negative finite number");
|
|
}
|
|
const startedAt = textField(result, "startedAt");
|
|
const finishedAt = textField(result, "finishedAt");
|
|
if (
|
|
startedAt === null
|
|
|| finishedAt === null
|
|
|| !Number.isFinite(Date.parse(startedAt))
|
|
|| !Number.isFinite(Date.parse(finishedAt))
|
|
) {
|
|
errors.push("timestamps must be valid ISO-compatible values");
|
|
}
|
|
const status = field(result, "status");
|
|
if (status !== "passed" && status !== "failed") {
|
|
errors.push("status must be passed or failed");
|
|
}
|
|
const failureClass = field(result, "failureClass");
|
|
if (failureClass !== undefined && !ALLOWED_FAILURE_CLASSES.has(failureClass as FailureClass)) {
|
|
errors.push("failureClass is unsupported");
|
|
}
|
|
const error = field(result, "error");
|
|
if (error !== undefined && typeof error !== "string") {
|
|
errors.push("error must be a string when present");
|
|
}
|
|
if (field(result, "redaction") !== "passed") errors.push("redaction did not pass");
|
|
const sensitivePayloadError = safeSensitivePayloadError(value);
|
|
if (sensitivePayloadError) errors.push(sensitivePayloadError);
|
|
|
|
const rawAssertions = field(result, "assertions");
|
|
const assertions = Array.isArray(rawAssertions) ? rawAssertions : [];
|
|
if (!Array.isArray(rawAssertions)) errors.push("assertions must be an array");
|
|
|
|
const outcomes = new Map<string, { passed: boolean }>();
|
|
let duplicateAssertionId = false;
|
|
for (const [index, rawAssertion] of assertions.entries()) {
|
|
const assertion = record(rawAssertion);
|
|
if (!assertion) {
|
|
errors.push(`assertion ${index} must be an object`);
|
|
continue;
|
|
}
|
|
const id = textField(assertion, "id");
|
|
if (!id || id.trim().length === 0) {
|
|
errors.push(`assertion ${index} id must be a non-empty string`);
|
|
continue;
|
|
}
|
|
const passed = field(assertion, "passed");
|
|
if (typeof passed !== "boolean") {
|
|
errors.push(`assertion ${id} passed must be a boolean`);
|
|
}
|
|
const detail = field(assertion, "detail");
|
|
if (detail !== undefined && typeof detail !== "string") {
|
|
errors.push(`assertion ${id} detail must be a string when present`);
|
|
}
|
|
if (outcomes.has(id)) duplicateAssertionId = true;
|
|
else outcomes.set(id, { passed: passed === true });
|
|
}
|
|
if (duplicateAssertionId) errors.push("duplicate assertion ids");
|
|
|
|
for (const assertionId of cell.assertions) {
|
|
const outcome = outcomes.get(assertionId);
|
|
if (!outcome) errors.push(`missing assertion ${assertionId}`);
|
|
else if (!outcome.passed) errors.push(`failed assertion ${assertionId}`);
|
|
}
|
|
const unknownAssertions = [...outcomes.keys()]
|
|
.filter((id) => !cell.assertions.includes(id));
|
|
if (unknownAssertions.length > 0) {
|
|
errors.push(`unknown assertions: ${redactText(unknownAssertions.join(", "))}`);
|
|
}
|
|
if (status !== "passed") {
|
|
errors.push(
|
|
typeof error === "string" && error.length > 0
|
|
? redactText(error)
|
|
: typeof failureClass === "string" && ALLOWED_FAILURE_CLASSES.has(failureClass as FailureClass)
|
|
? failureClass
|
|
: "acceptance cell failed",
|
|
);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function normalizedResult(
|
|
value: unknown,
|
|
cell: RunnerAcceptanceCell,
|
|
): RunnerAcceptanceResult {
|
|
const result = record(value) ?? {};
|
|
const attempt = numericField(result, "attempt");
|
|
const durationMs = numericField(result, "durationMs");
|
|
const status = field(result, "status");
|
|
const failureClass = field(result, "failureClass");
|
|
const error = textField(result, "error");
|
|
const startedAt = textField(result, "startedAt");
|
|
const finishedAt = textField(result, "finishedAt");
|
|
const rawAssertions = field(result, "assertions");
|
|
const assertions = Array.isArray(rawAssertions) ? rawAssertions : [];
|
|
return {
|
|
schema: RESULT_SCHEMA,
|
|
cellId: cell.id,
|
|
attempt: attempt !== null && Number.isSafeInteger(attempt) && attempt >= 1
|
|
? attempt
|
|
: 0,
|
|
status: status === "passed" ? "passed" : "failed",
|
|
...(typeof failureClass === "string" && ALLOWED_FAILURE_CLASSES.has(failureClass as FailureClass)
|
|
? { failureClass: failureClass as FailureClass }
|
|
: {}),
|
|
...(error ? { error: redactText(error) } : {}),
|
|
startedAt: startedAt === null ? "" : redactText(startedAt),
|
|
finishedAt: finishedAt === null ? "" : redactText(finishedAt),
|
|
durationMs: durationMs !== null && durationMs >= 0
|
|
? durationMs
|
|
: 0,
|
|
redaction: field(result, "redaction") === "passed" ? "passed" : "failed",
|
|
assertions: cell.assertions.flatMap((id) => {
|
|
const assertion = assertions
|
|
.map(record)
|
|
.find((candidate) => candidate && textField(candidate, "id") === id);
|
|
return assertion
|
|
? [{
|
|
id,
|
|
passed: field(assertion, "passed") === true,
|
|
...(typeof field(assertion, "detail") === "string"
|
|
? { detail: redactText(field(assertion, "detail") as string) }
|
|
: {}),
|
|
}]
|
|
: [];
|
|
}),
|
|
};
|
|
}
|
|
|
|
function missingResult(cell: RunnerAcceptanceCell, generatedAt: string): AggregatedAcceptanceResult {
|
|
return {
|
|
schema: "paperclip.runner-acceptance.result/v1",
|
|
cellId: cell.id,
|
|
attempt: 0,
|
|
status: "failed",
|
|
failureClass: "permanent_infrastructure",
|
|
error: "No result was supplied",
|
|
startedAt: generatedAt,
|
|
finishedAt: generatedAt,
|
|
durationMs: 0,
|
|
redaction: "passed",
|
|
assertions: [],
|
|
valid: false,
|
|
validationErrors: ["No result was supplied"],
|
|
};
|
|
}
|
|
|
|
export function buildRunnerAcceptanceReport(input: {
|
|
results: readonly unknown[];
|
|
cells?: readonly RunnerAcceptanceCell[];
|
|
generatedAt?: string;
|
|
}): RunnerAcceptanceReport {
|
|
const cells = input.cells ?? runnerAcceptanceMatrix;
|
|
const generatedAt = input.generatedAt ?? new Date().toISOString();
|
|
const candidates = new Map<string, Array<{ index: number; value: unknown }>>();
|
|
for (const [index, value] of input.results.entries()) {
|
|
const result = record(value);
|
|
const cellId = result ? textField(result, "cellId") : null;
|
|
const candidateCellId = cellId ?? `malformed-result-${index + 1}`;
|
|
candidates.set(candidateCellId, [
|
|
...(candidates.get(candidateCellId) ?? []),
|
|
{ index, value },
|
|
]);
|
|
}
|
|
|
|
const selected = cells.map((cell) => {
|
|
const attempts = (candidates.get(cell.id) ?? []).sort((left, right) => {
|
|
const leftRecord = record(left.value) ?? {};
|
|
const rightRecord = record(right.value) ?? {};
|
|
const leftAttempt = numericField(leftRecord, "attempt") ?? 0;
|
|
const rightAttempt = numericField(rightRecord, "attempt") ?? 0;
|
|
const leftFinishedAt = textField(leftRecord, "finishedAt");
|
|
const rightFinishedAt = textField(rightRecord, "finishedAt");
|
|
const leftFinishedTime = leftFinishedAt === null ? 0 : Date.parse(leftFinishedAt) || 0;
|
|
const rightFinishedTime = rightFinishedAt === null ? 0 : Date.parse(rightFinishedAt) || 0;
|
|
return rightAttempt - leftAttempt
|
|
|| rightFinishedTime - leftFinishedTime
|
|
|| left.index - right.index;
|
|
});
|
|
const candidate = attempts[0];
|
|
if (!candidate) return missingResult(cell, generatedAt);
|
|
const errors = validationErrors(candidate.value, cell);
|
|
return {
|
|
...normalizedResult(candidate.value, cell),
|
|
valid: errors.length === 0,
|
|
validationErrors: errors,
|
|
} satisfies AggregatedAcceptanceResult;
|
|
});
|
|
|
|
const knownCellIds = new Set(cells.map(({ id }) => id));
|
|
const unknownCellIds = [...candidates.keys()].filter((id) => !knownCellIds.has(id));
|
|
if (unknownCellIds.length > 0) {
|
|
throw new Error(`Results reference unknown acceptance cells: ${unknownCellIds.join(", ")}`);
|
|
}
|
|
|
|
return {
|
|
schema: "paperclip.runner-acceptance.report/v1",
|
|
generatedAt,
|
|
suiteDefinitionHash: cells[0]?.suiteDefinitionHash ?? "empty",
|
|
selected: selected.length,
|
|
passed: selected.filter(({ valid }) => valid).length,
|
|
failed: selected.filter(({ valid }) => !valid).length,
|
|
retries: selected.reduce((sum, result) => sum + Math.max(0, result.attempt - 1), 0),
|
|
results: selected,
|
|
};
|
|
}
|
|
|
|
function escapeMarkdown(value: string) {
|
|
return value.replaceAll("|", "\\|").replaceAll("\n", " ");
|
|
}
|
|
|
|
export function renderRunnerAcceptanceMarkdown(report: RunnerAcceptanceReport) {
|
|
return [
|
|
"# Runner acceptance",
|
|
"",
|
|
`Passed: ${report.passed}/${report.selected}`,
|
|
"",
|
|
"| Cell | Attempt | Result | Duration | Detail |",
|
|
"|---|---:|---|---:|---|",
|
|
...report.results.map((result) =>
|
|
`| ${escapeMarkdown(result.cellId)} | ${result.attempt} | ${result.valid ? "pass" : "fail"} | ${Math.round(result.durationMs / 1000)}s | ${escapeMarkdown(result.validationErrors.join("; ") || "ok")} |`),
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function xml(value: unknown) {
|
|
return String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
export function renderRunnerAcceptanceJUnit(report: RunnerAcceptanceReport) {
|
|
const cases = report.results.map((result) => {
|
|
const failure = result.valid
|
|
? ""
|
|
: `<failure message="${xml(result.validationErrors.join("; "))}"/>`;
|
|
return `<testcase classname="runner-acceptance" name="${xml(result.cellId)}" time="${result.durationMs / 1000}">${failure}</testcase>`;
|
|
}).join("");
|
|
return `<?xml version="1.0" encoding="UTF-8"?><testsuite name="Runner acceptance" tests="${report.selected}" failures="${report.failed}">${cases}</testsuite>\n`;
|
|
}
|