diff --git a/apps/web/src/app.css b/apps/web/src/app.css index b9179fb0..56c57305 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -3388,6 +3388,17 @@ tr.insights-row-clickable:hover { color: var(--muted-foreground); } +.new-workbench-picker-not-ready { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; +} + +.new-workbench-picker-not-ready .new-workbench-picker-sub { + margin: 0; +} + .new-workbench-pick-list { border: 1px solid var(--border); } diff --git a/apps/web/src/instant-agent-create.ts b/apps/web/src/instant-agent-create.ts index 4f7e0b41..8b2af614 100644 --- a/apps/web/src/instant-agent-create.ts +++ b/apps/web/src/instant-agent-create.ts @@ -50,8 +50,34 @@ export const NEW_WORKBENCH_TITLE = "New Workbench"; * that error type's own describer instead — allow-listing safe * throws, rather than denylisting unsafe ones, so a new error type * added later fails safe (masked) instead of leaking by default. + * + * `kind` lets a caller tell "the setup agent isn't deployed yet" apart + * from "this template genuinely doesn't exist here" without parsing + * `message` text: the first is very often a still-provisioning bench + * (CL-6457's background deploy hasn't finished, or never started + * without a credential) that the caller should check + * `fetchAgentReadiness` over before treating as a dead end; the second + * never resolves itself and should surface as-is. */ -export class WorkbenchPreconditionError extends Error {} +export class WorkbenchPreconditionError extends Error { + readonly kind: "setup-agent-missing" | "template-unavailable"; + constructor( + message: string, + kind: "setup-agent-missing" | "template-unavailable", + ) { + super(message); + this.kind = kind; + } +} + +/** + * Consumer-language stand-in for the system precondition this bench + * hit: "no deployed setup agent" describes an internal implementation + * detail, never something a person signing in for the first time + * should have to parse. + */ +const SETUP_AGENT_MISSING_MESSAGE = + "Your workbench is still finishing setup. Try again in a moment."; /** * Presents the connected org's repo list for the person to pick from once @@ -94,7 +120,8 @@ export async function createWorkbenchFromTemplate( const setupTemplate = findMyraDefinition(definitions); if (setupTemplate === undefined) { throw new WorkbenchPreconditionError( - "No default setup agent found for this workbench.", + SETUP_AGENT_MISSING_MESSAGE, + "setup-agent-missing", ); } // The manifest comes from the bench library (CL-6344), never from a @@ -112,6 +139,7 @@ export async function createWorkbenchFromTemplate( if (templateId !== "blank" && manifest === undefined) { throw new WorkbenchPreconditionError( `A ${templateId} workbench isn't available here yet.`, + "template-unavailable", ); } const requiresGithub = diff --git a/apps/web/src/pages/new-workbench-picker.test.ts b/apps/web/src/pages/new-workbench-picker.test.ts index b6955482..bfae8323 100644 --- a/apps/web/src/pages/new-workbench-picker.test.ts +++ b/apps/web/src/pages/new-workbench-picker.test.ts @@ -25,6 +25,7 @@ describe("describeWorkbenchCreateFailure", () => { describeWorkbenchCreateFailure( new WorkbenchPreconditionError( "A code-review workbench isn't available here yet.", + "template-unavailable", ), ), ).toBe("A code-review workbench isn't available here yet."); diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index 6c088528..f80e643c 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -29,6 +29,7 @@ import { WorkbenchPreconditionError, type PickGithubRepos, } from "../instant-agent-create"; +import { fetchAgentReadiness } from "../onboarding"; import { useNavigate } from "../navigation"; import { StageTopBar } from "../shell/stage-top-bar"; import { @@ -96,6 +97,13 @@ export function NewWorkbenchPickerRoute() { ); const [picked, setPicked] = useState(null); 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. + const [stillSettingUp, setStillSettingUp] = useState(false); const [repoPicker, setRepoPicker] = useState(null); // What this bench's library can actually serve (CL-6458). A kind whose @@ -127,6 +135,7 @@ export function NewWorkbenchPickerRoute() { async function handleCreate() { if (selectedTenantId === null || creating) return; setCreating(true); + setStillSettingUp(false); try { await createWorkbenchFromTemplate( selectedTenantId, @@ -135,6 +144,22 @@ export function NewWorkbenchPickerRoute() { pickGithubRepos, ); } catch (cause) { + // 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. + if ( + cause instanceof WorkbenchPreconditionError && + cause.kind === "setup-agent-missing" + ) { + const readiness = await fetchAgentReadiness(); + if (readiness.kind !== "ready" && readiness.kind !== "chat-ready") { + setCreating(false); + setStillSettingUp(true); + return; + } + } log.error("Couldn't create the workbench", { message: cause instanceof Error ? cause.message : String(cause), status: @@ -164,7 +189,22 @@ export function NewWorkbenchPickerRoute() { } />
- {creating ? ( + {stillSettingUp ? ( +
+

Still setting up your workbench

+

+ Your account's agents are finishing setup in the background. + This usually takes under a minute — try again in a moment. +

+ +
+ ) : creating ? ( ) : library.kind === "loading" ? ( diff --git a/apps/web/test/new-workbench-picker.test.tsx b/apps/web/test/new-workbench-picker.test.tsx index 06a5d254..554aa3bd 100644 --- a/apps/web/test/new-workbench-picker.test.tsx +++ b/apps/web/test/new-workbench-picker.test.tsx @@ -244,6 +244,47 @@ describe("NewWorkbenchPickerRoute", () => { expect(codeReview?.getAttribute("aria-checked")).toBe("false"); }); + // CL-6510: a bench whose default agents haven't finished deploying yet + // (CL-6457's background drain still running, or never started without a + // credential) must never dead-end the person on the raw internal + // precondition message — the picker checks readiness first and shows an + // honest, retryable "still setting up" state instead. + test("when the setup agent isn't deployed yet, creating shows an honest still-setting-up state, not the raw precondition error", async () => { + stubFetch((path) => { + if (path.includes("/workflows/definitions")) { + return json({ data: [], nextCursor: null }); + } + if (path.endsWith("/api/onboarding/provisioning-status")) { + return json({ + kind: "provisioning", + tenantId: "tnt_1", + tenantSlug: "corbits-bench", + setupAgentReady: false, + deployed: [], + pending: ["assistant"], + }); + } + return undefined; + }); + await renderPicker(); + + const createButton = Array.from( + container?.querySelectorAll("button") ?? [], + ).find((button) => button.textContent === "Create workbench"); + await act(async () => { + createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + for (let i = 0; i < 20; i++) { + await settle(); + if (container?.textContent?.includes("Still setting up")) break; + } + + expect(container?.textContent).toContain("Still setting up your workbench"); + expect(container?.textContent).not.toContain( + "No default setup agent found", + ); + }); + test("creating with Code review selected mints a workbench from the template, then navigates in", async () => { const createdAgentHandles: string[] = []; const calls = stubFetch((path, init) => { diff --git a/apps/web/test/toast-single-system.test.tsx b/apps/web/test/toast-single-system.test.tsx index 226e97b4..51cdf69b 100644 --- a/apps/web/test/toast-single-system.test.tsx +++ b/apps/web/test/toast-single-system.test.tsx @@ -47,6 +47,13 @@ const MEMBERSHIP = { nextCursor: null, }; +// A genuine create failure: the tenant has its setup agent deployed (so +// the flow gets past `findMyraDefinition`'s precondition), and the actual +// workbench-create request is what 500s. Serving an empty definitions +// list here instead would fail the precondition first, which is a +// different, already-covered path (a missing setup agent shows the +// retry panel below, not a toast) — this stub exists to prove the +// one-toast invariant for a real create failure, so it must reach one. function stubFailingCreate(): void { globalThis.fetch = ((input: RequestInfo | URL) => { const path = typeof input === "string" ? input : String(input); @@ -54,7 +61,23 @@ function stubFailingCreate(): void { return Promise.resolve(json(MEMBERSHIP)); } if (path.includes("/workflows/definitions")) { - return Promise.resolve(json({ data: [], nextCursor: null })); + return Promise.resolve( + json({ + data: [ + { + id: "def-assistant", + tenantId: "tnt_1", + name: "assistant", + currentVersion: "1", + status: "deployed", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + skills: [] as readonly string[], + }, + ], + nextCursor: null, + }), + ); } return Promise.resolve(json({ error: "boom" }, 500)); }) as typeof fetch; @@ -142,15 +165,59 @@ describe("the one toast system (CL-6372)", () => { const shown = visibleToasts(); expect(shown.length).toBe(1); - // The stub serves an empty definitions list, so the create fails its - // precondition before any request is sent. That is a - // `WorkbenchPreconditionError`, which the picker shows verbatim. + // The stub's setup agent is deployed, so this is a real create + // failure (the workbench-create request itself 500s) — a + // `ChatApiError`, described through `describeChatError`. expect(shown[0]?.textContent).toBe( - "No default setup agent found for this workbench.", + "Something went wrong on our end. Try again in a moment.", ); await waitForClear(); }); + // CL-6510: the new contract this file's own change introduced — a + // missing setup agent no longer fires a toast at all, since the + // picker now shows a retryable "still setting up" panel instead of + // treating that precondition as a dead end. + test("a missing setup agent shows the retry panel and fires no toast", async () => { + globalThis.fetch = ((input: RequestInfo | URL) => { + const path = typeof input === "string" ? input : String(input); + if (path.includes("/api/me/principals")) { + return Promise.resolve(json(MEMBERSHIP)); + } + if (path.includes("/workflows/definitions")) { + return Promise.resolve(json({ data: [], nextCursor: null })); + } + if (path.endsWith("/api/onboarding/provisioning-status")) { + return Promise.resolve( + json({ + kind: "provisioning", + tenantId: "tnt_1", + tenantSlug: "corbits-bench", + setupAgentReady: false, + deployed: [], + pending: ["assistant"], + }), + ); + } + return Promise.resolve(json({ error: "boom" }, 500)); + }) as typeof fetch; + await renderPickerWithToaster(); + + const createButton = Array.from( + container?.querySelectorAll("button") ?? [], + ).find((button) => button.textContent === "Create workbench"); + await act(async () => { + createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + for (let i = 0; i < 30; i++) { + await settle(); + if (container?.textContent?.includes("Still setting up")) break; + } + + expect(container?.textContent).toContain("Still setting up your workbench"); + expect(visibleToasts().length).toBe(0); + }); + test("the failure toast carries the house styling, not sonner's default", async () => { stubFailingCreate(); await renderPickerWithToaster(); diff --git a/packages/onboarding/test/provision.test.ts b/packages/onboarding/test/provision.test.ts index c950bd0a..a01fe610 100644 --- a/packages/onboarding/test/provision.test.ts +++ b/packages/onboarding/test/provision.test.ts @@ -1,13 +1,19 @@ import { describe, expect, test } from "bun:test"; -import { DEFAULT_WORKFLOWS, SEED_GRANTS } from "@workbench/hub-client"; +import { + DEFAULT_WORKFLOWS, + SEED_GRANTS, + SETUP_AGENT_ASSET_NAME, +} from "@workbench/hub-client"; import type { ApiCall } from "@workbench/hub-client"; import type { WorkflowPusher, ToolRegistryPublisher, } from "@workbench/hub-client"; import { + isFullySeeded, personalTenantSlug, provisionPersonalTenantIfNeeded, + seededWorkflowStatus, } from "../src/provision"; const TENANT_ID = "ten_new"; @@ -1224,4 +1230,242 @@ describe("provisionPersonalTenantIfNeeded", () => { }); expect(assetCreateCount).toBeGreaterThan(0); }); + + test("a tenant with zero workflow definitions recovers a live assistant deployment on sign-in (CL-6510)", async () => { + // Reproduces the live bug verbatim: a personal bench with a real + // membership and 0 rows in workflow_definition — exactly + // tnt_b780a4d8050c8d679f107642809ab7ab's shape — hitting sign-in + // with a seed model configured. The bar this test holds itself to: + // not "seedTenant was called", but that the same read the app's own + // `/provisioning-status` route and `findMyraDefinition` depend on + // (an "assistant"-named asset with a live deployment) is genuinely + // there afterward. + const assets: { id: string; name: string }[] = []; + const deployments: { id: string; definitionAssetId: string }[] = []; + const startedRuns: Record = {}; + + const api: ApiCall = async (method, path, body) => { + if (method === "GET" && path === "/api/me/principals") { + return { + status: 200, + data: { + data: [ + { + principalId: PRINCIPAL_ID, + tenantId: TENANT_ID, + tenantName: "alice's team", + tenantSlug: TENANT_SLUG, + kind: "user", + status: "active", + roles: [{ id: "rol_owner", name: "owner" }], + }, + ], + nextCursor: null, + }, + cookies: [], + }; + } + if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { + return { + status: 200, + data: { + id: TENANT_ID, + name: "alice's team", + slug: TENANT_SLUG, + domain: `${TENANT_SLUG}.localhost`, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + cookies: [], + }; + } + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/grants?`) + ) { + return { + status: 200, + data: { data: [], nextCursor: null }, + cookies: [], + }; + } + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { + return { status: 201, data: {}, cookies: [] }; + } + if ( + method === "GET" && + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` + ) { + // 0 rows, exactly like the live tenant, until seedTenant creates + // some — every subsequent read reflects whatever exists so far. + return { + status: 200, + data: assets.map((asset) => ({ + id: asset.id, + tenantId: TENANT_ID, + kind: "workflow", + name: asset.name, + displayName: null, + creatorPrincipalId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + origin: { tenantId: TENANT_ID, direct: true }, + })), + cookies: [], + }; + } + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) { + const name = + typeof body === "object" && body !== null && "name" in body + ? String((body as { name: unknown }).name) + : `wf_${assets.length + 1}`; + const asset = { id: `ast_${assets.length + 1}`, name }; + assets.push(asset); + return { + status: 201, + data: { + id: asset.id, + tenantId: TENANT_ID, + kind: "workflow", + name, + displayName: null, + creatorPrincipalId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + cookies: [], + }; + } + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/git-tokens` + ) { + return { + status: 201, + data: { id: "tok_1", secret: "s3cret" }, + cookies: [], + }; + } + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/skills/`) + ) { + return { status: 404, data: {}, cookies: [] }; + } + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/skills`) { + return { status: 201, data: {}, cookies: [] }; + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/definitions` + ) { + return { + status: 200, + data: { data: [], nextCursor: null }, + cookies: [], + }; + } + if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { + return { status: 200, data: { items: [] }, cookies: [] }; + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` + ) { + return { + status: 200, + data: deployments.map((deployment) => ({ + id: deployment.id, + tenantId: TENANT_ID, + definitionAssetId: deployment.definitionAssetId, + status: "deployed", + createdAt: "2026-01-01T00:00:00.000Z", + })), + cookies: [], + }; + } + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` + ) { + const definitionAssetId = + assets[assets.length - 1]?.id ?? "ast_unknown"; + const deployment = { + id: `dep_${deployments.length + 1}`, + definitionAssetId, + }; + deployments.push(deployment); + startedRuns[deployment.id] = []; + return { + status: 201, + data: { + id: deployment.id, + tenantId: TENANT_ID, + definitionAssetId, + status: "deployed", + createdAt: "2026-01-01T00:00:00.000Z", + }, + cookies: [], + }; + } + const runsMatch = + /^\/api\/tenants\/ten_new\/workflows\/(dep_\d+)\/runs$/.exec(path); + if (method === "GET" && runsMatch) { + const deploymentId = runsMatch[1] as string; + return { + status: 200, + data: { runIds: [...(startedRuns[deploymentId] ?? [])] }, + cookies: [], + }; + } + const mailMatch = + /^\/api\/tenants\/ten_new\/workflows\/(dep_\d+)\/mail$/.exec(path); + if (method === "POST" && mailMatch) { + const deploymentId = mailMatch[1] as string; + const runId = `run_${(startedRuns[deploymentId]?.length ?? 0) + 1}`; + startedRuns[deploymentId] = [ + ...(startedRuns[deploymentId] ?? []), + runId, + ]; + return { + status: 202, + data: { runId: deploymentId, address: "x@x", messageId: runId }, + cookies: [], + }; + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + // Confirm the bug is real before recovering from it: no assistant + // asset, no live deployment. + const before = await isFullySeeded(api, ["session=abc"], TENANT_ID); + expect(before).toBe(false); + + const result = await provisionPersonalTenantIfNeeded({ + api, + cookies: ["session=abc"], + hubUrl: "http://localhost:3000", + userId: "user_1", + userEmail: "alice@example.com", + userEmailVerified: true, + seedModel: MODEL, + pushWorkflow: noopPush, + publishToolRegistry: noopPublishToolRegistry, + log: collector().log, + }); + + expect(result).toEqual({ + kind: "existing-member", + seeded: true, + tenantId: TENANT_ID, + }); + + // The verification bar: the same read `findMyraDefinition` and the + // `/provisioning-status` route's `setupAgentReady` depend on now + // resolves the assistant, not merely "seedTenant ran". + const status = await seededWorkflowStatus(api, ["session=abc"], TENANT_ID); + expect(status.deployed).toContain(SETUP_AGENT_ASSET_NAME); + expect(status.pending).not.toContain(SETUP_AGENT_ASSET_NAME); + }); }); diff --git a/scripts/dev.ts b/scripts/dev.ts index 8980b1ec..1dda3285 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -334,16 +334,38 @@ async function seedDevAccount(config: HubConfig): Promise { const email = process.env["HUB_ADMIN_EMAIL"] ?? "alice@example.com"; const password = process.env["HUB_ADMIN_PASSWORD"] ?? "password123"; const name = email.split("@")[0] ?? email; - const deadline = Date.now() + 30_000; + const readinessTimeoutMs = 30_000; + const deadline = Date.now() + readinessTimeoutMs; + let hubReady = false; while (Date.now() < deadline) { try { const probe = await fetch(`${config.baseUrl}/api/auth/get-session`); - if (probe.ok) break; + if (probe.ok) { + hubReady = true; + break; + } } catch { // hub not listening yet; keep waiting } await new Promise((r) => setTimeout(r, 500)); } + // A timed-out wait must never fall through into the sign-in/sign-up + // attempt below: that attempt would only reproduce the same "unable to + // connect" failure this wait exists to rule out, and swallowing it (the + // former behavior) left alice's account permanently half-provisioned — + // created, but with none of its default agents ever deployed, and no + // sign a person could see. Fail the whole dev bootstrap loudly instead. + if (!hubReady) { + fail( + [ + `[dev] the hub at ${config.baseUrl} never answered ` + + `/api/auth/get-session within ${readinessTimeoutMs / 1000}s, so`, + `account seeding for ${email} did not run. Check the hub's own`, + "log output above for why it never came up, fix that, then re-run", + "`bun run dev`.", + ].join(" "), + ); + } try { const signIn = await fetch(`${config.baseUrl}/api/auth/sign-in/email`, { method: "POST", @@ -371,20 +393,17 @@ async function seedDevAccount(config: HubConfig): Promise { return; } if (signUp.status === 403 && /signup_closed/i.test(body)) { - console.error( + fail( [ `[dev] could not seed account ${email}: self-serve signup is closed`, "and the account does not exist yet. Set WORKBENCH_SIGNUP=open in", ".env, restart, then re-run `bun run dev` once.", ].join(" "), ); - return; } - console.error( - `[dev] could not seed account ${email}: ${signUp.status} ${body}`, - ); + fail(`[dev] could not seed account ${email}: ${signUp.status} ${body}`); } catch (error) { - console.error( + fail( `[dev] could not seed account ${email}: ${error instanceof Error ? error.message : String(error)}`, ); }