mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
## 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>
179 lines
12 KiB
TypeScript
179 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { cloudOnboarding, type CloudOnboardingInput } from "./cloud-onboarding.js";
|
|
import { onboardingFirstTask } from "./onboarding-first-task.js";
|
|
import { retainOnboardingArtifact } from "./artifact-evidence.js";
|
|
import { JourneyError } from "./types.js";
|
|
import type { OnboardingPreAcceptanceEvidence } from "./onboarding-pre-acceptance.js";
|
|
|
|
vi.mock("@playwright/test", () => ({ expect: (locator: { isVisible(): Promise<boolean> }) => ({
|
|
toBeVisible: async () => { expect(await locator.isVisible()).toBe(true); },
|
|
not: { toBeVisible: async () => { expect(await locator.isVisible()).toBe(false); } },
|
|
}) }));
|
|
vi.mock("./onboarding-first-task.js", async () => {
|
|
const { retainOnboardingArtifact } = await import("./artifact-evidence.js");
|
|
return { onboardingFirstTask: vi.fn(async ({ proof, onArtifact }) => {
|
|
const artifact = retainOnboardingArtifact({ id: "artifact", originatingRunId: "artifact-run", bytes: Buffer.from(JSON.stringify(proof.expected)), proof });
|
|
onArtifact?.(artifact);
|
|
return { artifact };
|
|
}) };
|
|
});
|
|
|
|
type Entry = "first-run" | "portfolio" | "create-form";
|
|
type Control = { role: string; name: string; scope?: string; fill?(value: string): void; click?(): void; select?(value: string): void };
|
|
|
|
/** Visible control contracts only; no browser, Cloud API, or provider is started. */
|
|
function fixture(entry: Entry, options: { intentFailure?: boolean; qualificationFailure?: boolean; traceFailure?: boolean } = {}) {
|
|
let stage: string = entry, name = "", apiMode = false, source = false, advanced = false, runnerOpen = false;
|
|
let modelsOpen = false, model = "", runner = "", saved = false, hired = false;
|
|
const fields: Record<string, string> = {}, events: string[] = [];
|
|
const gateway = { baseUrl: "https://owned-gateway.example.test/v1", model: "gpt-5.4-mini", apiKey: "synthetic-test-key" };
|
|
const connection = { id: "connection", name: "My owned-gateway.example.test", provider: "openai", method: "api_key", status: "connected" };
|
|
const create = () => {
|
|
expect(events).toContain("intent:workspace");
|
|
expect(name).toBe("Journey QA campaign");
|
|
events.push("create:workspace"); stage = "agent";
|
|
};
|
|
const controls = (): Control[] => {
|
|
if (stage === "portfolio") return [{ role: "link", name: "Create a new organization", click() { stage = "create-form"; } }];
|
|
if (stage === "first-run" || stage === "create-form") return [
|
|
{ role: "textbox", name: stage === "first-run" ? "Name" : "Organization name", fill(value) { name = value; } },
|
|
{ role: "button", name: stage === "first-run" ? "Next" : "Create organization", click: create },
|
|
];
|
|
if (stage === "agent") return [
|
|
{ role: "heading", name: "Create your first agent" },
|
|
{ role: "textbox", name: "Agent name", fill(value) { name = value; } },
|
|
{ role: "button", name: "Next", click() { stage = "connect"; } },
|
|
];
|
|
if (stage === "started") return [];
|
|
if (hired) return [{ role: "button", name: "Get started", click() { stage = "started"; } }];
|
|
const result: Control[] = [
|
|
{ role: "button", name: apiMode ? "Use subscription instead" : "Use API key instead", click() { apiMode = !apiMode; } },
|
|
{ role: "radio", name: apiMode ? "OpenAI API key" : "OpenAI Subscription", click() { source = true; } },
|
|
];
|
|
if (source && apiMode) result.push({ role: "button", name: "Use a custom gateway", click() { stage = "gateway"; } });
|
|
if (stage === "gateway") {
|
|
result.push({ role: "dialog", name: "Custom gateway connection" });
|
|
for (const field of ["Provider URL", "API key", ...(modelsOpen ? ["Model IDs"] : [])]) result.push({
|
|
role: "textbox", name: field, scope: "gateway", fill(value) { fields[field] = value; },
|
|
});
|
|
result.push({ role: "button", name: "Change", scope: "gateway", click() { modelsOpen = true; } },
|
|
{ role: "button", name: "Save connection", scope: "gateway", click() {
|
|
expect(fields).toEqual({ "Provider URL": gateway.baseUrl, "API key": gateway.apiKey, "Model IDs": gateway.model });
|
|
expect(events).toContain("intent:connection"); events.push("save:connection"); saved = true; stage = "connect";
|
|
} });
|
|
}
|
|
if (saved) result.push({ role: "combobox", name: "Model", select(value) { expect(value).toBe(gateway.model); model = value; } });
|
|
if (source) result.push({ role: "summary", name: "Advanced", click() { advanced = true; } });
|
|
if (advanced) result.push({ role: "button", name: "Runner", click() { runnerOpen = true; } });
|
|
if (runnerOpen) result.push({ role: "option", name: "Paperclip Runner (default)", click() { runner = "paperclip"; runnerOpen = false; } });
|
|
if (saved && model && runner) result.push({ role: "button", name: "Connect", click() {
|
|
expect(events).toContain("qualified"); events.push("paid:connect"); hired = true;
|
|
} });
|
|
return result;
|
|
};
|
|
const match = (value: string, expected: string | RegExp) => typeof expected === "string" ? value === expected : expected.test(value);
|
|
const locator = (resolve: () => Control[]): any => ({
|
|
first() { return locator(() => resolve().slice(0, 1)); },
|
|
or(other: { resolve(): Control[] }) { return locator(() => [...resolve(), ...other.resolve()]); },
|
|
resolve,
|
|
async isVisible() { return resolve().length > 0; },
|
|
async fill(value: string) { const control = resolve()[0]; if (!control?.fill) throw new Error("No visible text field"); control.fill(value); },
|
|
async click() { const control = resolve()[0]; if (!control?.click) throw new Error("No visible action"); events.push(`click:${control.name}`); control.click(); },
|
|
async selectOption(value: string) { const control = resolve()[0]; if (!control?.select) throw new Error("No visible native model picker"); control.select(value); },
|
|
async waitFor() { expect(resolve().length).toBeGreaterThan(0); },
|
|
filter({ hasText }: { hasText: string }) { return locator(() => resolve().filter(control => control.name.includes(hasText))); },
|
|
getByLabel(label: string) { return locator(() => controls().filter(control => control.scope === "gateway" && control.name === label)); },
|
|
getByRole(role: string, { name: label }: { name: string | RegExp }) {
|
|
return locator(() => controls().filter(control => control.scope === "gateway" && control.role === role && match(control.name, label)));
|
|
},
|
|
});
|
|
const page = {
|
|
on() { return this; },
|
|
off() { return this; },
|
|
getByLabel(label: string) { return locator(() => controls().filter(control => control.name === label && control.role === "textbox")); },
|
|
getByRole(role: string, { name: label }: { name: string | RegExp }) {
|
|
return locator(() => controls().filter(control => control.role === role && match(control.name, label)));
|
|
},
|
|
locator(selector: string) { expect(selector).toBe("summary"); return locator(() => controls().filter(control => control.role === "summary")); },
|
|
};
|
|
const context = { page, company: { id: "company" }, identity: { kind: "cloud" }, startingState: "recipe", api: {
|
|
origin: "https://tenant.example.test",
|
|
async get(route: string) {
|
|
if (route.endsWith("/ai-connections")) return { connections: saved ? [connection] : [] };
|
|
if (route.endsWith("/agents")) return hired ? [{ id: "agent", name, adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: gateway.model } }] : [];
|
|
throw new Error("Unexpected API read");
|
|
},
|
|
} };
|
|
const input = { page, campaignId: "campaign", deadlineAt: Date.now() + 60_000, gateway, maxRuns: 8, evidence: { checks: [] },
|
|
async beforeWorkspace() { if (options.intentFailure) throw new Error("ownership unavailable"); events.push("intent:workspace"); },
|
|
async workspaceCreated() { expect(stage).toBe("agent"); return context; },
|
|
async own(resource: { kind: string }) { events.push(`intent:${resource.kind}`); },
|
|
async phase() {}, async observeRun() {},
|
|
async qualify() { if (options.qualificationFailure) throw new Error("admission denied"); events.push("qualified"); },
|
|
async verifyExecution(_context: unknown, runIds: string[]) {
|
|
expect(runIds).toEqual(["artifact-run"]);
|
|
if (options.traceFailure) throw new Error("trace verification failed");
|
|
events.push("native:verified");
|
|
},
|
|
} as unknown as CloudOnboardingInput;
|
|
return { input, events };
|
|
}
|
|
|
|
describe("Cloud onboarding visible controls", () => {
|
|
it("retains pre-acceptance evidence while preserving the failed error and withholding completion markers", async () => {
|
|
const f = fixture("portfolio");
|
|
const receipt: OnboardingPreAcceptanceEvidence = { version: 1, checkpoint: "awaiting-proposal", violations: ["attachment_present"],
|
|
taskCount: 1, attachmentCount: 1, documentCount: 0, nonPlanningDocumentCount: 0,
|
|
firstTaskMatchesCanonicalParent: true, firstTaskStatus: "in_review", canonicalParentStatus: "in_review" };
|
|
vi.mocked(onboardingFirstTask).mockImplementationOnce(async input => {
|
|
input.onPreAcceptanceFailure?.(receipt);
|
|
throw new JourneyError("failed", "onboarding_work_before_acceptance");
|
|
});
|
|
await expect(cloudOnboarding(f.input)).rejects.toMatchObject({ outcome: "failed", code: "onboarding_work_before_acceptance" });
|
|
expect(JSON.parse(JSON.stringify(f.input.evidence)).onboardingPreAcceptance).toEqual(receipt);
|
|
expect(f.input.evidence.checks.map(check => check.id)).toEqual(["fresh-workspace", "ui-connection-and-native-agent"]);
|
|
expect(f.events).not.toContain("native:verified");
|
|
});
|
|
it.each(["first-run", "portfolio", "create-form"] as const)("creates from %s with intent before dispatch and qualification before paid Connect", async entry => {
|
|
const f = fixture(entry);
|
|
const result = await cloudOnboarding(f.input);
|
|
expect(result.startingState).toBe("onboarding");
|
|
expect(f.events.indexOf("intent:workspace")).toBeLessThan(f.events.indexOf("create:workspace"));
|
|
expect(f.events.indexOf("intent:connection")).toBeLessThan(f.events.indexOf("save:connection"));
|
|
expect(f.events.indexOf("qualified")).toBeLessThan(f.events.indexOf("paid:connect"));
|
|
expect(f.events).toContain("native:verified");
|
|
});
|
|
it("does not dispatch workspace creation without durable ownership", async () => {
|
|
const f = fixture("first-run", { intentFailure: true });
|
|
await expect(cloudOnboarding(f.input)).rejects.toThrow("ownership unavailable");
|
|
expect(f.events).not.toContain("create:workspace");
|
|
});
|
|
it("never presses paid Connect when route admission fails", async () => {
|
|
const f = fixture("portfolio", { qualificationFailure: true });
|
|
const previous = vi.mocked(onboardingFirstTask).mock.calls.length;
|
|
await expect(cloudOnboarding(f.input)).rejects.toThrow("admission denied");
|
|
expect(f.events).not.toContain("paid:connect");
|
|
expect(vi.mocked(onboardingFirstTask).mock.calls).toHaveLength(previous);
|
|
});
|
|
it("keeps verified artifact bytes and the independent oracle when later trace verification fails", async () => {
|
|
const f = fixture("portfolio", { traceFailure: true });
|
|
await expect(cloudOnboarding(f.input)).rejects.toThrow("trace verification failed");
|
|
const retained = JSON.parse(JSON.stringify(f.input.evidence)).artifact;
|
|
expect(JSON.parse(Buffer.from(retained.contentBase64, "base64").toString("utf8"))).toEqual(retained.expected);
|
|
expect(retained.originatingRunId).toBe("artifact-run");
|
|
expect(f.input.evidence.checks).not.toContainEqual({ id: "native-trace-verified", passed: true });
|
|
});
|
|
it("retains the artifact but fails the journey without pass markers when first-task readback fails", async () => {
|
|
const f = fixture("portfolio");
|
|
vi.mocked(onboardingFirstTask).mockImplementationOnce(async ({ proof, onArtifact }) => {
|
|
onArtifact?.(retainOnboardingArtifact({ id: "artifact", originatingRunId: "artifact-run", bytes: Buffer.from(JSON.stringify(proof.expected)), proof }));
|
|
throw new Error("first-task readback failed");
|
|
});
|
|
await expect(cloudOnboarding(f.input)).rejects.toThrow("first-task readback failed");
|
|
const retained = JSON.parse(JSON.stringify(f.input.evidence)).artifact;
|
|
expect(JSON.parse(Buffer.from(retained.contentBase64, "base64").toString("utf8"))).toEqual(retained.expected);
|
|
expect(f.input.evidence.checks.map(check => check.id)).toEqual(["fresh-workspace", "ui-connection-and-native-agent"]);
|
|
expect(f.events).not.toContain("native:verified");
|
|
});
|
|
});
|