From be8d24d3c49a4674b147a14762ac56e0c3107357 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 21:22:03 -0700 Subject: [PATCH 1/3] Add tests for read-triggered template library convergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bench library's template shelf never seeded in a real environment: the boot seed waited for an operator bench that dev onboarding never creates, and gave up after a bounded retry window. These tests state what the shelf should do instead — converge per tenant on the first library read, retry rather than latch on failure, and never leave the new-workbench picker offering a kind the library cannot serve. --- apps/web/test/new-workbench-picker.test.tsx | 65 +++++++- .../src/template-library.test.ts | 145 +++++++++++++++++- 2 files changed, 203 insertions(+), 7 deletions(-) diff --git a/apps/web/test/new-workbench-picker.test.tsx b/apps/web/test/new-workbench-picker.test.tsx index 38a5b28a8..06a5d2545 100644 --- a/apps/web/test/new-workbench-picker.test.tsx +++ b/apps/web/test/new-workbench-picker.test.tsx @@ -58,8 +58,21 @@ function stubFetch( if (path.includes("/api/me/principals")) { return Promise.resolve(json(MEMBERSHIP)); } - // The bench library the hub seeded at boot (CL-6344) — what the - // create flow instantiates from instead of a hardcoded import. + // The bench library the first read converges (CL-6458) — what the + // picker offers rows from and the create flow instantiates from, + // never a hardcoded import. + if (path.endsWith("/library/templates")) { + return Promise.resolve( + json({ + data: [ + { + id: "code-review", + content: serializeWorkbenchTemplateManifest(CODE_REVIEW_TEMPLATE), + }, + ], + }), + ); + } if (path.endsWith("/library/templates/code-review")) { return Promise.resolve( json({ @@ -131,6 +144,54 @@ describe("NewWorkbenchPickerRoute", () => { expect(container?.textContent).toContain("More kinds soon"); }); + // CL-6458: the picker offers what the bench's library can actually + // serve. A row the library has no manifest for is shown as not set up + // — never offered and then dead-ended on a 404 at create time. + test("a kind this bench's library cannot serve is not offered", 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.endsWith("/library/templates")) { + return Promise.resolve(json({ data: [] })); + } + throw new Error(`unexpected fetch: ${path}`); + }) as typeof fetch; + await renderPicker(); + + const radios = Array.from( + container?.querySelectorAll('[role="radio"]') ?? [], + ); + expect(radios.length).toBe(1); + expect(radios[0]?.textContent).toContain("Just start talking"); + expect(container?.textContent).toContain("Code review"); + expect(container?.textContent).toContain("Not set up on this bench yet"); + }); + + test("when the library can't be read, the row list says so instead of offering a dead end", 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.endsWith("/library/templates")) { + return Promise.resolve(json({ error: "boom" }, 503)); + } + throw new Error(`unexpected fetch: ${path}`); + }) as typeof fetch; + await renderPicker(); + + expect(container?.textContent).toContain( + "Couldn't load what this bench can set up", + ); + const radios = Array.from( + container?.querySelectorAll('[role="radio"]') ?? [], + ); + expect(radios.length).toBe(1); + expect(radios[0]?.textContent).toContain("Just start talking"); + }); + test("Code review is selected on entry, so Create workbench starts enabled", async () => { stubFetch(() => undefined); await renderPicker(); diff --git a/packages/artifacts-hub/src/template-library.test.ts b/packages/artifacts-hub/src/template-library.test.ts index ed82d5cdf..6656f4153 100644 --- a/packages/artifacts-hub/src/template-library.test.ts +++ b/packages/artifacts-hub/src/template-library.test.ts @@ -4,10 +4,13 @@ import { Hono } from "hono"; import { WORKBENCH_TEMPLATE_ARTIFACT_KIND, + createTemplateLibraryDbStore, createTemplateLibraryRoutes, + createTemplateLibrarySeeder, createUnavailableTemplateLibraryRoutes, seedTemplateLibrary, type TemplateLibraryEngine, + type TemplateLibrarySeeder, type TemplateLibraryStore, } from "./template-library"; @@ -441,11 +444,92 @@ describe("seedTemplateLibrary", () => { }); }); +const OTHER_TENANT = { id: "tenant_b" }; + +describe("createTemplateLibrarySeeder", () => { + test("seeds a tenant the first time it is asked, and not again after", async () => { + let passes = 0; + const seeder = createTemplateLibrarySeeder({ + db: fakeDb, + entries: ENTRIES, + seed: async (args) => { + passes += 1; + return seedTemplateLibrary(args); + }, + engine: memoryEngine().engine, + }); + + await seeder.ensureSeeded(SCOPE); + await seeder.ensureSeeded(SCOPE); + expect(passes).toBe(1); + }); + + test("concurrent first reads share one seed pass", async () => { + let passes = 0; + const seeder = createTemplateLibrarySeeder({ + db: fakeDb, + entries: ENTRIES, + seed: async (args) => { + passes += 1; + await new Promise((resolve) => setTimeout(resolve, 5)); + return seedTemplateLibrary(args); + }, + engine: memoryEngine().engine, + }); + + await Promise.all([seeder.ensureSeeded(SCOPE), seeder.ensureSeeded(SCOPE)]); + expect(passes).toBe(1); + }); + + test("a failed pass never latches: the next read retries instead of giving up", async () => { + let passes = 0; + const seeder = createTemplateLibrarySeeder({ + db: fakeDb, + entries: ENTRIES, + seed: async (args) => { + passes += 1; + if (passes === 1) throw new Error("database is booting"); + return seedTemplateLibrary(args); + }, + engine: memoryEngine().engine, + }); + + await expect(seeder.ensureSeeded(SCOPE)).rejects.toThrow( + "database is booting", + ); + await seeder.ensureSeeded(SCOPE); + expect(passes).toBe(2); + }); + + test("each tenant converges on its own first read — no operator bench involved", async () => { + const seededTenants: string[] = []; + const seeder = createTemplateLibrarySeeder({ + db: fakeDb, + entries: ENTRIES, + seed: async (args) => { + seededTenants.push(args.scope.tenantId); + return seedTemplateLibrary(args); + }, + engine: memoryEngine().engine, + }); + + await seeder.ensureSeeded(SCOPE); + await seeder.ensureSeeded({ + tenantId: OTHER_TENANT.id, + principalId: "prin_b", + }); + expect(seededTenants).toEqual([TENANT.id, OTHER_TENANT.id]); + }); +}); + type TestEnv = { Variables: { tenant: { id: string }; principal: { id: string } }; }; -function mountRoutes(store: TemplateLibraryStore): Hono { +function mountRoutes( + store: TemplateLibraryStore, + seeder: TemplateLibrarySeeder, +): Hono { const app = new Hono(); app.use("*", async (c, next) => { c.set("tenant", TENANT); @@ -454,11 +538,18 @@ function mountRoutes(store: TemplateLibraryStore): Hono { }); app.route( "/library/templates", - createTemplateLibraryRoutes({ store, requireGrant: allowAll }), + createTemplateLibraryRoutes({ + store, + seeder, + requireGrant: allowAll, + log: () => {}, + }), ); return app; } +const noopSeeder: TemplateLibrarySeeder = { ensureSeeded: async () => {} }; + const fakeStore: TemplateLibraryStore = { async list(tenantId) { return tenantId === TENANT.id ? ENTRIES : []; @@ -469,15 +560,29 @@ const fakeStore: TemplateLibraryStore = { }, }; +/** The bench the owner actually hit: a tenant nothing ever seeded, on a + * hub that never resolved an operator bench. Reads go through the real + * store over the same memory engine the seeder writes into, so a served + * template proves the read itself converged the shelf. */ +function mountUnseededTenant(): Hono { + const { engine } = memoryEngine(); + return mountRoutes( + createTemplateLibraryDbStore(fakeDb, engine), + createTemplateLibrarySeeder({ db: fakeDb, entries: ENTRIES, engine }), + ); +} + describe("template library routes", () => { test("lists the seeded entries", async () => { - const res = await mountRoutes(fakeStore).request("/library/templates"); + const res = await mountRoutes(fakeStore, noopSeeder).request( + "/library/templates", + ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ data: ENTRIES }); }); test("fetches one template by id", async () => { - const res = await mountRoutes(fakeStore).request( + const res = await mountRoutes(fakeStore, noopSeeder).request( "/library/templates/code-review", ); expect(res.status).toBe(200); @@ -485,11 +590,41 @@ describe("template library routes", () => { }); test("404s an id the library does not hold", async () => { - const res = await mountRoutes(fakeStore).request( + const res = await mountRoutes(fakeStore, noopSeeder).request( "/library/templates/standup", ); expect(res.status).toBe(404); }); + + test("a never-seeded tenant's first list read seeds the shelf and serves it", async () => { + const res = await mountUnseededTenant().request("/library/templates"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ data: ENTRIES }); + }); + + test("a never-seeded tenant's first template read serves the template instead of 404ing", async () => { + const res = await mountUnseededTenant().request( + "/library/templates/code-review", + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(CODE_REVIEW_ENTRY); + }); + + test("a seed the read could not run answers 503, never a 404 that reads as 'no such template'", async () => { + const failing: TemplateLibrarySeeder = { + ensureSeeded: async () => { + throw new Error("artifacts database unreachable"); + }, + }; + const app = mountRoutes(fakeStore, failing); + + const list = await app.request("/library/templates"); + expect(list.status).toBe(503); + const one = await app.request("/library/templates/code-review"); + expect(one.status).toBe(503); + const body = (await one.json()) as { error: { code: string } }; + expect(body.error.code).toBe("unavailable"); + }); }); describe("createUnavailableTemplateLibraryRoutes", () => { From accd22cc6cb7485a5fc4360c6602cd27992d089c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 21:33:53 -0700 Subject: [PATCH 2/3] Template library: seed on read, per tenant, instead of at boot behind an operator bench MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shelf never seeded in a real environment. The boot seed signed in as the operator admin, looked for a bench named ORG_SLUG among that account's memberships, and only then wrote the shipped manifests — a precondition dev onboarding never satisfies, wrapped in a retry that gave up after a bounded window. The result was a library that stayed empty until the next restart, and a new-workbench picker whose "Code review" row 404d at create time. The bench lookup was only ever standing in for a scope to own the rows. Template rows are tenant-scoped artifacts, so the tenant being read is the honest owner, and every bench needs its own — one operator bench could never have served the rest. Seeding now happens on the library read itself: one reconciliation pass per tenant per process, shared by concurrent first reads, not remembered when it fails, so the next read retries. Reconciliation is unchanged (CL-6400's content-hash markers), so re-running stays safe. A read whose seed could not run answers 503, never a 404 that reads as "no such template". The picker offers only the kinds this bench's library can serve and shows the rest as not set up, so a kind is never offered and then dead-ended at create time. The boot seed also published the @corbits/* tool tarballs for that one bench; every tenant already gets them from seedTenant, which each provisioning path runs. --- apps/hub/src/index.ts | 30 +-- apps/hub/src/template-library-seed.ts | 212 ------------------ apps/web/src/instant-agent-create.ts | 14 +- apps/web/src/pages/new-workbench-picker.tsx | 79 ++++++- apps/web/src/workbench-templates-api.ts | 18 +- packages/artifacts-hub/src/index.ts | 3 + .../artifacts-hub/src/template-library.ts | 111 ++++++++- packages/evals/src/targets/real-target.ts | 43 +--- scripts/evals-run.ts | 19 -- 9 files changed, 220 insertions(+), 309 deletions(-) delete mode 100644 apps/hub/src/template-library-seed.ts diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 90597f7c3..9b8c89387 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -257,6 +257,7 @@ import { createArtifactRoutes, createTemplateLibraryDbStore, createTemplateLibraryRoutes, + createTemplateLibrarySeeder, createUnavailableArtifactRoutes, createUnavailableTemplateLibraryRoutes, createUnavailableWorkflowArtifactRoutes, @@ -333,7 +334,6 @@ import { } from "./config"; import type { SidecarProvisioner } from "@intx/hub-sessions"; import { scheduleEnvProviderCredentialPlant } from "./env-credential-plant"; -import { scheduleTemplateLibrarySeed } from "./template-library-seed"; import { createHubRoutineLauncher } from "./routine-launcher"; import { withTurnPartWriteDefaults } from "./turn-part-content-default"; import { createHubRunSummaryResolver } from "./routine-run-summary"; @@ -3307,15 +3307,23 @@ export async function createHub(config: HubConfig) { // The bench library's template shelf (CL-6344): what the // new-workbench picker instantiates from — seeded rows, never a - // hardcoded import. + // hardcoded import. Reading the shelf is what seeds it (CL-6458), so + // a bench created at any point after boot carries the shipped + // manifests the first time its picker opens. app.route( `${TENANT_PREFIX}/library/templates`, createTemplateLibraryRoutes({ store: createTemplateLibraryDbStore(artifactsHandle.db), + seeder: createTemplateLibrarySeeder({ + db: artifactsHandle.db, + entries: workbenchTemplateLibraryEntries(), + log: (line) => log.info`${line}`, + }), requireGrant: createRequireGrant({ grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, }), + log: (line) => log.error`${line}`, }), ); @@ -3456,23 +3464,6 @@ export async function createHub(config: HubConfig) { fetch: (request) => Promise.resolve(guardedApp.fetch(request)), }); - // Bench-library template seed (CL-6344): the hub, as system, plants - // the shipped workbench template manifests and their tool tarballs - // into the operator bench's library at boot — idempotently, so a - // second boot leaves exactly one entry per template. Skipped in - // degraded (no-artifacts) mode: with no library to seed into there is - // nothing honest to do. See ./template-library-seed.ts. - const templateLibrarySeed = - artifactsHandle !== undefined - ? scheduleTemplateLibrarySeed({ - baseUrl: config.baseUrl, - admin: config.envCredentialPlantAdmin, - fetch: (request) => Promise.resolve(guardedApp.fetch(request)), - artifactsDb: artifactsHandle.db, - entries: workbenchTemplateLibraryEntries(), - }) - : { stop: (): void => {} }; - return { app: guardedApp, db, @@ -3482,7 +3473,6 @@ export async function createHub(config: HubConfig) { clearTimeout(sidecarAllocationReconciliationTimer); } envCredentialPlant.stop(); - templateLibrarySeed.stop(); chatOrchestrator.dispose(); taskOrchestrator.dispose(); taskLifecycle.stop(); diff --git a/apps/hub/src/template-library-seed.ts b/apps/hub/src/template-library-seed.ts deleted file mode 100644 index 3fa0100b9..000000000 --- a/apps/hub/src/template-library-seed.ts +++ /dev/null @@ -1,212 +0,0 @@ -// CL-6344: schedules the bench-library template seed at hub boot. The -// hub, as the operator's admin principal, plants the shipped workbench -// template manifests into the bench's library (see -// `@corbits/artifacts-hub`'s `seedTemplateLibrary` — idempotent, so a -// second boot changes nothing) and publishes the `@corbits/*` tool -// tarballs those templates' workflows pin (reusing -// `@corbits/tool-registry-publish`, which already skips -// already-published filenames), so instantiating a template never -// resolves against a registry the bench doesn't have yet. -// -// Target resolution, retry cadence, and the sign-in-only rule all -// mirror `./env-credential-plant.ts`: sign in as the configured admin -// (never sign up), find the bench named ORG_SLUG among that account's -// memberships, retry with backoff while it doesn't exist yet, and -// never block or fail hub boot. - -import { getLogger } from "@intx/log"; -import { paginatedSchema, PrincipalSummary } from "@intx/types"; -import type { ArtifactDb } from "@corbits/artifacts"; -import { - seedTemplateLibrary, - type TemplateLibraryEntry, -} from "@corbits/artifacts-hub"; -import { - publishCorbitsToolsRegistry, - type PublishCorbitsToolsRegistryArgs, -} from "@corbits/tool-registry-publish"; -import { - signIn, - createHubAPI, - parseAs, - type ApiCall, - type Session, -} from "@workbench/hub-client"; -import type { HubConfig } from "./config"; - -const DEFAULT_RETRY_INTERVAL_MS = 10_000; -const DEFAULT_MAX_RETRY_INTERVAL_MS = 5 * 60_000; -const DEFAULT_GIVE_UP_AFTER_MS = 24 * 60 * 60_000; - -const log = getLogger(["hub", "template-library-seed"]); - -export type TemplateLibrarySeedDeps = { - baseUrl: string; - admin: HubConfig["envCredentialPlantAdmin"]; - /** The fully composed, guarded app's own request entry point. */ - fetch: (request: Request) => Promise; - artifactsDb: ArtifactDb; - entries: readonly TemplateLibraryEntry[]; - retryIntervalMs?: number; - maxRetryIntervalMs?: number; - giveUpAfterMs?: number; - /** Test seams, replacing the real library seed and tarball publish. */ - seed?: typeof seedTemplateLibrary; - publishTools?: (args: PublishCorbitsToolsRegistryArgs) => Promise; -}; - -function localFetchImpl( - entry: (request: Request) => Promise, -): typeof fetch { - return ((input, init) => - entry(new Request(input as string, init as RequestInit))) as typeof fetch; -} - -type BenchTarget = { tenantId: string; principalId: string }; - -async function lookUpBench( - api: ApiCall, - cookies: string[], - orgSlug: string, -): Promise { - const response = await api("GET", "/api/me/principals", undefined, cookies); - if (response.status === 401) return "unauthorized"; - if (response.status !== 200) return "not-found"; - const summary = parseAs( - paginatedSchema(PrincipalSummary), - response.data, - "principals response", - ); - const membership = summary.data.find((p) => p.tenantSlug === orgSlug); - return membership !== undefined - ? { tenantId: membership.tenantId, principalId: membership.principalId } - : "not-found"; -} - -type ResolveResult = - | { status: "resolved"; target: BenchTarget; session: Session } - | { status: "unresolved"; session: Session }; - -async function resolveWithSession( - api: ApiCall, - deps: TemplateLibrarySeedDeps, - cachedSession: Session | undefined, -): Promise { - const session = cachedSession ?? (await signIn(api, deps.admin)); - const lookup = await lookUpBench(api, session.cookies, deps.admin.orgSlug); - if (lookup !== "unauthorized") { - return lookup === "not-found" - ? { status: "unresolved", session } - : { status: "resolved", target: lookup, session }; - } - // Stale cookie — re-authenticate once rather than waiting a full tick. - const freshSession = await signIn(api, deps.admin); - const retried = await lookUpBench( - api, - freshSession.cookies, - deps.admin.orgSlug, - ); - return retried === "unauthorized" || retried === "not-found" - ? { status: "unresolved", session: freshSession } - : { status: "resolved", target: retried, session: freshSession }; -} - -/** - * Fires the first seed attempt immediately (never blocking the caller) - * and keeps retrying with exponential backoff until the operator bench - * resolves and one full seed pass (library entries + tool tarballs) - * completes. Both halves are idempotent, so a retry after a partial - * failure redoes only what's missing. - */ -export function scheduleTemplateLibrarySeed(deps: TemplateLibrarySeedDeps): { - stop: () => void; -} { - if (deps.entries.length === 0) { - return { stop: () => {} }; - } - - const intervalMs = deps.retryIntervalMs ?? DEFAULT_RETRY_INTERVAL_MS; - const maxIntervalMs = - deps.maxRetryIntervalMs ?? DEFAULT_MAX_RETRY_INTERVAL_MS; - const giveUpAfterMs = deps.giveUpAfterMs ?? DEFAULT_GIVE_UP_AFTER_MS; - const seed = deps.seed ?? seedTemplateLibrary; - const publishTools = deps.publishTools ?? publishCorbitsToolsRegistry; - - let stopped = false; - let timer: ReturnType | undefined; - let currentIntervalMs = intervalMs; - let lastLoggedReason: string | undefined; - let session: Session | undefined; - const startedAt = Date.now(); - - function scheduleRetry(): void { - if (stopped) return; - if (Date.now() - startedAt >= giveUpAfterMs) { - log.error`template library seed: giving up after ${Math.round(giveUpAfterMs / 3_600_000)}h with the operator bench still unresolved (last reason: ${lastLoggedReason ?? "unknown"})`; - session = undefined; - return; - } - timer = setTimeout(() => void attempt(), currentIntervalMs); - if (typeof timer.unref === "function") timer.unref(); - currentIntervalMs = Math.min(currentIntervalMs * 2, maxIntervalMs); - } - - function logUnresolved(reason: string): void { - if (reason === lastLoggedReason) return; - const level = lastLoggedReason === undefined ? "info" : "error"; - const message = `template library seed: not ready yet (${reason}); will keep retrying with backoff up to ${Math.round(maxIntervalMs / 1000)}s`; - if (level === "error") log.error`${message}`; - else log.info`${message}`; - lastLoggedReason = reason; - } - - async function attempt(): Promise { - if (stopped) return; - const api = createHubAPI(deps.baseUrl, localFetchImpl(deps.fetch)); - try { - const resolved = await resolveWithSession(api, deps, session); - if (resolved.status === "unresolved") { - session = resolved.session; - logUnresolved( - `operator bench "${deps.admin.orgSlug}" does not exist yet (or ${deps.admin.email} is not a member of it)`, - ); - scheduleRetry(); - return; - } - const outcomes = await seed({ - db: deps.artifactsDb, - scope: { - tenantId: resolved.target.tenantId, - principalId: resolved.target.principalId, - }, - entries: deps.entries, - }); - for (const outcome of outcomes) { - log.info`template library seed: "${outcome.id}" ${outcome.outcome}`; - } - await publishTools({ - api, - cookies: resolved.session.cookies, - hubUrl: deps.baseUrl, - tenantId: resolved.target.tenantId, - fetchImpl: localFetchImpl(deps.fetch), - log: (line) => log.info`${line}`, - }); - session = undefined; - } catch (cause) { - session = undefined; - logUnresolved(cause instanceof Error ? cause.message : String(cause)); - scheduleRetry(); - } - } - - void attempt(); - - return { - stop(): void { - stopped = true; - session = undefined; - if (timer !== undefined) clearTimeout(timer); - }, - }; -} diff --git a/apps/web/src/instant-agent-create.ts b/apps/web/src/instant-agent-create.ts index 85a5660ea..da8be86f2 100644 --- a/apps/web/src/instant-agent-create.ts +++ b/apps/web/src/instant-agent-create.ts @@ -106,11 +106,13 @@ export async function createWorkbenchFromTemplate( if (setupTemplate === undefined) { throw new Error("No default setup agent found for this workbench."); } - // The manifest comes from the bench library the hub seeded at boot - // (CL-6344), never from a hardcoded catalog import. `blank` is the one - // id with no manifest by design; any other id resolving to nothing - // means this bench's boot seed hasn't run — fail loud rather than - // mint a workbench missing its agents. + // The manifest comes from the bench library (CL-6344), never from a + // hardcoded catalog import; reading it is what seeds the shelf + // (CL-6458). `blank` is the one id with no manifest by design; any + // other id resolving to nothing means this build ships no such + // template — fail loud rather than mint a workbench missing its + // agents. The picker only offers ids the library listed, so this is + // the race-loser's message, not the everyday path. const manifest = templateId === "blank" ? undefined @@ -118,7 +120,7 @@ export async function createWorkbenchFromTemplate( undefined); if (templateId !== "blank" && manifest === undefined) { throw new Error( - `The "${templateId}" template isn't in this bench's library yet — its boot seed hasn't run.`, + `This bench can't set up a ${templateId} workbench yet — it isn't in the library.`, ); } const requiresGithub = diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index 48129341e..76f45e898 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -15,6 +15,8 @@ import { useState } from "react"; import type { ConnectGithubRepo } from "@corbits/chat-ui"; +import { useAPIQuery } from "../api"; +import { TemplateLibraryPage } from "../workbench-templates-api"; import { useBench } from "../bench-context"; import { createWorkbenchFromTemplate, @@ -45,15 +47,40 @@ function ctaLabel(selected: boolean): string { return selected ? "Selected" : "Choose"; } +/** The one kind that needs no manifest: an empty room is always + * something this bench can set up. */ +const BLANK_TEMPLATE_ID: WorkbenchTemplateId = "blank"; + export function NewWorkbenchPickerRoute() { const navigate = useNavigate(); const { selectedTenantId } = useBench(); - const [selectedId, setSelectedId] = useState( - WORKBENCH_TEMPLATES[0]?.id ?? "blank", + const library = useAPIQuery( + selectedTenantId === null + ? "" + : `/api/tenants/${selectedTenantId}/library/templates`, + TemplateLibraryPage, ); + const [picked, setPicked] = useState(null); const [creating, setCreating] = useState(false); const [repoPicker, setRepoPicker] = useState(null); + // What this bench's library can actually serve (CL-6458). A kind whose + // manifest the library doesn't hold is shown as not set up rather than + // offered and then dead-ended on a 404 at create time. + const servedTemplateIds = + library.kind === "ready" + ? new Set(library.data.data.map((entry) => entry.id)) + : new Set(); + const offeredTemplates = WORKBENCH_TEMPLATES.filter( + (template) => + template.id === BLANK_TEMPLATE_ID || servedTemplateIds.has(template.id), + ); + const unavailableTemplates = WORKBENCH_TEMPLATES.filter( + (template) => !offeredTemplates.includes(template), + ); + const selectedId = + picked ?? offeredTemplates[0]?.id ?? WORKBENCH_TEMPLATES[0]?.id ?? "blank"; + const pickGithubRepos: PickGithubRepos = ({ orgName, repos, @@ -97,6 +124,8 @@ export function NewWorkbenchPickerRoute() {
{creating ? ( + ) : library.kind === "loading" ? ( + ) : ( <>

What should this workbench do?

@@ -104,12 +133,27 @@ export function NewWorkbenchPickerRoute() { Pick one. You can change your mind later — nothing is locked in.

+ {library.kind === "error" ? ( +

+ Couldn't load what this bench can set up, so only a plain room + is on offer right now.{" "} + +

+ ) : null} +
- {WORKBENCH_TEMPLATES.map((template) => { + {offeredTemplates.map((template) => { const Icon = ROW_ICON[template.id]; const selected = template.id === selectedId; return ( @@ -120,7 +164,7 @@ export function NewWorkbenchPickerRoute() { aria-checked={selected} data-selected={selected ? "true" : undefined} className="new-workbench-pick-row" - onClick={() => setSelectedId(template.id)} + onClick={() => setPicked(template.id)} > { + const Icon = ROW_ICON[template.id]; + return ( + + + + + {template.title} + + + Not set up on this bench yet. + + + Unavailable + + ); + })} +