diff --git a/apps/web/src/agents-api.ts b/apps/web/src/agents-api.ts index 7f48c1759..263bc3eb4 100644 --- a/apps/web/src/agents-api.ts +++ b/apps/web/src/agents-api.ts @@ -289,18 +289,3 @@ export function useDeployAgentMutation(tenantId: string) { }, }); } - -// The guided capability-add surface: only what this tenant actually has. -const CapabilityInventoryWire = type({ - toolPackages: type({ name: "string" }).array(), - skills: type({ name: "string" }).array(), - models: type({ canonicalName: "string" }).array(), -}); -export type CapabilityInventory = typeof CapabilityInventoryWire.infer; - -export function listCapabilityInventory(tenantId: string): Promise { - return getJSON( - `/api/tenants/${tenantId}/agent-definitions/capabilities/inventory`, - CapabilityInventoryWire, - ); -} diff --git a/apps/web/src/pages/tools-page.tsx b/apps/web/src/pages/tools-page.tsx index 178221fd5..8692deabd 100644 --- a/apps/web/src/pages/tools-page.tsx +++ b/apps/web/src/pages/tools-page.tsx @@ -1,11 +1,8 @@ -// Tools: a standalone rail destination listing what this tenant can call — -// Interchange tool packages, read from the same capability inventory the -// Agents detail view's "Add a capability" picker already uses -// (`listCapabilityInventory`, `packages/agent-directory`'s package-registry -// read). There is no connect flow here: pinning a tool package to an agent -// happens on that agent's own detail page. No stock route lists a tenant's -// MCP servers yet, so this page has nothing to show for those until one -// exists. +// Tools: a standalone rail destination listing the tool packages this +// tenant has published into its own registry — the same +// `corbits-tools` package-registry asset `registry-publish.ts` writes to. +// No stock route lists a tenant's MCP servers yet, so this page has +// nothing to show for those until one exists. import { PageShell, @@ -17,46 +14,19 @@ import { TableHeader, TableRow, } from "@corbits/react-ui"; +import { QueryView } from "@/lib/api-query"; import { Plugs } from "@/lib/icons"; -import { WorkbenchLoadingState } from "@/chat"; -import { useCallback, useEffect, useState } from "react"; -import { listCapabilityInventory, type CapabilityInventory } from "../agents-api"; +import { useToolPackages } from "../tools/registry-read"; import { useBench } from "../bench-context"; import { StageTopBar } from "../shell/stage-top-bar"; -type ToolsState = - | { readonly status: "loading" } - | { readonly status: "ready"; readonly inventory: CapabilityInventory } - | { readonly status: "error"; readonly message: string }; - -function messageOf(cause: unknown): string { - return cause instanceof Error ? cause.message : String(cause); -} - /** - * The tenant's installed tool packages, over the same capability inventory - * the Agents detail view reads. `tenantId` is the tenant every read is - * scoped to. + * The tenant's published tool packages, read off the stock registry asset. + * `tenantId` is the tenant every read is scoped to. */ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) { - const [state, setState] = useState({ status: "loading" }); - - const reload = useCallback(async () => { - if (tenantId === null) return; - setState({ status: "loading" }); - try { - const inventory = await listCapabilityInventory(tenantId); - setState({ status: "ready", inventory }); - } catch (cause) { - setState({ status: "error", message: messageOf(cause) }); - } - }, [tenantId]); - - useEffect(() => { - void reload(); - }, [reload]); - + const query = useToolPackages(tenantId); const crumbs = [{ label: "Tools" }]; function stage(body: React.ReactNode) { @@ -78,56 +48,43 @@ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) { ); } - if (state.status === "loading") { - return stage(); - } - - if (state.status === "error") { - return stage( - } - title="Couldn't load your tools" - description="Something went wrong on our side. Try again in a moment." - actions={[{ label: "Retry", onClick: () => void reload() }]} - />, - ); - } - - const { toolPackages } = state.inventory; - - if (toolPackages.length === 0) { - return stage( - } - title="No tools yet" - description="A tool package gives every agent in this workbench a new capability. Install one to see it here." - />, - ); - } - return stage( -
- - - - Tool package - - - - {toolPackages.map((tool) => ( - - {tool.name} - - ))} - -
-
, + + {(toolPackages) => + toolPackages.length === 0 ? ( + } + title="No tools yet" + description="A tool package gives every agent in this workbench a new capability. Publish one to see it here." + /> + ) : ( +
+ + + + Tool package + Version + + + + {toolPackages.map((tool) => ( + + {tool.name} + {tool.version} + + ))} + +
+
+ ) + } +
, ); } /** * Tools roster mount at `/tools`: a thin adapter that resolves which - * workbench's inventory is listed. The stage chrome lives on `ToolsPage`. + * workbench's registry is listed. The stage chrome lives on `ToolsPage`. */ export function ToolsRoute() { const { selectedTenantId } = useBench(); diff --git a/apps/web/src/tools/registry-read.ts b/apps/web/src/tools/registry-read.ts new file mode 100644 index 000000000..5ff0cfce4 --- /dev/null +++ b/apps/web/src/tools/registry-read.ts @@ -0,0 +1,84 @@ +// Reads the tenant's published tool packages back off the stock registry +// surface `registry-publish.ts` writes to: the `corbits-tools` +// package-registry asset's tarball listing. There is no packument route — +// a tarball entry is only ever `-.tgz` (see +// `tarballFilenameFor` in `./registry-publish.ts`) — so name/version are +// recovered from the filename and nothing further (description, declared +// tools) is available without unpacking the tarball itself. + +import { type } from "arktype"; +import { useQuery } from "@tanstack/react-query"; + +import { ApiQueryError, UnauthenticatedError, toAPIQuery, type APIQuery } from "@/lib/api-query"; + +const AssetListShape = type({ id: "string", name: "string" }).array(); +const TarballListShape = type({ filename: "string", size: "number", integrity: "string" }).array(); + +export type ToolPackage = { + readonly filename: string; + readonly name: string; + readonly version: string; +}; + +// Mirrors `tarballFilenameFor`: `-.tgz`. +const TARBALL_NAME_PATTERN = /^(.+)-(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\.tgz$/; + +function parseTarballFilename(filename: string): ToolPackage | null { + const match = TARBALL_NAME_PATTERN.exec(filename); + if (match?.[1] === undefined || match[2] === undefined) return null; + return { filename, name: match[1], version: match[2] }; +} + +async function getJSON(path: string, schema: (data: unknown) => T | type.errors): Promise { + let response: Response; + try { + response = await fetch(path, { headers: { accept: "application/json" } }); + } catch (cause) { + throw new ApiQueryError( + cause instanceof Error ? cause.message : String(cause), + undefined, + path, + ); + } + if (response.status === 401) throw new UnauthenticatedError(); + if (!response.ok) { + throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path); + } + const parsed = schema(await response.json().catch(() => undefined)); + if (parsed instanceof type.errors) { + throw new ApiQueryError(`Unexpected response shape: ${parsed.summary}`, undefined, path); + } + return parsed; +} + +/** Every tarball published across the tenant's own package-registry assets + * (`inherited=false`: a tool package published to an ancestor tenant is + * that ancestor's own concern, not this bench's roster). */ +async function listToolPackages(tenantId: string): Promise { + const registries = await getJSON( + `/api/tenants/${tenantId}/assets?kind=package-registry&inherited=false`, + AssetListShape, + ); + const perRegistry = await Promise.all( + registries.map((registry) => + getJSON( + `/api/tenants/${tenantId}/assets/${encodeURIComponent(registry.id)}/tarballs`, + TarballListShape, + ), + ), + ); + return perRegistry + .flat() + .map((tarball) => parseTarballFilename(tarball.filename)) + .filter((pkg): pkg is ToolPackage => pkg !== null); +} + +/** The tenant's published tool packages, for the Tools page. */ +export function useToolPackages(tenantId: string | null): APIQuery { + const result = useQuery({ + queryKey: ["tenant", tenantId ?? "none", "tools", "packages"] as const, + enabled: tenantId !== null, + queryFn: () => listToolPackages(tenantId as string), + }); + return toAPIQuery(result); +}