diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts index 13b6b7b3279..fd609ba1496 100644 --- a/ui/src/api/adapters.ts +++ b/ui/src/api/adapters.ts @@ -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(`/adapters${query.size ? `?${query}` : ""}`); + return api.get(`/adapters${query.size ? `?${query}` : ""}`, options); }, /** Install an external adapter from npm or a local path. */ diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 07358ef8377..0c729e0dbfb 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -271,10 +271,12 @@ export const agentsApi = { testCredentials?: Record; environmentId?: string | null; }, + options?: RequestOptions, ) => api.post( `/companies/${companyId}/adapters/${type}/test-environment`, data, + options, ), getAdapterAuthSignal: (companyId: string, type: string, environmentId?: string | null) => { const query = environmentId ? `?environmentId=${encodeURIComponent(environmentId)}` : ""; diff --git a/ui/src/api/environments.ts b/ui/src/api/environments.ts index 3e9c8d4da5e..107abf9a798 100644 --- a/ui/src/api/environments.ts +++ b/ui/src/api/environments.ts @@ -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( companyId ? `/environments/${environmentId}/probe?${companyIdQuery(companyId)}` : `/environments/${environmentId}/probe`, {}, + options, ), probeConfig: (companyId: string, body: { name?: string; diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index 4309528d47c..0850c21ec6d 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -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 }; const saved = (mockAgentsApi.hire.mock.calls[0] as unknown[])[1] as { adapterConfig: Record }; 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 { diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index affa2f1c19b..7d23cc1ed2c 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -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 ; } -// 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" diff --git a/ui/src/lib/onboarding-auto-open.test.ts b/ui/src/lib/onboarding-auto-open.test.ts index c5845efbd93..962e31c513d 100644 --- a/ui/src/lib/onboarding-auto-open.test.ts +++ b/ui/src/lib/onboarding-auto-open.test.ts @@ -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); diff --git a/ui/src/lib/onboarding-auto-open.ts b/ui/src/lib/onboarding-auto-open.ts index 5be578f8962..17da4730822 100644 --- a/ui/src/lib/onboarding-auto-open.ts +++ b/ui/src/lib/onboarding-auto-open.ts @@ -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[] | 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. * diff --git a/ui/src/lib/onboarding-connection-test.test.ts b/ui/src/lib/onboarding-connection-test.test.ts new file mode 100644 index 00000000000..c4898cb7525 --- /dev/null +++ b/ui/src/lib/onboarding-connection-test.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/onboarding-connection-test.ts b/ui/src/lib/onboarding-connection-test.ts new file mode 100644 index 00000000000..a3f4fdbf5b2 --- /dev/null +++ b/ui/src/lib/onboarding-connection-test.ts @@ -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( + operation: (signal: AbortSignal) => Promise, +): Promise { + const controller = new AbortController(); + let timeout: ReturnType | undefined; + const deadline = new Promise((_, 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); + } +} diff --git a/ui/src/lib/onboarding-state.ts b/ui/src/lib/onboarding-state.ts index e9dfed0e8aa..6f990a3ff5c 100644 --- a/ui/src/lib/onboarding-state.ts +++ b/ui/src/lib/onboarding-state.ts @@ -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. + } + }, +}; diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 44de6bb0e79..23130b0198e 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -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" }]);