From 827f16e1ecb2a737421ef024e347066d0078d758 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 10:41:03 -0700 Subject: [PATCH] CL-5786: Wire Library to tenant assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Library page was an empty seam. Tenant assets already exist at GET /api/tenants/:id/assets — workflows, skills, package registries, and agent state — so the page fetches that list and maps each row into the existing ArtifactSummary shape instead of staying empty forever. --- apps/web/src/api.ts | 9 +++++ apps/web/src/pages/library-page.tsx | 47 +++++++++++++++++------ apps/web/src/query-client.ts | 3 ++ apps/web/src/shell/library-artifacts.ts | 48 +++++++++++++++++++++++ apps/web/test/library-artifacts.test.ts | 51 +++++++++++++++++++++++++ apps/web/test/pages.test.tsx | 3 +- apps/web/test/query-client.test.ts | 6 +++ packages/artifact-ui/src/types.ts | 7 ++-- 8 files changed, 156 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/shell/library-artifacts.ts create mode 100644 apps/web/test/library-artifacts.test.ts diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 46f6dd2d5..5ddfd1880 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -5,6 +5,7 @@ import { ApprovalResponse, + AssetWithOriginResponse, PrincipalSummary, UserProfile, WorkflowRunSummary, @@ -21,6 +22,13 @@ export const PrincipalsSchema = paginatedSchema(PrincipalSummary); export const RunsSchema = paginatedSchema(WorkflowRunSummary); export const TenantApprovalsSchema = paginatedSchema(ApprovalResponse); +// `GET /api/tenants/:tenantId/assets` returns a bare array of +// `AssetWithOriginResponse` rows (not the paginated envelope), so the schema +// validates the array directly. These tenant assets — workflows, skills, +// package registries, agent state — are the real, listable store the Library +// page renders as artifacts. +export const AssetsSchema = AssetWithOriginResponse.array(); + // `@corbits/approvals`'s "needs you" read: the same pending approvals as // `TenantApprovalsSchema`, but with the agent and bench names already // resolved server-side, so nothing here ever needs a raw id to render. @@ -40,6 +48,7 @@ export type Profile = typeof UserProfile.infer; export type Principal = typeof PrincipalSummary.infer; export type WorkflowRun = typeof WorkflowRunSummary.infer; export type Approval = typeof ApprovalResponse.infer; +export type AssetRow = typeof AssetWithOriginResponse.infer; export type NeedsYou = typeof NeedsYouSchema.infer; export type NeedsYouItem = NeedsYou["items"][number]; diff --git a/apps/web/src/pages/library-page.tsx b/apps/web/src/pages/library-page.tsx index ba1986ef0..9739f4b72 100644 --- a/apps/web/src/pages/library-page.tsx +++ b/apps/web/src/pages/library-page.tsx @@ -27,6 +27,11 @@ import type { ArtifactSort, ArtifactSummary } from "@corbits/artifact-ui"; import { ArrowDownUp, FileStack } from "lucide-react"; import { useMemo, useState } from "react"; +import { AssetsSchema, useAPIQuery } from "../api"; +import { useBench } from "../bench-context"; +import { mapAssetsToArtifacts } from "../shell/library-artifacts"; +import { QueryView } from "../query-view"; + const SORT_LABEL: Record = { newest: "Newest first", oldest: "Oldest first", @@ -96,10 +101,9 @@ function ArtifactRows({ /** * The artifact gallery. Real data all the way down — search, sort, view - * mode — but the hub does not yet expose a cross-tenant artifact store (see - * `LibraryRoute` below), so `artifacts` is honestly empty until that - * endpoint exists. Nothing here is placeholder content: an empty `artifacts` - * array renders the teaching empty state, never fabricated rows. + * mode. The route resolves the current bench's assets into the + * `ArtifactSummary` rows this page renders (see `LibraryRoute`); an empty + * list is a truthful empty bench, never fabricated rows. */ export function LibraryPage({ artifacts, @@ -147,7 +151,7 @@ export function LibraryPage({ } title="No artifacts yet" - description="The hub doesn't expose an artifact store across benches yet. Once a workflow run can publish an output — a document, an export, a deck — it appears here: searchable, sortable, and grouped by kind." + description="This workbench has no assets yet — workflows, skills, package registries, and agent state show up here as soon as they exist." /> ) : visible.length === 0 ? ( ; + const { selectedTenantId } = useBench(); + // Tenant-local assets are the honest Library source today: the hub has no + // separate artifact store, but every bench already owns workflows, skills, + // package registries, and agent state at GET /api/tenants/:id/assets. + const assets = useAPIQuery( + selectedTenantId === null ? "" : `/api/tenants/${selectedTenantId}/assets`, + AssetsSchema, + ); + + if (selectedTenantId === null) { + return ( + + } + title="Select a workbench" + description="Pick a workbench from the switcher to browse the assets it owns." + /> + + ); + } + + return ( + + {(rows) => } + + ); } diff --git a/apps/web/src/query-client.ts b/apps/web/src/query-client.ts index 0d3b1ce16..516f6c2cf 100644 --- a/apps/web/src/query-client.ts +++ b/apps/web/src/query-client.ts @@ -48,6 +48,7 @@ export const tenantKeys = { ["tenant", tenantId, "definitions"] as const, agentDirectory: (tenantId: string) => ["tenant", tenantId, "agents", "directory"] as const, + assets: (tenantId: string) => ["tenant", tenantId, "assets"] as const, }; /** @@ -60,5 +61,7 @@ export function pathToQueryKey(path: string): readonly unknown[] { if (path === "/api/me/workflows/runs") return meKeys.runs; const needsYou = /^\/api\/tenants\/([^/]+)\/approvals\/needs-you$/.exec(path); if (needsYou?.[1] !== undefined) return tenantKeys.needsYou(needsYou[1]); + const assets = /^\/api\/tenants\/([^/]+)\/assets$/.exec(path); + if (assets?.[1] !== undefined) return tenantKeys.assets(assets[1]); return ["path", path]; } diff --git a/apps/web/src/shell/library-artifacts.ts b/apps/web/src/shell/library-artifacts.ts new file mode 100644 index 000000000..c2fdfb6ce --- /dev/null +++ b/apps/web/src/shell/library-artifacts.ts @@ -0,0 +1,48 @@ +// The Library page's one seam to the hub's asset store. The hub has no +// dedicated artifact endpoint yet, but it already lists real, tenant-scoped +// assets — workflows, skills, package registries, agent state — at +// `GET /api/tenants/:tenantId/assets`. Each asset is an honest artifact a bench +// owns, so the Library renders them directly rather than staying empty. +// +// This module owns only the AssetRow -> ArtifactSummary mapping, kept pure and +// apart from React so the shape contract has its own test. When a real artifact +// endpoint lands, only this file's body changes; the page above it is already +// wired against `ArtifactSummary`. + +import type { ArtifactSummary } from "@corbits/artifact-ui"; + +import type { AssetRow } from "../api"; + +/** + * The display title an asset contributes as an artifact: an author-chosen + * `displayName` when present, otherwise the kebab `name` the asset is + * addressed by. Never fabricated. + */ +function assetTitle(asset: AssetRow): string { + return asset.displayName ?? asset.name; +} + +/** One tenant asset, re-shaped into the row the Library page renders. */ +export function assetToArtifact(asset: AssetRow): ArtifactSummary { + return { + id: asset.id, + title: assetTitle(asset), + // Open vocabulary by design (see packages/artifact-ui/src/types.ts): the + // asset's own kind — "workflow", "skill", "package-registry", + // "agent-state" — passes straight through so a new asset kind needs no + // mapping change here. + kind: asset.kind, + // The asset response carries a `creatorPrincipalId`, not a display name, + // so owner is honestly unknown rather than guessed. + ownerName: null, + createdAt: asset.createdAt, + updatedAt: asset.updatedAt, + }; +} + +/** Map a full asset listing into artifact rows, preserving order and count. */ +export function mapAssetsToArtifacts( + assets: readonly AssetRow[], +): ArtifactSummary[] { + return assets.map(assetToArtifact); +} diff --git a/apps/web/test/library-artifacts.test.ts b/apps/web/test/library-artifacts.test.ts new file mode 100644 index 000000000..36c7c5cbe --- /dev/null +++ b/apps/web/test/library-artifacts.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; + +import type { AssetRow } from "../src/api"; +import { + assetToArtifact, + mapAssetsToArtifacts, +} from "../src/shell/library-artifacts"; + +const sample: AssetRow = { + id: "ast_1", + tenantId: "ten_1", + kind: "workflow", + name: "nightly-digest", + displayName: "Nightly Digest", + creatorPrincipalId: "pri_1", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + origin: { tenantId: "ten_1", direct: true }, +}; + +describe("library-artifacts", () => { + test("prefers displayName for the artifact title", () => { + expect(assetToArtifact(sample)).toEqual({ + id: "ast_1", + title: "Nightly Digest", + kind: "workflow", + ownerName: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }); + }); + + test("falls back to the asset name when displayName is null", () => { + const unnamed = { ...sample, displayName: null }; + expect(assetToArtifact(unnamed).title).toBe("nightly-digest"); + }); + + test("maps a list without inventing or dropping rows", () => { + const second = { + ...sample, + id: "ast_2", + kind: "skill", + name: "summarize", + displayName: null, + }; + const mapped = mapAssetsToArtifacts([sample, second]); + expect(mapped).toHaveLength(2); + expect(mapped[0]?.id).toBe("ast_1"); + expect(mapped[1]?.kind).toBe("skill"); + }); +}); diff --git a/apps/web/test/pages.test.tsx b/apps/web/test/pages.test.tsx index d2635ce77..851221d9c 100644 --- a/apps/web/test/pages.test.tsx +++ b/apps/web/test/pages.test.tsx @@ -39,8 +39,7 @@ describe("empty states", () => { test("library teaches what will appear once the seam is real", () => { const markup = renderToStaticMarkup(); expect(markup).toContain("No artifacts yet"); - expect(markup).toContain("hub doesn"); - expect(markup).toContain("expose an artifact store"); + expect(markup).toContain("This workbench has no assets yet"); }); test("skills describes itself instead of faking content", () => { diff --git a/apps/web/test/query-client.test.ts b/apps/web/test/query-client.test.ts index 56486e0de..a4b77aeee 100644 --- a/apps/web/test/query-client.test.ts +++ b/apps/web/test/query-client.test.ts @@ -24,6 +24,12 @@ describe("pathToQueryKey", () => { ); }); + test("maps tenant assets onto a tenant-scoped key", () => { + expect(pathToQueryKey("/api/tenants/tnt_1/assets")).toEqual( + tenantKeys.assets("tnt_1"), + ); + }); + test("falls back to a path key for unknown routes", () => { expect(pathToQueryKey("/api/mystery")).toEqual(["path", "/api/mystery"]); }); diff --git a/packages/artifact-ui/src/types.ts b/packages/artifact-ui/src/types.ts index e402d1c17..f51dcbcb5 100644 --- a/packages/artifact-ui/src/types.ts +++ b/packages/artifact-ui/src/types.ts @@ -1,7 +1,6 @@ -// The row a Library page renders. The hub exposes no artifact store yet -// (see `apps/web/src/pages/library-page.tsx`), so this schema is the seam a -// future `/api/.../artifacts` response gets validated against — not a mirror -// of any endpoint that exists today. +// The row a Library page renders. Today the web app maps tenant assets +// (`GET /api/tenants/:id/assets`) into this shape; a dedicated +// `/api/.../artifacts` endpoint would validate against the same schema. import { type } from "arktype";