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 + + ); + })} +