Files
PaperClipAI/tests/runner-e2e/product-journeys/onboarding-connect.ts
T
DottaandPaperclip 970127a022 test: retain onboarding Connect failure evidence (#17529)
## Thinking Path

> - Paperclip manages agents and their work through normal company
controls.
> - Product E2E journeys check these controls through the browser and
public API.
> - Cloud onboarding uses one Connect action to validate the environment
and hire the first agent.
> - A failed Connect action previously left only a general journey error
in the report.
> - This pull request retains a closed diagnostic receipt for that
action.
> - The report can show the observed failure boundary without retaining
private response text.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Cloud onboarding Product E2E failure evidence and report detail.

**Current behavior**

A validation or hire failure can reach the journey as a general
operation failure. The report does not retain which Connect response
failed.

**Proposed behavior**

Observe only the same-origin, same-company validation and hire POST
requests made during one Connect action. Retain a closed receipt with
the checkpoint, response state, bounded HTTP status, and allowlisted
adapter, outcome, check code, level, and server code. The Get started
control and saved-agent readback remain required.

**Reason and benefit**

The next failure can be attributed to the exact observed browser/API
boundary. This change does not claim the cause of a historical failure.

**Breaking changes**

The new receipt is optional. Historical results remain valid. Explicit
validation and hire rejections now stop the journey with a precise
closed error code. This changes only E2E source; it does not change
product behavior or qualification gates.

## What Changed

- Add a bounded Connect observer with exact request matching and
listener cleanup.
- Retain known server codes from `details.code` or `code` without
response messages, hints, headers, request bodies, provider text, or
credentials.
- Validate the receipt as a closed schema and render its allowlisted
fields in existing report detail.
- Include the new helper in the journey definition hash.
- Add route, privacy, malformed-body, deadline, late-response,
delayed-failure, success, and historical compatibility tests. Document
the evidence boundary.

## Verification

- Focused Product E2E tests passed: 3 files, 58 tests on the review-fix
head.
- E2E typecheck, workspace typecheck, and full build passed on the
preceding implementation head. The review fix changes only report
labels, checkpoint timing, and the matching tests.
- The local full `pnpm test:run` command stopped progressing at the
general-server start header. Its owned session was interrupted. This is
not recorded as a pass.
- All 58 remote checks completed on the final review-fix head: 55
passed, one neutral, and two expected skips. Both Greptile and Paperclip
reviews are 5/5 with no unresolved review threads.
- No live campaign was run with this patch. The observed historical
failure is preserved.

## Risks

- Unknown or missing responses remain explicit diagnostic states. They
do not establish a product root cause.
- Response body reads stop after five seconds or the action deadline and
accept at most 64 KiB of JSON. Checks are capped at 32 retained entries.
- New server or check codes appear as `unrecognized` until the closed
allowlist is updated.
- The production launch classifier, native gateway qualification, and
Get started requirement are unchanged.

## Model Used

OpenAI Codex (GPT-6 family), with code execution and tool use. The host
does not expose the exact model ID or context window to this run; these
values are unavailable.

## 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>
2026-10-24 21:32:42 -07:00

132 lines
11 KiB
TypeScript

import type { Page, Response, Request } from "@playwright/test";
import { JourneyError } from "./types.js";
export const onboardingServerCodes = ["agent_runner_config_incompatible", "agent_runner_unavailable", "agent_runner_platform_probe_failed", "managed_sandbox_unavailable", "environment_company_mismatch"] as const;
export const onboardingCheckCodes = ["adapter_auth_missing", "codex_hello_probe_failed", "codex_hello_probe_passed", "codex_hello_probe_timeout", "codex_hello_probe_auth_required", "environment_lease_acquire_failed", "environment_workspace_realize_failed", "environment_target_failed", "environment_target_unsupported", "environment_not_found", "environment_target_unavailable", "managed_sandbox_unavailable", "environment_env_binding_missing", "environment_env_resolution_failed", "ai_connection_api_key_rejected", "ai_connection_verification_failed", "sandbox_test_identity"] as const;
const checkpoints = ["connect", "validation", "hire", "review", "agent-verification"] as const;
const states = ["missing", "pending", "json", "unreadable", "oversized"] as const;
const requestedAdapters = ["codex_local", "paperclip_runner"] as const;
const adapters = ["codex_local", "paperclip_runner", "unrecognized"] as const;
const outcomes = ["pass", "warn", "fail", "unrecognized"] as const;
const levels = ["info", "warn", "error", "unrecognized"] as const;
type Known<T extends readonly string[]> = T[number] | "unrecognized";
export interface OnboardingResponseEvidence {
state: typeof states[number]; httpStatus: number | null;
requestedAdapter: "codex_local" | "paperclip_runner" | null;
adapter: typeof adapters[number] | null; outcome: typeof outcomes[number] | null;
serverCode: Known<typeof onboardingServerCodes> | null;
checks: Array<{ code: Known<typeof onboardingCheckCodes>; level: typeof levels[number] }>;
truncatedChecks: boolean;
}
export interface OnboardingConnectEvidence {
version: 1; checkpoint: typeof checkpoints[number]; validation: OnboardingResponseEvidence; hire: OnboardingResponseEvidence;
}
const blank = (): OnboardingResponseEvidence => ({ state: "missing", httpStatus: null, requestedAdapter: null, adapter: null, outcome: null, serverCode: null, checks: [], truncatedChecks: false });
const record = (v: unknown): v is Record<string, unknown> => Boolean(v && typeof v === "object" && !Array.isArray(v));
const member = <T extends readonly string[]>(values: T, v: unknown): v is T[number] => typeof v === "string" && values.includes(v);
const closed = (v: unknown, keys: string[]) => record(v) && Object.keys(v).length === keys.length && keys.every(key => Object.hasOwn(v, key));
export function validateOnboardingConnectEvidence(value: unknown): void {
if (!closed(value, ["version", "checkpoint", "validation", "hire"]) || !record(value) || value.version !== 1 || !member(checkpoints, value.checkpoint)) throw new Error("invalid_onboarding_connect_evidence");
for (const response of [value.validation, value.hire]) {
if (!closed(response, ["state", "httpStatus", "requestedAdapter", "adapter", "outcome", "serverCode", "checks", "truncatedChecks"]) || !record(response) ||
!member(states, response.state) || !(response.httpStatus === null || Number.isSafeInteger(response.httpStatus) && Number(response.httpStatus) >= 100 && Number(response.httpStatus) <= 599) ||
!(response.requestedAdapter === null || member(requestedAdapters, response.requestedAdapter)) ||
!(response.adapter === null || member(adapters, response.adapter)) || !(response.outcome === null || member(outcomes, response.outcome)) ||
!(response.serverCode === null || response.serverCode === "unrecognized" || member(onboardingServerCodes, response.serverCode)) ||
typeof response.truncatedChecks !== "boolean" || !Array.isArray(response.checks) || response.checks.length > 32 ||
response.checks.some(check => !closed(check, ["code", "level"]) || !record(check) || !(check.code === "unrecognized" || member(onboardingCheckCodes, check.code)) || !member(levels, check.level))) throw new Error("invalid_onboarding_connect_evidence");
}
}
/** Observe only responses to this Connect action; never retain bodies, text, headers or browser errors. */
export async function onboardingConnect(input: { page: Page; origin: string; companyId: string; deadlineAt: number; retain(value: OnboardingConnectEvidence): void }): Promise<OnboardingConnectEvidence> {
const { page } = input, receipt: OnboardingConnectEvidence = { version: 1, checkpoint: "connect", validation: blank(), hire: blank() };
input.retain(receipt);
let active = true, failure: JourneyError | undefined;
let signalFailure!: (error: JourneyError) => void;
const failed = new Promise<JourneyError>(resolve => { signalFailure = resolve; });
const requests = new Set<Request>(), pending = new Set<Promise<void>>();
const deadlineAt = Math.min(input.deadlineAt, Date.now() + 180_000);
const remaining = () => Math.max(1, deadlineAt - Date.now());
const advance = (checkpoint: OnboardingConnectEvidence["checkpoint"]) => {
if (checkpoints.indexOf(checkpoint) > checkpoints.indexOf(receipt.checkpoint)) receipt.checkpoint = checkpoint;
};
const reject = (code: string) => { failure ??= new JourneyError("failed", code); signalFailure(failure); };
const route = (request: Request): { kind: "validation" | "hire"; adapter: "codex_local" | "paperclip_runner" | null } | null => {
try {
const url = new URL(request.url());
if (request.method() !== "POST" || url.origin !== input.origin || url.search || url.hash) return null;
const prefix = `/api/companies/${encodeURIComponent(input.companyId)}`;
if (url.pathname === `${prefix}/agent-hires`) return { kind: "hire", adapter: null };
for (const adapter of ["codex_local", "paperclip_runner"] as const) if (url.pathname === `${prefix}/adapters/${adapter}/test-environment`) return { kind: "validation", adapter };
} catch { /* An unrelated or malformed URL is not this action's evidence. */ }
return null;
};
const onRequest = (request: Request) => { if (active && route(request)) requests.add(request); };
const onResponse = (response: Response) => {
const request = response.request(), matched = route(request);
if (!active || !matched || !requests.delete(request)) return;
const observed = receipt[matched.kind];
// One Connect action can report each boundary once. Later traffic cannot replace its failure.
if (observed.state !== "missing") return;
advance(matched.kind); observed.state = "pending";
const status = response.status();
observed.httpStatus = Number.isSafeInteger(status) && status >= 100 && status <= 599 ? status : null;
observed.requestedAdapter = matched.adapter;
if (response.status() >= 400) reject(matched.kind === "validation" ? "onboarding_native_validation_failed" : "onboarding_agent_hire_failed");
const classify = async () => {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const bytes = await Promise.race([response.body(), new Promise<null>(resolve => { timer = setTimeout(() => resolve(null), Math.min(5000, remaining())); })]);
if (!active) return;
if (!bytes) { observed.state = "unreadable"; return; }
if (bytes.byteLength > 65_536) { observed.state = "oversized"; return; }
let body: unknown; try { body = JSON.parse(bytes.toString("utf8")); } catch { observed.state = "unreadable"; return; }
if (!record(body)) { observed.state = "unreadable"; return; }
observed.state = "json";
const detailCode = record(body.details) ? body.details.code : undefined;
const code = member(onboardingServerCodes, detailCode) ? detailCode : member(onboardingServerCodes, body.code) ? body.code : detailCode ?? body.code;
if (code !== undefined) observed.serverCode = member(onboardingServerCodes, code) ? code : "unrecognized";
if (matched.kind === "validation") {
observed.adapter = member(adapters, body.adapterType) ? body.adapterType : "unrecognized";
observed.outcome = member(outcomes, body.status) ? body.status : "unrecognized";
if (Array.isArray(body.checks)) {
observed.truncatedChecks = body.checks.length > 32;
observed.checks = body.checks.slice(0, 32).map(check => ({ code: record(check) && member(onboardingCheckCodes, check.code) ? check.code : "unrecognized", level: record(check) && member(levels, check.level) ? check.level : "unrecognized" }));
if (body.status === "fail" || body.checks.some(check => record(check) && check.code === "adapter_auth_missing")) reject("onboarding_native_validation_failed");
} else if (body.status === "fail") reject("onboarding_native_validation_failed");
}
} catch { if (active) observed.state = "unreadable"; }
finally { clearTimeout(timer); }
};
const work = classify(); pending.add(work); void work.then(() => pending.delete(work), () => pending.delete(work));
};
page.on("request", onRequest); page.on("response", onResponse);
try {
if (Date.now() >= deadlineAt) throw new JourneyError("failed", "onboarding_connect_timeout");
const clicked = page.getByRole("button", { name: "Connect", exact: true }).click({ timeout: remaining() })
.then(() => null, error => new JourneyError("failed", error?.name === "TimeoutError" ? "onboarding_connect_timeout" : "onboarding_connect_failed"));
const clickResult = await Promise.race([failed, clicked]);
if (clickResult) throw clickResult;
if (failure) throw failure;
const result = await Promise.race([failed, page.getByRole("button", { name: "Get started", exact: true }).waitFor({ timeout: remaining() })
.then(() => null, error => new JourneyError("failed", error?.name === "TimeoutError" ? "onboarding_get_started_timeout" : "onboarding_get_started_failed"))]);
const boundaryFailure = failure;
// Seal the observed boundary before awaiting body reads. A response arriving
// after Get started or its timeout cannot change this action's outcome.
page.off("request", onRequest); page.off("response", onResponse);
if (pending.size) await Promise.all([...pending]);
if (boundaryFailure) throw boundaryFailure;
if (result) throw result;
if (failure) throw failure;
if (Date.now() >= deadlineAt) throw new JourneyError("failed", "onboarding_get_started_timeout");
advance("review");
return receipt;
} finally {
// Settle only already observed responses within their bounded read; detach before later traffic.
page.off("request", onRequest); page.off("response", onResponse);
if (pending.size) await Promise.all([...pending]);
active = false; requests.clear();
}
}