Files
PaperClipAI/tests/runner-e2e/product-journeys/cloud-onboarding.ts
T
DottaandPaperclip d40f9ac377 fix: enforce first-task completion decisions and retain E2E evidence (#17940)
## Thinking Path

> - Paperclip lets people delegate work to agents and approve proposed
work.
> - The onboarding first task asks for scope, proposes a task, and waits
for a human decision.
> - An answer to its opening question supplies scope. It does not
approve a proposal.
> - Task creation already checks the saved proposal decision.
> - Native completion used the general completion policy and could mark
the onboarding parent Done without that decision.
> - This PR checks the trusted decision before completion feedback and
again when the native status is committed.
> - The first task stays in review until a saved human decision permits
closure. Ordinary completion and acknowledged refusal keep their current
behavior.
> - The Product E2E guard also retains closed scalar evidence so future
failures show which boundary condition was observed.

## Linked Issues or Issue Description

Refs: #17914. Profile compatibility follow-up to #17922.

**What happened?**

A native onboarding run can submit a valid low-risk Done result after
only the opening answer. The native finalizer then marks the first task
Done even though no human has accepted or rejected a proposal.

**Expected behavior**

The opening answer must not permit completion. A trusted saved proposal
decision must permit it. A saved rejection permits acknowledgment and
closure, but does not authorize new work. A later pending proposal must
keep the task in review.

**Steps to reproduce**

Create a server-owned onboarding first task with an answered opening
question, no proposal decision, and a valid low-risk completion
contract. Pass a Done result through native completion feedback, the
control-plane port, and the finalizer. Before this fix, the persisted
status is Done. The regression expects In review. Ordinary completion
and acknowledged rejection pass as controls.

## What Changed

- Share the trusted latest-proposal and human-response checks with
completion admission. Keep task creation acceptance-only.
- Refuse premature native completion feedback. Keep an already saved
premature result in review during finalization.
- Check the decision again under the existing task and run locks before
committing Done.
- Cover accepted decisions, saved refusals, forged context, pending or
changed proposals, and a real database race.
- Retain a closed scalar receipt when the existing E2E pre-acceptance
guard fails. Validate and display it without task content, IDs,
filenames, or raw rows. Preserve the original failure, blocked
dependents, and historical reports.
- Include the evidence module in the journey definition hash. Bind the
receipt to its exact failure code and check its closed keys against the
full type.
- Repair base-branch profile drift found by the full harness check:
include the active Copilot v66 fixture in image hashing and update stale
Pi version/hash expectations from #17922. Keep all older image inputs
and strict hash checks.
- Update the onboarding feature and agent access maps and E2E evidence
documentation.

## Verification

- Semantic regression before implementation: one failure and two passing
controls on PostgreSQL 18. The failed assertion observed Done instead of
In review.
- The local database uses an owned disposable container with an
immutable image digest. Actual migrations, feedback, control-plane port,
finalizer, and status commit run against PostgreSQL. A temporary test
adapter replaces only the embedded database transport because this host
has exhausted its PostgreSQL semaphores.
- Native control-plane suite: 55 tests passed, including 17 new
controls. Existing task-creation admission: 25 passed. Existing
completion feedback: 19 passed.
- Credential-free E2E support: 206 Vitest suites passed, 4,407 tests
passed, and two skipped. All 137 Node support checks passed. Harness
typecheck passed.
- The failure-receipt regression had 11 failed assertions before capture
was implemented. Two failure-code binding assertions fail without their
gate. Eight placement/binding cases first validate the receipt-free
control, then reject the misplaced receipt.
- The untouched base reproduced the three profile compatibility
failures. The narrow repair passed all 83 affected tests before the full
harness run.
- Private workflow preparation and credential-free onboarding preview:
nine compatibility checks passed. Ownership, recovery, evidence,
shared-contract and workflow compatibility: 77 passed. The actual
provider preview also confirms immediate success deletion, earlier
declared failure eligibility, limits, and zero retries without
credential access. Map checks and git diff checks passed.
- Required repository typecheck passed for the product fix. The combined
branch build passed. The local full repository suite was stopped after
repeated PostgreSQL startup failures; a separate initdb probe confirmed
host SysV semaphore exhaustion. No shared IPC resources were changed.
[Hosted
CI](https://github.com/paperclipai/paperclip/actions/runs/40591020873)
passed the full database suites, typecheck, build, runner checks,
serialized suites, and browser shards on final head
`1ac4c23605a03bfd1f1f8a017b14ce29fda85f6b` (55 success, two expected
skips, one advisory neutral).
- Independent source review approved the product guard, atomic race
regression, evidence contract, and maps with no actionable findings.
Both automated reviews are 5/5 on final head
`1ac4c23605a03bfd1f1f8a017b14ce29fda85f6b`; prior findings are fixed and
resolved. Final CI is green and the PR is conflict-free.
- No Cloud workspace, provider call, release publication, or deployment
is part of this verification. These tests do not identify a historical
Cloud failure or qualify live onboarding.
- Local dependency installation uses the existing CI-generated lockfile
from #17811. No lockfile change is included.

## Risks

- Existing onboarding runs without a trusted saved proposal decision
remain in review even when the agent reports success. This is the
intended approval boundary.
- Decision changes can race completion. The status commit checks the
decision under its existing locks and uses the existing status-race
recovery path.
- A saved refusal permits closure only. Creation admission still rejects
it.
- Evidence readers reject malformed receipts and unrelated failure
codes. The version-1 key registry is checked against every receipt field
at compile time. Existing attempts do not gain evidence after the fact.

## Model Used

OpenAI GPT-6.1 Sol (`gpt-6.1-sol`), ultra reasoning, through Codex. Used
repository inspection, code execution, and collaborating coding agents.
The runtime did not report a context-window size.

## 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 references)
- [x] My branch name describes the change and contains no internal
ticket 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-27 17:07:50 -05:00

93 lines
6.2 KiB
TypeScript

import { expect } from "@playwright/test";
import { createConnectionProof } from "../connection-evidence.js";
import { onboardingConnect } from "./onboarding-connect.js";
import { onboardingFirstTask } from "./onboarding-first-task.js";
import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js";
type Row = Record<string, any>;
export interface CloudOnboardingInput {
page: JourneyContext["page"];
campaignId: string;
deadlineAt: number;
gateway: { model: string; baseUrl: string; apiKey: string };
maxRuns: number;
evidence: JourneyEvidence;
own: TargetSession["own"];
beforeWorkspace(name: string): Promise<void>;
workspaceCreated(): Promise<JourneyContext>;
/** The saved route must pass a deny-only runtime probe before Connect can spend. */
qualify(context: JourneyContext, connectionId: string): Promise<void>;
verifyExecution(context: JourneyContext, runIds: string[]): Promise<void>;
phase(phase: "setup" | "agent"): Promise<void>;
observeRun(run: Row): Promise<void>;
}
/** Measured actions use normal Cloud and tenant controls; infrastructure stays in the provider. */
export async function cloudOnboarding(input: CloudOnboardingInput): Promise<JourneyContext> {
const { page, evidence, campaignId } = input;
const name = `Journey QA ${campaignId}`;
const firstRunName = page.getByLabel("Name", { exact: true });
const organizationName = page.getByLabel("Organization name", { exact: true });
const createLink = page.getByRole("link", { name: /^(Create (?:a new )?organization|\+ New organization)$/ }).first();
// The first organization uses the signup Name/Next form. Later organizations
// use Organization name/Create organization, reached from the managed portfolio.
await expect(firstRunName.or(organizationName).or(createLink).first()).toBeVisible();
if (!await firstRunName.isVisible() && !await organizationName.isVisible()) {
await createLink.click();
await expect(organizationName).toBeVisible();
}
const firstRun = await firstRunName.isVisible();
await (firstRun ? firstRunName : organizationName).fill(name);
await input.beforeWorkspace(name);
await page.getByRole("button", { name: firstRun ? "Next" : "Create organization", exact: true }).click();
const context = await input.workspaceCreated();
const { api, company } = context;
evidence.deployment = context.identity;
evidence.checks.push({ id: "fresh-workspace", passed: true });
await input.phase("setup");
await expect(page.getByRole("heading", { name: "Create your first agent", exact: true })).toBeVisible({ timeout: 180_000 });
await input.own({ kind: "agent", name });
await page.getByLabel("Agent name", { exact: true }).fill(name);
await page.getByRole("button", { name: "Next", exact: true }).click();
await page.getByRole("button", { name: "Use API key instead", exact: true }).click();
await page.getByRole("radio", { name: /OpenAI.*API/ }).click();
await page.getByRole("button", { name: "Use a custom gateway", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Custom gateway connection", exact: true });
await dialog.getByLabel("Provider URL", { exact: true }).fill(input.gateway.baseUrl);
await dialog.getByLabel("API key", { exact: true }).fill(input.gateway.apiKey);
await dialog.getByRole("button", { name: "Change", exact: true }).click();
await dialog.getByLabel("Model IDs", { exact: true }).fill(input.gateway.model);
const connectionName = `My ${new URL(input.gateway.baseUrl).hostname}`;
await input.own({ kind: "connection", name: connectionName });
await dialog.getByRole("button", { name: "Save connection", exact: true }).click();
await expect(dialog).not.toBeVisible();
const connections = (await api.get(`/api/companies/${company.id}/ai-connections`)).connections as Row[];
const connection = connections.find(c => c.name === connectionName && c.provider === "openai" && c.method === "api_key" && c.status === "connected");
if (!connection || connections.length !== 1) throw new JourneyError("failed", "onboarding_connection_not_saved");
await input.own({ kind: "connection", name: connectionName, id: connection.id });
await page.getByRole("combobox", { name: "Model", exact: true }).selectOption(input.gateway.model);
await page.locator("summary").filter({ hasText: "Advanced" }).click();
await page.getByRole("button", { name: "Runner", exact: true }).click();
await page.getByRole("option", { name: /^Paperclip Runner/ }).click();
await input.qualify(context, connection.id);
// Connect performs the production probe and agent creation. Never press Retry.
const connect = await onboardingConnect({ page, origin: api.origin, companyId: company.id, deadlineAt: input.deadlineAt,
retain: value => { evidence.onboardingConnect = value; } });
const agents = await api.get<Row[]>(`/api/companies/${company.id}/agents`);
const agent = agents.find(a => a.name === name);
if (!agent || agents.length !== 1 || agent.adapterType !== "paperclip_runner" || agent.adapterConfig?.provider !== "codex" || agent.adapterConfig?.model !== input.gateway.model) throw new JourneyError("failed", "onboarding_agent_configuration_mismatch");
connect.checkpoint = "agent-verification";
await input.own({ kind: "agent", name, id: agent.id });
evidence.checks.push({ id: "ui-connection-and-native-agent", passed: true });
await input.own({ kind: "issue", name: "Paperclip onboarding" });
await page.getByRole("button", { name: "Get started", exact: true }).click();
await input.phase("agent");
const completed = await onboardingFirstTask({ context, agent: { id: agent.id }, connectionId: connection.id,
proof: createConnectionProof(), campaignId, maxRuns: input.maxRuns, deadlineAt: input.deadlineAt,
own: input.own, observeRun: input.observeRun, onArtifact: artifact => { evidence.artifact = artifact; },
onPreAcceptanceFailure: receipt => { evidence.onboardingPreAcceptance = receipt; } });
await input.verifyExecution(context, [completed.artifact.originatingRunId]);
evidence.checks.push({ id: "first-task-complete", passed: true },
{ id: "downloadable-artifact-verified", passed: true }, { id: "native-trace-verified", passed: true });
return { ...context, startingState: "onboarding" };
}