diff --git a/apps/hub/package.json b/apps/hub/package.json index ca8aa1e3f2..ce64937b90 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -60,7 +60,6 @@ "@intx/workflow": "workspace:*", "@intx/workflow-deploy": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", - "@workbench/access-policy": "workspace:*", "@corbits/connections": "workspace:*", "@corbits/codex-provider": "workspace:*", "@corbits/xai-provider": "workspace:*", diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts index ef770ae638..2e0f4908da 100644 --- a/apps/hub/src/config.ts +++ b/apps/hub/src/config.ts @@ -104,7 +104,7 @@ const HubEnv = type({ "override for the GitHub REST API origin the `github` connector's PAT probe and credential delivery target, e.g. a fake server recorded for tests/evals; unset (default) targets https://api.github.com — never set this for a real deployment", ), "CREDENTIAL_ENCRYPTION_KEY?": type(/^[0-9a-fA-F]{64}$/).describe( - "a 64-character hex-encoded 32-byte AES-256 key (openssl rand -hex 32) encrypting secrets at rest through Interchange's CredentialCipher seam — webhook-trigger signing secrets and onboarding's OAuth PKCE connect state; boot fails without it unless ALLOW_PLAINTEXT_SECRETS opts into dev/test's unencrypted fallback", + "a 64-character hex-encoded 32-byte AES-256 key (openssl rand -hex 32) encrypting secrets at rest through Interchange's CredentialCipher seam — webhook-trigger signing secrets; boot fails without it unless ALLOW_PLAINTEXT_SECRETS opts into dev/test's unencrypted fallback", ), "PRINCIPAL_KEY_ENCRYPTION_KEY?": type(/^[0-9a-fA-F]{64}$/).describe( "a 64-character hex-encoded 32-byte AES-256 key (openssl rand -hex 32) sealing every principal's per-principal signing key at rest through Interchange's principal-key store — deliberately separate from CREDENTIAL_ENCRYPTION_KEY so the two rotate independently; boot fails without it unless ALLOW_PLAINTEXT_SECRETS opts into dev/test's unencrypted fallback", diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 5caf7dfd15..4b7507f27a 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -27,7 +27,7 @@ import { user as userTable, workflowDefinition, } from "@intx/db/schema"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, count, eq, inArray } from "drizzle-orm"; import { createEnvKeyCredentialCipher, createNoopCredentialCipher, @@ -293,6 +293,7 @@ import { mintRepoGrantViaHttp, } from "./native-repo-grants"; import { createSignInAttemptLimiter } from "./sign-in-rate-limit"; +import { createSetupStatusRoutes } from "./setup-status"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { type Context, Hono, type Next } from "hono"; @@ -402,14 +403,9 @@ export function createStaticHandler(staticDir: string) { /** * The `CredentialCipher` (see `@intx/types`) every secret-at-rest seam - * in this composition root shares — `webhookTriggerStore`'s signing - * secrets, `@workbench/onboarding`'s in-flight OAuth connect state - * (the PKCE verifier parked between `/start` and `/callback`, sealed - * into the state itself so it survives a restart between the two), and - * (since CL-6031) the same package's `pending_seed` table — a - * just-connected credential's plaintext key, parked server-side - * between the OAuth callback and the onboarding page's own - * `/complete-setup` follow-up (see `packages/onboarding/src/pending-seed.ts`). + * in this composition root shares — currently `webhookTriggerStore`'s + * signing secrets (CL-8112 unmounted the onboarding router whose OAuth + * connect state and `pending_seed` table used to share it). * A real key (`CREDENTIAL_ENCRYPTION_KEY`) builds an AES-256-GCM * cipher. An unset key hard-fails boot — a self-hosting operator who * forgets this variable must not silently end up storing those secrets @@ -425,9 +421,8 @@ export function credentialCipherFrom( throw new Error( [ "CREDENTIAL_ENCRYPTION_KEY is not set.", - "It encrypts secrets at rest — webhook-trigger signing secrets,", - "onboarding's OAuth PKCE connect state, and its pending-seed", - "table — so the hub refuses to boot without it. Generate one and", + "It encrypts secrets at rest — webhook-trigger signing secrets —", + "so the hub refuses to boot without it. Generate one and", "add it to .env:", "", " openssl rand -hex 32", @@ -438,7 +433,7 @@ export function credentialCipherFrom( ].join("\n"), ); } - log.warn`No CREDENTIAL_ENCRYPTION_KEY configured; secrets (e.g. webhook-trigger signing secrets, onboarding OAuth connect state, onboarding's pending-seed table) will NOT be encrypted at rest. ALLOW_PLAINTEXT_SECRETS is set — expected in dev/test only, never for a real deployment.`; + log.warn`No CREDENTIAL_ENCRYPTION_KEY configured; secrets (e.g. webhook-trigger signing secrets) will NOT be encrypted at rest. ALLOW_PLAINTEXT_SECRETS is set — expected in dev/test only, never for a real deployment.`; return createNoopCredentialCipher(); } return createEnvKeyCredentialCipher( @@ -1348,6 +1343,20 @@ export async function createHub(config: HubConfig) { // agents never produce text. `config.baseUrl` (not `localhost`) is // what makes the URL usable from a sidecar on another machine. app.route("/api/chat/noop-inference", createNoopInferenceRoutes()); + // Native cold-boot setup status (CL-8112). Mounted outside the tenant + // prefix like the noop-inference route: an empty hub has no tenant to + // scope to, and the first-login hook must read it before any bench + // exists. Counts come straight off the native user/tenant tables — no + // workbench package involved. + app.route( + "/api/setup", + createSetupStatusRoutes({ + countUsers: async () => + (await db.select({ n: count() }).from(userTable))[0]?.n ?? 0, + countTenants: async () => + (await db.select({ n: count() }).from(tenantTable))[0]?.n ?? 0, + }), + ); const selfApi = createHubAPI(config.baseUrl); // The chat platform's invite-launch fallback: a definition with no // model requirements of its own resolves the tenant-catalog default. diff --git a/apps/hub/src/setup-status.test.ts b/apps/hub/src/setup-status.test.ts new file mode 100644 index 0000000000..db01b83878 --- /dev/null +++ b/apps/hub/src/setup-status.test.ts @@ -0,0 +1,74 @@ +// The native cold-boot status read (CL-8112): empty hub demands setup, +// any tenant means setup is behind us, and a count failure answers the +// hub's error envelope (never a leak, never a bare 500). +import { describe, expect, test } from "bun:test"; + +import { + createSetupStatusRoutes, + type SetupStatusCounts, +} from "./setup-status"; + +function counts(counters: SetupStatusCounts) { + return createSetupStatusRoutes(counters); +} + +describe("createSetupStatusRoutes", () => { + test("an empty hub reports setupRequired with zero counts", async () => { + const res = await counts({ + countUsers: async () => 0, + countTenants: async () => 0, + }).request("/status"); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + setupRequired: true, + userCount: 0, + tenantCount: 0, + }); + }); + + test("a hub with tenants reports setupRequired false with real counts", async () => { + const res = await counts({ + countUsers: async () => 3, + countTenants: async () => 2, + }).request("/status"); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + setupRequired: false, + userCount: 3, + tenantCount: 2, + }); + }); + + test("users with zero tenants still require setup — no bench exists", async () => { + const res = await counts({ + countUsers: async () => 1, + countTenants: async () => 0, + }).request("/status"); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + setupRequired: true, + userCount: 1, + tenantCount: 0, + }); + }); + + test("a count failure answers the error envelope with a refId", async () => { + const res = await counts({ + countUsers: async () => 0, + countTenants: async () => { + throw new Error("connection refused"); + }, + }).request("/status"); + + expect(res.status).toBe(500); + const body = (await res.json()) as { + error: { code: string; userMessage: string; refId: string }; + }; + expect(body.error.code).toBe("setup_status_failed"); + expect(body.error.userMessage).not.toContain("connection refused"); + expect(body.error.refId.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/hub/src/setup-status.ts b/apps/hub/src/setup-status.ts new file mode 100644 index 0000000000..f661b62bda --- /dev/null +++ b/apps/hub/src/setup-status.ts @@ -0,0 +1,53 @@ +// Native cold-boot setup status (CL-8112). A read-only, unauthenticated +// `GET /status` reporting whether this hub has ever been set up, derived +// from native user/tenant counts — the same genesis rule the seed path +// uses: zero tenants means no bench has ever been minted, so the first +// login must route into setup. Replaces the web first-login hook's old +// provision-POST read, which belonged to the unmounted onboarding router. No policy, no auth logic (those are +// T6/T7) — just counts. + +import { Hono } from "hono"; +import { makeErrorEnvelope, reportError } from "@corbits/error-sink"; + +export type SetupStatusCounts = { + /** Native user-row count. */ + countUsers(): Promise; + /** Native tenant-row count. */ + countTenants(): Promise; +}; + +export type SetupStatus = { + readonly setupRequired: boolean; + readonly userCount: number; + readonly tenantCount: number; +}; + +export function createSetupStatusRoutes(deps: SetupStatusCounts): Hono { + const app = new Hono(); + app.get("/status", async (c) => { + try { + const [userCount, tenantCount] = await Promise.all([ + deps.countUsers(), + deps.countTenants(), + ]); + const body: SetupStatus = { + setupRequired: tenantCount === 0, + userCount, + tenantCount, + }; + return c.json(body); + } catch (error) { + const refId = reportError(error, { operation: "hub.setup_status" }); + return c.json( + makeErrorEnvelope({ + code: "setup_status_failed", + userMessage: + "Checking your workbench hit a snag. Try again in a moment.", + refId, + }), + 500, + ); + } + }); + return app; +} diff --git a/apps/hub/src/templates/block-workflows.ts b/apps/hub/src/templates/block-workflows.ts index d9690fd80f..fbef8e457b 100644 --- a/apps/hub/src/templates/block-workflows.ts +++ b/apps/hub/src/templates/block-workflows.ts @@ -21,6 +21,9 @@ // (mounted in `apps/hub`) imports this; it is deliberately not // re-exported from the package root. import { deployableCatalogWorkflow } from "@workbench/onboarding/tenant-seed"; +// TODO(CL-8113, hub-zero T2): this tenant-seed import is the hub's last +// `@workbench/onboarding` edge — cut it over to the native catalog when +// T2 lands, then drop the dependency from `apps/hub/package.json`. export interface BlockWorkflowBuildInput { readonly tenantDomain: string; diff --git a/apps/hub/test/credential-cipher.test.ts b/apps/hub/test/credential-cipher.test.ts index 1d79fb7cfc..d691653668 100644 --- a/apps/hub/test/credential-cipher.test.ts +++ b/apps/hub/test/credential-cipher.test.ts @@ -38,7 +38,6 @@ describe("credentialCipherFrom", () => { const message = (error as Error).message; expect(message).toContain("CREDENTIAL_ENCRYPTION_KEY"); expect(message).toContain("webhook-trigger signing secrets"); - expect(message).toContain("onboarding's OAuth PKCE connect state"); expect(message).toContain("openssl rand -hex 32"); expect(message).toContain("ALLOW_PLAINTEXT_SECRETS"); } diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index d301afb904..13894c07d9 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -58,20 +58,14 @@ function LoginBounceHome({ navigate }: { readonly navigate: Navigate }) { /** * Onboarding renders above the shell entirely — no rail, no col2, no bench - * dock, nothing that implies a workbench already exists. A signed-in - * account with no completed onboarding must never see "Select a workbench"; - * the wizard is the only thing on screen until it hands off to `/`. + * dock, nothing that implies a workbench already exists. An account the + * hub reports as setup-required must never see "Select a workbench"; the + * setup screen is the only thing on screen until it hands off to `/`. */ -function OnboardingGate({ - navigate, - user, -}: { - readonly navigate: Navigate; - readonly user: SessionUser; -}) { +function OnboardingGate({ navigate }: { readonly navigate: Navigate }) { return ( - + ); } @@ -197,7 +191,7 @@ export function App({ return ; } if (path === ONBOARDING_PATH) { - return ; + return ; } return ( { if (cancelled) return; - if (result.kind === "needs-onboarding" || result.kind === "provisioned") { + if (result.kind === "needs-onboarding") { navigate(ONBOARDING_PATH); } else if (result.kind === "error") { setProvisioningError({ message: result.message, refId: result.refId }); diff --git a/apps/web/src/onboarding.ts b/apps/web/src/onboarding.ts index 5f1aa8e36b..90687ac37e 100644 --- a/apps/web/src/onboarding.ts +++ b/apps/web/src/onboarding.ts @@ -1,26 +1,23 @@ -// The browser side of the first-login hook: one call against the -// hub's native onboarding route, made once per session. A session with -// zero principals and no display name is reported as needs-onboarding so -// the UI can route into the naming wizard; only an explicit name creates -// the personal bench. Distinguishes real failures from "nothing to do", -// so a broken provisioning call never leaves the user silently benchless. +// The browser side of the first-login hook: one GET against the hub's +// native setup-status route, made once per session. A hub with zero +// tenants reports setup-required so the UI routes into the setup wizard; +// any tenant means a bench exists and the shell loads normally. Read-only +// on purpose (CL-8112): provisioning itself back onto the hub is T6/T7's +// surface, so this never mints anything — a broken status read blocks the +// shell loudly rather than leaving the user silently benchless. import { type } from "arktype"; import { reportError } from "@corbits/error-sink"; -import type { SupportedCredentialProvider } from "@corbits/connections/credential-test"; -const ProvisionResult = type({ - kind: "'existing-member' | 'provisioned' | 'needs-onboarding'", - "tenantId?": "string", - "tenantSlug?": "string", - "seeded?": "boolean", - "seedSkipReason?": "string", +const SetupStatus = type({ + setupRequired: "boolean", + "userCount?": "number", + "tenantCount?": "number", }); /** Any credential row this bench actually has stored — the cheap - * pre-skip read `OnboardingPage` uses to independently confirm a - * working inference credential exists before trusting a `seeded: true` - * hard-skip; see that page's own comment. */ + * pre-skip read the home page's first-workbench flow uses to tell "no + * credential yet" apart from "still coming online". */ const CredentialsPage = type({ data: type({ status: "string" }).array(), }); @@ -39,10 +36,8 @@ export type ActiveCredentialProbe = /** * Whether `tenantId` has at least one credential in the `active` * status. A cheap, single read — no provider/catalog chain resolution — - * so this is deliberately the weaker of the two checks the onboarding - * page's own comment describes; it exists to keep the hard-skip honest - * without adding a second round trip's worth of catalog/provider - * plumbing to the browser bundle. + * so the caller treats it as the weaker check it is: only a confirmed + * miss means "no key", never a probe failure (CL-6868). */ export async function hasActiveCredential( tenantId: string, @@ -72,63 +67,24 @@ const ErrorEnvelope = type({ const FALLBACK_ERROR_MESSAGE = "Setting up your workbench hit a snag — we're on it. Try again in a moment."; -/** Consumer sentence when the pre-skip credentials probe cannot complete - * (CL-6868). Paired with Retry on the current setup step — never opens - * paste-a-key as if no key exists. */ -export const CREDENTIAL_PROBE_FAILURE_MESSAGE = - "Checking for a connected key hit a snag. Try again in a moment."; - +/** What the first-login hook can conclude from the native status read: + * the account either lands on an already-set-up hub (`existing-member`) + * or on one that still needs setup (`needs-onboarding`). The hub's own + * counts ride along in the status payload but the hook needs only the + * verdict — seed/credential detail belongs to the setup flow (T6/T7), + * not to this routing decision. */ export type ProvisionOutcome = - | { - readonly kind: "existing-member"; - /** - * Present only for the caller's own personal bench: `false` means - * it still has no working inference credential (`bench_unseeded`) - * and the credential step should stay open rather than read as - * finished. Absent when membership is on some other tenant, whose - * seed state this account has no say over. - */ - readonly seeded?: boolean; - /** - * Present under the same condition as `seeded`: the caller's own - * personal bench. Lets the onboarding page independently confirm - * (`hasActiveCredential`) a working credential exists before - * trusting `seeded: true` enough to skip the credential step. - * A probe `error` must not be treated as absent (CL-6868). - */ - readonly tenantId?: string; - /** Present when the caller just joined the root as a plain - * member: the tenant they joined, for the member-onboarding UX - * to surface later (CL-7584). */ - readonly tenantSlug?: string; - } + | { readonly kind: "existing-member" } | { readonly kind: "needs-onboarding" } - | { - readonly kind: "provisioned"; - readonly tenantId: string; - readonly tenantSlug: string; - readonly seeded: boolean; - readonly seedSkipReason?: string; - } | { readonly kind: "error"; readonly message: string; readonly refId?: string; }; -export async function triggerFirstLoginProvisioning( - displayName?: string, -): Promise { +export async function triggerFirstLoginProvisioning(): Promise { try { - const response = await fetch("/api/onboarding/provision", { - method: "POST", - ...(displayName !== undefined - ? { - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: displayName }), - } - : {}), - }); + const response = await fetch("/api/setup/status"); const body: unknown = await response.json().catch(() => null); if (!response.ok) { const envelope = ErrorEnvelope(body); @@ -140,503 +96,29 @@ export async function triggerFirstLoginProvisioning( refId: envelope.error.refId, }; } - const parsed = ProvisionResult(body); + const parsed = SetupStatus(body); if (parsed instanceof type.errors) { return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; } - if (parsed.kind === "existing-member") { - if (parsed.seeded === undefined) { - return parsed.tenantId === undefined - ? { kind: "existing-member" } - : { - kind: "existing-member", - tenantId: parsed.tenantId, - ...(parsed.tenantSlug !== undefined - ? { tenantSlug: parsed.tenantSlug } - : {}), - }; - } - return parsed.tenantId === undefined - ? { kind: "existing-member", seeded: parsed.seeded } - : { - kind: "existing-member", - seeded: parsed.seeded, - tenantId: parsed.tenantId, - }; - } - if (parsed.kind === "needs-onboarding") return { kind: "needs-onboarding" }; - if ( - parsed.tenantId === undefined || - parsed.tenantSlug === undefined || - parsed.seeded === undefined - ) { - return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; - } - return parsed.seedSkipReason === undefined - ? { - kind: "provisioned", - tenantId: parsed.tenantId, - tenantSlug: parsed.tenantSlug, - seeded: parsed.seeded, - } - : { - kind: "provisioned", - tenantId: parsed.tenantId, - tenantSlug: parsed.tenantSlug, - seeded: parsed.seeded, - seedSkipReason: parsed.seedSkipReason, - }; - } catch { - return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; - } -} - -export type CredentialProvider = SupportedCredentialProvider; - -// The one-click paths: a plain navigation to the hub's connect route, -// which round-trips through the provider's consent page and lands back -// on /onboarding with the outcome in the query string. -export const OPENROUTER_CONNECT_START_PATH = - "/api/onboarding/oauth/openrouter/start"; -export const HUGGINGFACE_CONNECT_START_PATH = - "/api/onboarding/oauth/huggingface/start"; - -export type ConnectReturn = - | { - readonly kind: "connected"; - readonly tenantSlug: string; - } - | { readonly kind: "error"; readonly message: string }; - -export type OpenRouterConnectReturn = ConnectReturn; - -const OPENROUTER_CONNECT_ERROR_COPY: Readonly> = { - state_expired: - "The OpenRouter connection took too long or was already used. Start it again.", - exchange_failed: - "OpenRouter did not hand back a key for that connection. Try connecting again.", - key_rejected: - "OpenRouter minted a key, but connecting it failed. Try connecting again.", - no_bench: - "No personal workbench was found for this account yet. Reload and try again.", - setup_failed: - "Your OpenRouter key was added, but setting up your workbench failed. Try again in a moment.", - signed_out: - "Your session ended during the OpenRouter connection. Sign in and try again.", - rate_limited: - "OpenRouter limits how often it can create a new key. Wait a minute, then try connecting again.", -}; - -const HUGGINGFACE_CONNECT_ERROR_COPY: Readonly> = { - state_expired: - "The Hugging Face connection took too long or was already used. Start it again.", - exchange_failed: - "Hugging Face did not hand back a token for that connection. Try connecting again.", - key_rejected: - "Hugging Face minted a token, but connecting it failed. Try connecting again.", - no_bench: - "No personal workbench was found for this account yet. Reload and try again.", - setup_failed: - "Your Hugging Face token was added, but setting up your workbench failed. Try again in a moment.", - signed_out: - "Your session ended during the Hugging Face connection. Sign in and try again.", - rate_limited: - "Hugging Face limits how often it can create a new token. Wait a minute, then try connecting again.", - not_configured: - "Hugging Face connect isn't set up on this workbench yet. Paste a token instead.", -}; - -/** - * Reads a connect round-trip's outcome out of the wizard's query string, - * for the `connect=providerId` this provider's callback writes. The - * parameters are hub-written but arrive through a redirect the browser - * (or anyone) can replay, so they are treated as untrusted: anything - * malformed collapses to an honest error, never a fabricated success. - */ -function readConnectReturn( - search: string, - providerId: string, - providerLabel: string, - errorCopy: Readonly>, -): ConnectReturn | null { - const params = new URLSearchParams(search); - if (params.get("connect") !== providerId) return null; - const outcome = params.get("outcome"); - if (outcome === "connected") { - const tenantSlug = params.get("tenantSlug"); - if (tenantSlug === null || tenantSlug === "") { - return { - kind: "error", - message: `The ${providerLabel} connection finished but its result was incomplete. Try connecting again.`, - }; - } - return { kind: "connected", tenantSlug }; - } - const code = params.get("code"); - return { - kind: "error", - message: - (code !== null ? errorCopy[code] : undefined) ?? - `The ${providerLabel} connection did not finish. Try connecting again.`, - }; -} - -export function readOpenRouterConnectReturn( - search: string, -): ConnectReturn | null { - return readConnectReturn( - search, - "openrouter", - "OpenRouter", - OPENROUTER_CONNECT_ERROR_COPY, - ); -} - -export function readHuggingFaceConnectReturn( - search: string, -): ConnectReturn | null { - return readConnectReturn( - search, - "huggingface", - "Hugging Face", - HUGGINGFACE_CONNECT_ERROR_COPY, - ); -} - -export type CredentialProviderCard = { - readonly id: CredentialProvider; - readonly label: string; - /** One honest line of what picking this provider gets you — shown next - * to the key field once it's selected, never a marketing pitch. */ - readonly description: string; - readonly keyConsoleUrl: string; - readonly keyHint: string; - /** - * Absent (the default) means this card's single field collects an API - * key. `"url"` means it collects the origin of an already-running - * instance instead — Ollama needs no key at all — and - * `urlDefaultValue` prefills that field. `submitCredential` sends the - * fixed `OLLAMA_PLACEHOLDER_SECRET` as `apiKey` and this value as - * `baseURL` for any `"url"` card; see that function's own doc. - */ - readonly fieldKind?: "url"; - readonly urlDefaultValue?: string; -}; - -/** The six the wizard leads with — the providers most people reach for - * first. Order is deliberate and matches the card row left to right. */ -export const PRIMARY_CREDENTIAL_PROVIDERS: readonly CredentialProviderCard[] = [ - { - id: "openai", - label: "OpenAI", - description: "GPT models, direct from OpenAI.", - keyConsoleUrl: "https://platform.openai.com/api-keys", - keyHint: "sk-", - }, - { - id: "anthropic", - label: "Anthropic", - description: "Claude models, direct from Anthropic.", - keyConsoleUrl: "https://console.anthropic.com/settings/keys", - keyHint: "sk-ant-", - }, - { - id: "google-genai", - label: "Google", - description: "Gemini models, direct from Google.", - keyConsoleUrl: "https://aistudio.google.com/apikey", - keyHint: "AIza", - }, - { - id: "xai", - label: "xAI", - description: "Grok models, direct from xAI.", - keyConsoleUrl: "https://console.x.ai/team/default/api-keys", - keyHint: "xai-", - }, - { - id: "openrouter", - label: "OpenRouter", - description: "One key, many models — hundreds of models, one bill.", - keyConsoleUrl: "https://openrouter.ai/keys", - keyHint: "sk-or-", - }, - { - id: "opencode-zen", - label: "Opencode Zen", - description: "OpenCode's curated gateway of coding-agent-tested models.", - keyConsoleUrl: "https://opencode.ai/auth", - keyHint: "", - }, -]; - -/** The rest — still fully supported, just tucked behind the "More - * providers" expander so the primary row stays to six cards. */ -export const SECONDARY_CREDENTIAL_PROVIDERS: readonly CredentialProviderCard[] = - [ - { - id: "groq", - label: "Groq", - description: "Open models served at very high inference speed.", - keyConsoleUrl: "https://console.groq.com/keys", - keyHint: "gsk_", - }, - { - id: "deepseek", - label: "DeepSeek", - description: "DeepSeek's own models, direct from DeepSeek.", - keyConsoleUrl: "https://platform.deepseek.com/api_keys", - keyHint: "sk-", - }, - { - id: "mistral", - label: "Mistral", - description: "Mistral's own models, direct from Mistral.", - keyConsoleUrl: "https://console.mistral.ai/api-keys", - keyHint: "", - }, - { - id: "huggingface", - label: "Hugging Face", - description: - "Pay-as-you-go across Groq, Together, Fireworks & more, billed to your HF account. A fine-grained access token (permission: Make calls to Inference Providers) never expires — the one-click connect above mints a token that does.", - keyConsoleUrl: "https://huggingface.co/settings/tokens/new", - keyHint: "hf_", - }, - { - id: "ollama", - label: "Ollama (local)", - description: "Ollama (local) — runs models on your machine.", - keyConsoleUrl: "https://ollama.com", - keyHint: "", - fieldKind: "url", - urlDefaultValue: "http://localhost:11434", - }, - ]; - -/** Every provider card, primary row first — the flat list a lookup by id - * (e.g. the active provider's description) reads from regardless of which - * group renders it. */ -export const CREDENTIAL_PROVIDERS: readonly CredentialProviderCard[] = [ - ...PRIMARY_CREDENTIAL_PROVIDERS, - ...SECONDARY_CREDENTIAL_PROVIDERS, -]; - -/** What every provisioning-aware onboarding route answers with - * (CL-6457). `ready` means the bench's agents are all live; - * `provisioning` means connecting succeeded and the agents are still - * coming online in the background — both are success, and the wizard - * moves the person forward either way. */ -const CredentialConnected = type({ - kind: "'ready' | 'provisioning'", - "tenantId?": "string", - tenantSlug: "string", - deployed: "string[]", - pending: "string[]", -}); - -export type CredentialOutcome = - | { - readonly kind: "connected"; - /** Absent only for a response older than this field existing -- - * every current `/complete` response carries it. The wizard itself - * no longer branches on it (CL-6104 dropped the optional "Connect - * your tools" phase this once fed) — it stays parsed because the - * server response carries it regardless. */ - readonly tenantId?: string; - readonly tenantSlug: string; - /** Whether this account's agents still have deploying left to do. - * The wizard does not wait on it — it decides whether the next - * screen shows the warm "getting your agents ready" state. */ - readonly agentsPending: boolean; - } - | { - readonly kind: "rejected"; - readonly message: string; - readonly refId?: string; - } - | { - readonly kind: "error"; - readonly message: string; - readonly refId?: string; - }; - -async function postOnboarding( - path: string, - provider: CredentialProvider, - apiKey: string, - baseURL?: string, -): Promise<{ readonly response: Response; readonly body: unknown }> { - // A key copied from a provider console often carries a trailing - // newline; sent verbatim the provider 401s a valid key (CL-6682). - const trimmedKey = apiKey.trim(); - const response = await fetch(`/api/onboarding/${path}`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify( - baseURL !== undefined - ? { provider, apiKey: trimmedKey, baseURL } - : { provider, apiKey: trimmedKey }, - ), - }); - const body: unknown = await response.json().catch(() => null); - return { response, body }; -} - -function readErrorEnvelope(body: unknown): { - readonly message: string; - readonly refId?: string; -} { - const envelope = ErrorEnvelope(body); - return envelope instanceof type.errors - ? { message: FALLBACK_ERROR_MESSAGE } - : { message: envelope.error.userMessage, refId: envelope.error.refId }; -} - -/** - * Hands a user's own key to the hub, which stores it immediately — no - * live call to the provider gates this (CL-6123) — then seeds the - * caller's personal bench: deploys every default workflow, then plants - * the disabled-by-default preset routines against them (CL-6201). - * The credential itself is stored through the hub's native `POST - * /api/tenants/:id/credentials` route — this call only tells the hub - * which provider and key to use, and reports the outcome. A wrong key - * is caught later, the first time it's actually dialed, and surfaces - * in-chat through the credential-error + "Fix this connection" flow - * (CL-6092) — not here. - * - * `baseURL` is the configurable-base-URL seam Ollama's card uses - * (`CredentialProviderCard.fieldKind === "url"`): the field's value is - * sent here, never as `apiKey` — `apiKey` for that one provider is - * always `OLLAMA_PLACEHOLDER_SECRET`, since Ollama has no key to paste. - */ -export async function submitCredential( - provider: CredentialProvider, - apiKey: string, - baseURL?: string, -): Promise { - try { - const { response, body } = await postOnboarding( - "complete", - provider, - apiKey, - baseURL, - ); - if (!response.ok) { - const { message, refId } = readErrorEnvelope(body); - if (response.status === 422) { - return refId === undefined - ? { kind: "rejected", message } - : { kind: "rejected", message, refId }; - } - return refId === undefined - ? { kind: "error", message } - : { kind: "error", message, refId }; - } - const parsed = CredentialConnected(body); - if (parsed instanceof type.errors) { - return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; - } - const agentsPending = parsed.pending.length > 0; - return parsed.tenantId === undefined - ? { - kind: "connected", - tenantSlug: parsed.tenantSlug, - agentsPending, - } - : { - kind: "connected", - tenantId: parsed.tenantId, - tenantSlug: parsed.tenantSlug, - agentsPending, - }; - } catch { - return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; - } -} - -export const OnboardingStep = type({ - name: "string", - label: "string", - status: "'present' | 'pending' | 'blocked'", -}); -export type OnboardingStep = typeof OnboardingStep.infer; - -const CompleteSetupResult = type({ - kind: "'ready' | 'provisioning' | 'unseeded'", - "tenantId?": "string", - "tenantSlug?": "string", - "deployed?": "string[]", - "pending?": "string[]", - // CL-7584: the doc-labeled desired-state steps the finishing-setup - // view renders while agents are still coming online. - "steps?": OnboardingStep.array(), -}); - -export type CompleteSetupOutcome = - | { - readonly kind: "connected"; - /** See `CredentialOutcome`'s own note on this field. */ - readonly tenantId?: string; - readonly tenantSlug: string; - /** See `CredentialOutcome.agentsPending`. */ - readonly agentsPending: boolean; - /** CL-7584: the desired-state steps still pending, when the hub - * reports any; absent (collapsed) once everything is present. */ - readonly steps?: readonly OnboardingStep[]; - } - | { readonly kind: "unseeded" } - | { - readonly kind: "error"; - readonly message: string; - readonly refId?: string; - }; - -/** - * The follow-up call the wizard makes once it lands back from a - * one-click connect: the OAuth callback itself only stored the key - * (fast, so the browser is never left waiting on a redirect), and this - * is what actually deploys the default workflows and their preset - * routines against it. - * `"unseeded"` is not a failure — it means the workbench genuinely has - * nothing to finish setting up with yet, and the wizard falls back to - * the ordinary credential step rather than treating it as broken. - */ -export async function completeSetup(): Promise { - try { - const response = await fetch("/api/onboarding/complete-setup", { - method: "POST", - }); - const body: unknown = await response.json().catch(() => null); - if (!response.ok) { - const { message, refId } = readErrorEnvelope(body); - return refId === undefined - ? { kind: "error", message } - : { kind: "error", message, refId }; - } - const parsed = CompleteSetupResult(body); - if (parsed instanceof type.errors) { - return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; - } - if (parsed.kind === "unseeded") return { kind: "unseeded" }; - if (parsed.tenantSlug === undefined || parsed.pending === undefined) { - return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; - } - const agentsPending = parsed.pending.length > 0; - const steps = parsed.steps; - const common = { - tenantSlug: parsed.tenantSlug, - agentsPending, - ...(steps !== undefined && steps.length > 0 ? { steps } : {}), - }; - return parsed.tenantId === undefined - ? { kind: "connected", ...common } - : { kind: "connected", tenantId: parsed.tenantId, ...common }; + return parsed.setupRequired + ? { kind: "needs-onboarding" } + : { kind: "existing-member" }; } catch { return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; } } +/* CL-8112 cut the rest of this module's provisioning surface: the + * credential-submit, one-click OAuth connect, and complete-setup helpers + * all spoke to the deleted `/api/onboarding/*` routes, so they went with + * the hub mount. What remains is the first-login status read above plus + * the agent-readiness tombstone below. Live callers (the home page's + * first-workbench flow, the picker) no longer use it — they read Myra's + * native agent definition directly — so this helper exists only to keep + * the deleted route's absence explicit: a 404/410 is `route-gone`, never + * a generic agent error. The setup flow itself is T6/T7's to build. + * TODO(CL-8112-T6/T7): replace this tombstone with native readiness once + * T6/T7 builds it — no new `/api/onboarding/*` client calls until then. */ const ProvisioningStatus = type({ kind: "'ready' | 'provisioning'", setupAgentReady: "boolean", @@ -653,17 +135,25 @@ const ProvisioningStatus = type({ * converge in the background. * - `preparing` — Myra is not live yet; this is the only state worth * holding someone on a loader for. + * - `route-gone` — the legacy `/api/onboarding/provisioning-status` + * route is gone (CL-8112 deleted it with the hub mount). Explicit on + * purpose: a deleted route must never read as "your agent is broken". * - `error` — readiness could not be checked; show the message and allow retry. */ export type AgentReadiness = | { readonly kind: "ready" } | { readonly kind: "chat-ready" } | { readonly kind: "preparing" } + | { readonly kind: "route-gone" } | { readonly kind: "error"; readonly message: string }; /** - * Where this account's agents stand right now. Cheap and read-only, so a - * surface that has to wait may poll it on a short interval. + * Legacy agent-readiness read against the deleted + * `/api/onboarding/provisioning-status` route. No live caller uses this + * (CL-8112 T1): the home page's first-workbench flow and the picker read + * Myra's native agent definition directly instead. Kept so the route's + * absence stays an explicit, testable outcome — a 404/410 answers + * `route-gone`, never the generic agent error below. */ export async function fetchAgentReadiness( tenantId: string, @@ -672,6 +162,9 @@ export async function fetchAgentReadiness( const response = await fetch( `/api/onboarding/provisioning-status?${new URLSearchParams({ tenantId })}`, ); + if (response.status === 404 || response.status === 410) { + return { kind: "route-gone" }; + } const body: unknown = await response.json(); if (!response.ok) { const envelope = ErrorEnvelope(body); diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 8c890e41a9..2005c8ebbd 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -32,7 +32,7 @@ import { useEffect, useState } from "react"; import { listAllWorkbenches, WorkbenchLoadingState } from "@corbits/chat-ui"; import { describeApiError } from "@corbits/api-query"; -import { fetchAgentReadiness, hasActiveCredential } from "../onboarding"; +import { hasActiveCredential } from "../onboarding"; import { listAgentDefinitions } from "../agents-api"; import { openAgentDmChat } from "../agent-dm-launch"; import { findMyraDefinition } from "../myra-workbench"; @@ -103,45 +103,47 @@ export function HomeRoute({ // Zero workbenches: wait for Myra's own definition to exist, then // open her DM the same way "Talk to Myra" does — never `/new`. - // "She can't start yet" and "here is her chat" are different - // messages, and only the readiness check plus a real definition - // tell them apart. Without a credential the drain never starts - // (CL-6780), so a not-ready status with no credential is an honest - // next step, not a forever spin on "getting ready". + // Readiness is Myra's deployed definition itself (CL-8112 T1 cut the + // deleted `/api/onboarding/provisioning-status` read, so this flow + // makes zero `/api/onboarding/*` requests): a definition means "she + // can start", no definition means keep waiting. Without a credential + // the drain never starts (CL-6780), so no definition with no + // credential is an honest next step, not a forever spin on + // "getting ready". + // TODO(CL-8112-T6/T7): read native readiness here once T6/T7 builds + // it, instead of the definition's existence. const awaitFirstWorkbench = () => { - void fetchAgentReadiness(selectedTenantId).then((readiness) => { - if (cancelled) return; - if (readiness.kind === "error") { - setState({ kind: "error", message: readiness.message }); - return; - } - if (readiness.kind === "ready" || readiness.kind === "chat-ready") { - void listAgentDefinitions(selectedTenantId).then((definitions) => { + void listAgentDefinitions(selectedTenantId).then( + (definitions) => { + if (cancelled) return; + const myra = findMyraDefinition(definitions); + if (myra !== undefined) { + void openAgentDmChat(selectedTenantId, myra.id, navigate); + return; + } + void hasActiveCredential(selectedTenantId).then((probe) => { if (cancelled) return; - const myra = findMyraDefinition(definitions); - if (myra === undefined) { - waitAndRetry(); + // Only a confirmed miss means "connect a provider". A probe + // failure must not pretend no key exists (CL-6868) — keep + // waiting and retry; the key may already be connected. + if (probe.kind === "none") { + setState({ kind: "needs-provider" }); return; } - void openAgentDmChat(selectedTenantId, myra.id, navigate); + setState((current) => + current.kind === "slow" ? current : { kind: "waiting-for-agent" }, + ); + waitAndRetry(); }); - return; - } - void hasActiveCredential(selectedTenantId).then((probe) => { + }, + (cause: unknown) => { if (cancelled) return; - // Only a confirmed miss means "connect a provider". A probe - // failure must not pretend no key exists (CL-6868) — keep - // waiting and retry; the key may already be connected. - if (probe.kind === "none") { - setState({ kind: "needs-provider" }); - return; - } - setState((current) => - current.kind === "slow" ? current : { kind: "waiting-for-agent" }, - ); - waitAndRetry(); - }); - }); + setState({ + kind: "error", + message: describeApiError(cause, "preparing your agent"), + }); + }, + ); }; void listAllWorkbenches(selectedTenantId).then( diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index 205c4a0849..82a14f23fc 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -39,7 +39,6 @@ import { WorkbenchPostCreateError, WorkbenchPreconditionError, } from "../instant-agent-create"; -import { fetchAgentReadiness } from "../onboarding"; import { useNavigate } from "../navigation"; import { StageTopBar } from "../shell/stage-top-bar"; import { workbenchPath } from "../workbench-path"; @@ -135,11 +134,12 @@ export function NewWorkbenchPickerRoute() { const [activeAgentIndex, setActiveAgentIndex] = useState(0); const [creating, setCreating] = useState(false); // Set only when `createWorkbenchFromTemplate` hit the missing-setup-agent - // precondition *and* a readiness check confirmed the bench genuinely - // isn't chat-ready yet — never a guess from the error alone, since that - // precondition is also what a template-that-will-never-exist looks like. - // Distinct from `creating`'s loader: this is a dead end until setup - // finishes, not a request in flight. + // precondition — thrown only after its own native definitions read found + // no setup agent, so that read is the readiness check, never a guess + // from the error alone. (That precondition is distinct from + // template-unavailable, which is what a template-that-will-never-exist + // looks like.) Distinct from `creating`'s loader: this is a dead end + // until setup finishes, not a request in flight. const [stillSettingUp, setStillSettingUp] = useState(false); const promptRef = useRef(null); const { @@ -258,26 +258,22 @@ export function NewWorkbenchPickerRoute() { toast(describeWorkbenchCreateFailure(cause, refId)); return; } - // The missing-setup-agent precondition reads identically whether - // this bench's default agents never finished deploying (CL-6457's - // background drain is still running, or never started without a - // credential) or something is genuinely broken. Only a readiness - // check tells those apart — never assume from the throw alone. + // The missing-setup-agent precondition is thrown only after a + // successful native definitions read found no setup agent + // (CL-6457's background drain still running, or never started + // without a credential) — that read IS the readiness check, so no + // second round-trip is needed before showing the retryable + // still-setting-up state. (CL-8112 T1 cut the deleted + // `/api/onboarding/provisioning-status` re-check here.) + // TODO(CL-8112-T6/T7): read native readiness here once T6/T7 + // builds it, if the panel needs more than the definition's absence. if ( cause instanceof WorkbenchPreconditionError && cause.kind === "setup-agent-missing" ) { - const readiness = await fetchAgentReadiness(selectedTenantId); - if (readiness.kind === "error") { - toast(readiness.message); - setCreating(false); - return; - } - if (readiness.kind === "preparing") { - setCreating(false); - setStillSettingUp(true); - return; - } + setCreating(false); + setStillSettingUp(true); + return; } // Every remaining cause is a genuinely failed create: report it with // operation context and a quotable refId rather than a log line alone. diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index b259ac77b3..7f63328b2c 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -1,459 +1,72 @@ -// First-run wizard: provision the account's one workbench under a -// default name derived from the account, then add an inference -// credential. There is no naming step — CL-6089 collapsed the -// multi-bench model down to one workbench per account, so nothing is -// left to name. The heavy lifting — storing the key immediately (no -// probe gates this, CL-6123), seeding the bench, and deploying every -// default workflow — happens server-side in `@workbench/onboarding`; -// this page is the guided shell around it. The credential step is -// skipped entirely -// (straight to `navigate("/")`) only once this page has independently -// confirmed (`hasActiveCredential`, a cheap credentials read) that the -// bench actually has a working credential — a hub-owned key (env-key -// auto-plant, CL-6101, or the older ANTHROPIC_API_KEY path) for a fresh -// bench, or a real working credential for a returning member. The -// server's own `seeded: true` is not enough on its own to hard-skip on: -// for an existing member it means every default workflow has an active -// deployment, never that a credential was checked; for a freshly -// provisioned bench it means the seed run's validation trigger started a -// workflow run, never that the run actually succeeded against a real -// credential. Either shape with no confirmed credential falls through to -// the credential step, same as an unseeded bench. -// -// Once a credential is confirmed working, this page's job is done — it -// hands off to `/` (`HomeRoute`), which is where the guided -// first-workbench creation and the drafted agent's greeting actually -// happen (CL-6104): a brand-new account has no workbench yet, so `/` -// itself renders the describe-it screen rather than this wizard growing -// a third step. - -import { Button, EmptyState, Input, ProviderMark } from "@corbits/react-ui"; -import { Key, WarningCircle } from "@corbits/icons"; +// The setup gate (CL-8112): the screen a signed-in session lands on when +// the hub reports setup-required. It reads the hub's native setup-status +// route and does one of three honest things — a hub that turns out to +// already have tenants bounces straight into the shell (`/`), an empty +// hub renders a static pending panel, and a broken status read blocks +// with retry. Read-only on purpose: the provisioning, credential, and +// OAuth-connect machinery that used to live here spoke to the deleted +// `/api/onboarding/*` routes, so it went with the hub mount — the setup +// flow itself (first workbench, first credential) is T6/T7's to build, +// and this screen is where it will land. +import { Button, EmptyState } from "@corbits/react-ui"; +import { WarningCircle } from "@corbits/icons"; import { WorkbenchLoadingState } from "@corbits/chat-ui"; import { useCallback, useEffect, useState } from "react"; -import type { FormEvent } from "react"; -import { OLLAMA_PLACEHOLDER_SECRET } from "@corbits/connections/credential-test"; -import { getLogger } from "@corbits/client-log"; import { useNavigate } from "../navigation"; -import { - logBootstrapResult, - logBootstrapThrown, - runPortableClientBootstrap, -} from "../client-bootstrap"; -import { - completeSetup, - CREDENTIAL_PROBE_FAILURE_MESSAGE, - CREDENTIAL_PROVIDERS, - hasActiveCredential, - HUGGINGFACE_CONNECT_START_PATH, - OPENROUTER_CONNECT_START_PATH, - PRIMARY_CREDENTIAL_PROVIDERS, - readHuggingFaceConnectReturn, - readOpenRouterConnectReturn, - SECONDARY_CREDENTIAL_PROVIDERS, - submitCredential, - triggerFirstLoginProvisioning, -} from "../onboarding"; -import type { - CredentialProvider, - CredentialProviderCard, - OnboardingStep, -} from "../onboarding"; +import { triggerFirstLoginProvisioning } from "../onboarding"; import { OnboardingLayout } from "../onboarding/onboarding-layout"; -import type { SessionUser } from "../session"; - -/** No naming step means provisioning always needs a name to send — this - * derives one from the account so `/api/onboarding/provision` never gets - * called bare. Prefers the account's display name; an account with no - * usable name falls back to the email's local part. Editable later from - * Settings, same as any other display name. - * - * This names the account's one root tenant — the container real - * workbenches (each its own child tenant, CL-6089) live under, never a - * workbench itself (CL-6368). "…'s workbench" mislabeled it as one; - * every fresh account now mints under its own name instead ("team space" - * / "workspace" stay off the table too — check:ui-vocabulary bans both as - * synonyms the CL-6089 product collapse deliberately retired). */ -function defaultTeamName(user: SessionUser): string { - const source = - user.name.trim().length > 0 ? user.name.trim() : user.email.split("@")[0]; - return `${source || "Your"}'s team`; -} -type WizardState = - | { readonly phase: "provisioning" } +type GateState = + | { readonly phase: "checking" } + | { readonly phase: "setup-pending" } | { - readonly phase: "provisioning-error"; + readonly phase: "error"; readonly message: string; readonly refId?: string; - } - | { - readonly phase: "credential"; - readonly error: string | null; - readonly errorRefId?: string; - } - | { readonly phase: "submitting" } - | { - readonly phase: "finishing-setup"; - /** CL-7584: the desired-state steps the hub reports as still - * pending or blocked, rendered under the loader until ready - * collapses them. */ - readonly steps: readonly OnboardingStep[]; }; -function ProviderCardButton({ - provider, - selected, - disabled, - onSelect, -}: { - readonly provider: CredentialProviderCard; - readonly selected: CredentialProvider; - readonly disabled: boolean; - readonly onSelect: (provider: CredentialProvider) => void; -}) { - return ( - - ); -} - -function ProviderPicker({ - selected, - onSelect, - disabled, -}: { - readonly selected: CredentialProvider; - readonly onSelect: (provider: CredentialProvider) => void; - readonly disabled: boolean; -}) { - // The six providers most people reach for lead the row; the rest stay - // fully functional but tucked behind "More providers" so the primary - // choice never has to compete with a wall of cards. A secondary pick - // (e.g. returning to the flow with Groq already selected) opens the - // expander automatically rather than hiding the active choice. - const secondaryHasSelection = SECONDARY_CREDENTIAL_PROVIDERS.some( - (provider) => provider.id === selected, - ); - return ( -
- {PRIMARY_CREDENTIAL_PROVIDERS.map((provider) => ( - - ))} -
- - More providers - -
- {SECONDARY_CREDENTIAL_PROVIDERS.map((provider) => ( - - ))} -
-
-
- ); -} - -/** Where the wizard starts: at the top, unless the URL carries a connect - * round-trip's outcome (OpenRouter or Hugging Face) — a fresh connect - * lands on the finishing-setup phase (the OAuth callback only stored - * the key; this page's own follow-up call is what actually deploys the - * default workflows and their preset routines), and a failed round trip lands on the - * credential step with the failure spelled out. Either way, the mount - * effect below re-checks the account's real state before trusting a - * connect outcome carried in the URL — see its own comment. */ -function initialWizardState(): WizardState { - if (typeof window === "undefined") return { phase: "provisioning" }; - const returned = - readOpenRouterConnectReturn(window.location.search) ?? - readHuggingFaceConnectReturn(window.location.search); - if (returned === null) return { phase: "provisioning" }; - if (returned.kind === "connected") - return { phase: "finishing-setup", steps: [] }; - return { phase: "credential", error: returned.message }; -} - -export function OnboardingPage({ user }: { readonly user: SessionUser }) { +export function OnboardingPage() { const navigate = useNavigate(); - const log = getLogger("web.onboarding"); - const [state, setState] = useState(initialWizardState); - const [provider, setProvider] = useState("anthropic"); - const [apiKey, setApiKey] = useState(""); - // Ollama's card collects a URL, not a key — its own field, defaulted - // to its card's `urlDefaultValue` the moment that card is picked (see - // `handleSelectProvider` below), so the field is never blank the - // first time a person lands on it. - const [urlValue, setUrlValue] = useState(""); - // True for a returning user whose workbench already exists but is - // still missing a working credential (`existing-member` with - // `seeded: false`) — the copy below tells them connecting one now - // finishes setup, instead of the first-run pitch. - const [resumingUnseeded, setResumingUnseeded] = useState(false); - - const runProvisioning = useCallback( - (name: string) => { - setState({ phase: "provisioning" }); - // The portable client lane runs beside server provisioning: it - // converges the needs-list over stock routes and persists created - // child tenant ids. Non-gating — a stock capability gap is logged, - // never shown, until the upstream capability lands. - void runPortableClientBootstrap(user).then( - (bootstrap) => { - logBootstrapResult(log, bootstrap); - }, - (error) => { - logBootstrapThrown(log, error); - }, - ); - void triggerFirstLoginProvisioning(name).then(async (result) => { - if (result.kind === "error") { - setState( - result.refId === undefined - ? { phase: "provisioning-error", message: result.message } - : { - phase: "provisioning-error", - message: result.message, - refId: result.refId, - }, - ); - } else if ( - result.kind === "existing-member" && - result.seeded === true - ) { - // `seeded: true` only means every default workflow has an - // active deployment — never that a credential was checked. - // Confirm one independently (a cheap credentials read) before - // handing off; no tenantId (should not happen alongside - // seeded: true) falls through to the credential step too. - // A probe that cannot complete stays on this setup step with - // Retry (CL-6868) — never opens paste-a-key as if none exists. - if (result.tenantId === undefined) { - setResumingUnseeded(false); - setState({ phase: "credential", error: null }); - return; - } - const probe = await hasActiveCredential(result.tenantId); - if (probe.kind === "active") { - navigate("/"); - } else if (probe.kind === "error") { - setState({ - phase: "provisioning-error", - message: CREDENTIAL_PROBE_FAILURE_MESSAGE, - }); - } else { - setResumingUnseeded(false); - setState({ phase: "credential", error: null }); - } - } else if (result.kind === "existing-member") { - // `seeded === false` is the bench_unseeded condition: this - // account's own workbench exists but never got a working - // credential (no operator key configured, and none connected - // yet). `undefined` (membership on some other tenant) also lands - // here rather than handing off — there is nothing to skip ahead to. - const unseeded = result.seeded === false; - setResumingUnseeded(unseeded); - setState({ phase: "credential", error: null }); - } else if (result.kind === "provisioned" && result.seeded) { - // The seed run's validation trigger only proves a workflow run - // started, never that it succeeded against a real credential. - // Confirm one independently before handing off. Probe failure - // stays here with Retry (CL-6868) — never paste-a-key as none. - const probe = await hasActiveCredential(result.tenantId); - if (probe.kind === "active") { - setResumingUnseeded(false); - navigate("/"); - } else if (probe.kind === "error") { - setState({ - phase: "provisioning-error", - message: CREDENTIAL_PROBE_FAILURE_MESSAGE, - }); - } else { - setResumingUnseeded(false); - setState({ phase: "credential", error: null }); - } - } else if (result.kind === "provisioned") { - setResumingUnseeded(false); - setState({ phase: "credential", error: null }); - } else { - // needs-onboarding after an explicit name should not happen — a - // default name is always sent — so this reads as a soft error. - setState({ - phase: "provisioning-error", - message: "Setup couldn't create your workbench. Try again.", - }); - } - }); - }, - [navigate, user], - ); - - // A connect round-trip's outcome is consumed into the initial wizard - // state above; dropping it from the URL keeps a reload or a shared - // link from replaying a stale ending. - useEffect(() => { - if ( - readOpenRouterConnectReturn(window.location.search) !== null || - readHuggingFaceConnectReturn(window.location.search) !== null - ) { - window.history.replaceState(null, "", window.location.pathname); - } - }, []); - - // Two jobs, split by what landed the wizard here. A fresh connect - // (`finishing-setup`) actually finishes the job the OAuth callback - // deferred: `completeSetup` runs the workflow deploy the callback - // never ran inline. Everything else — the ordinary first-run landing, - // or a stale connect error from a duplicate callback this page never - // saw resolved — provisions with a default name derived from the - // account: there is no naming step to gate this on, so it must always - // send a name (see `defaultTeamName`). A returning member's - // already-provisioned workbench is unaffected — the hub route only - // creates one the first time an account has none. - useEffect(() => { - if (state.phase === "finishing-setup") { - let cancelled = false; - // Poll until ready: each pass renders the hub's own desired-state - // step list (CL-7584); a ready answer collapses it and hands off. - void (async () => { - for (;;) { - const outcome = await completeSetup(); - if (cancelled) return; - if (outcome.kind === "connected") { - if (!outcome.agentsPending) { - navigate("/"); - return; - } - setState({ - phase: "finishing-setup", - steps: outcome.steps ?? [], - }); - await new Promise((resolve) => setTimeout(resolve, 2000)); - continue; - } - if (outcome.kind === "unseeded") { - setResumingUnseeded(true); - setState({ phase: "credential", error: null }); - return; - } - setState( - outcome.refId === undefined - ? { phase: "credential", error: outcome.message } - : { - phase: "credential", - error: outcome.message, - errorRefId: outcome.refId, - }, - ); - return; - } - })(); - return () => { - cancelled = true; - }; - } - runProvisioning(defaultTeamName(user)); - // Mount-only: this reads `state.phase` exactly once, at the value - // `initialWizardState` produced, to decide which of the two checks - // above applies to this landing. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const activeProvider = CREDENTIAL_PROVIDERS.find((p) => p.id === provider); - const isUrlProvider = activeProvider?.fieldKind === "url"; - - const handleSelectProvider = useCallback( - (nextId: CredentialProvider) => { - setProvider(nextId); - const nextCard = CREDENTIAL_PROVIDERS.find((p) => p.id === nextId); - if (nextCard?.fieldKind === "url" && urlValue === "") { - setUrlValue(nextCard.urlDefaultValue ?? ""); + const [state, setState] = useState({ phase: "checking" }); + + // One status read per landing (plus each manual recheck): the hook's + // verdict decides everything, so there is no step to replay — a reload + // just re-reads. + const checkStatus = useCallback(() => { + setState({ phase: "checking" }); + void triggerFirstLoginProvisioning().then((result) => { + if (result.kind === "error") { + setState( + result.refId === undefined + ? { phase: "error", message: result.message } + : { + phase: "error", + message: result.message, + refId: result.refId, + }, + ); + } else if (result.kind === "existing-member") { + navigate("/"); + } else { + setState({ phase: "setup-pending" }); } - }, - [urlValue], - ); - - // Not every account has a provider ready the moment they land here — an - // Ollama instance that is not running yet is the exact case this build - // exists for. Provisioning already treats that as an anticipated state - // (`bench_unseeded`, not an error), so the wizard should not be the one - // hard-blocking control: skipping just hands off to `/` the same way a - // confirmed credential does, and the no-usable-model banner there (CL-6568) - // is what tells them, honestly, that a connection still needs finishing — - // this screen does not need to be the only place that can say so. - const handleSkip = useCallback(() => { - navigate("/"); + }); }, [navigate]); + useEffect(() => { + checkStatus(); + }, [checkStatus]); - const handleSubmitCredential = useCallback( - (event: FormEvent) => { - event.preventDefault(); - setState({ phase: "submitting" }); - const submission = isUrlProvider - ? submitCredential(provider, OLLAMA_PLACEHOLDER_SECRET, urlValue) - : submitCredential(provider, apiKey); - void submission.then((outcome) => { - // Connected is the finish line for this screen (CL-6457): - // whether or not the agents have finished deploying, the person - // moves on now, and the warm loading state on the other side - // covers whatever is still coming online. - if (outcome.kind === "connected") { - navigate("/"); - } else { - setState( - outcome.refId === undefined - ? { phase: "credential", error: outcome.message } - : { - phase: "credential", - error: outcome.message, - errorRefId: outcome.refId, - }, - ); - } - }); - }, - [provider, apiKey, urlValue, isUrlProvider, navigate], - ); - - if (state.phase === "provisioning") { + if (state.phase === "checking") { return ( -
-

Preparing your account

+
+

Checking your workbench

One moment.

@@ -461,254 +74,51 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { ); } - if (state.phase === "finishing-setup") { + if (state.phase === "setup-pending") { return ( -
-

Preparing your agent

+
+

Set up your workbench

- Hooking up your agents. This takes about ten seconds. + Your workbench isn't set up yet. The first-workbench setup flow + is still being built; this screen will host it.

- - {state.steps.length > 0 && ( -
    - {state.steps.map((step) => ( -
  • - {step.label} - {/* `blocked` (sidecar unavailable, the hub keeps - retrying) reads differently from plain `pending` - (still working) so a held-up pin is visible, not an - identical spinner row. */} - {step.status === "blocked" && ( - - {" "} - — waiting, retrying… - - )} -
  • - ))} -
- )} -
-
- - ); - } - - if (state.phase === "provisioning-error") { - return ( - -
-
- } - title="Couldn't set up your workbench" - description={ - state.refId === undefined ? ( - state.message - ) : ( - <> - {state.message} -
- - If you tell us about this, mention {state.refId}. - - - ) - } - action={ - - } - /> +
); } - const submitting = state.phase === "submitting"; - const error = state.phase === "credential" ? state.error : null; - const errorRefId = - state.phase === "credential" ? state.errorRefId : undefined; - return ( -
-

- {resumingUnseeded - ? "Finish setting up your workbench" - : "Bring your own AI"} -

-

- {resumingUnseeded - ? "Connect a provider below to finish setup." - : "Connect in one click, or paste a key you already have. Your key stays yours — Workbench only uses it to run your agents, and you can pull it any time."} -

+
-
-
- -
-

OpenRouter

-

One click, ~50 models, pay-as-you-go.

-
- -
-
- -
-

Hugging Face

-

Groq, Together, Fireworks & more, one sign-in.

-
- -
-
-
- or paste a provider API key -
-
- - {activeProvider !== undefined && ( -

- {activeProvider.description} -

- )} - {isUrlProvider ? ( - <> - - setUrlValue(event.target.value)} - required - disabled={submitting} - aria-describedby="onboarding-provider-url-help" - /> -

- The origin your Ollama instance listens on — local, or reached - through a tunnel. -

- - ) : ( - <> - - setApiKey(event.target.value)} - required - disabled={submitting} - aria-describedby="onboarding-api-key-help" - /> -

- - Get a key from the {activeProvider?.label} console - - {activeProvider?.keyHint ? ( - <> - {" "} - — it starts with {activeProvider.keyHint}. - - ) : ( - "." - )} -

- - )} - {error !== null && ( - } - title="That key didn't work" - description={ - errorRefId === undefined ? ( - error - ) : ( - <> - {error} -
- - Reference: {errorRefId} - - - ) - } - /> - )} - - -
- -

- No provider ready yet? You can connect one anytime from Settings → - AI providers. -

-
+ } + />
diff --git a/apps/web/test/home-page.test.tsx b/apps/web/test/home-page.test.tsx index 84707d931d..7a3fb99891 100644 --- a/apps/web/test/home-page.test.tsx +++ b/apps/web/test/home-page.test.tsx @@ -162,19 +162,21 @@ describe("HomeRoute (the `/` land hop every entry point funnels through)", () => test("a brand-new bench with zero workbenches opens Myra's one DM, not the guided picker", async () => { let posted: unknown; + // CL-8112 T1: the deleted `/api/onboarding/provisioning-status` + // route must never be requested on this flow — record every path + // and pin the absence below, alongside the no-error-state land. + const requestedPaths: string[] = []; stubFetch((path, method, init) => { + requestedPaths.push(path); if (path === "/api/me/principals") { return json(PRINCIPALS_RESPONSE); } if (path.endsWith("/chat/workbenches") && method === "GET") { // listAllWorkbenches finds nothing — this bench has no workbenches - // yet, so HomeRoute waits for Myra's readiness and opens her DM - // rather than sending anyone to `/new`. + // yet, so HomeRoute reads Myra's native definition and opens her + // DM rather than sending anyone to `/new`. return json({ items: [] }); } - if (path === "/api/onboarding/provisioning-status?tenantId=tnt_1") { - return json({ kind: "ready", setupAgentReady: true }); - } if (path.includes("/workflows/definitions")) { return json({ data: [MYRA_DEFINITION], nextCursor: null }); } @@ -211,6 +213,12 @@ describe("HomeRoute (the `/` land hop every entry point funnels through)", () => reuseExisting: true, }); expect(navigated).toEqual(["/w/chan_myra_dm"]); + // The land above is the no-error state: no `/api/onboarding/*` + // request fired, and no error copy rendered on the way there. + expect( + requestedPaths.filter((path) => path.includes("/api/onboarding")), + ).toEqual([]); + expect(container?.textContent).not.toContain("Couldn't open the workbench"); }); }); @@ -220,32 +228,34 @@ describe("HomeRoute (the `/` land hop every entry point funnels through)", () => // that happens the moment Myra herself can answer, and an honest way out // if she never does. describe("the wait right after connecting a provider", () => { - /** A bench with no workbenches yet whose setup agent (Myra) reports - * ready only after `readyAfter` reads — everything before that is the - * window the person spends waiting. */ + /** A bench with no workbenches yet whose setup agent (Myra) deploys + * only after `readyAfter` definition reads — everything before that is + * the window the person spends waiting. Readiness is the native + * definitions read itself (CL-8112 T1): no `/api/onboarding/*` + * request anywhere in this helper. */ function benchWhereMyraArrivesAfter(readyAfter: number) { - let statusReads = 0; - const state = { statusCalls: 0 }; + let definitionReads = 0; + const state = { definitionCalls: 0 }; stubFetch((path, method) => { if (path === "/api/me/principals") return json(PRINCIPALS_RESPONSE); if (path.endsWith("/chat/workbenches") && method === "GET") { return json({ items: [] }); } - // CL-6780: while Myra is still coming online we also confirm a - // credential exists — without one the drain never starts, so the - // wait must not pretend a workbench is "getting ready". + // CL-6780: while Myra is still deploying, the credential read is + // what tells "still coming online" apart from "nothing to deploy + // with" — an active credential means the former, so serve one: the + // no-credential case has its own test below, and this helper must + // never trip that branch by accident (a missing stub would throw). if (path === "/api/tenants/tnt_1/credentials") { return json({ data: [{ status: "active" }], nextCursor: null }); } - if (path === "/api/onboarding/provisioning-status?tenantId=tnt_1") { - statusReads += 1; - state.statusCalls += 1; - const setupAgentReady = statusReads > readyAfter; + if (path.includes("/workflows/definitions")) { + definitionReads += 1; + state.definitionCalls += 1; + const arrived = definitionReads > readyAfter; return json({ - kind: "provisioning", - setupAgentReady, - deployed: [], - pending: ["assistant"], + data: arrived ? [MYRA_DEFINITION] : [], + nextCursor: null, }); } const myraLaunch = respondMyraDmLaunch(path, method); @@ -279,25 +289,19 @@ describe("the wait right after connecting a provider", () => { }); } - test("shows a readiness error and retries without pretending the agent is preparing", async () => { - let failReadiness = true; + // CL-8112 T1: the definitions read IS the readiness check now — a + // failed read must surface as an honest, retryable error, never a + // forever "preparing" spin. + test("shows a definitions error and retries without pretending the agent is preparing", async () => { + let failDefinitions = true; stubFetch((path, method) => { if (path === "/api/me/principals") return json(PRINCIPALS_RESPONSE); if (path.endsWith("/chat/workbenches") && method === "GET") return json({ items: [] }); - if (path === "/api/onboarding/provisioning-status?tenantId=tnt_1") { - return failReadiness - ? json( - { - error: { - code: "bench_unavailable", - userMessage: "This workspace is unavailable. Try again.", - refId: "ref_readiness", - }, - }, - 409, - ) - : json({ kind: "ready", setupAgentReady: true }); + if (path.includes("/workflows/definitions")) { + return failDefinitions + ? json({ error: "boom" }, 500) + : json({ data: [MYRA_DEFINITION], nextCursor: null }); } const launch = respondMyraDmLaunch(path, method); if (launch !== null) return launch; @@ -306,15 +310,16 @@ describe("the wait right after connecting a provider", () => { const navigated: string[] = []; await renderHome({ retryMs: 10, stallAfterMs: 10_000, navigated }); for (let i = 0; i < 20; i++) await settle(); + expect(container?.textContent).toContain("Couldn't open the workbench"); expect(container?.textContent).toContain( - "This workspace is unavailable. Try again.", + "Something went wrong preparing your agent. Try again.", ); expect(container?.textContent).not.toContain("Preparing your agent"); const retry = Array.from(container?.querySelectorAll("button") ?? []).find( (button) => button.textContent === "Retry", ); expect(retry).toBeDefined(); - failReadiness = false; + failDefinitions = false; await act(async () => retry?.click()); for (let i = 0; i < 20 && navigated.length === 0; i++) await settle(); expect(navigated).toEqual(["/w/chan_myra_dm"]); @@ -349,13 +354,8 @@ describe("the wait right after connecting a provider", () => { if (path === "/api/tenants/tnt_1/credentials") { return json({ data: [], nextCursor: null }); } - if (path === "/api/onboarding/provisioning-status?tenantId=tnt_1") { - return json({ - kind: "provisioning", - setupAgentReady: false, - deployed: [], - pending: ["assistant"], - }); + if (path.includes("/workflows/definitions")) { + return json({ data: [], nextCursor: null }); } throw new Error(`unexpected fetch: ${method} ${path}`); }); @@ -375,8 +375,8 @@ describe("the wait right after connecting a provider", () => { }); test("lands the moment Myra can answer, without waiting on the rest of the seeds", async () => { - // The status route still says "provisioning" — other workflows are - // mid-deploy — and the land happens anyway. + // Other workflows may still be mid-deploy — readiness is Myra's own + // native definition, and the land happens anyway. const state = benchWhereMyraArrivesAfter(1); const navigated: string[] = []; await renderHome({ retryMs: 10, stallAfterMs: 10_000, navigated }); @@ -386,7 +386,7 @@ describe("the wait right after connecting a provider", () => { } expect(navigated).toEqual(["/w/chan_myra_dm"]); - expect(state.statusCalls).toBeGreaterThan(0); + expect(state.definitionCalls).toBeGreaterThan(0); }); test("says so honestly with a retry once the wait has gone on too long", async () => { diff --git a/apps/web/test/new-workbench-picker.test.tsx b/apps/web/test/new-workbench-picker.test.tsx index 690cddae51..1b280bd752 100644 --- a/apps/web/test/new-workbench-picker.test.tsx +++ b/apps/web/test/new-workbench-picker.test.tsx @@ -843,16 +843,8 @@ describe("NewWorkbenchPickerRoute", () => { if (path.includes("/workflows/definitions")) { return json({ data: [], nextCursor: null }); } - if (path.endsWith("/api/onboarding/provisioning-status?tenantId=tnt_1")) { - return json({ - kind: "provisioning", - tenantId: "tnt_1", - tenantSlug: "corbits-bench", - setupAgentReady: false, - deployed: [], - pending: ["assistant"], - }); - } + // CL-8112 T1: no `/api/onboarding/*` request anywhere on this + // path — the empty native definition alone drives the retry panel. return undefined; }); await renderPicker(); diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx index ed040429ee..c42d4d7b75 100644 --- a/apps/web/test/onboarding.test.tsx +++ b/apps/web/test/onboarding.test.tsx @@ -1,33 +1,27 @@ -// The failure path for first-login provisioning: a broken hub call -// must not collapse to "nothing to do". `triggerFirstLoginProvisioning` -// should report it as a distinct error outcome, and the app shell must -// render a full blocking screen for it rather than silently continuing -// into a shell with zero benches. +// The setup gate (CL-8112): the first-login hook is a read-only status +// read now — `triggerFirstLoginProvisioning` GETs the hub's native +// `/api/setup/status` and reports only a routing verdict +// (`existing-member` / `needs-onboarding` / `error`), never minting +// anything. The screen at the onboarding path is a thin gate over that +// verdict: an empty hub renders a static pending panel, a set-up hub +// bounces into the shell, and a broken status read blocks with retry. +// Nothing here may touch `/api/onboarding/*` — those routes went with +// the hub mount, and the setup flow itself (T6/T7) has yet to be built. import { ThemeProvider } from "@corbits/react-ui"; import { afterEach, describe, expect, test } from "bun:test"; -import { act, createElement } from "react"; +import { act } from "react"; +import type { ReactElement } from "react"; import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; -import { - OLLAMA_PLACEHOLDER_SECRET, - supportedCredentialProviders, -} from "@corbits/connections/credential-test"; import { App } from "../src/app"; -import { NavigationProvider } from "../src/navigation"; import { - CREDENTIAL_PROVIDERS, + fetchAgentReadiness, hasActiveCredential, - PRIMARY_CREDENTIAL_PROVIDERS, - readHuggingFaceConnectReturn, - readOpenRouterConnectReturn, - SECONDARY_CREDENTIAL_PROVIDERS, - submitCredential, triggerFirstLoginProvisioning, } from "../src/onboarding"; -import { OnboardingPage } from "../src/pages/onboarding-page"; import { ONBOARDING_PATH } from "../src/routes"; import type { SessionState } from "../src/session"; @@ -45,10 +39,9 @@ const json = (body: unknown, status = 200) => const noop = () => undefined; -/** A `navigate` that records every call — the wizard now hands off to - * `/` (`HomeRoute`, CL-6104) instead of rendering a "your workbench is - * ready" ending of its own, so proving the hand-off means proving the - * navigate call, not scraping ending copy. */ +/** A `navigate` that records every call — the gate hands off to `/` + * with a navigate call, so proving the hand-off means proving the + * call, not scraping ending copy. */ function trackedNavigate() { const calls: string[] = []; return { navigate: (to: string) => calls.push(to), calls }; @@ -59,94 +52,25 @@ const signedIn: SessionState = { user: { id: "user_1", name: "Ada", email: "ada@example.com" }, }; -describe("CREDENTIAL_PROVIDERS", () => { - // ProviderPicker (onboarding-page.tsx) renders one card per entry here — - // this is the data the cards are built from, so covering it covers what - // actually shows up: one card per `@corbits/connections` provider the - // hub can actually test a credential against, each with a distinct - // honest one-liner and a real key-console link. Compared against - // `supportedCredentialProviders()` directly, not a second hand-copied - // list, so a provider added or removed there is caught here rather than - // drifting silently. - test("has one card for every provider the hub can test a credential against", () => { - // The CL-7510 loopback-OAuth ids (`codex`, `xai-oauth`) are - // deliberately absent: their credential only exists after a loopback - // OAuth login, so there is no key-paste card to render until that - // flow is wired — a card here would offer a paste form no key can - // satisfy. - const LOOPBACK_OAUTH_ONLY = new Set(["codex", "xai-oauth"]); - expect(CREDENTIAL_PROVIDERS.map((p) => p.id).sort()).toEqual( - supportedCredentialProviders() - .map((p) => p.id) - .filter((id) => !LOOPBACK_OAUTH_ONLY.has(id)) - .sort(), - ); - }); - - test("every card has a non-empty label, description, and key console link", () => { - for (const provider of CREDENTIAL_PROVIDERS) { - expect(provider.label.length).toBeGreaterThan(0); - expect(provider.description.length).toBeGreaterThan(0); - expect(provider.keyConsoleUrl).toMatch(/^https:\/\//); - } - }); - - test("no two cards share the same description", () => { - const descriptions = CREDENTIAL_PROVIDERS.map((p) => p.description); - expect(new Set(descriptions).size).toBe(descriptions.length); - }); -}); - -describe("PRIMARY_CREDENTIAL_PROVIDERS and SECONDARY_CREDENTIAL_PROVIDERS", () => { - test("the primary six lead in the owner's exact order", () => { - expect(PRIMARY_CREDENTIAL_PROVIDERS.map((p) => p.id)).toEqual([ - "openai", - "anthropic", - "google-genai", - "xai", - "openrouter", - "opencode-zen", - ]); - }); - - test("groq, deepseek, mistral, huggingface, and ollama sit behind the secondary group", () => { - expect(SECONDARY_CREDENTIAL_PROVIDERS.map((p) => p.id)).toEqual([ - "groq", - "deepseek", - "mistral", - "huggingface", - "ollama", - ]); - }); - - test("primary and secondary together account for every card, primary first", () => { - expect(CREDENTIAL_PROVIDERS).toEqual([ - ...PRIMARY_CREDENTIAL_PROVIDERS, - ...SECONDARY_CREDENTIAL_PROVIDERS, - ]); - }); -}); - describe("triggerFirstLoginProvisioning", () => { test("a structured error envelope becomes an error outcome, not null", async () => { globalThis.fetch = (async () => json( { error: { - code: "provisioning_failed", + code: "setup_status_failed", userMessage: - "Setting up your workbench hit a snag — we're on it. Try again in a moment.", + "Checking your workbench hit a snag. Try again in a moment.", refId: "abc123", }, }, 500, )) as unknown as typeof fetch; - const result = await triggerFirstLoginProvisioning("Ada's bench"); + const result = await triggerFirstLoginProvisioning(); expect(result).toEqual({ kind: "error", - message: - "Setting up your workbench hit a snag — we're on it. Try again in a moment.", + message: "Checking your workbench hit a snag. Try again in a moment.", refId: "abc123", }); }); @@ -159,7 +83,7 @@ describe("triggerFirstLoginProvisioning", () => { throw new Error("connection refused"); }) as unknown as typeof fetch; - const result = await triggerFirstLoginProvisioning("Ada's bench"); + const result = await triggerFirstLoginProvisioning(); expect(result.kind).toBe("error"); if (result.kind !== "error") throw new Error("unreachable"); expect(result.message).not.toContain("connection refused"); @@ -168,96 +92,73 @@ describe("triggerFirstLoginProvisioning", () => { ); }); - test("an ordinary success still parses through", async () => { + test("an empty hub reports needs-onboarding, never a silent pass-through", async () => { globalThis.fetch = (async () => - json({ kind: "existing-member" })) as unknown as typeof fetch; + json({ + setupRequired: true, + userCount: 1, + tenantCount: 0, + })) as unknown as typeof fetch; - const result = await triggerFirstLoginProvisioning("Ada's bench"); - expect(result).toEqual({ kind: "existing-member" }); + const result = await triggerFirstLoginProvisioning(); + expect(result).toEqual({ kind: "needs-onboarding" }); }); - test("sends the workbench name in the provision request body", async () => { - let requestBody: unknown = undefined; - let requestInit: RequestInit | undefined; - globalThis.fetch = (async (_url: string, init: RequestInit) => { - requestInit = init; - requestBody = - init.body === undefined ? undefined : JSON.parse(init.body as string); - return json({ kind: "existing-member" }); - }) as unknown as typeof fetch; + test("a hub with tenants reports existing-member", async () => { + globalThis.fetch = (async () => + json({ + setupRequired: false, + userCount: 1, + tenantCount: 1, + })) as unknown as typeof fetch; - await triggerFirstLoginProvisioning("Research bench"); - expect(requestBody).toEqual({ name: "Research bench" }); - expect(requestInit?.method).toBe("POST"); + const result = await triggerFirstLoginProvisioning(); + expect(result).toEqual({ kind: "existing-member" }); }); - test("omits a body when no name is given (the shell routing probe)", async () => { - let sentBody: unknown = "__sentinel__"; - globalThis.fetch = (async (_url: string, init: RequestInit) => { - sentBody = init.body; - return json({ kind: "existing-member" }); - }) as unknown as typeof fetch; + test("an unparseable status body is an error, never a fabricated verdict", async () => { + globalThis.fetch = (async () => + json({ not: "a status" })) as unknown as typeof fetch; - await triggerFirstLoginProvisioning(); - expect(sentBody).toBeUndefined(); + const result = await triggerFirstLoginProvisioning(); + expect(result.kind).toBe("error"); }); - test("a provisioned bench with a server seed stays a 'provisioned' outcome — seeded is reported faithfully", async () => { - // Regression guard: a server-side seed (operator-configured or - // env-key-auto-planted key) must not collapse the outcome into - // `existing-member` or otherwise hide that the bench was just - // provisioned. The wizard reads `seeded` off this exact shape to - // decide whether to skip the credential step (see - // apps/web/test/onboarding.test.tsx's "App landing fresh on a - // hub-seeded workbench" suite). - let requestBody: unknown = undefined; - globalThis.fetch = (async (_url: string, init: RequestInit) => { - requestBody = - init.body === undefined ? undefined : JSON.parse(init.body as string); - return json({ - kind: "provisioned", - tenantId: "ten_1", - tenantSlug: "ada-user1", - seeded: true, - seedSkipReason: "operator_seed_key", - }); + test("reads the native status route with a bare GET — no body, no minting", async () => { + const seen: { url: string; init: RequestInit | undefined }[] = []; + globalThis.fetch = (async (url: string, init: RequestInit) => { + seen.push({ url, init }); + return json({ setupRequired: false, userCount: 1, tenantCount: 1 }); }) as unknown as typeof fetch; - const result = await triggerFirstLoginProvisioning("Ada's bench"); - expect(requestBody).toEqual({ name: "Ada's bench" }); - expect(result).toEqual({ - kind: "provisioned", - tenantId: "ten_1", - tenantSlug: "ada-user1", - seeded: true, - seedSkipReason: "operator_seed_key", - }); - // The credential step stays in the flow precisely because seeded is - // reported faithfully, not folded away. - if (result.kind === "provisioned") expect(result.seeded).toBe(true); + await triggerFirstLoginProvisioning(); + expect(seen.map((s) => s.url)).toEqual(["/api/setup/status"]); + expect(seen[0]?.init?.method ?? "GET").toBe("GET"); + expect(seen[0]?.init?.body).toBeUndefined(); }); +}); - test("an existing member whose bench is not fully seeded reports seeded: false, not silently dropped", async () => { - // Regression guard for the bench_unseeded defect: a returning user - // with a real membership but no working credential yet must not read - // as an ordinary, fully-set-up existing member. Before this fix the - // client discarded `seeded` entirely for `existing-member`. +describe("fetchAgentReadiness", () => { + // CL-8112 T1: the legacy `/api/onboarding/provisioning-status` route is + // gone with the hub mount — a 404/410 must answer `route-gone`, never + // the generic agent error, so no caller can read a deleted route as + // "your agent is broken". + test("a 404 is route-gone, not a generic agent error", async () => { globalThis.fetch = (async () => - json({ - kind: "existing-member", - seeded: false, - })) as unknown as typeof fetch; + new Response("not found", { status: 404 })) as unknown as typeof fetch; - const result = await triggerFirstLoginProvisioning(); - expect(result).toEqual({ kind: "existing-member", seeded: false }); + expect(await fetchAgentReadiness("tnt_1")).toEqual({ + kind: "route-gone", + }); }); - test("an existing member on someone else's tenant carries no seeded field", async () => { + test("a 410 is route-gone, not a generic agent error", async () => { globalThis.fetch = (async () => - json({ kind: "existing-member" })) as unknown as typeof fetch; + new Response("gone", { status: 410 })) as unknown as typeof fetch; - const result = await triggerFirstLoginProvisioning(); - expect(result).toEqual({ kind: "existing-member" }); + expect(await fetchAgentReadiness("tnt_1")).toEqual({ + kind: "route-gone", + }); }); }); @@ -305,109 +206,6 @@ describe("hasActiveCredential", () => { }); }); -describe("submitCredential", () => { - test("a rejected key comes back as a rejected outcome with the hub's own reason", async () => { - globalThis.fetch = (async () => - json( - { - error: { - code: "invalid_credential", - userMessage: "invalid x-api-key", - refId: "xyz789", - }, - }, - 422, - )) as unknown as typeof fetch; - - const result = await submitCredential("anthropic", "sk-ant-bad"); - expect(result).toEqual({ - kind: "rejected", - message: "invalid x-api-key", - refId: "xyz789", - }); - }); - - // CL-6457: connecting no longer waits for agents to deploy, so the - // success answer is "connected, and here is whether agents are still - // coming" — not a list of confirmed workflows. - test("a connected bench whose agents are still deploying reports connected, not an error", async () => { - let requestBody: unknown = null; - globalThis.fetch = (async (_url: string, init: RequestInit) => { - requestBody = JSON.parse((init as RequestInit).body as string); - return json({ - kind: "provisioning", - tenantId: "ten_1", - tenantSlug: "ada-user1", - deployed: ["echo"], - pending: ["assistant"], - }); - }) as unknown as typeof fetch; - - const result = await submitCredential("google-genai", "AIza-good"); - expect(result).toEqual({ - kind: "connected", - tenantId: "ten_1", - tenantSlug: "ada-user1", - agentsPending: true, - }); - expect(requestBody).toEqual({ - provider: "google-genai", - apiKey: "AIza-good", - }); - }); - - // CL-6682: a key copied from a provider console often carries a - // trailing newline; sent verbatim the provider 401s a valid key. - test("strips leading/trailing whitespace from the pasted key", async () => { - let requestBody: unknown = null; - globalThis.fetch = (async (_url: string, init: RequestInit) => { - requestBody = JSON.parse((init as RequestInit).body as string); - return json({ - kind: "ready", - tenantSlug: "ada-user1", - deployed: [], - pending: [], - }); - }) as unknown as typeof fetch; - - await submitCredential("anthropic", " sk-ant-good\n"); - expect(requestBody).toEqual({ - provider: "anthropic", - apiKey: "sk-ant-good", - }); - }); - - test("a bench whose agents are all live reports nothing pending", async () => { - globalThis.fetch = (async () => - json({ - kind: "ready", - tenantId: "ten_1", - tenantSlug: "ada-user1", - deployed: ["echo", "assistant"], - pending: [], - })) as unknown as typeof fetch; - - const result = await submitCredential("google-genai", "AIza-good"); - expect(result).toEqual({ - kind: "connected", - tenantId: "ten_1", - tenantSlug: "ada-user1", - agentsPending: false, - }); - }); - - test("a network failure is reported, never mistaken for a bad key, and never the raw cause", async () => { - globalThis.fetch = (async () => { - throw new Error("connection refused"); - }) as unknown as typeof fetch; - - const result = await submitCredential("anthropic", "sk-ant-good"); - expect(result.kind).toBe("error"); - if (result.kind !== "error") throw new Error("unreachable"); - expect(result.message).not.toContain("connection refused"); - }); -}); - describe("App with a provisioning error", () => { test("blocks the shell with a retry action instead of rendering it", () => { const markup = renderToStaticMarkup( @@ -450,328 +248,9 @@ describe("App at the onboarding path", () => { expect(markup).not.toContain("shell-bench-dock"); }); - test("starts provisioning immediately under a default name, no naming step", () => { + test("lands on the checking phase first — the verdict arrives after the status read", () => { const markup = renderOnboarding(); - // No naming step (CL-6089): provisioning starts immediately, under a - // default name derived from the account, before the credential step. - // Onboarding is down to a single screen at this point (CL-6104) — no - // stepper needed for one step. - // CL-6780: this prepares the account; no workbench exists yet. - expect(markup).toContain("Preparing your account"); - expect(markup).not.toContain("Getting your workbench ready"); - expect(markup).not.toContain("Setting up your workbench"); - }); -}); - -describe("App landing fresh on a hub-seeded workbench", () => { - test("a freshly provisioned, fully seeded bench skips the credential step entirely (CL-6101)", async () => { - // A hub-owned key (the env-key auto-plant, or the older - // ANTHROPIC_API_KEY path) already deployed and confirmed the - // default workflow set by the time `/api/onboarding/provision` - // answers — there is nothing left to prove or connect, so the - // wizard must land straight on the finished ending, exactly like a - // returning fully-seeded member does. - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/provision") { - return json({ - kind: "provisioned", - tenantId: "ten_1", - tenantSlug: "ada-user1", - seeded: true, - }); - } - // The page independently confirms `seeded: true` against a real - // stored credential (CL-6101 review) before hard-skipping — this - // scenario's premise (a hub-owned key already deployed) means that - // confirmation succeeds. - if (url === "/api/tenants/ten_1/credentials") { - return json({ - data: [{ id: "cred_1", status: "active" }], - nextCursor: null, - }); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const { navigate, calls } = trackedNavigate(); - try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - expect(calls).toEqual(["/"]); - expect(container.textContent).not.toContain("Bring your own AI"); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); - - test("provisioned, seeded: true does NOT hard-skip when no active credential is confirmed (CL-6101 review)", async () => { - // `seeded: true` here only means the seed run's validation trigger - // started a workflow run — never that it succeeded against a real - // credential. With the credentials read confirming nothing active, - // the page must fall through to the credential step, not the - // finished ending. - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/provision") { - return json({ - kind: "provisioned", - tenantId: "ten_1", - tenantSlug: "ada-user1", - seeded: true, - }); - } - if (url === "/api/tenants/ten_1/credentials") { - return json({ data: [], nextCursor: null }); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const { navigate, calls } = trackedNavigate(); - try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - expect(container.textContent).toContain("Bring your own AI"); - expect(calls).toEqual([]); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); - - test("existing-member, seeded: true does NOT hard-skip when no active credential is confirmed (CL-6101 review)", async () => { - // `seeded: true` for an existing member means every default workflow - // has an active deployment (the doc reader's workflow pins) — never - // that a credential was checked. With the credentials read - // confirming nothing active, the page must fall through to the - // credential step. - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/provision") { - return json({ - kind: "existing-member", - seeded: true, - tenantId: "ten_1", - }); - } - if (url === "/api/tenants/ten_1/credentials") { - return json({ data: [], nextCursor: null }); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const { navigate, calls } = trackedNavigate(); - try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - expect(container.textContent).toContain("Bring your own AI"); - expect(calls).toEqual([]); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); - - test("provisioned, seeded: true stays on setup with Retry when the credential probe fails (CL-6868)", async () => { - // Transient credentials-read failure must not open paste-a-key as if - // none exists — the key may already be connected. Stay on the setup - // step with one consumer sentence and Retry. - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/provision") { - return json({ - kind: "provisioned", - tenantId: "ten_1", - tenantSlug: "ada-user1", - seeded: true, - }); - } - if (url === "/api/tenants/ten_1/credentials") { - throw new Error("connection refused"); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const { navigate, calls } = trackedNavigate(); - try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - const text = container.textContent ?? ""; - expect(text).not.toContain("Bring your own AI"); - expect(text).not.toContain("or paste a provider API key"); - expect(text).toContain( - "Checking for a connected key hit a snag. Try again in a moment.", - ); - const retry = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent === "Try again", - ); - expect(retry).not.toBeUndefined(); - expect(calls).toEqual([]); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); - - test("existing-member, seeded: true stays on setup with Retry when the credential probe fails (CL-6868)", async () => { - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/provision") { - return json({ - kind: "existing-member", - seeded: true, - tenantId: "ten_1", - }); - } - if (url === "/api/tenants/ten_1/credentials") { - return json({ error: "boom" }, 500); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const { navigate, calls } = trackedNavigate(); - try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - const text = container.textContent ?? ""; - expect(text).not.toContain("Bring your own AI"); - expect(text).not.toContain("or paste a provider API key"); - expect(text).toContain( - "Checking for a connected key hit a snag. Try again in a moment.", - ); - const retry = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent === "Try again", - ); - expect(retry).not.toBeUndefined(); - expect(calls).toEqual([]); - } finally { - act(() => root.unmount()); - container.remove(); - } - }); - - test("a freshly provisioned bench with no working credential still shows the credential step", async () => { - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/provision") { - return json({ - kind: "provisioned", - tenantId: "ten_1", - tenantSlug: "ada-user1", - seeded: false, - seedSkipReason: "no operator key configured", - }); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const { navigate, calls } = trackedNavigate(); - try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - expect(container.textContent).toContain("Bring your own AI"); - expect(calls).toEqual([]); - } finally { - act(() => root.unmount()); - container.remove(); - } + expect(markup).toContain("Checking your workbench"); }); }); @@ -797,848 +276,108 @@ describe("App once onboarding is behind you", () => { }); }); -describe("readOpenRouterConnectReturn", () => { - test("an unrelated query string is nobody's outcome", () => { - expect(readOpenRouterConnectReturn("")).toBeNull(); - expect(readOpenRouterConnectReturn("?foo=bar")).toBeNull(); - }); - - test("a connected return carries the bench, with no routine list yet — that comes from completeSetup", () => { - expect( - readOpenRouterConnectReturn( - "?connect=openrouter&outcome=connected&tenantSlug=ada-user1", - ), - ).toEqual({ - kind: "connected", - tenantSlug: "ada-user1", - }); - }); - - test("a connected return missing its details is an error, never a fabricated success", () => { - const result = readOpenRouterConnectReturn( - "?connect=openrouter&outcome=connected", - ); - expect(result?.kind).toBe("error"); - }); - - test("a known failure code maps to its own copy", () => { - const result = readOpenRouterConnectReturn( - "?connect=openrouter&outcome=error&code=exchange_failed", - ); - expect(result?.kind).toBe("error"); - if (result?.kind === "error") - expect(result.message).toContain("did not hand back a key"); - }); - - test("a rate-limited start explains OpenRouter limits key creation, not internal vocabulary", () => { - const result = readOpenRouterConnectReturn( - "?connect=openrouter&outcome=error&code=rate_limited", - ); - expect(result?.kind).toBe("error"); - if (result?.kind === "error") { - expect(result.message).toContain("limits how often it can create"); - expect(result.message).toContain("Wait a minute"); - } - }); - - test("an unknown failure code still reads as a failure", () => { - const result = readOpenRouterConnectReturn( - "?connect=openrouter&outcome=error&code=who_knows", - ); - expect(result?.kind).toBe("error"); - if (result?.kind === "error") - expect(result.message).toContain("did not finish"); - }); -}); - -describe("readHuggingFaceConnectReturn", () => { - test("an unrelated query string, including an OpenRouter one, is nobody's outcome", () => { - expect(readHuggingFaceConnectReturn("")).toBeNull(); - expect( - readHuggingFaceConnectReturn("?connect=openrouter&outcome=connected"), - ).toBeNull(); - }); - - test("a connected return carries the bench, with no routine list yet — that comes from completeSetup", () => { - expect( - readHuggingFaceConnectReturn( - "?connect=huggingface&outcome=connected&tenantSlug=ada-user1", - ), - ).toEqual({ - kind: "connected", - tenantSlug: "ada-user1", - }); - }); - - test("the not_configured code maps to its own copy", () => { - const result = readHuggingFaceConnectReturn( - "?connect=huggingface&outcome=error&code=not_configured", - ); - expect(result?.kind).toBe("error"); - if (result?.kind === "error") - expect(result.message).toContain("Paste a token instead"); - }); -}); - -describe("the OpenRouter connect card", () => { - const renderOnboardingAt = (url: string) => { - window.history.replaceState(null, "", url); - try { - return renderToStaticMarkup( - , - ); - } finally { - window.history.replaceState(null, "", "/"); - } - }; - - test("the credential phase leads with the one-click connect above the key form", () => { - const markup = renderOnboardingAt( - "/onboarding?connect=openrouter&outcome=error&code=state_expired", - ); - - expect(markup).toContain("OpenRouter"); - expect(markup).toContain("/api/onboarding/oauth/openrouter/start"); - expect(markup.indexOf("onboarding-connect-card")).toBeLessThan( - markup.indexOf("onboarding-credential-form"), - ); - // The failed round trip's reason is spelled out in the same phase. - expect(markup).toContain("took too long"); - }); - - test("a connected return shows the finishing-setup progress state, not a fabricated done checklist", () => { - // Before `completeSetup` resolves, the wizard must never claim the - // routines already ran — that was the bug this split fixes: the - // OAuth callback only stored the key, so the checklist showing - // "confirmed running" before the deploy step even started would be - // a lie. - // CL-6780: finishing-setup prepares the agent; no workbench exists yet. - const markup = renderOnboardingAt( - "/onboarding?connect=openrouter&outcome=connected&tenantSlug=ada-user1", - ); - - expect(markup).toContain("Preparing your agent"); - expect(markup).not.toContain("Getting your workbench ready"); - expect(markup).not.toContain("Setting up your workbench"); - expect(markup).not.toContain("Your workbench is ready"); - }); - - test("a connected return finishes setup and hands off to `/` once completeSetup reports the bench connected", async () => { +describe("the setup gate", () => { + function renderGateAtOnboarding(statusBody: unknown, status = 200) { + const seen: string[] = []; globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/complete-setup") { - return json({ - kind: "ready", - tenantSlug: "ada-user1", - deployed: ["echo", "assistant"], - pending: [], - }); + seen.push(url); + // Anything outside the native status read is a failure: the gate + // must never touch the deleted `/api/onboarding/*` routes. + if (url !== "/api/setup/status") { + throw new Error(`unexpected fetch: ${url}`); } - throw new Error(`unexpected fetch: ${url}`); + return json(statusBody, status); }) as unknown as typeof fetch; - window.history.replaceState( - null, - "", - "/onboarding?connect=openrouter&outcome=connected&tenantSlug=ada-user1", - ); const container = document.createElement("div"); document.body.appendChild(container); - const root = createRoot(container); + const root: Root = createRoot(container); const { navigate, calls } = trackedNavigate(); - try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - expect(calls).toEqual(["/"]); - } finally { - act(() => root.unmount()); - container.remove(); - window.history.replaceState(null, "", "/"); - } - }); - - test("a blocked desired-state step renders distinctly from a pending one", async () => { - // CL-7584: `blocked` (sidecar unavailable — the hub keeps retrying) - // must not read as plain `pending` (still working). A person staring - // at "Preparing your agent" while the sidecar is down deserves to see - // which pin is held up rather than an identical spinner row. - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/complete-setup") { - return json({ - kind: "provisioning", - tenantSlug: "ada-user1", - deployed: [], - pending: ["assistant"], - steps: [ - { name: "assistant", label: "Myra", status: "pending" }, - { - name: "corbits-tools", - label: "Tool packages", - status: "blocked", - }, - ], - }); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - window.history.replaceState( - null, - "", - "/onboarding?connect=openrouter&outcome=connected&tenantSlug=ada-user1", - ); - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - const blocked = container.querySelector('li[data-status="blocked"]'); - const pending = container.querySelector('li[data-status="pending"]'); - expect(blocked?.textContent).toContain("Tool packages"); - expect(pending?.textContent).toContain("Myra"); - // The blocked row carries retry copy the pending row does not. - expect(blocked?.textContent).toContain("retrying"); - expect(pending?.textContent).not.toContain("retrying"); - } finally { - act(() => root.unmount()); - container.remove(); - window.history.replaceState(null, "", "/"); - } - }); - - test("a connected return falls back to the credential phase, not a dead end, when completeSetup reports unseeded", async () => { - // The degraded-but-correct path: the pending-seed cookie can be - // missing (a loser duplicate-callback response the browser never - // applied, a cookie that already expired) even though the - // credential itself connected fine. `completeSetup` answers - // `unseeded` — not an error — and the wizard must land somewhere a - // person can actually finish from, never stuck on the spinner or a - // blank state. - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/complete-setup") { - return json({ kind: "unseeded" }); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - window.history.replaceState( - null, - "", - "/onboarding?connect=openrouter&outcome=connected&tenantSlug=ada-user1", - ); - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - // Lands back on the credential step, reading as unfinished — the - // same "finish setting up" copy a returning bench_unseeded member - // gets — with no error banner and no pre-satisfied skip option, - // since nothing here actually proved a working key is in place. - expect(container.textContent).toContain( - "Finish setting up your workbench", - ); - expect(container.textContent).not.toContain( - "A working key is already in place", - ); - expect(container.textContent).not.toContain("That key didn't work"); - // Still offers the one-click connect and the paste-a-key form — - // never a dead end. - expect(container.textContent).toContain("OpenRouter"); - } finally { - act(() => root.unmount()); - container.remove(); - window.history.replaceState(null, "", "/"); - } - }); - - test("a stale connect error is superseded when the account turns out to already be fully seeded", async () => { - // The belt to the idempotent-duplicate-callback fix's suspenders: a - // browser landing on the credential phase's stale `state_expired` - // error must not stay stuck there once a real check shows the - // connect actually succeeded — it lands on the same finished state - // as a fresh connect would. - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/provision") { - return json({ - kind: "existing-member", - seeded: true, - tenantId: "ten_1", - }); - } - // The page independently confirms `seeded: true` against a real - // stored credential (CL-6101 review) before hard-skipping — this - // scenario's premise (a real check shows the connect succeeded) - // means that confirmation succeeds. - if (url === "/api/tenants/ten_1/credentials") { - return json({ - data: [{ id: "cred_1", status: "active" }], - nextCursor: null, - }); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - window.history.replaceState( - null, - "", - "/onboarding?connect=openrouter&outcome=error&code=state_expired", - ); - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const { navigate, calls } = trackedNavigate(); - try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - expect(calls).toEqual(["/"]); - expect(container.textContent).not.toContain("took too long"); - } finally { - act(() => root.unmount()); - container.remove(); - window.history.replaceState(null, "", "/"); - } - }); -}); - -describe("the provider picker's primary row and secondary expander", () => { - const renderCredentialPhase = () => { - window.history.replaceState( - null, - "", - "/onboarding?connect=openrouter&outcome=error&code=state_expired", - ); - try { - return renderToStaticMarkup( - , - ); - } finally { - window.history.replaceState(null, "", "/"); - } - }; - - test("the primary six render in the owner's exact order, ahead of the expander", () => { - const markup = renderCredentialPhase(); - // Search only from the radiogroup onward — "OpenRouter" also appears - // in the one-click connect card above the picker. - const pickerMarkup = markup.slice( - markup.indexOf('aria-label="Inference provider"'), - ); - const labels = [ - "OpenAI", - "Anthropic", - "Google", - "xAI", - "OpenRouter", - "Opencode Zen", - "More providers", - ]; - const positions = labels.map((label) => pickerMarkup.indexOf(label)); - - expect(positions.every((index) => index >= 0)).toBe(true); - const isStrictlyIncreasing = positions.every( - (position, i) => i === 0 || position > (positions[i - 1] ?? -Infinity), - ); - expect(isStrictlyIncreasing).toBe(true); - }); - - test("groq, deepseek, and mistral still render, fully functional, inside the expander", () => { - const markup = renderCredentialPhase(); - // Search only from the radiogroup onward — provider names also appear - // in the one-click connect cards' copy above the picker. - const pickerMarkup = markup.slice( - markup.indexOf('aria-label="Inference provider"'), - ); - const moreIndex = pickerMarkup.indexOf("More providers"); - - for (const label of ["Groq", "DeepSeek", "Mistral"]) { - const labelIndex = pickerMarkup.indexOf(label); - expect(labelIndex).toBeGreaterThan(moreIndex); - } - // Still real radio buttons, not disabled or decorative. - expect(markup).toContain('role="radio"'); - }); + return { container, root, navigate, calls, seen }; + } - test("the expander is collapsed by default — anthropic is the initial selection", () => { - const markup = renderCredentialPhase(); - expect(markup).toContain('class="onboarding-provider-more"'); - expect(markup).not.toMatch(/class="onboarding-provider-more" open/); - }); -}); + async function settle(root: Root, element: ReactElement) { + act(() => { + root.render(element); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } -describe("the Hugging Face connect card", () => { - const renderOnboardingAt = (url: string) => { - window.history.replaceState(null, "", url); - try { - return renderToStaticMarkup( + function gateElement(navigate: (to: string) => void) { + return ( + , - ); - } finally { - window.history.replaceState(null, "", "/"); - } - }; - - test("sits below the OpenRouter card, above the key form", () => { - const markup = renderOnboardingAt( - "/onboarding?connect=huggingface&outcome=error&code=not_configured", - ); - - expect(markup).toContain("Sign in with Hugging Face"); - expect(markup).toContain("/api/onboarding/oauth/huggingface/start"); - expect(markup.indexOf("Connect with OpenRouter")).toBeLessThan( - markup.indexOf("Sign in with Hugging Face"), - ); - expect(markup.indexOf("Sign in with Hugging Face")).toBeLessThan( - markup.indexOf("onboarding-credential-form"), - ); - expect(markup).toContain("Paste a token instead"); - }); - - test("a connected return shows the finishing-setup progress state, not a fabricated done checklist", () => { - const markup = renderOnboardingAt( - "/onboarding?connect=huggingface&outcome=connected&tenantSlug=ada-user1", + /> + ); + } - // CL-6780: finishing-setup prepares the agent; no workbench exists yet. - expect(markup).toContain("Preparing your agent"); - expect(markup).not.toContain("Getting your workbench ready"); - expect(markup).not.toContain("Setting up your workbench"); - expect(markup).not.toContain("Your workbench is ready"); - }); - - test("a connected return finishes setup and hands off to `/` once completeSetup reports the bench connected", async () => { - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/complete-setup") { - return json({ - kind: "ready", - tenantSlug: "ada-user1", - deployed: ["echo", "assistant"], - pending: [], - }); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - window.history.replaceState( - null, - "", - "/onboarding?connect=huggingface&outcome=connected&tenantSlug=ada-user1", - ); - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const { navigate, calls } = trackedNavigate(); + test("an empty hub renders the setup-pending panel with a recheck, and never navigates", async () => { + const { container, root, navigate, calls, seen } = renderGateAtOnboarding({ + setupRequired: true, + userCount: 1, + tenantCount: 0, + }); try { - act(() => { - root.render( - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); + await settle(root, gateElement(navigate)); - expect(calls).toEqual(["/"]); + expect(container.textContent).toContain("Set up your workbench"); + expect(container.textContent).toContain("Check again"); + expect(calls).toEqual([]); + expect(seen).toEqual(["/api/setup/status"]); } finally { act(() => root.unmount()); container.remove(); - window.history.replaceState(null, "", "/"); } }); -}); -describe("OnboardingPage resuming a bench_unseeded account", () => { - const noop = () => undefined; - const settle = () => - act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); + test("a hub with tenants bounces straight into the shell", async () => { + const { container, root, navigate, calls, seen } = renderGateAtOnboarding({ + setupRequired: false, + userCount: 1, + tenantCount: 1, }); - - let container: HTMLDivElement | null = null; - let root: Root | null = null; - - afterEach(() => { - if (root !== null) act(() => root?.unmount()); - container?.remove(); - container = null; - root = null; - }); - - test("an existing member with seeded: false lands on the credential step reading as unfinished, not pre-satisfied", async () => { - // No naming step (CL-6089) — provisioning fires automatically, under - // a default name, the moment the wizard mounts. - globalThis.fetch = (async () => - json({ - kind: "existing-member", - seeded: false, - })) as unknown as typeof fetch; - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - act(() => { - root?.render( - createElement(NavigationProvider, { - navigate: noop, - children: createElement(OnboardingPage, { - user: { id: "user_1", name: "Ada", email: "ada@example.com" }, - }), - }), - ); - }); - await settle(); - - // The wizard reads this account as still needing a working - // credential — never "a working key is already in place", the copy - // a fully seeded existing member gets. - expect(container.textContent).toContain("Finish setting up your workbench"); - expect(container.textContent).not.toContain( - "A working key is already in place", - ); - }); -}); - -describe("connecting a local Ollama instance from onboarding", () => { - const settle = () => - act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - function findByRoleText( - container: HTMLElement, - role: string, - text: string, - ): HTMLElement { - const match = Array.from( - container.querySelectorAll(`[role="${role}"]`), - ).find((el) => el.textContent?.includes(text)); - if (match === undefined) { - throw new Error(`no [role="${role}"] element containing "${text}"`); - } - return match; - } - - function findButtonByText(container: HTMLElement, text: string): HTMLElement { - const match = Array.from(container.querySelectorAll("button")).find((el) => - el.textContent?.includes(text), - ); - if (match === undefined) { - throw new Error(`no button containing "${text}"`); - } - return match; - } - - test("picking the Ollama card prefills its base URL, and submitting connects it and marks the tenant as having a usable model", async () => { - let completeRequestBody: unknown = null; - globalThis.fetch = (async (url: string, init?: RequestInit) => { - if (url === "/api/onboarding/provision") { - return json({ kind: "existing-member", seeded: false }); - } - if (url === "/api/onboarding/complete") { - completeRequestBody = JSON.parse((init?.body as string) ?? "{}"); - // The hub's own connect route is what actually marks the tenant - // as having a usable model (persisting the credential + seeding - // its catalog) — this response is what that route answers once - // it has. The onboarding page's job, proven below, is sending - // the URL as `baseURL` with the fixed Ollama placeholder secret, - // and moving on once this comes back. - return json({ - kind: "ready", - tenantId: "ten_1", - tenantSlug: "ada-user1", - deployed: ["echo"], - pending: [], - }); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const { navigate, calls } = trackedNavigate(); try { - act(() => { - root.render( - , - ); - }); - await settle(); + await settle(root, gateElement(navigate)); - act(() => { - findByRoleText(container, "radio", "Ollama (local)").click(); - }); - - const urlInput = container.querySelector( - "#onboarding-provider-url", - ); - expect(urlInput).not.toBeNull(); - expect(urlInput?.value).toBe("http://localhost:11434"); - - act(() => { - findButtonByText(container, "Connect this address").click(); - }); - await settle(); - - expect(completeRequestBody).toEqual({ - provider: "ollama", - apiKey: OLLAMA_PLACEHOLDER_SECRET, - baseURL: "http://localhost:11434", - }); expect(calls).toEqual(["/"]); + expect(seen).toEqual(["/api/setup/status"]); } finally { act(() => root.unmount()); container.remove(); } }); -}); - -describe("skipping the onboarding credential step", () => { - const settle = () => - act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - function findButtonByText(container: HTMLElement, text: string): HTMLElement { - const match = Array.from(container.querySelectorAll("button")).find((el) => - el.textContent?.includes(text), + test("a broken status read blocks with retry instead of the shell", async () => { + const { container, root, navigate, calls } = renderGateAtOnboarding( + { + error: { + code: "setup_status_failed", + userMessage: + "Checking your workbench hit a snag. Try again in a moment.", + refId: "r1", + }, + }, + 500, ); - if (match === undefined) { - throw new Error(`no button containing "${text}"`); - } - return match; - } - - test("skipping hands off to the workbench without connecting a provider", async () => { - // A bench with no provider ready yet — this is the anticipated - // `bench_unseeded` state, not an error (see `handleSkip`'s own - // comment): skipping must never call the credential-complete route. - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/provision") { - return json({ kind: "existing-member", seeded: false }); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const { navigate, calls } = trackedNavigate(); try { - act(() => { - root.render( - , - ); - }); - await settle(); + await settle(root, gateElement(navigate)); - act(() => { - findButtonByText(container, "Skip for now").click(); - }); - - expect(calls).toEqual(["/"]); + expect(container.textContent).toContain("Couldn't check your workbench"); + expect(container.textContent).toContain("Try again"); + expect(container.textContent).toContain("r1"); + expect(calls).toEqual([]); } finally { act(() => root.unmount()); container.remove(); } }); }); - -// CL-7584: the finishing-setup view renders the hub's own desired-state -// step list while agents are still coming online, and a ready answer -// collapses it — the page hands off to `/` without ever showing one. -describe("CL-7584 desired-state steps in finishing-setup", () => { - const settle = (ms = 10) => - act(async () => { - await new Promise((resolve) => setTimeout(resolve, ms)); - }); - - let container: HTMLDivElement | null = null; - let root: Root | null = null; - - afterEach(() => { - if (root !== null) act(() => root?.unmount()); - container?.remove(); - container = null; - root = null; - }); - - function renderFinishingSetup(navigate: (path: string) => void) { - window.history.replaceState( - null, - "", - "/onboarding?connect=openrouter&outcome=connected&tenantSlug=ada-user1", - ); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - act(() => { - root?.render( - , - ); - }); - } - - test("renders the doc-labeled steps from the status body while pins are pending", async () => { - let calls = 0; - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/complete-setup") { - calls += 1; - return json({ - kind: "provisioning", - tenantId: "ten_1", - tenantSlug: "ada-user1", - setupAgentReady: false, - deployed: [], - pending: ["assistant"], - steps: [ - { name: "assistant", label: "Myra", status: "pending" }, - { - name: "writing-system-prompts", - label: "writing-system-prompts", - status: "pending", - }, - ], - }); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - renderFinishingSetup(noop); - await settle(50); - - expect(calls).toBeGreaterThan(0); - expect(container?.querySelector(".onboarding-steps")).not.toBeNull(); - expect(container?.textContent).toContain("Myra"); - expect(container?.querySelector('[data-status="pending"]')).not.toBeNull(); - }); - - test("a ready answer collapses the steps and hands off to /", async () => { - globalThis.fetch = (async (url: string) => { - if (url === "/api/onboarding/complete-setup") { - return json({ - kind: "ready", - tenantId: "ten_1", - tenantSlug: "ada-user1", - setupAgentReady: true, - deployed: ["assistant"], - pending: [], - steps: [{ name: "assistant", label: "Myra", status: "present" }], - }); - } - throw new Error(`unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const { navigate, calls } = trackedNavigate(); - renderFinishingSetup(navigate); - await settle(50); - - expect(calls).toEqual(["/"]); - }); -}); diff --git a/apps/web/test/toast-single-system.test.tsx b/apps/web/test/toast-single-system.test.tsx index afdb85871c..2a13e8ff2b 100644 --- a/apps/web/test/toast-single-system.test.tsx +++ b/apps/web/test/toast-single-system.test.tsx @@ -192,18 +192,8 @@ describe("the one toast system (CL-6372)", () => { if (path.includes("/workflows/definitions")) { return Promise.resolve(json({ data: [], nextCursor: null })); } - if (path.endsWith("/api/onboarding/provisioning-status?tenantId=tnt_1")) { - return Promise.resolve( - json({ - kind: "provisioning", - tenantId: "tnt_1", - tenantSlug: "corbits-bench", - setupAgentReady: false, - deployed: [], - pending: ["assistant"], - }), - ); - } + // CL-8112 T1: no `/api/onboarding/*` request anywhere on this + // path — the empty native definition alone drives the retry panel. return Promise.resolve(json({ error: "boom" }, 500)); }) as typeof fetch; await renderPickerWithToaster(); diff --git a/bun.lock b/bun.lock index 5a86a54149..ee8690bb8f 100644 --- a/bun.lock +++ b/bun.lock @@ -90,7 +90,6 @@ "@intx/workflow": "workspace:*", "@intx/workflow-deploy": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", - "@workbench/access-policy": "workspace:*", "@workbench/onboarding": "workspace:*", "@workbench/templates": "workspace:*", "arktype": "catalog:", @@ -3600,11 +3599,17 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/inbox/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], + "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], + + "@corbits/chat-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/context-menu/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -3628,12 +3633,12 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="], - "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], - - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], + "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], @@ -3688,8 +3693,6 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@corbits/inbox/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -3736,8 +3739,6 @@ "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@workbench/hub/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt index c1b5d0ee68..f7870a7295 100644 --- a/scripts/checks/report-error-baseline.txt +++ b/scripts/checks/report-error-baseline.txt @@ -70,8 +70,6 @@ apps/web/src/last-workbench.ts 1 // A private-browsing tab with storage disabled apps/web/src/last-workbench.ts 1 return null; apps/web/src/onboarding.ts 1 return { kind: "error" }; apps/web/src/onboarding.ts 1 return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; -apps/web/src/onboarding.ts 2 return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; -apps/web/src/onboarding.ts 3 return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; apps/web/src/pages/agent-detail-page.tsx 1 // A handle collision is the one failure retrying can never clear, so apps/web/src/pages/agent-detail-page.tsx 1 setLifecycle({ apps/web/src/pages/agent-detail-page.tsx 1 setSave({ kind: "idle" });