fix: preserve Product TestOps evidence and CI readiness (#19000)

## Thinking Path

> - Paperclip manages agent work through governed product workflows.
> - Product E2E tests check those workflows through the app and Runner.
> - A trusted reporter sends retained results to TestOps.
> - Free-form metadata and a short upload observer can lose safe, valid
evidence.
> - UUID navigation can also discard task metadata before the canonical
query returns.
> - Hermetic CI files start child processes that need CPU capacity
within their readiness limits.
> - This change closes the reporting contract, retains authenticated
core aliases, and reserves CI CPU capacity.
> - Original case identities, conditions, assertions, verdicts and
deadlines remain intact.

## Linked Issues or Issue Description

Refs #18983. This addresses its late [metadata disclosure
finding](https://github.com/paperclipai/paperclip/pull/18983#discussion_r4470789114)
and [upload readiness
finding](https://github.com/paperclipai/paperclip/pull/18983#discussion_r4470790366).

**What happened?**

The reporter accepted model IDs and matcher names through syntax
filters. A secret-shaped string could pass those filters. First-task
results also use their top-level model field for an observed alias.
Treating it as configured metadata can reject valid retained evidence.
The upload observer stopped after 22 seconds of waits.

[Required CI on the earlier
head](https://github.com/paperclipai/paperclip/actions/runs/41886885193)
also found two real browser title failures and five native package
readiness failures. A deterministic control reproduced metadata loss
during UUID-to-identifier navigation. A separate fixed-resource control
reproduced startup deadline failures with four concurrent hermetic
fixtures, while one and two fixtures passed.

**Expected behavior**

Reject unknown transmitted metadata before creating upload files.
Preserve every original matcher verdict and attempt receipt. Observe
server processing without rerunning the product. Keep authenticated task
metadata visible through canonical navigation. Reserve capacity for
hermetic child processes without changing the original readiness
deadlines.

**Steps to reproduce**

Use a secret-shaped model ID or cleanup state in retained evidence,
return an observed first-task model alias, or let upload processing
settle after 40 seconds of virtual time. Hold the canonical core query
pending after an authenticated UUID read. For the CPU control, run the
same providerless startup/recovery fixture alone, four times
concurrently, and twice concurrently under a fixed 4-vCPU quota and its
original 8-second readiness limit.

## What Changed

- Bind configured model receipts to each exact declared case model. Four
first-task cases use captured agent configuration; their observed
aliases and fallback text remain digest-only. A missing capture stays
null / Not recorded. The other 16 cells use their configured profile
model. Reject wrong or unknown configuration before any upload file
exists.
- Keep the existing model distribution: 18 Codex cells on `gpt-5.6-sol`,
configured-repository case 9 on its existing `gpt-6.1-sol` override, and
task-workspaces case 54 on `openrouter/deepseek/deepseek-v4-flash-0731`.
The repository override exists at the [original canary
source](https://github.com/paperclipai/paperclip/blob/86814c6550f52e99ede150a883bdc773cbb540e3/tests/runner-e2e/catalog.ts#L1148).
No model configuration changes.
- Allow only existing Matcher kinds, closed failure classes, and cleanup
states `passed`, `failed`, or `not_started`. Publish each matcher's
kind, ordinal, original boolean verdict and SHA256 path receipt. Full
conditions remain in the linked original workflow artifact; raw dynamic
path bytes do not enter TestOps.
- Observe the same upload for at most 55 seconds of poll waits. Product
scenarios, provider retries and scenario deadlines do not change.
- Retain task core data under only the authenticated response's own UUID
and identifier core keys. Preserve existing canonical data. Keep
arbitrary references and full-detail keys empty until their own reads.
- Retain a first-failure trace for the existing Cloud recovery test.
Keep its assertions, timeout, retries, mock data and artifact policy
unchanged. This adds diagnosis evidence; it does not claim to fix that
separate Cloud title timeout.
- Reserve half the available CPUs for Runner package CI workers, with
the existing upper cap of four. A 4-vCPU host uses two workers; an
8-vCPU host still uses four. Local defaults and test selection do not
change.

## Verification

- Reporting controls first reproduced the two original gaps. All 14 Node
contract tests now pass. Unknown configuration creates neither a result
directory nor an export summary. A model valid for a different selected
case is also rejected. Original functional failures and earlier
accounting receipts survive.
- The catalog compatibility test passes for all 20 selected cells, exact
model tuples, real pure grader outputs and actual
`projectFirstTaskResult` shapes. It covers observed aliases, fallback
text, absent evidence and wrong or secret-shaped configured metadata.
These are contract controls, not live journey passes.
- Virtual-clock controls accept a correct result that settles at 40
seconds and reject a persistently wrong verdict after 55 seconds. They
make no product or provider calls.
- Two new core-cache controls failed before repair. The query/cache
suites now pass all 8 tests. They hold canonical refresh pending and
verify title/company retention, newer canonical data, full-detail
enrichment and unrelated keys. UI typecheck and token gates pass.
- The unchanged exact UUID cross-company link/upload browser test passed
once after repair: 5.8-second scenario, 32.0-second complete command.
The unchanged exact Cloud task-entry diagnostic passed once: 5.4-second
scenario, 23.3-second complete command. Its original canonical-only
timeout remains unexplained. All five Cloud tests still collect after
the trace setting.
- The providerless Linux control used the retained Runner and compiled
controller from source `3a2249dd`, with a fixed 4-vCPU quota and
original 8-second readiness limit. Solo passed 1/1; four concurrent
fixtures failed 4/4; two concurrent fixtures passed 2/2. At each
failure, Rust had accepted command 2 but startup was still pending;
checkpoint and published status agreed, one connection was
authenticated, no replay backlog existed, and the sidecar had not
launched. Passing controls preserved the expected startup failure
through controller replacement. No network or credentials were
available.
- That control uses Linux amd64 emulation and Node 24.21; hosted CI used
Node 24.22.1. It supports the worker-cap change, but does not prove that
the original `m5n.xlarge` host caused every CI failure. The original red
attempts remain recorded.
- Actual config-load controls verify 1 CPU → 1 worker, 2 → 1, 4 → 2, 8 →
4 and 16 → 4, with no local, timeout or retry override. The isolated
policy change preserves the same 297 test-file inventory; integration of
current master `89703c3ff7` retains all 298 current native package
files. The original PR package/native command plans remain unchanged.
- Final head `877e28da32f2d2f0d12906c89aff085e2c6ea943` passes both
required checks, `ci / verify` and `ci / e2e`, in [CI run 41897677652,
attempt
2](https://github.com/paperclipai/paperclip/actions/runs/41897677652/attempts/2).
The native package passes 4,950 tests with 10 existing skips. All eight
browser shards pass; the Cloud shard passes all 41 tests, including all
four unchanged recovery cases.
- Attempt 1 preserved 46 successful jobs but its one-file heartbeat
recovery shard hit the original 20-minute job limit without a named
assertion or completion verdict. Its cause remains unidentified. One
explicitly bounded debug replay retained those 46 successes and ran only
that leaf and its dependent rollup. The unchanged file passed all 412
tests (346.91-second file, 371.34-second Vitest command, 7m11s job).
Source, cases and deadlines did not change. This establishes
intermittency, not a causal repair. The original timeout and debug pass
both remain recorded.
- Fresh [Greptile
5/5](https://github.com/paperclipai/paperclip/pull/19000#issuecomment-6637604918)
and [Ponytail
5/5](https://github.com/paperclipai/paperclip/pull/19000#issuecomment-6637489454)
reviews name exact head `877e28`. The single review thread is resolved.
Live TestOps bootstrap and complete 20-case acceptance remain held until
reviewed delivery. CI and focused controls do not count as product plan
passes.

## Risks

- Future model or Matcher kind changes require a reviewed
reporting-contract update.
- TestOps shows a closed Matcher kind and ordinal. The path digest binds
the original condition in retained evidence.
- Core alias data retains the existing authenticated query-client and
server company boundaries. It never becomes full-detail evidence.
- Fewer CI workers can increase one job's duration. The full suite must
verify the final policy. The paid evaluation matrix and its parallelism
do not change.
- The original Cloud title timeout and the first-attempt heartbeat CI
hang do not have proven causes. Current unchanged assertions pass, but
this does not establish repeatability. The Cloud test now retains its
first-failure trace. A successful upload or focused control alone cannot
establish a complete product pass.

## Model Used

OpenAI Codex with reasoning, tool use and code execution. The host does
not expose the exact model ID or context window to 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
#` 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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-04 14:41:28 -06:00
1 parent 513c03cb3e
commit c99bcd89fe
9 files changed
+246 -19

No files matched your search

+5 -1
View File
@@ -19,7 +19,11 @@ export default defineConfig({
? { reporters: [...configDefaults.reporters, resolve(packageRoot, "../../scripts/ci-profile/vitest-reporter.mjs")] }
: {}),
environment: "node",
...(process.env.GITHUB_ACTIONS === "true" ? { maxWorkers: Math.min(4, availableParallelism()) } : {}),
// Hermetic files also launch Runner and sidecar processes. Reserve CPU
// capacity for their startup within the original readiness deadlines.
...(process.env.GITHUB_ACTIONS === "true"
? { maxWorkers: Math.min(4, Math.max(1, Math.floor(availableParallelism() / 2))) }
: {}),
sequence: { sequencer: RunnerSequencer },
include: [
"src/**/*.test.ts",
+2
View File
@@ -1,6 +1,8 @@
import { randomUUID } from "node:crypto";
import { expect, test } from "@playwright/test";
test.use({ trace: "retain-on-failure" });
// The tenant UI and task database are real. Cloud is an external dependency:
// simulate its entry endpoint and independent session states at the HTTP edge.
for (const cloudOrigin of ["https://my.paperclip.app", "https://my-staging.paperclip.app"]) {
@@ -1,6 +1,14 @@
import { expect, it } from "vitest";
import { selectRunnerExecutions, parseRunnerSelectors } from "./selectors.js";
import { productRegressionCases } from "./product-regression-testops.mjs";
import { productRegressionCases, productReportingModels, productReportingMatcherKinds, projectProductResults } from "./product-regression-testops.mjs";
import { gradeFirstTask, type FirstTaskEvidence } from "./first-task-scoring.js";
import { projectFirstTaskResult } from "./first-task-flow.js";
import { gradeContinuation } from "./continuation-scoring.js";
import type { ContinuationCase } from "./continuation-cases.js";
import { gradeTaskWorkspaces } from "./task-workspaces-scoring.js";
import { gradeRepositoryProof } from "./repository-verification.js";
import type { Matcher, RunnerE2EResult } from "./types.js";
import { createHash } from "node:crypto";
it("runs every standing Product regression case through its existing full-stack fixture", () => {
const executions = selectRunnerExecutions(parseRunnerSelectors(
@@ -14,5 +22,80 @@ it("runs every standing Product regression case through its existing full-stack
expect(execution.profile.id).toBe(row.profile);
expect(execution.environment.id).toBe("local");
expect(execution.requiredCredentials).not.toContain("CODEX_AUTH_JSON");
expect(productReportingModels).toContain(execution.profile.model);
expect(execution.profile.model).toBe(row.model);
for (const matcher of execution.task.buildMatchers("report-contract-control", execution)) {
expect(productReportingMatcherKinds).toContain(matcher.kind);
}
// Incomplete synthetic observations exercise the actual pure graders' output
// contracts. Their failures must survive reporting; these are not live runs.
let checks: Array<{ id: string; passed: boolean }> = [];
let firstTask: FirstTaskEvidence | undefined;
if (row.suite === "first-task") {
firstTask = {
caseId: row.name, nonce: "report-control", onboardingIssueId: "issue", agentId: "agent", initialTaskIds: ["issue"],
instructions: [], configuredModel: execution.profile.model ?? null, observedModels: [], checks: [],
checkpoints: [{ id: "opening", at: "2026-11-04T00:00:00Z", phase: "opening", issueId: "issue",
tasks: [], agents: [], comments: [], interactions: [], documents: [], runs: [] }],
};
checks = gradeFirstTask(firstTask);
}
if (row.suite === "continuation") checks = gradeContinuation({ id: row.name as ContinuationCase,
fact: "fact", marker: "marker", old: "old", injected: "injected", childTitle: "child", checkpoints: [], runtimeMode: "native" });
if (row.suite === "task-workspaces") checks = gradeTaskWorkspaces({ nonce: "control", native: true, daytona: false,
initial: null, checkpoints: [], projectCount: 0, runCount: 0, restarted: false, pendingInteractions: 0,
activeOperations: 0, activeRecovery: false, artifact: null, remoteLeaseRunIds: [] });
if (row.suite === "configured-repository") checks = gradeRepositoryProof({
conversion: false, expectedLegacyAdapter: "codex_local", originalAdapter: "paperclip_runner", finalAdapter: "paperclip_runner",
configurationBefore: "", configurationAfter: "", instructionsBefore: "", instructionsAfter: "", permissionsPreserved: false,
expectedModel: execution.profile.model!, actualModel: null, actualProvider: null, expectedProvider: "codex", environmentRetained: false,
issueDone: false, runCount: 0, committedRuns: 0, sourceBytes: "", currentSourceBytes: null, expectedCommit: "",
downloaded: null, downloadVerified: false, visibleDelivery: false, pendingInteractions: 0,
});
const matcherResults = [
...execution.task.buildMatchers("report-contract-control", execution).map(matcher => ({ matcher, passed: false, detail: "contract control" })),
...checks.map(check => ({ matcher: { kind: "json_path", path: check.id, expected: true } satisfies Matcher, passed: check.passed, detail: "contract control" })),
];
expect(matcherResults.length).toBeGreaterThan(0);
const sourceSha = "b".repeat(40);
const raw: RunnerE2EResult = { schema: "paperclip.runner-e2e.result/v2", executionId: row.executionId,
suiteId: row.suite, caseId: row.name, profileId: row.profile, environmentId: "local",
source: { sha: sourceSha, ref: null, workflowRunUrl: null }, firstTask,
attempt: 1, status: "failed", failureClass: "candidate_failure", cleanup: "passed", model: execution.profile.model,
provider: execution.profile.provider, runtimeMode: "native", startedAt: "2026-11-04T00:00:00Z",
finishedAt: "2026-11-04T00:00:01Z", durationMs: 1000, matcherResults };
const project = (result: RunnerE2EResult) => projectProductResults({ schema: "paperclip.runner-e2e.campaign/v2", campaignId: "gha-123-1",
source: { sha: sourceSha }, expected: [row.executionId], results: [projectFirstTaskResult(result)] },
{ runId: "123", attempt: "1", workflowSha: "a".repeat(40), cases: [row] }, sourceSha);
const projected = project(raw);
expect(projected.summary).toMatchObject({ selected: 1, passed: 0, failed: 1 });
expect(projected.records[1]!.value.checks).toHaveLength(matcherResults.length);
expect(projected.records[1]!.value.checks?.map(check => check.passed)).toEqual(matcherResults.map(check => check.passed));
if (firstTask) {
const alias = "gpt-5.6-sol-2026-11-01", secret = "sk-proj-syntheticcredentialabcdefghijklmnop";
for (const [configuredModel, observedModels] of [[row.model, [alias]], [null, []], [row.model, [secret]]] as const) {
const reported = observedModels[0] ?? configuredModel ?? "provider-default (unreported)";
const output = project({ ...raw, model: reported, firstTask: { ...firstTask, configuredModel, observedModels: [...observedModels] } });
expect(output.summary).toMatchObject({ passed: 0, failed: 1 });
expect(output.records[1]!.value.model).toBe(configuredModel);
expect(output.records[1]!.value.declaredModel).toBe(row.model);
expect(output.records[1]!.value.modelSource).toBe(configuredModel == null ? "not_recorded" : "first_task_configuration");
expect(output.records[1]!.value.reportedModelSha256).toBe(createHash("sha256").update(reported).digest("hex"));
expect(JSON.stringify(output)).not.toContain(reported);
}
for (const configuredModel of ["gpt-6.1-sol", secret]) {
expect(() => project({ ...raw, model: alias, firstTask: { ...firstTask, configuredModel, observedModels: [alias] } }))
.toThrow("product_regression_testops_metadata_invalid");
}
// An error before the first observe() leaves no firstTaskEvidence. The
// production constructor then reports execution.profile.model, which is
// declared intent, not a captured agent-configuration receipt.
const early = project({ ...raw, firstTask: undefined, model: execution.profile.model });
expect(early.summary).toMatchObject({ passed: 0, failed: 1 });
expect(early.records[1]!.value.model).toBeNull();
expect(early.records[1]!.value.modelSource).toBe("not_recorded");
expect(early.records[1]!.value.declaredModel).toBe(row.model);
expect(early.records[1]!.value.reportedModelSha256).toBe(createHash("sha256").update(row.model).digest("hex"));
}
}
});
@@ -132,7 +132,8 @@ export async function productTestOps(command, argument, env = process.env, exec
const api = await testopsClient(env.ALLURE_TOKEN, fetchImpl);
await verifyProductJob(api, selection, env);
const summary = await readJson(env.EXPORT_SUMMARY, 16_384);
// Check server-side identity and count. A successful upload is not a pass.
// Observe one upload with at most 55 seconds of poll waits, matching the
// mature chat lane. This budget never retries or extends a product scenario.
let observed, lastVerification;
for (let attempt = 0; attempt < 12; attempt++) {
observed = await api(`/api/testresult?${new URLSearchParams({ launchId: String(selection.launchId), page: '0', size: '100' })}`);
@@ -150,7 +151,7 @@ export async function productTestOps(command, argument, env = process.env, exec
// their run binding and verdict. Observe this one upload, without rerunning
// the product or accepting unknown/partial results.
if (attempt === 11) { if (lastVerification) throw lastVerification; fail('upload_incomplete'); }
await wait(2_000);
await wait(5_000);
}
}
@@ -3,8 +3,17 @@ export interface ProductRegressionCase {
suite: string;
name: string;
profile: string;
model: string;
fullName: string;
executionId: string;
}
export const productRegressionCases: readonly ProductRegressionCase[];
export const productReportingModels: readonly string[];
export const productReportingMatcherKinds: readonly string[];
export function projectProductResults(campaign: unknown, selection: unknown, sourceSha: string): {
summary: { selected: number; passed: number; failed: number; broken: number };
records: Array<{ file: string; value: { model?: string | null; declaredModel?: string; reportedModelSha256?: string | null;
modelSource?: 'first_task_configuration' | 'execution_profile' | 'not_recorded';
checks?: Array<{ name: string; ordinal: number; passed: boolean; pathSha256: string | null }> } }>;
};
@@ -25,14 +25,22 @@ export const productRegressionCases = [
[168, 'agent-chat', 'new-session'],
].map(([id, suite, name, profile = 'runner-codex']) => Object.freeze({
id, suite, name, profile,
model: suite === 'task-workspaces' ? 'openrouter/deepseek/deepseek-v4-flash-0731'
: suite === 'configured-repository' ? 'gpt-6.1-sol' : 'gpt-5.6-sol',
fullName: `paperclip.Product E2E.${suite}.${name}`,
executionId: `${suite}.${profile}.local.${name}`,
}));
const fail = code => { throw new Error(`product_regression_testops_${code}`); };
const sha = value => createHash('sha256').update(value).digest('hex');
const identifier = value => typeof value === 'string' && /^[a-zA-Z0-9._:-]{1,200}$/.test(value);
const modelIdentifier = value => typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,199}$/.test(value);
// Closed transmission contracts, checked against all 20 catalog cells. Dynamic
// matcher paths stay in the linked source artifact; only their digest leaves it.
export const productReportingModels = [...new Set(productRegressionCases.map(row => row.model))];
export const productReportingMatcherKinds = ['message_exact', 'message_contains', 'message_occurrences', 'message_regex',
'message_ordered', 'issue_status', 'run_status', 'runtime_mode', 'environment', 'file_exists', 'file_exact',
'file_contains', 'artifact_exists', 'artifact_exact', 'json_path', 'json_schema'];
const failureClasses = ['candidate_failure', 'provider_variance', 'transient_infrastructure', 'permanent_infrastructure',
'secret_leak', 'cleanup_failure', 'setup_blocked'];
export const endpoint = 'https://paperclip.testops.cloud';
export const workflowUrl = 'https://github.com/paperclipai/paperclip/actions/workflows/runner-full-stack-e2e.yml';
// Official GitHub/Allure job metadata uses these exact aliases for workflow
@@ -106,6 +114,14 @@ export function projectProductResults(campaign, selection, sourceSha) {
|| !['passed', 'failed'].includes(result.status) || (!missingArtifact && !Array.isArray(result.matcherResults)))) fail('result_binding');
const checks = result?.matcherResults ?? [];
if (checks.length > 300 || checks.some(check => typeof check.passed !== 'boolean')) fail('checks_invalid');
// First-task's producer reports an observed alias in result.model. Its
// independently captured agent configuration is the configuration receipt.
const firstTask = selected.suite === 'first-task';
const configuredModel = (firstTask ? result?.firstTask?.configuredModel : result?.model) ?? null;
if ((configuredModel != null && configuredModel !== selected.model)
|| (result?.failureClass != null && !failureClasses.includes(result.failureClass))
|| (result && !['passed', 'failed', 'not_started'].includes(result.cleanup))
|| checks.some(check => !productReportingMatcherKinds.includes(check.matcher?.kind))) fail('metadata_invalid');
const passed = result?.status === 'passed' && result.cleanup === 'passed' && checks.length > 0 && checks.every(check => check.passed);
const infrastructure = !result || ['permanent_infrastructure', 'transient_infrastructure', 'setup_blocked'].includes(result.failureClass);
const status = passed ? 'passed' : infrastructure ? 'broken' : 'failed';
@@ -115,12 +131,15 @@ export function projectProductResults(campaign, selection, sourceSha) {
workflowSha: selection.workflowSha, sourceSha, status, originalStatus: result?.status ?? null,
attempt: result?.attempt ?? null, cleanup: result?.cleanup ?? 'not_started',
profile: selected.profile, environment: 'local',
model: modelIdentifier(result?.model) ? result.model : null,
declaredModel: selected.model, model: configuredModel,
modelSource: configuredModel == null ? 'not_recorded' : firstTask ? 'first_task_configuration' : 'execution_profile',
reportedModelSha256: firstTask && typeof result?.model === 'string' ? sha(result.model) : null,
suiteDefinitionHash: /^[a-f0-9]{64}$/.test(result?.suiteDefinitionHash ?? '') ? result.suiteDefinitionHash : null,
failureClass: identifier(result?.failureClass) ? result.failureClass : null,
failureClass: result?.failureClass ?? null,
durationMs: Number.isFinite(result?.durationMs) && result.durationMs >= 0 ? result.durationMs : null,
checks: checks.map(check => ({
name: identifier(check.matcher?.path) ? check.matcher.path : identifier(check.matcher?.kind) ? check.matcher.kind : 'assertion',
checks: checks.map((check, index) => ({
name: check.matcher.kind, ordinal: index + 1,
pathSha256: typeof check.matcher.path === 'string' ? sha(check.matcher.path) : null,
passed: check.passed,
})),
retainedAttempts: (accounting?.attempts ?? []).filter(row => row.executionId === selected.executionId).map(row => ({
@@ -142,12 +161,13 @@ export function projectProductResults(campaign, selection, sourceSha) {
labels: [{ name: 'AS_ID', value: String(selected.id) }, { name: 'parentSuite', value: 'Product E2E' },
{ name: 'suite', value: selected.suite }, { name: 'framework', value: 'Paperclip Evals' }],
parameters: [{ name: 'profile', value: selected.profile }, { name: 'environment', value: 'local / GitHub Actions' },
{ name: 'model', value: provenance.model ?? 'Not recorded' }, { name: 'sourceSha', value: sourceSha }],
{ name: 'declaredModel', value: selected.model }, { name: 'configuredModel', value: provenance.model ?? 'Not recorded' },
{ name: 'sourceSha', value: sourceSha }],
links: [{ name: 'Full-stack execution and retained diagnostics', type: 'custom',
url: `https://github.com/paperclipai/paperclip/actions/runs/${selection.runId}` },
{ name: 'Evaluated source', type: 'custom', url: `https://github.com/paperclipai/paperclip/commit/${sourceSha}` }],
attachments: [{ name: 'Bound execution provenance and assertions', source: attachment, type: 'application/json' }],
steps: provenance.checks.map(check => ({ name: check.name, status: check.passed ? 'passed' : 'failed', stage: 'finished' })),
steps: provenance.checks.map(check => ({ name: `${check.ordinal}. ${check.name}`, status: check.passed ? 'passed' : 'failed', stage: 'finished' })),
};
if (result && !missingArtifact) for (const [key, date] of [['start', result.startedAt], ['stop', result.finishedAt]]) {
const timestamp = Date.parse(date);
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { mkdtemp, mkdir, readFile, rm, writeFile, lstat } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { productRegressionCases, selectProductRegression, validateSelection, projectProductResults, workflowUrl, workflowUrls } from './product-regression-testops.mjs';
@@ -19,7 +20,7 @@ const selection = cases => ({ schema: 'paperclip.product-regression-testops-sele
const result = (overrides = {}) => ({
executionId: selected.executionId, suiteId: selected.suite, caseId: selected.name,
profileId: selected.profile, environmentId: 'local', source: { sha: targetSha }, attempt: 1,
status: 'passed', cleanup: 'passed', model: 'gpt-6.1-sol', suiteDefinitionHash: 'c'.repeat(64),
status: 'passed', cleanup: 'passed', model: 'gpt-5.6-sol', suiteDefinitionHash: 'c'.repeat(64),
matcherResults: [{ matcher: { kind: 'json_path', path: 'observed.outputVerified', expected: true }, passed: true }],
...overrides,
});
@@ -114,7 +115,7 @@ test('authoritative normalized accounting preserves earlier failed attempt verdi
assert.deepEqual(projected.records[1].value.attemptAccounting, { scope: 'retained_attempts', missingAttempts: 0, excludedArtifacts: 0 });
assert.throws(() => projectProductResults({ ...measured, accounting: { ...measured.accounting, attempts: [{ ...measured.accounting.attempts[0], status: 'unknown' }] } }, selection([selected]), targetSha));
const openCode = productRegressionCases.find(row => row.id === 54);
const model = 'openrouter/deepseek/deepseek-v4.1-flash-1017';
const model = 'openrouter/deepseek/deepseek-v4-flash-0731';
const record = result({ executionId: openCode.executionId, suiteId: openCode.suite, caseId: openCode.name, profileId: openCode.profile, model });
const openCodeCampaign = { ...campaign([record]), expected: [openCode.executionId] };
assert.equal(projectProductResults(openCodeCampaign, selection([openCode]), targetSha).records[1].value.model, model);
@@ -123,13 +124,31 @@ test('authoritative normalized accounting preserves earlier failed attempt verdi
test('the transmission projection excludes raw errors, provider IDs, narratives and unexpected fields', () => {
const dirty = result({ error: 'secret-error', runIds: ['private-provider-session'], detail: 'private-context',
usage: { credential: 'private-token' }, matcherResults: [{ passed: true, detail: 'secret-detail',
matcher: { kind: 'contains', expected: 'fixture-answer-secret', path: 'unsafe narrative with secrets' } }] });
matcher: { kind: 'message_contains', expected: 'fixture-answer-secret', path: 'unsafe narrative with secrets' } }] });
const text = JSON.stringify(projectProductResults(campaign([dirty]), selection([selected]), targetSha));
for (const secret of ['secret-error', 'private-provider-session', 'private-context', 'private-token', 'secret-detail', 'fixture-answer-secret', 'unsafe narrative']) {
assert.equal(text.includes(secret), false, secret);
}
});
test('secret-shaped and unknown metadata cannot cross the reporting boundary', () => {
const sentinel = 'sk-proj-syntheticcredentialabcdefghijklmnop';
for (const mutation of [{ model: sentinel }, { model: 'gpt-6.1-sol' }, { failureClass: sentinel }, { cleanup: sentinel },
{ matcherResults: [{ passed: true, matcher: { kind: sentinel } }] }]) {
assert.throws(() => projectProductResults(campaign([result(mutation)]), selection([selected]), targetSha),
error => error.message === 'product_regression_testops_metadata_invalid');
}
const projected = projectProductResults(campaign([result({ matcherResults: [
{ passed: false, matcher: { kind: 'json_path', path: sentinel } },
] })]), selection([selected]), targetSha);
assert.equal(JSON.stringify(projected).includes(sentinel), false);
assert.equal(projected.summary.passed, 0);
assert.equal(projected.records[1].value.checks[0].name, 'json_path');
assert.equal(projected.records[1].value.checks[0].ordinal, 1);
assert.equal(projected.records[1].value.checks[0].passed, false);
assert.equal(projected.records[1].value.checks[0].pathSha256, createHash('sha256').update(sentinel).digest('hex'));
});
test('a TestOps job run must belong to this workflow, source launch and GitHub execution', async () => {
const values = { '/api/jobrun/456': { id: 456, launchId: 789, job: { id: 50, type: 'github' }, url: 'https://github.com/paperclipai/paperclip/actions/runs/123' },
'/api/job/50': { id: 50, type: 'github', url: workflowUrl }, '/api/launch/789': { id: 789, projectId: 1 } };
@@ -193,11 +212,28 @@ test('the separated selection, retained-report projection and server verificatio
await mkdir(path.join(runtime.REPORT_DIR, 'normalized'), { recursive: true });
await writeFile(path.join(runtime.REPORT_DIR, 'normalized', 'normalized-results.json'), JSON.stringify(campaign()));
assert.equal(await productTestOps('project', selectionFile, runtime, exec, fetchImpl), 0);
const unsafeRuntime = { ...runtime, ALLURE_RESULTS: path.join(root, 'unsafe-results'), EXPORT_SUMMARY: path.join(root, 'unsafe-export.json') };
for (const mutation of [{ model: 'sk-proj-syntheticcredentialabcdefghijklmnop' },
{ cleanup: 'sk-proj-syntheticcredentialabcdefghijklmnop' }]) {
await writeFile(path.join(runtime.REPORT_DIR, 'normalized', 'normalized-results.json'), JSON.stringify(campaign([result(mutation)])));
await assert.rejects(productTestOps('project', selectionFile, unsafeRuntime, exec, fetchImpl),
error => error.message === 'product_regression_testops_metadata_invalid');
await assert.rejects(lstat(unsafeRuntime.ALLURE_RESULTS), { code: 'ENOENT' });
await assert.rejects(lstat(unsafeRuntime.EXPORT_SUMMARY), { code: 'ENOENT' });
}
await writeFile(path.join(runtime.REPORT_DIR, 'normalized', 'normalized-results.json'), JSON.stringify(campaign()));
assert.equal(await productTestOps('verify', selectionFile, runtime, exec, fetchImpl), 0);
uploadedStatus = 'unknown';
let readinessWaits = 0;
assert.equal(await productTestOps('verify', selectionFile, runtime, exec, fetchImpl, async () => { readinessWaits++; uploadedStatus = 'passed'; }), 0);
assert.equal(readinessWaits, 1);
uploadedStatus = 'unknown';
let virtualMs = 0;
assert.equal(await productTestOps('verify', selectionFile, runtime, exec, fetchImpl, async ms => {
virtualMs += ms;
if (virtualMs >= 40_000) uploadedStatus = 'passed';
}), 0);
assert.equal(virtualMs, 40_000);
const failureOutput = path.join(root, 'failed-selection-output');
const badPlan = async (command, args) => args[1] === 'plan'
? (await writeFile(args.at(-1), JSON.stringify({ version: '1.0', tests: [{ id: '999', selector: selected.fullName }] })), { stdout: '' })
@@ -211,7 +247,10 @@ test('the separated selection, retained-report projection and server verificatio
{ ...runtime, ALLURE_JOB_RUN_ID: '', TESTOPS_CASE: 'unknown' }, async () => { opened = true; }, fetchImpl), /manual_selection_invalid/);
assert.equal(opened, false);
uploadedStatus = 'failed';
await assert.rejects(productTestOps('verify', selectionFile, runtime, exec, fetchImpl, async () => {}), /uploaded_status_mismatch/);
let deadlineMs = 0;
await assert.rejects(productTestOps('verify', selectionFile, runtime, exec, fetchImpl,
async ms => { deadlineMs += ms; }), /uploaded_status_mismatch/);
assert.equal(deadlineMs, 55_000);
await writeFile(selectionFile, JSON.stringify({ ...receipt, targetSha: workflowSha }));
await assert.rejects(productTestOps('project', selectionFile, runtime, exec, fetchImpl), /selection_hash/);
});
+10 -1
View File
@@ -116,12 +116,21 @@ export function seedIssueDetailCache(
return merged;
}
/** Seed saved projections without replacing lists already read or refreshed. */
/** Retain authenticated core aliases and saved projections without replacing
* data already read or refreshed. Core entries never fill full-detail keys. */
export function seedIssueDetailProjections(
queryClient: QueryClient,
issue: Issue,
issueRef?: string | null,
) {
// Canonical navigation must not discard metadata just fetched through a UUID.
// Only the response's own refs are aliases; an arbitrary requested ref is not.
for (const ref of getIssueDetailCacheRefs(issue)) {
const key = [...queryKeys.issues.detail(ref), "core"];
if (queryClient.getQueryData(key) === undefined) {
queryClient.setQueryData(key, issue);
}
}
const planKey = [...queryKeys.issues.documents(issue.id), "plan"];
if (issue.planDocument !== undefined && queryClient.getQueryData(planKey) === undefined) {
queryClient.setQueryData(planKey, issue.planDocument);
+61 -1
View File
@@ -7,11 +7,12 @@ import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tan
import { afterEach, describe, expect, it, vi } from "vitest";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { getIssueDetailQueryOptions } from "./issueDetailCache";
import { getIssueDetailQueryOptions, seedIssueDetailProjections } from "./issueDetailCache";
vi.mock("@/api/issues", () => ({
issuesApi: {
get: vi.fn(),
getCore: vi.fn(),
},
}));
@@ -75,6 +76,19 @@ function IssueDetailQueryHarness({
return <div>{query.data?.description ?? "EMPTY"}</div>;
}
function IssueCoreQueryHarness({ issueRef }: { issueRef: string }) {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: [...queryKeys.issues.detail(issueRef), "core"],
queryFn: async ({ signal }) => {
const core = await issuesApi.getCore(issueRef, { signal });
seedIssueDetailProjections(queryClient, core, issueRef);
return core;
},
});
return <div>{query.data?.title ?? "EMPTY"} / {query.data?.companyId ?? "UNKNOWN"} / {query.fetchStatus}</div>;
}
async function flush() {
// Multiple act cycles to allow React Query to process the async queryFn
for (let i = 0; i < 5; i++) {
@@ -89,6 +103,52 @@ describe("getIssueDetailQueryOptions", () => {
vi.clearAllMocks();
});
it("retains authenticated task metadata across UUID-to-identifier navigation while canonical core refresh is pending", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const issue = makeIssue({ id: "c1267799-90d5-4cb3-8311-38bd480dd76e", title: "Organization-bound task link", companyId: "owning-company" });
let release!: (value: Issue) => void;
const canonical = new Promise<Issue>((resolve) => { release = resolve; });
vi.mocked(issuesApi.getCore).mockImplementation(async (ref) => ref === issue.id ? issue : canonical);
const render = async (ref: string) => {
await act(async () => root.render(<QueryClientProvider client={queryClient}><IssueCoreQueryHarness issueRef={ref} /></QueryClientProvider>));
await flush();
};
try {
await render(issue.id);
expect(container.textContent).toContain(issue.title);
await render(issue.identifier!);
expect(issuesApi.getCore).toHaveBeenCalledWith(issue.identifier, { signal: expect.any(AbortSignal) });
expect(container.textContent).toContain(issue.title);
expect(container.textContent).toContain("owning-company");
expect(container.textContent).toContain("fetching");
expect(queryClient.getQueryData(queryKeys.issues.detail(issue.identifier!))).toBeUndefined();
expect(queryClient.getQueryData([...queryKeys.issues.detail("unrelated-task"), "core"])).toBeUndefined();
} finally {
release(issue);
await act(async () => root.unmount());
queryClient.clear();
container.remove();
}
});
it("does not replace an existing canonical core receipt or fill full-detail keys", () => {
const queryClient = new QueryClient();
const issue = makeIssue();
const newer = makeIssue({ title: "Newer saved title", updatedAt: new Date("2026-11-04T00:00:00Z") });
const key = [...queryKeys.issues.detail(issue.identifier!), "core"];
queryClient.setQueryData(key, newer);
seedIssueDetailProjections(queryClient, issue, "unrelated-task");
expect(queryClient.getQueryData(key)).toBe(newer);
expect(queryClient.getQueryData([...queryKeys.issues.detail(issue.id), "core"])).toEqual(issue);
expect(queryClient.getQueryData(queryKeys.issues.detail(issue.id))).toBeUndefined();
expect(queryClient.getQueryData(queryKeys.issues.detail(issue.identifier!))).toBeUndefined();
expect(queryClient.getQueryData([...queryKeys.issues.detail("unrelated-task"), "core"])).toBeUndefined();
queryClient.clear();
});
it("treats cached issue data as placeholder and still fetches full detail", async () => {
const container = document.createElement("div");
document.body.appendChild(container);