fix(ui): recover stalled onboarding connection checks (#19505)

Bound the complete onboarding connection check to two minutes, abort its
requests on timeout, and ignore late results. Keep the saved account
available for retry and resume its matching unfinished setup after refresh.

Verified 242 focused tests, workspace typecheck and build, and the complete
hosted CI check set. Greptile is 5/5 with no unresolved review comments.
Local full-UI timing failure and interrupted CI runner attempts remain
documented in the PR evidence.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-11-06 10:31:18 -08:00
1 parent beaa9a622d
commit e3d8f42e19
11 files changed
+324 -182

No files matched your search

+3 -3
View File
@@ -2,7 +2,7 @@
* @fileoverview Frontend API client for external adapter management.
*/
import { api } from "./client";
import { api, type RequestOptions } from "./client";
/**
* The safe scalar login fields the server projects for an adapter that declares
@@ -67,13 +67,13 @@ export interface AdapterInstallResult {
export const adaptersApi = {
/** List all registered adapters (built-in + external). */
list: (context?: { companyId: string; environmentId?: string | null }) => {
list: (context?: { companyId: string; environmentId?: string | null }, options?: RequestOptions) => {
const query = new URLSearchParams();
if (context) {
query.set("companyId", context.companyId);
if (context.environmentId) query.set("environmentId", context.environmentId);
}
return api.get<AdapterInfo[]>(`/adapters${query.size ? `?${query}` : ""}`);
return api.get<AdapterInfo[]>(`/adapters${query.size ? `?${query}` : ""}`, options);
},
/** Install an external adapter from npm or a local path. */
+2
View File
@@ -271,10 +271,12 @@ export const agentsApi = {
testCredentials?: Record<string, string>;
environmentId?: string | null;
},
options?: RequestOptions,
) =>
api.post<AdapterEnvironmentTestResult>(
`/companies/${companyId}/adapters/${type}/test-environment`,
data,
options,
),
getAdapterAuthSignal: (companyId: string, type: string, environmentId?: string | null) => {
const query = environmentId ? `?environmentId=${encodeURIComponent(environmentId)}` : "";
+3 -2
View File
@@ -12,7 +12,7 @@ import type {
StartEnvironmentCustomImageSetupSession,
CreateEnvironmentCustomImageTerminalSessionToken,
} from "@paperclipai/shared";
import { api } from "./client";
import { api, type RequestOptions } from "./client";
export interface EnvironmentCustomImageOverview {
activeTemplate: EnvironmentCustomImageTemplate | null;
@@ -152,12 +152,13 @@ export const environmentsApi = {
: `/environments/${environmentId}`,
body,
),
probe: (environmentId: string, companyId?: string | null) =>
probe: (environmentId: string, companyId?: string | null, options?: RequestOptions) =>
api.post<EnvironmentProbeResult>(
companyId
? `/environments/${environmentId}/probe?${companyIdQuery(companyId)}`
: `/environments/${environmentId}/probe`,
{},
options,
),
probeConfig: (companyId: string, body: {
name?: string;
+39 -7
View File
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { act, StrictMode } from "react";
import { ONBOARDING_CONNECTION_TEST_TIMEOUT_MS } from "../lib/onboarding-connection-test";
import { buildClaudeLocalConfig } from "../../../packages/adapters/claude-local/src/ui/build-config";
import { buildCodexRunnerConfig } from "../adapters/codex-local/config-fields";
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
@@ -2235,7 +2236,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
const binding = { provider: "openai", method: "api_key", mode: "delegated", connectionId: "managed-connection", grantId: "managed-grant" };
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledExactlyOnceWith("company-new", "codex_local", expect.objectContaining({
environmentId: "env-sandbox-1", aiConnection: binding, adapterConfig: expect.objectContaining({ model: "gpt-5.4-mini" }),
}));
}), expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(mockAgentsApi.hire).toHaveBeenCalledExactlyOnceWith("company-new", expect.objectContaining({
runtimeConfig: expect.objectContaining({ aiConnection: binding }), adapterConfig: expect.objectContaining({ model: "gpt-5.4-mini" }),
}));
@@ -2279,7 +2280,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled();
await pickSource(/Claude/);
expect(calls).toEqual(["discovery-before", "platform", "discovery-after", "adapter", "hire"]);
expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new");
expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new", expect.objectContaining({ signal: expect.any(AbortSignal) }));
const tested = (mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2] as { adapterConfig: Record<string, unknown> };
const saved = (mockAgentsApi.hire.mock.calls[0] as unknown[])[1] as { adapterConfig: Record<string, unknown> };
expect(saved).not.toHaveProperty("runner");
@@ -2312,7 +2313,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await flushReact();
await pressArcPrimary();
expect(managedApi.create).toHaveBeenCalledExactlyOnceWith("company-new", expect.objectContaining({ provider: "anthropic", method: "api_key", apiKey: key }));
expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new");
expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new", expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
const tested = (mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2] as { adapterConfig: object };
@@ -2739,7 +2740,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
pauseReason: "setup", defaultEnvironmentId: "saved-environment" } as never);
const { root } = await openStep4({ adapterType: "claude_local", setupAgentId: setupId });
await pickSource(/Claude/);
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledWith("company-new", "claude_local", expect.objectContaining({ environmentId: "saved-environment" }));
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledWith("company-new", "claude_local", expect.objectContaining({ environmentId: "saved-environment" }), expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(mockAgentsApi.hire).toHaveBeenCalledWith("company-new", expect.objectContaining({ defaultEnvironmentId: "saved-environment" }));
await act(async () => root.unmount());
});
@@ -2779,7 +2780,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await pressArcPrimary();
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledWith("company-new", "codex_local", expect.objectContaining({ agentId: setupId,
adapterConfig: expect.objectContaining({ env: expect.objectContaining({ CODEX_HOME: { type: "plain", value: "***REDACTED***" } }) }),
}));
}), expect.objectContaining({ signal: expect.any(AbortSignal) }));
await act(async () => root.unmount());
});
@@ -3984,8 +3985,8 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await pickSource(/OpenAI/);
expect(calls).toEqual(["discovery-before", "platform", "discovery-after", "adapter", "hire"]);
expect(handles.queryClient.getQueryData(queryKeys.adapters.availability("company-new", "env-sandbox-1"))).toEqual(discovery(defaultRunner));
expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new");
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledWith("company-new", "codex_local", expect.objectContaining({ environmentId: "env-sandbox-1", aiConnection: { provider: "openai", method: "subscription", mode: "responsible_user" } }));
expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new", expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledWith("company-new", "codex_local", expect.objectContaining({ environmentId: "env-sandbox-1", aiConnection: { provider: "openai", method: "subscription", mode: "responsible_user" } }), expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect((mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2]).not.toHaveProperty("runner");
expect((mockAgentsApi.hire.mock.calls[0] as unknown[])[1]).not.toHaveProperty("runner");
expect(managedApi.connectLocal).not.toHaveBeenCalled();
@@ -4455,6 +4456,37 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
} finally { await act(async () => root.unmount()); }
});
it("releases a hung test, ignores its late result, and retries with the saved login", async () => {
let finish!: (result: typeof passed) => void;
mockAgentsApi.testEnvironment.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
const timerSpy = vi.spyOn(globalThis, "setTimeout");
const { root } = await openStep4({ adapterType: "claude_local" });
try {
await pickSource(/Claude/);
await detected();
expectTesting();
const deadline = timerSpy.mock.calls.find(([, ms]) => ms === ONBOARDING_CONNECTION_TEST_TIMEOUT_MS);
expect(deadline).toBeDefined();
const options = (mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[3] as { signal: AbortSignal };
await act(async () => (deadline![0] as () => void)());
await settle();
expect(options.signal.aborted).toBe(true);
expect(document.body.textContent).toContain("The connection test took too long.");
expect(button("Connect").disabled).toBe(false);
await act(async () => finish(passed));
await settle();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await click("Connect");
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2);
expect(managedApi.connectLocal).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("is ready to work!");
} finally {
timerSpy.mockRestore();
await act(async () => root.unmount());
}
});
it.each(providers)("keeps Connect usable when %s detection has not completed", async (adapterType, _provider, label) => {
const { root } = await openStep4({ adapterType });
try {
+120 -157
View File
@@ -1,6 +1,7 @@
import { useQueryView } from "@/components/QueryView";
import type { AgentRunner, AgentRunnerChoice } from "@paperclipai/shared";
import { needsClaudeSetupQualification, qualifyClaudeSetup } from "../lib/qualify-claude-setup";
import { runOnboardingConnectionTest } from "../lib/onboarding-connection-test";
import { agentHarnessType } from "@paperclipai/shared";
import { adaptersApi, type AdapterInfo } from "../api/adapters";
import { AgentRunnerSelect } from "./AgentRunnerSelect";
@@ -106,7 +107,7 @@ import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
import { buildFixedClaudeOAuthBinding, CLAUDE_OAUTH_TOKEN_ENV_KEY } from "./environment-variables-editor/model";
import { defaultCreateValues } from "./agent-config-defaults";
import { restoreOnboardingState } from "../lib/onboarding-state";
import { onboardingDraftStorage, restoreOnboardingState } from "../lib/onboarding-state";
import {
buildOnboardingIssuePayload,
buildOnboardingProjectPayload,
@@ -277,50 +278,8 @@ function ModelSourceMark({
return <img src={brand} alt="" className="size-full" />;
}
// Exported so tests write/read the exact key the component uses, instead of
// duplicating the literal and silently drifting from it if it's ever renamed.
export const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state";
export { ONBOARDING_STORAGE_KEY } from "../lib/onboarding-state";
const DEFAULT_TASK_TITLE = "Paperclip onboarding";
/**
* The onboarding draft in `localStorage`, via a browser that is allowed to say
* no.
*
* Storage access throws outright where a browser denies it — Safari's private
* mode, a blocked third-party context — and every call site here sits in a
* render, an effect, or a close handler, so an escaping exception takes down
* something the customer was using. Losing the ability to resume onboarding is
* a far smaller failure than the wizard tearing down mid-answer, or refusing
* to close.
*
* Routed through one object on purpose. Guarding these one at a time is how
* three of the four call sites ended up unguarded while the fourth looked
* fixed: the read, the stale-blob cleanup, the persist effect, and `reset()`
* all have the same failure and want the same answer.
*/
const onboardingDraftStorage = {
read(): string | null {
try {
return window.localStorage.getItem(ONBOARDING_STORAGE_KEY);
} catch {
return null;
}
},
write(value: string): void {
try {
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, value);
} catch {
// Storage unavailable: the draft is simply not resumable this session.
}
},
clear(): void {
try {
window.localStorage.removeItem(ONBOARDING_STORAGE_KEY);
} catch {
// Nothing to do. A draft that cannot be cleared is re-rejected on the
// next load by the same ownership check that rejected it here.
}
},
};
const INCOMPLETE_ONBOARDING_STATE_MESSAGE =
"Onboarding state is incomplete. Please restart onboarding and try again.";
@@ -2166,128 +2125,132 @@ function OnboardingWizardInner({
return null;
}
const selection = adapterSetupSelectionRef.current;
const testIsCurrent = () => isCurrent() && adapterSetupSelectionRef.current === selection;
const selectionIsCurrent = () => isCurrent() && adapterSetupSelectionRef.current === selection;
adapterEnvErrorSelectionRef.current = null;
setAdapterEnvLoading(true);
setAdapterEnvError(null);
try {
let environmentList: Environment[];
let settings: InstanceSettings;
let managedSandboxOnly: boolean;
try {
const [list, generalSettings, experimentalSettings] = await Promise.all([
queryClient.ensureQueryData({
queryKey: queryKeys.environments.list(createdCompanyId),
queryFn: () => environmentsApi.list(createdCompanyId),
}),
queryClient.ensureQueryData({
queryKey: queryKeys.instance.settings,
queryFn: () => instanceSettingsApi.get(),
}),
queryClient.ensureQueryData({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
}),
]);
environmentList = list;
settings = generalSettings;
managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true;
} catch {
return await runOnboardingConnectionTest(async signal => {
const testIsCurrent = () => selectionIsCurrent() && !signal.aborted;
let environmentList: Environment[];
let settings: InstanceSettings;
let managedSandboxOnly: boolean;
try {
const [list, generalSettings, experimentalSettings] = await Promise.all([
queryClient.ensureQueryData({
queryKey: queryKeys.environments.list(createdCompanyId),
queryFn: () => environmentsApi.list(createdCompanyId),
}),
queryClient.ensureQueryData({
queryKey: queryKeys.instance.settings,
queryFn: () => instanceSettingsApi.get(),
}),
queryClient.ensureQueryData({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
}),
]);
environmentList = list;
settings = generalSettings;
managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true;
} catch {
if (!testIsCurrent()) return null;
setAdapterEnvError(
"Could not load environment settings to determine which environment to test in. Retry the test.",
);
return null;
}
// Mirror the server run-time resolution, including the managed-sandbox-only
// redirect: when the resolution lands on the local environment and the
// policy is on, probe the managed sandbox the real run uses instead. The
// resolver throws when no managed sandbox is available, which the outer
// catch surfaces as a fail-closed error rather than a local host probe.
const environmentId = resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: restoredSetup?.companyId === createdCompanyId ? restoredSetup.defaultEnvironmentId : null,
instanceDefaultEnvironmentId: settings?.defaultEnvironmentId ?? null,
localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(environmentList),
managedSandboxOnly,
managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(environmentList),
// The policy hides the local environment, so an instance default that
// still points at the hidden local row names no visible environment.
// Pass the visible ids so the resolver redirects that stale local
// default to the managed sandbox instead of sending the hidden local id.
visibleEnvironmentIds: environmentList.map((environment) => environment.id),
});
if (!testIsCurrent()) return null;
setAdapterEnvError(
"Could not load environment settings to determine which environment to test in. Retry the test.",
const selectedEnvironment = environmentList.find(environment => environment.id === environmentId);
const selectedConfig = selectedEnvironment?.config;
if (!createdAgentId && adapterType === "codex_local" && runner === "auto"
&& selectedEnvironment?.driver === "sandbox" && typeof selectedConfig?.image === "string"
&& /^[^\s@]+@sha256:[a-f0-9]{64}$/.test(selectedConfig.image)
&& !selectedConfig.snapshot && !selectedConfig.template && !selectedConfig.templateRef) {
const availabilityKey = queryKeys.adapters.availability(createdCompanyId, environmentId);
const adapters = await queryClient.fetchQuery({ queryKey: availabilityKey, staleTime: 0,
queryFn: () => adaptersApi.list({ companyId: createdCompanyId, environmentId }, { signal }) });
if (!testIsCurrent()) return null;
const codex = adapters.find(adapter => adapter.type === "codex_local");
const native = adapters.find(adapter => adapter.type === "paperclip_runner");
if (!codex || (codex.defaultRunner !== "paperclip" && codex.defaultRunner !== "legacy")
|| !codex.supportedRunners?.includes(codex.defaultRunner)) {
throw new Error("Could not confirm Codex runner availability for this environment. Retry setup.");
}
if (codex.defaultRunner !== "paperclip" && !codex?.disabled && !codex?.overriddenBuiltin
&& !native?.disabled && !native?.overriddenBuiltin) {
const probe = await environmentsApi.probe(environmentId!, createdCompanyId, { signal });
if (!testIsCurrent()) return null;
if (probe.ok !== true || probe.details?.platformQualificationRecorded !== true
|| typeof probe.details.leaseId !== "string" || !probe.details.leaseId) {
throw new Error(typeof probe.details?.platformQualificationDiagnostic === "string"
? probe.details.platformQualificationDiagnostic
: probe.summary || "The environment's platform qualification could not be confirmed. Retry the connection probe or select Legacy runner in Advanced.");
}
const refreshed = await queryClient.fetchQuery({ queryKey: availabilityKey, staleTime: 0,
queryFn: () => adaptersApi.list({ companyId: createdCompanyId, environmentId }, { signal }) });
if (!testIsCurrent()) return null;
const refreshedCodex = refreshed.find(adapter => adapter.type === "codex_local");
const defaultRunner = refreshedCodex?.defaultRunner;
if ((defaultRunner !== "paperclip" && defaultRunner !== "legacy")
|| !refreshedCodex?.supportedRunners?.includes(defaultRunner)) {
throw new Error("Could not confirm the environment's default runner. Retry setup.");
}
}
}
if (!testIsCurrent()) return null;
const selection = { adapterType, runner, savedAgentId: createdAgentId,
environment: environmentList.find(environment => environment.id === environmentId) };
const qualified = await qualifyClaudeSetup({ ...selection,
readAdapters: () => adaptersApi.list({ companyId: createdCompanyId, environmentId }, { signal }),
probeEnvironment: () => environmentsApi.probe(environmentId!, createdCompanyId, { signal }),
isCurrent: testIsCurrent,
});
if (!testIsCurrent() || (needsClaudeSetupQualification(selection) && !qualified)) return null;
let testedConfig = adapterConfigOverride ?? buildAdapterConfig();
if (qualified) {
testedConfig = buildAdapterConfig(true, qualified.runner);
if (appliedStoredClaudeLoginBinding) testedConfig.env = {
...(isEnvRecord(testedConfig.env) ? testedConfig.env : {}), ...buildFixedClaudeOAuthBinding(),
};
onQualifiedConfig?.(testedConfig, qualified.adapters, environmentId);
}
const result = await agentsApi.testEnvironment(
createdCompanyId,
adapterType,
{
adapterConfig: testedConfig,
...(restoredSetup?.companyId === createdCompanyId ? { agentId: restoredSetup.id } : {}),
...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
...(managedBindingForStep() ? { aiConnection: managedBindingForStep() } : {}),
environmentId,
},
{ signal },
);
return null;
}
// Mirror the server run-time resolution, including the managed-sandbox-only
// redirect: when the resolution lands on the local environment and the
// policy is on, probe the managed sandbox the real run uses instead. The
// resolver throws when no managed sandbox is available, which the outer
// catch surfaces as a fail-closed error rather than a local host probe.
const environmentId = resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: restoredSetup?.companyId === createdCompanyId ? restoredSetup.defaultEnvironmentId : null,
instanceDefaultEnvironmentId: settings?.defaultEnvironmentId ?? null,
localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(environmentList),
managedSandboxOnly,
managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(environmentList),
// The policy hides the local environment, so an instance default that
// still points at the hidden local row names no visible environment.
// Pass the visible ids so the resolver redirects that stale local
// default to the managed sandbox instead of sending the hidden local id.
visibleEnvironmentIds: environmentList.map((environment) => environment.id),
});
if (!testIsCurrent()) return null;
const selectedEnvironment = environmentList.find(environment => environment.id === environmentId);
const selectedConfig = selectedEnvironment?.config;
if (!createdAgentId && adapterType === "codex_local" && runner === "auto"
&& selectedEnvironment?.driver === "sandbox" && typeof selectedConfig?.image === "string"
&& /^[^\s@]+@sha256:[a-f0-9]{64}$/.test(selectedConfig.image)
&& !selectedConfig.snapshot && !selectedConfig.template && !selectedConfig.templateRef) {
const availabilityKey = queryKeys.adapters.availability(createdCompanyId, environmentId);
const adapters = await queryClient.fetchQuery({ queryKey: availabilityKey, staleTime: 0,
queryFn: () => adaptersApi.list({ companyId: createdCompanyId, environmentId }) });
if (!testIsCurrent()) return null;
const codex = adapters.find(adapter => adapter.type === "codex_local");
const native = adapters.find(adapter => adapter.type === "paperclip_runner");
if (!codex || (codex.defaultRunner !== "paperclip" && codex.defaultRunner !== "legacy")
|| !codex.supportedRunners?.includes(codex.defaultRunner)) {
throw new Error("Could not confirm Codex runner availability for this environment. Retry setup.");
}
if (codex.defaultRunner !== "paperclip" && !codex?.disabled && !codex?.overriddenBuiltin
&& !native?.disabled && !native?.overriddenBuiltin) {
const probe = await environmentsApi.probe(environmentId!, createdCompanyId);
if (!testIsCurrent()) return null;
if (probe.ok !== true || probe.details?.platformQualificationRecorded !== true
|| typeof probe.details.leaseId !== "string" || !probe.details.leaseId) {
throw new Error(typeof probe.details?.platformQualificationDiagnostic === "string"
? probe.details.platformQualificationDiagnostic
: probe.summary || "The environment's platform qualification could not be confirmed. Retry the connection probe or select Legacy runner in Advanced.");
}
const refreshed = await queryClient.fetchQuery({ queryKey: availabilityKey, staleTime: 0,
queryFn: () => adaptersApi.list({ companyId: createdCompanyId, environmentId }) });
if (!testIsCurrent()) return null;
const refreshedCodex = refreshed.find(adapter => adapter.type === "codex_local");
const defaultRunner = refreshedCodex?.defaultRunner;
if ((defaultRunner !== "paperclip" && defaultRunner !== "legacy")
|| !refreshedCodex?.supportedRunners?.includes(defaultRunner)) {
throw new Error("Could not confirm the environment's default runner. Retry setup.");
}
}
}
if (!testIsCurrent()) return null;
const selection = { adapterType, runner, savedAgentId: createdAgentId,
environment: environmentList.find(environment => environment.id === environmentId) };
const qualified = await qualifyClaudeSetup({ ...selection,
readAdapters: () => adaptersApi.list({ companyId: createdCompanyId, environmentId }),
probeEnvironment: () => environmentsApi.probe(environmentId!, createdCompanyId),
isCurrent: testIsCurrent,
setAdapterEnvResult(result);
adapterEnvResultAppliedStoredLoginRef.current = appliedStoredClaudeLoginBinding;
return result;
});
if (!testIsCurrent() || (needsClaudeSetupQualification(selection) && !qualified)) return null;
let testedConfig = adapterConfigOverride ?? buildAdapterConfig();
if (qualified) {
testedConfig = buildAdapterConfig(true, qualified.runner);
if (appliedStoredClaudeLoginBinding) testedConfig.env = {
...(isEnvRecord(testedConfig.env) ? testedConfig.env : {}), ...buildFixedClaudeOAuthBinding(),
};
onQualifiedConfig?.(testedConfig, qualified.adapters, environmentId);
}
const result = await agentsApi.testEnvironment(
createdCompanyId,
adapterType,
{
adapterConfig: testedConfig,
...(restoredSetup?.companyId === createdCompanyId ? { agentId: restoredSetup.id } : {}),
...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
...(managedBindingForStep() ? { aiConnection: managedBindingForStep() } : {}),
environmentId,
}
);
if (!testIsCurrent()) return null;
setAdapterEnvResult(result);
adapterEnvResultAppliedStoredLoginRef.current = appliedStoredClaudeLoginBinding;
return result;
} catch (err) {
if (!testIsCurrent()) return null;
if (!selectionIsCurrent()) return null;
adapterEnvErrorSelectionRef.current = selection || null;
setAdapterEnvError(
err instanceof Error ? err.message : "Adapter environment test failed"
+29 -1
View File
@@ -1,5 +1,33 @@
import { beforeEach, describe, expect, it } from "vitest";
import { claimOnboardingOffer, resetOnboardingOffers } from "./onboarding-auto-open";
import { claimOnboardingOffer, dashboardOnboardingOptions, resetOnboardingOffers } from "./onboarding-auto-open";
describe("dashboard onboarding recovery", () => {
const setup = { id: "setup-1", companyId: "company-1", pauseReason: "setup" as const, status: "paused" as const };
const draft = JSON.stringify({ createdCompanyId: "company-1", setupAgentId: "setup-1", step: 4 });
it("resumes the saved setup after a reload", () => {
expect(dashboardOnboardingOptions("company-1", [setup], draft)).toEqual({ companyId: "company-1", initialStep: 4 });
});
it("waits for agents and starts an empty company at the agent step", () => {
expect(dashboardOnboardingOptions("company-1", undefined, draft)).toBeNull();
expect(dashboardOnboardingOptions("company-1", [], null)).toEqual({ companyId: "company-1", initialStep: 3 });
});
it("keeps the dashboard after the agent is hired or another agent exists", () => {
expect(dashboardOnboardingOptions("company-1", [{ ...setup, pauseReason: null, status: "idle" }], draft)).toBeNull();
expect(dashboardOnboardingOptions("company-1", [setup, { ...setup, id: "ready", pauseReason: null, status: "idle" }], draft)).toBeNull();
expect(dashboardOnboardingOptions("company-1", [setup], JSON.stringify({ createdCompanyId: "company-1", setupAgentId: "setup-1", createdAgentId: "setup-1" }))).toBeNull();
});
it("does not adopt missing, malformed, foreign or terminated drafts", () => {
for (const raw of [null, "{", "null", "[]", JSON.stringify({ createdCompanyId: "company-other", setupAgentId: "setup-1" }), JSON.stringify({ createdCompanyId: "company-1", setupAgentId: "other" })]) {
expect(dashboardOnboardingOptions("company-1", [setup], raw)).toBeNull();
}
expect(dashboardOnboardingOptions("company-other", [setup], draft)).toBeNull();
expect(dashboardOnboardingOptions("company-1", [{ ...setup, status: "terminated" }], draft)).toBeNull();
});
});
describe("claimOnboardingOffer", () => {
beforeEach(resetOnboardingOffers);
+22
View File
@@ -1,3 +1,25 @@
import type { Agent } from "@paperclipai/shared";
import { ONBOARDING_AGENT_STEP } from "./onboarding-route";
/** Resume only the draft this browser saved, while no completed agent exists. */
export function dashboardOnboardingOptions(
companyId: string,
agents: Pick<Agent, "id" | "companyId" | "pauseReason" | "status">[] | undefined,
rawDraft: string | null,
): { companyId: string; initialStep: 3 | 4 } | null {
if (!agents) return null;
if (agents.length === 0) return { companyId, initialStep: ONBOARDING_AGENT_STEP };
if (agents.some(agent => agent.pauseReason !== "setup" || agent.status === "terminated")) return null;
try {
const draft = JSON.parse(rawDraft ?? "null");
if (draft?.createdCompanyId !== companyId || draft.createdAgentId
|| !agents.some(agent => agent.id === draft.setupAgentId && agent.companyId === companyId)) return null;
return { companyId, initialStep: 4 };
} catch {
return null;
}
}
/**
* Which companies have already been offered onboarding automatically.
*
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ONBOARDING_CONNECTION_TEST_TIMEOUT_MS, runOnboardingConnectionTest } from "./onboarding-connection-test";
describe("onboarding connection test deadline", () => {
afterEach(() => vi.useRealTimers());
it("bounds an unresponsive check and aborts its requests", async () => {
vi.useFakeTimers();
let signal!: AbortSignal;
const result = runOnboardingConnectionTest(async current => {
signal = current;
return new Promise(() => {});
});
const failed = expect(result).rejects.toThrow("The connection test took too long. Try connecting again.");
await vi.advanceTimersByTimeAsync(ONBOARDING_CONNECTION_TEST_TIMEOUT_MS);
await failed;
expect(signal.aborted).toBe(true);
expect(vi.getTimerCount()).toBe(0);
});
it("clears the deadline after success and preserves genuine failures", async () => {
vi.useFakeTimers();
await expect(runOnboardingConnectionTest(async () => "ready")).resolves.toBe("ready");
const failure = new Error("Paperclip is temporarily unavailable.");
await expect(runOnboardingConnectionTest(async () => { throw failure; })).rejects.toBe(failure);
expect(vi.getTimerCount()).toBe(0);
});
});
+21
View File
@@ -0,0 +1,21 @@
export const ONBOARDING_CONNECTION_TEST_TIMEOUT_MS = 120_000;
/** Bound the entire check, including environment preparation and response parsing. */
export async function runOnboardingConnectionTest<T>(
operation: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
const error = new Error("The connection test took too long. Try connecting again.");
reject(error);
controller.abort(error);
}, ONBOARDING_CONNECTION_TEST_TIMEOUT_MS);
});
try {
return await Promise.race([operation(controller.signal), deadline]);
} finally {
clearTimeout(timeout);
}
}
+44
View File
@@ -29,3 +29,47 @@ export function restoreOnboardingState(
return companies.some((c) => c.id === companyId) ? saved : null;
}
// Exported so tests write/read the exact key the component uses, instead of
// duplicating the literal and silently drifting from it if it's ever renamed.
export const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state";
/**
* The onboarding draft in `localStorage`, via a browser that is allowed to say
* no.
*
* Storage access throws outright where a browser denies it — Safari's private
* mode, a blocked third-party context — and every call site here sits in a
* render, an effect, or a close handler, so an escaping exception takes down
* something the customer was using. Losing the ability to resume onboarding is
* a far smaller failure than the wizard tearing down mid-answer, or refusing
* to close.
*
* Routed through one object on purpose. Guarding these one at a time is how
* three of the four call sites ended up unguarded while the fourth looked
* fixed: the read, the stale-blob cleanup, the persist effect, and `reset()`
* all have the same failure and want the same answer.
*/
export const onboardingDraftStorage = {
read(): string | null {
try {
return window.localStorage.getItem(ONBOARDING_STORAGE_KEY);
} catch {
return null;
}
},
write(value: string): void {
try {
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, value);
} catch {
// Storage unavailable: the draft is simply not resumable this session.
}
},
clear(): void {
try {
window.localStorage.removeItem(ONBOARDING_STORAGE_KEY);
} catch {
// Nothing to do. A draft that cannot be cleared is re-rejected on the
// next load by the same ownership check that rejected it here.
}
},
};
+13 -12
View File
@@ -3,11 +3,9 @@ import { TaskSummaryList } from "../components/TaskSummaryList";
import { useDesignSandbox } from "../context/DesignSandboxContext";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "@/lib/router";
import {
onboardingStepForCompany,
shouldRouteAgentlessCompanyToOnboarding,
} from "../lib/onboarding-route";
import { claimOnboardingOffer } from "../lib/onboarding-auto-open";
import { shouldRouteAgentlessCompanyToOnboarding } from "../lib/onboarding-route";
import { claimOnboardingOffer, dashboardOnboardingOptions } from "../lib/onboarding-auto-open";
import { onboardingDraftStorage } from "../lib/onboarding-state";
import { Link } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { dashboardApi } from "../api/dashboard";
@@ -138,11 +136,17 @@ export function Dashboard() {
// start the wizard at the front door with no company, and the new-company
// path there would create a *second* company instead of giving this one an
// agent.
// A saved agent that is still in setup is unfinished onboarding after reload.
// Only the matching browser draft may resume it; other setup records stay put.
const onboardingOptions = selectedCompanyId
? dashboardOnboardingOptions(selectedCompanyId, agents, onboardingDraftStorage.read())
: null;
const onboardingInitialStep = onboardingOptions?.initialStep;
const shouldOpenOnboarding = shouldRouteAgentlessCompanyToOnboarding({
pathname: location.pathname,
agentsLoaded: agents !== undefined,
agentsRefreshing,
agentCount: agents?.length ?? 0,
agentCount: onboardingOptions ? 0 : agents?.length ?? 0,
});
// Auto-open once per company. Every input to the effect sits behind a query,
// so a refetch re-runs it, and the customer can also navigate away and come
@@ -150,15 +154,12 @@ export function Dashboard() {
// wizard that was deliberately closed. `claimOnboardingOffer` holds the
// companies already offered; see it for why that outlives this component.
useEffect(() => {
if (!shouldOpenOnboarding || !selectedCompanyId) return;
if (!shouldOpenOnboarding || !selectedCompanyId || !onboardingInitialStep) return;
if (!claimOnboardingOffer(selectedCompanyId)) return;
openOnboarding({
companyId: selectedCompanyId,
initialStep: onboardingStepForCompany(),
});
openOnboarding({ companyId: selectedCompanyId, initialStep: onboardingInitialStep });
// No mission lookup to wait on any more: the step this opens is the same
// whatever the goals say, so waiting only delayed the open.
}, [shouldOpenOnboarding, selectedCompanyId, openOnboarding]);
}, [shouldOpenOnboarding, selectedCompanyId, onboardingInitialStep, openOnboarding]);
useEffect(() => {
setBreadcrumbs([{ label: "Dashboard" }]);