mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
## 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>
132 lines
11 KiB
TypeScript
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();
|
|
}
|
|
}
|