From 1fdf413d5579ad9e77ff497f6ea26a0102a83b8d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 18:47:28 -0700 Subject: [PATCH 1/4] Add tests for Library artifacts upload, search, and UI cutover Cover tenant-scoped list/search/upload/get, the unavailable 503 surface, and the web mapping helpers that replace the asset shim. --- apps/hub/src/artifact-routes.test.ts | 283 ++++++++++++++++-------- apps/web/test/library-artifacts.test.ts | 63 +++--- apps/web/test/pages.test.tsx | 5 +- 3 files changed, 232 insertions(+), 119 deletions(-) diff --git a/apps/hub/src/artifact-routes.test.ts b/apps/hub/src/artifact-routes.test.ts index efad463ef..2482d421f 100644 --- a/apps/hub/src/artifact-routes.test.ts +++ b/apps/hub/src/artifact-routes.test.ts @@ -1,131 +1,238 @@ -import { describe, expect, test } from "bun:test"; -import type { RequireGrant, TenantEnv } from "@intx/hub-api"; +import { beforeEach, describe, expect, test } from "bun:test"; import { Hono } from "hono"; import { createArtifactRoutes, + createUnavailableArtifactRoutes, type ArtifactRoutesStore, - type ArtifactListPage, + type ArtifactUploadInput, } from "./artifact-routes"; -import type { SerializedArtifact } from "@corbits/artifacts"; -function listItem(id: string): ArtifactListPage["data"][number] { +type Tenant = { id: string }; +type Principal = { id: string }; + +type TestEnv = { + Variables: { + tenant: Tenant; + principal: Principal; + }; +}; + +const TENANT = { id: "tenant_a" }; +const OTHER = { id: "tenant_b" }; +const PRINCIPAL = { id: "prin_1" }; + +function allowAllRequireGrant() { + return () => async (_c: unknown, next: () => Promise) => { + await next(); + }; +} + +function sampleListItem(id: string, tenantId: string) { return { id, - kind: "document", - title: `Title ${id}`, - source: { origin: "manual" }, + kind: "file", + title: `doc-${id}.txt`, + source: { origin: "library-upload" }, version: 1, - ownerPrincipalId: null, + ownerPrincipalId: PRINCIPAL.id, ownerName: null, archivedAt: null, createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-02T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + // tenantId is store-side only; list response omits it + _tenantId: tenantId, }; } -function detail(id: string): SerializedArtifact { +function memoryStore(): ArtifactRoutesStore & { + rows: Array & { content: string }>; +} { + const rows: Array< + ReturnType & { content: string } + > = []; return { - ...listItem(id), - content: `body of ${id}`, - }; -} - -function memoryStore(seed: { - listByTenant: Record; - details: Record; -}): ArtifactRoutesStore { - return { - async list(tenantId, _opts) { - const data = seed.listByTenant[tenantId] ?? []; - return { data, nextCursor: null }; + rows, + async list(tenantId, opts) { + let data = rows + .filter((r) => r._tenantId === tenantId) + .map(({ content: _c, _tenantId: _t, ...rest }) => rest); + if (opts.query !== null) { + const q = opts.query.toLowerCase(); + data = data.filter((r) => r.title.toLowerCase().includes(q)); + } + return { + data: data.slice(0, opts.limit), + nextCursor: null, + }; }, async get(tenantId, artifactId) { - const hit = seed.details[artifactId]; - if (hit === undefined || hit.tenantId !== tenantId) return null; - return hit.row; + const row = rows.find( + (r) => r.id === artifactId && r._tenantId === tenantId, + ); + if (row === undefined) return null; + const { _tenantId: _t, ...rest } = row; + return rest; + }, + async upload( + tenantId: string, + principalId: string, + files: readonly ArtifactUploadInput[], + ) { + const created = files.map((file, index) => { + const item = { + ...sampleListItem(`up_${rows.length + index}`, tenantId), + title: file.filename, + ownerPrincipalId: principalId, + content: new TextDecoder().decode(file.bytes), + }; + rows.push(item); + const { _tenantId: _t, ...rest } = item; + return rest; + }); + return created; }, }; } -/** Pass-through grant middleware for route unit tests (authz is hub-owned). */ -const allowAll: RequireGrant = () => async (_c, next) => { - await next(); -}; - -function appWith( - store: ArtifactRoutesStore, - tenantId: string, -): Hono { - const routes = createArtifactRoutes({ store, requireGrant: allowAll }); - const outer = new Hono(); - outer.use("*", async (c, next) => { - c.set("tenant", { id: tenantId } as TenantEnv["Variables"]["tenant"]); - c.set("principal", { - id: "principal_test", - } as TenantEnv["Variables"]["principal"]); +function mount(store: ArtifactRoutesStore) { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("tenant", TENANT); + c.set("principal", PRINCIPAL); await next(); }); - outer.route("/api/tenants/:tenantId/artifacts", routes); - return outer; + app.route( + "/artifacts", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createArtifactRoutes({ + store, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + requireGrant: allowAllRequireGrant() as any, + }) as any, + ); + return app; } -describe("createArtifactRoutes", () => { - test("lists artifacts for the tenant (happy path)", async () => { - const store = memoryStore({ - listByTenant: { - tenant_a: [listItem("art_1"), listItem("art_2")], - }, - details: {}, - }); - const app = appWith(store, "tenant_a"); - const res = await app.request("/api/tenants/tenant_a/artifacts"); +describe("artifact routes", () => { + let store: ReturnType; + let app: ReturnType; + + beforeEach(() => { + store = memoryStore(); + app = mount(store); + }); + + test("GET / lists empty data for a tenant with no artifacts", async () => { + const res = await app.request("/artifacts"); expect(res.status).toBe(200); - const body = (await res.json()) as ArtifactListPage; - expect(body.data).toHaveLength(2); - expect(body.data[0]?.id).toBe("art_1"); - expect(body.nextCursor).toBeNull(); + expect(await res.json()).toEqual({ data: [], nextCursor: null }); }); - test("empty list returns data: []", async () => { - const store = memoryStore({ listByTenant: {}, details: {} }); - const app = appWith(store, "tenant_empty"); - const res = await app.request("/api/tenants/tenant_empty/artifacts"); + test("GET / returns only the calling tenant's rows", async () => { + store.rows.push({ + ...sampleListItem("a1", TENANT.id), + content: "mine", + }); + store.rows.push({ + ...sampleListItem("b1", OTHER.id), + content: "theirs", + }); + const res = await app.request("/artifacts"); expect(res.status).toBe(200); - const body = (await res.json()) as ArtifactListPage; - expect(body.data).toEqual([]); + const body = (await res.json()) as { data: { id: string }[] }; + expect(body.data.map((r) => r.id)).toEqual(["a1"]); }); - test("get returns the artifact body for the owning tenant", async () => { - const row = detail("art_9"); - const store = memoryStore({ - listByTenant: {}, - details: { art_9: { tenantId: "tenant_a", row } }, + test("GET /?q= filters by title", async () => { + store.rows.push({ + ...sampleListItem("a1", TENANT.id), + title: "Quarterly report.pdf", + content: "x", }); - const app = appWith(store, "tenant_a"); - const res = await app.request("/api/tenants/tenant_a/artifacts/art_9"); + store.rows.push({ + ...sampleListItem("a2", TENANT.id), + title: "notes.txt", + content: "y", + }); + const res = await app.request("/artifacts?q=report"); expect(res.status).toBe(200); - const body = (await res.json()) as SerializedArtifact; - expect(body.id).toBe("art_9"); - expect(body.content).toBe("body of art_9"); + const body = (await res.json()) as { data: { id: string }[] }; + expect(body.data.map((r) => r.id)).toEqual(["a1"]); }); - test("get returns 404 for a missing id", async () => { - const store = memoryStore({ listByTenant: {}, details: {} }); - const app = appWith(store, "tenant_a"); - const res = await app.request("/api/tenants/tenant_a/artifacts/missing"); + test("GET /:id returns 404 for a foreign tenant row", async () => { + store.rows.push({ + ...sampleListItem("b1", OTHER.id), + content: "secret", + }); + const res = await app.request("/artifacts/b1"); expect(res.status).toBe(404); }); - test("get returns 404 when the artifact belongs to another tenant", async () => { - const row = detail("art_x"); - const store = memoryStore({ - listByTenant: {}, - details: { art_x: { tenantId: "tenant_b", row } }, + test("GET /:id returns the row for the calling tenant", async () => { + store.rows.push({ + ...sampleListItem("a1", TENANT.id), + content: "hello", }); - // Request as tenant_a — store enforces tenant match. - const app = appWith(store, "tenant_a"); - const res = await app.request("/api/tenants/tenant_a/artifacts/art_x"); - expect(res.status).toBe(404); + const res = await app.request("/artifacts/a1"); + expect(res.status).toBe(200); + const body = (await res.json()) as { id: string; content: string }; + expect(body.id).toBe("a1"); + expect(body.content).toBe("hello"); + }); + + test("POST /upload creates artifacts from multipart files", async () => { + const form = new FormData(); + form.append( + "file", + new File(["hello library"], "hello.txt", { type: "text/plain" }), + ); + const res = await app.request("/artifacts/upload", { + method: "POST", + body: form, + }); + expect(res.status).toBe(201); + const body = (await res.json()) as { + data: { title: string; content: string }[]; + }; + expect(body.data).toHaveLength(1); + expect(body.data[0]?.title).toBe("hello.txt"); + expect(body.data[0]?.content).toBe("hello library"); + expect(store.rows).toHaveLength(1); + }); + + test("POST /upload rejects an empty multipart body", async () => { + const form = new FormData(); + form.append("note", "not a file"); + const res = await app.request("/artifacts/upload", { + method: "POST", + body: form, + }); + expect(res.status).toBe(400); + }); +}); + +describe("unavailable artifact routes", () => { + test("every surface answers 503", async () => { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("tenant", TENANT); + c.set("principal", PRINCIPAL); + await next(); + }); + app.route( + "/artifacts", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createUnavailableArtifactRoutes(allowAllRequireGrant() as any) as any, + ); + + for (const path of ["/artifacts", "/artifacts/upload", "/artifacts/x"]) { + const method = path.endsWith("/upload") ? "POST" : "GET"; + const res = await app.request(path, { method }); + expect(res.status).toBe(503); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("unavailable"); + } }); }); diff --git a/apps/web/test/library-artifacts.test.ts b/apps/web/test/library-artifacts.test.ts index 36c7c5cbe..7ad793aae 100644 --- a/apps/web/test/library-artifacts.test.ts +++ b/apps/web/test/library-artifacts.test.ts @@ -1,51 +1,54 @@ import { describe, expect, test } from "bun:test"; -import type { AssetRow } from "../src/api"; import { - assetToArtifact, - mapAssetsToArtifacts, + artifactListRowToSummary, + isArtifactsUnavailableMessage, + mapArtifactListToSummaries, + type ArtifactListRow, } 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", +const sample: ArtifactListRow = { + id: "art_1", + kind: "file", + title: "Quarterly report.pdf", + ownerName: "Ada", 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, + test("maps list rows onto ArtifactSummary without inventing fields", () => { + expect(artifactListRowToSummary(sample)).toEqual({ + id: "art_1", + title: "Quarterly report.pdf", + kind: "file", + ownerName: "Ada", 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 = { + const second: ArtifactListRow = { ...sample, - id: "ast_2", - kind: "skill", - name: "summarize", - displayName: null, + id: "art_2", + kind: "document", + title: "notes.txt", + ownerName: null, }; - const mapped = mapAssetsToArtifacts([sample, second]); + const mapped = mapArtifactListToSummaries([sample, second]); expect(mapped).toHaveLength(2); - expect(mapped[0]?.id).toBe("ast_1"); - expect(mapped[1]?.kind).toBe("skill"); + expect(mapped[0]?.id).toBe("art_1"); + expect(mapped[1]?.kind).toBe("document"); + expect(mapped[1]?.ownerName).toBeNull(); + }); + + test("detects the unconfigured-plane error message", () => { + expect( + isArtifactsUnavailableMessage( + "The hub answered 503 for /api/tenants/t/artifacts.", + ), + ).toBe(true); + expect(isArtifactsUnavailableMessage("network failed")).toBe(false); }); }); diff --git a/apps/web/test/pages.test.tsx b/apps/web/test/pages.test.tsx index 8b7c97cb8..56e696e5c 100644 --- a/apps/web/test/pages.test.tsx +++ b/apps/web/test/pages.test.tsx @@ -26,7 +26,10 @@ 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("This workbench has no assets yet"); + expect(markup).toContain( + "Upload a file or wait for agents and workflows to produce artifacts", + ); + }); test("skills renders the shell with an honest empty state and Create action", () => { From cfc4d22c199e147c7dbcfe595de5f08980f702ca Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 18:47:33 -0700 Subject: [PATCH 2/4] Library: real artifacts list, search, upload, and UI cutover Wire hub tenant routes over the mounted @corbits/artifacts engine (list with q, get, multipart upload; 503 when unmounted) and cut the Library page off the asset shim onto that plane with server-side search and an Upload control. --- apps/hub/src/artifact-routes.ts | 195 +++++++++++++++++++++++- apps/hub/src/artifacts-mount.ts | 14 +- apps/hub/src/index.ts | 21 ++- apps/web/src/api.ts | 30 ++++ apps/web/src/pages/library-page.tsx | 155 ++++++++++++++++--- apps/web/src/query-client.ts | 5 + apps/web/src/shell/library-artifacts.ts | 128 +++++++++++----- 7 files changed, 473 insertions(+), 75 deletions(-) diff --git a/apps/hub/src/artifact-routes.ts b/apps/hub/src/artifact-routes.ts index 87abc234d..22cc0f7c0 100644 --- a/apps/hub/src/artifact-routes.ts +++ b/apps/hub/src/artifact-routes.ts @@ -1,7 +1,7 @@ /** - * Tenant-scoped Library L2 HTTP surface over the mounted `@corbits/artifacts` - * engine. List (newest-first, paginated) and get-by-id only — upload/search - * UI stays on later tickets. + * Tenant-scoped Library HTTP surface over the mounted `@corbits/artifacts` + * engine: list (newest-first, paginated, optional text search), get-by-id, + * and multipart upload. * * Authz uses the existing `asset` resource family so Library grants keep * working without inventing a parallel vocabulary. @@ -10,12 +10,16 @@ * without a live Postgres. */ import { + ARTIFACT_UPLOAD_POLICY, anonymousIdentity, + createFileArtifact, getArtifact, listArtifacts, serializeArtifact, serializeArtifactListItem, + UnsupportedUploadTypeError, type ArtifactDb, + type ContentStore, type SerializedArtifact, type SerializedArtifactListItem, } from "@corbits/artifacts"; @@ -24,19 +28,33 @@ import { Hono } from "hono"; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 100; +const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; +const MAX_UPLOAD_FILE_COUNT = 50; +const MAX_UPLOAD_TOTAL_BYTES = 100 * 1024 * 1024; export type ArtifactListPage = { readonly data: readonly SerializedArtifactListItem[]; readonly nextCursor: string | null; }; +export type ArtifactUploadInput = { + readonly filename: string; + readonly mimeType: string; + readonly bytes: Uint8Array; +}; + /** Minimal port the routes need — production wraps the engine db. */ export type ArtifactRoutesStore = { list( tenantId: string, - opts: { limit: number; cursor: string | null }, + opts: { limit: number; cursor: string | null; query: string | null }, ): Promise; get(tenantId: string, artifactId: string): Promise; + upload( + tenantId: string, + principalId: string, + files: readonly ArtifactUploadInput[], + ): Promise; }; export type CreateArtifactRoutesDeps = { @@ -63,14 +81,33 @@ function parseCursor( return { at, id }; } -/** Production store over an artifacts engine db handle. */ -export function createArtifactDbStore(db: ArtifactDb): ArtifactRoutesStore { +function parseQuery(raw: string | undefined): string | null { + if (raw === undefined) return null; + const trimmed = raw.trim(); + if (trimmed === "") return null; + return trimmed.slice(0, 200); +} + +/** Prefer the browser-declared type when the upload policy accepts it. */ +function resolveUploadMime(file: { name: string; type: string }): string { + if (file.type !== "" && ARTIFACT_UPLOAD_POLICY.accepts(file.type)) { + return file.type; + } + return file.type === "" ? "application/octet-stream" : file.type; +} + +/** Production store over an artifacts engine db handle + content store. */ +export function createArtifactDbStore( + db: ArtifactDb, + contentStore: ContentStore, +): ArtifactRoutesStore { return { async list(tenantId, opts) { const cursor = parseCursor(opts.cursor ?? undefined); const result = await listArtifacts(db, anonymousIdentity, tenantId, { limit: opts.limit, ...(cursor !== undefined ? { cursor } : {}), + ...(opts.query !== null ? { query: opts.query } : {}), }); return { data: result.rows.map(serializeArtifactListItem), @@ -82,6 +119,27 @@ export function createArtifactDbStore(db: ArtifactDb): ArtifactRoutesStore { if (row === null || row.tenantId !== tenantId) return null; return serializeArtifact(row); }, + async upload(tenantId, principalId, files) { + const scope = { tenantId, principalId }; + const rows = await db.transaction(async (tx) => { + const created = []; + for (const file of files) { + created.push( + await createFileArtifact(tx, contentStore, { + scope, + ownerPrincipalId: principalId, + filename: file.filename, + mimeType: file.mimeType, + policy: ARTIFACT_UPLOAD_POLICY, + bytes: file.bytes, + origin: "library-upload", + }), + ); + } + return created; + }); + return rows.map(serializeArtifact); + }, }; } @@ -94,10 +152,105 @@ export function createArtifactRoutes( const tenant = c.get("tenant"); const limit = parseLimit(c.req.query("limit")); const cursor = c.req.query("cursor") ?? null; - const page = await deps.store.list(tenant.id, { limit, cursor }); + const query = parseQuery(c.req.query("q") ?? undefined); + const page = await deps.store.list(tenant.id, { limit, cursor, query }); return c.json(page); }); + app.post("/upload", deps.requireGrant("asset:*", "write"), async (c) => { + const tenant = c.get("tenant"); + const principal = c.get("principal"); + + let parsed: Record; + try { + parsed = (await c.req.parseBody({ all: true })) as Record< + string, + unknown + >; + } catch { + return c.json( + { error: { code: "bad_request", message: "Invalid multipart body" } }, + 400, + ); + } + + const files: File[] = []; + for (const value of Object.values(parsed)) { + for (const entry of Array.isArray(value) ? value : [value]) { + if (entry instanceof File) files.push(entry); + } + } + + if (files.length === 0) { + return c.json( + { + error: { + code: "bad_request", + message: "Expected at least one file field", + }, + }, + 400, + ); + } + if (files.length > MAX_UPLOAD_FILE_COUNT) { + return c.json( + { + error: { + code: "payload_too_large", + message: `Too many files: ${files.length} exceeds the ${MAX_UPLOAD_FILE_COUNT} file limit`, + }, + }, + 413, + ); + } + + let totalBytes = 0; + const inputs: ArtifactUploadInput[] = []; + for (const file of files) { + if (file.size > MAX_UPLOAD_BYTES) { + return c.json( + { + error: { + code: "payload_too_large", + message: `File "${file.name}" exceeds the ${MAX_UPLOAD_BYTES} byte limit`, + }, + }, + 413, + ); + } + totalBytes += file.size; + if (totalBytes > MAX_UPLOAD_TOTAL_BYTES) { + return c.json( + { + error: { + code: "payload_too_large", + message: `Upload exceeds the ${MAX_UPLOAD_TOTAL_BYTES} byte aggregate limit`, + }, + }, + 413, + ); + } + inputs.push({ + filename: file.name, + mimeType: resolveUploadMime(file), + bytes: new Uint8Array(await file.arrayBuffer()), + }); + } + + try { + const data = await deps.store.upload(tenant.id, principal.id, inputs); + return c.json({ data }, 201); + } catch (err) { + if (err instanceof UnsupportedUploadTypeError) { + return c.json( + { error: { code: "unsupported_media_type", message: err.message } }, + 415, + ); + } + throw err; + } + }); + app.get("/:artifactId", deps.requireGrant("asset:*", "read"), async (c) => { const tenant = c.get("tenant"); const artifactId = c.req.param("artifactId"); @@ -113,3 +266,31 @@ export function createArtifactRoutes( return app; } + +/** + * Honest degraded surface when the artifacts plane is not mounted: every + * route answers 503 so the Library UI can distinguish "not configured" + * from "empty bench" without inventing silent empty lists. + */ +export function createUnavailableArtifactRoutes( + requireGrant: RequireGrant, +): Hono { + const app = new Hono(); + const unavailable = (c: { + json: (body: unknown, status: 503) => Response | Promise; + }) => + c.json( + { + error: { + code: "unavailable", + message: "Artifacts plane is not configured on this hub", + }, + }, + 503, + ); + + app.get("/", requireGrant("asset:*", "read"), unavailable); + app.post("/upload", requireGrant("asset:*", "write"), unavailable); + app.get("/:artifactId", requireGrant("asset:*", "read"), unavailable); + return app; +} diff --git a/apps/hub/src/artifacts-mount.ts b/apps/hub/src/artifacts-mount.ts index 2d2f1b661..867f54020 100644 --- a/apps/hub/src/artifacts-mount.ts +++ b/apps/hub/src/artifacts-mount.ts @@ -12,14 +12,16 @@ * the dock mount. * * This module lands the mount + factory only. Tenant-scoped HTTP - * list/get routes live in `artifact-routes.ts` and are registered from - * the hub composition root when the mount succeeds. + * list/get/upload routes live in `artifact-routes.ts` and are registered + * from the hub composition root when the mount succeeds. */ import { getLogger } from "@intx/log"; import { createArtifactDb, + InlineContentStore, runArtifactMigrations, type ArtifactDb, + type ContentStore, } from "@corbits/artifacts"; const log = getLogger(["hub", "artifacts-mount"]); @@ -31,12 +33,11 @@ export type MountArtifactsOptions = { /** * Handle returned by a successful mount. The `db` is the engine's own - * drizzle handle (the same shape dock's `mountArtifacts` exposes) so a - * later routes module can build the persist/find/search/read surface on - * top of it without re-deriving the connection. + * drizzle handle; `contentStore` is the byte sink used by upload routes. */ export type ArtifactsMountHandle = { db: ArtifactDb; + contentStore: ContentStore; }; export async function mountArtifacts( @@ -56,5 +57,6 @@ export async function mountArtifacts( log.info( "Artifacts engine mounted — artifacts persist as versioned rows by kind", ); - return { db }; + return { db, contentStore: InlineContentStore }; + } diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 726aef4d6..5c08213dc 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -59,7 +59,12 @@ import { createGitWorkflowPusher } from "@workbench/hub-client"; import { createOnboardingRoutes } from "@workbench/onboarding"; import { mountMemory } from "./memory-mount"; import { mountArtifacts } from "./artifacts-mount"; -import { createArtifactDbStore, createArtifactRoutes } from "./artifact-routes"; +import { + createArtifactDbStore, + createArtifactRoutes, + createUnavailableArtifactRoutes, +} from "./artifact-routes"; + import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { type Context, type Next } from "hono"; @@ -517,7 +522,10 @@ export async function createHub(config: HubConfig) { app.route( `${TENANT_PREFIX}/artifacts`, createArtifactRoutes({ - store: createArtifactDbStore(artifactsHandle.db), + store: createArtifactDbStore( + artifactsHandle.db, + artifactsHandle.contentStore, + ), requireGrant: createRequireGrant({ grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, @@ -526,6 +534,15 @@ export async function createHub(config: HubConfig) { ); } else { log.info("Artifacts handle unavailable (degraded mode)"); + app.route( + `${TENANT_PREFIX}/artifacts`, + createUnavailableArtifactRoutes( + createRequireGrant({ + grantStore: chatGrantStore, + conditionRegistry: chatConditionRegistry, + }), + ), + ); } // Tells the signed-out screen which OAuth buttons to draw, without diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 5ddfd1880..05726f271 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -29,6 +29,33 @@ export const TenantApprovalsSchema = paginatedSchema(ApprovalResponse); // page renders as artifacts. export const AssetsSchema = AssetWithOriginResponse.array(); +// Real Library plane: paginated list from GET /api/tenants/:id/artifacts. +// Content is intentionally omitted on list; detail fetches include it. +export const ArtifactListItemSchema = type({ + id: "string", + kind: "string", + title: "string", + source: "Record", + version: "number", + ownerPrincipalId: "string | null", + ownerName: "string | null", + archivedAt: "string | null", + createdAt: "string", + updatedAt: "string", +}); +export const ArtifactListPageSchema = type({ + data: ArtifactListItemSchema.array(), + nextCursor: "string | null", +}); +export const ArtifactDetailSchema = ArtifactListItemSchema.merge( + type({ + content: "string", + }), +); +export const ArtifactUploadResponseSchema = type({ + data: ArtifactDetailSchema.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. @@ -49,6 +76,9 @@ 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 ArtifactListItem = typeof ArtifactListItemSchema.infer; +export type ArtifactListPage = typeof ArtifactListPageSchema.infer; +export type ArtifactDetail = typeof ArtifactDetailSchema.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 9739f4b72..67c0801d7 100644 --- a/apps/web/src/pages/library-page.tsx +++ b/apps/web/src/pages/library-page.tsx @@ -24,13 +24,20 @@ import { sortArtifacts, } from "@corbits/artifact-ui"; import type { ArtifactSort, ArtifactSummary } from "@corbits/artifact-ui"; -import { ArrowDownUp, FileStack } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { ArrowDownUp, FileStack, Upload } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; -import { AssetsSchema, useAPIQuery } from "../api"; +import { ArtifactListPageSchema, useAPIQuery } from "../api"; import { useBench } from "../bench-context"; -import { mapAssetsToArtifacts } from "../shell/library-artifacts"; +import { tenantKeys } from "../query-client"; + import { QueryView } from "../query-view"; +import { + isArtifactsUnavailableMessage, + mapArtifactListToSummaries, + uploadArtifactFiles, +} from "../shell/library-artifacts"; const SORT_LABEL: Record = { newest: "Newest first", @@ -101,25 +108,48 @@ function ArtifactRows({ /** * The artifact gallery. Real data all the way down — search, sort, view - * 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. + * mode, and upload. The route resolves the current bench's artifacts into + * the `ArtifactSummary` rows this page renders (see `LibraryRoute`); an empty + * list is a truthful empty library, never fabricated rows. */ export function LibraryPage({ artifacts, now, + onUpload, + uploading, + uploadError, + query, + onQueryChange, }: { readonly artifacts: readonly ArtifactSummary[]; /** Reference time for relative timestamps; injectable for tests. */ readonly now?: number; + readonly onUpload?: (files: readonly File[]) => void; + readonly uploading?: boolean; + readonly uploadError?: string | null; + /** Controlled search string — when provided, the route owns server-side `q`. */ + readonly query?: string; + readonly onQueryChange?: (value: string) => void; }) { - const [query, setQuery] = useState(""); + const [localQuery, setLocalQuery] = useState(""); const [sort, setSort] = useState("newest"); const [viewMode, setViewMode] = useState("grid"); + const fileInputRef = useRef(null); + const activeQuery = query ?? localQuery; + const setActiveQuery = onQueryChange ?? setLocalQuery; + + // When the route owns server-side search, filter is a no-op pass-through + // (rows already match). Local-only consumers still filter client-side. const visible = useMemo( - () => sortArtifacts(filterArtifacts(artifacts, query), sort), - [artifacts, query, sort], + () => + sortArtifacts( + onQueryChange === undefined + ? filterArtifacts(artifacts, activeQuery) + : artifacts, + sort, + ), + [artifacts, activeQuery, sort, onQueryChange], ); return ( @@ -127,8 +157,8 @@ export function LibraryPage({
@@ -145,19 +175,50 @@ export function LibraryPage({ + {onUpload !== undefined ? ( + <> + { + const list = event.target.files; + if (list !== null && list.length > 0) { + onUpload(Array.from(list)); + } + event.target.value = ""; + }} + /> + + + ) : null}
+ {uploadError !== undefined && uploadError !== null ? ( +

+ {uploadError} +

+ ) : null} {artifacts.length === 0 ? ( } title="No artifacts yet" - description="This workbench has no assets yet — workflows, skills, package registries, and agent state show up here as soon as they exist." + description="Upload a file or wait for agents and workflows to produce artifacts — they land here as soon as they exist." /> ) : visible.length === 0 ? ( } title="Nothing matches" - description={`No artifact matches "${query}".`} + description={`No artifact matches "${activeQuery}".`} /> ) : viewMode === "rows" ? (
@@ -177,13 +238,21 @@ export function LibraryPage({ export function LibraryRoute() { 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, - ); + const queryClient = useQueryClient(); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + + // Real artifacts plane — list is paginated; `q` is server-side text search. + const listPath = + selectedTenantId === null + ? "" + : `/api/tenants/${selectedTenantId}/artifacts${ + searchQuery.trim() === "" + ? "" + : `?q=${encodeURIComponent(searchQuery.trim())}` + }`; + const page = useAPIQuery(listPath, ArtifactListPageSchema); if (selectedTenantId === null) { return ( @@ -191,15 +260,53 @@ export function LibraryRoute() { } title="Select a workbench" - description="Pick a workbench from the switcher to browse the assets it owns." + description="Pick a workbench from the switcher to browse the artifacts it owns." + /> + + ); + } + + if (page.kind === "error" && isArtifactsUnavailableMessage(page.message)) { + return ( + + } + title="Library not configured" + description="This hub has no artifacts plane mounted yet. Set ARTIFACTS_DATABASE_URL and restart the hub to enable Library." /> ); } return ( - - {(rows) => } + + {(rows) => ( + { + void (async () => { + setUploading(true); + setUploadError(null); + try { + await uploadArtifactFiles(selectedTenantId, files); + await queryClient.invalidateQueries({ + queryKey: tenantKeys.artifacts(selectedTenantId), + }); + } catch (err) { + setUploadError( + err instanceof Error ? err.message : String(err), + ); + } finally { + setUploading(false); + } + })(); + }} + /> + )} ); } diff --git a/apps/web/src/query-client.ts b/apps/web/src/query-client.ts index 516f6c2cf..6d3227da6 100644 --- a/apps/web/src/query-client.ts +++ b/apps/web/src/query-client.ts @@ -49,6 +49,7 @@ export const tenantKeys = { agentDirectory: (tenantId: string) => ["tenant", tenantId, "agents", "directory"] as const, assets: (tenantId: string) => ["tenant", tenantId, "assets"] as const, + artifacts: (tenantId: string) => ["tenant", tenantId, "artifacts"] as const, }; /** @@ -63,5 +64,9 @@ export function pathToQueryKey(path: string): readonly unknown[] { 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]); + const artifacts = /^\/api\/tenants\/([^/]+)\/artifacts(?:\?(.*))?$/.exec(path); + if (artifacts?.[1] !== undefined) { + return [...tenantKeys.artifacts(artifacts[1]), artifacts[2] ?? ""] as const; + } return ["path", path]; } diff --git a/apps/web/src/shell/library-artifacts.ts b/apps/web/src/shell/library-artifacts.ts index c2fdfb6ce..3fee6acfe 100644 --- a/apps/web/src/shell/library-artifacts.ts +++ b/apps/web/src/shell/library-artifacts.ts @@ -1,48 +1,104 @@ -// 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. +// The Library page's one seam to the hub's real artifacts plane: +// `GET /api/tenants/:tenantId/artifacts` (list) and +// `GET /api/tenants/:tenantId/artifacts/:id` (detail), plus +// `POST .../artifacts/upload` for file ingest. // -// 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`. +// This module owns pure mapping + upload helper so the page stays thin and +// the shape contract has its own tests. The old asset-shim path is gone. import type { ArtifactSummary } from "@corbits/artifact-ui"; -import type { AssetRow } from "../api"; +/** List row from the hub artifacts surface (content omitted). */ +export type ArtifactListRow = { + readonly id: string; + readonly kind: string; + readonly title: string; + readonly ownerName: string | null; + readonly createdAt: string; + readonly updatedAt: string; +}; -/** - * 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; -} +/** Detail body — list metadata plus the stored content string. */ +export type ArtifactDetail = ArtifactListRow & { + readonly content: string; + readonly version: number; +}; -/** One tenant asset, re-shaped into the row the Library page renders. */ -export function assetToArtifact(asset: AssetRow): ArtifactSummary { +/** One list row reshaped for the Library gallery. */ +export function artifactListRowToSummary( + row: ArtifactListRow, +): 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, + id: row.id, + title: row.title, + kind: row.kind, + ownerName: row.ownerName, + createdAt: row.createdAt, + updatedAt: row.updatedAt, }; } -/** Map a full asset listing into artifact rows, preserving order and count. */ -export function mapAssetsToArtifacts( - assets: readonly AssetRow[], +/** Map a full listing into gallery rows, preserving order and count. */ +export function mapArtifactListToSummaries( + rows: readonly ArtifactListRow[], ): ArtifactSummary[] { - return assets.map(assetToArtifact); + return rows.map(artifactListRowToSummary); +} + +/** + * POST multipart upload against the tenant artifacts surface. Returns the + * created detail rows on 201; throws with status on non-2xx so the page can + * surface an honest failure. + */ +export async function uploadArtifactFiles( + tenantId: string, + files: readonly File[], +): Promise { + const form = new FormData(); + for (const file of files) { + form.append("file", file, file.name); + } + let response: Response; + try { + response = await fetch(`/api/tenants/${tenantId}/artifacts/upload`, { + method: "POST", + body: form, + }); + } catch (cause) { + throw new ArtifactUploadError( + cause instanceof Error ? cause.message : String(cause), + ); + } + if (!response.ok) { + throw new ArtifactUploadError( + `The hub answered ${response.status} for artifact upload.`, + response.status, + ); + } + const body = (await response.json()) as { + data?: readonly ArtifactDetail[]; + }; + if (!Array.isArray(body.data)) { + throw new ArtifactUploadError( + "Unexpected response shape from artifact upload.", + ); + } + return body.data; +} + +export class ArtifactUploadError extends Error { + constructor( + message: string, + readonly status?: number, + ) { + super(message); + } +} + +/** True when the hub answered "artifacts plane not configured". */ +export function isArtifactsUnavailableMessage(message: string): boolean { + return ( + message.includes(" answered 503 ") || + message.toLowerCase().includes("not configured") + ); } From 7ac535933da232e50a67684b576bffc8ab41b56d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 18:51:42 -0700 Subject: [PATCH 3/4] Fix Prettier on Library cutover files --- apps/hub/src/artifact-routes.test.ts | 5 ++--- apps/hub/src/artifacts-mount.ts | 1 - apps/web/src/query-client.ts | 4 +++- apps/web/test/pages.test.tsx | 1 - 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/hub/src/artifact-routes.test.ts b/apps/hub/src/artifact-routes.test.ts index 2482d421f..b3abc2706 100644 --- a/apps/hub/src/artifact-routes.test.ts +++ b/apps/hub/src/artifact-routes.test.ts @@ -48,9 +48,8 @@ function sampleListItem(id: string, tenantId: string) { function memoryStore(): ArtifactRoutesStore & { rows: Array & { content: string }>; } { - const rows: Array< - ReturnType & { content: string } - > = []; + const rows: Array & { content: string }> = + []; return { rows, async list(tenantId, opts) { diff --git a/apps/hub/src/artifacts-mount.ts b/apps/hub/src/artifacts-mount.ts index 867f54020..0573ab07d 100644 --- a/apps/hub/src/artifacts-mount.ts +++ b/apps/hub/src/artifacts-mount.ts @@ -58,5 +58,4 @@ export async function mountArtifacts( "Artifacts engine mounted — artifacts persist as versioned rows by kind", ); return { db, contentStore: InlineContentStore }; - } diff --git a/apps/web/src/query-client.ts b/apps/web/src/query-client.ts index 6d3227da6..9a8bfb217 100644 --- a/apps/web/src/query-client.ts +++ b/apps/web/src/query-client.ts @@ -64,7 +64,9 @@ export function pathToQueryKey(path: string): readonly unknown[] { 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]); - const artifacts = /^\/api\/tenants\/([^/]+)\/artifacts(?:\?(.*))?$/.exec(path); + const artifacts = /^\/api\/tenants\/([^/]+)\/artifacts(?:\?(.*))?$/.exec( + path, + ); if (artifacts?.[1] !== undefined) { return [...tenantKeys.artifacts(artifacts[1]), artifacts[2] ?? ""] as const; } diff --git a/apps/web/test/pages.test.tsx b/apps/web/test/pages.test.tsx index 56e696e5c..1a013fa23 100644 --- a/apps/web/test/pages.test.tsx +++ b/apps/web/test/pages.test.tsx @@ -29,7 +29,6 @@ describe("empty states", () => { expect(markup).toContain( "Upload a file or wait for agents and workflows to produce artifacts", ); - }); test("skills renders the shell with an honest empty state and Create action", () => { From 7254b00a3468f7d01894ef023793832296f4e2fa Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 18:56:47 -0700 Subject: [PATCH 4/4] Fix artifact routes tests: typed RequireGrant, no any casts Use RequireGrant from @intx/hub-api and plain Row[] so ESLint no-explicit-any / array-type rules stay clean. --- apps/hub/src/artifact-routes.test.ts | 103 ++++++++++++--------------- 1 file changed, 47 insertions(+), 56 deletions(-) diff --git a/apps/hub/src/artifact-routes.test.ts b/apps/hub/src/artifact-routes.test.ts index b3abc2706..69d57d8a3 100644 --- a/apps/hub/src/artifact-routes.test.ts +++ b/apps/hub/src/artifact-routes.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from "bun:test"; +import type { RequireGrant } from "@intx/hub-api"; import { Hono } from "hono"; import { @@ -22,13 +23,26 @@ const TENANT = { id: "tenant_a" }; const OTHER = { id: "tenant_b" }; const PRINCIPAL = { id: "prin_1" }; -function allowAllRequireGrant() { - return () => async (_c: unknown, next: () => Promise) => { - await next(); - }; -} +const allowAll: RequireGrant = () => async (_c, next) => { + await next(); +}; -function sampleListItem(id: string, tenantId: string) { +type Row = { + id: string; + kind: string; + title: string; + source: { origin: string }; + version: number; + ownerPrincipalId: string; + ownerName: string | null; + archivedAt: string | null; + createdAt: string; + updatedAt: string; + content: string; + _tenantId: string; +}; + +function sampleRow(id: string, tenantId: string, content = ""): Row { return { id, kind: "file", @@ -40,22 +54,27 @@ function sampleListItem(id: string, tenantId: string) { archivedAt: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", - // tenantId is store-side only; list response omits it + content, _tenantId: tenantId, }; } -function memoryStore(): ArtifactRoutesStore & { - rows: Array & { content: string }>; -} { - const rows: Array & { content: string }> = - []; +function stripTenant(row: Row) { + const { _tenantId: _t, ...rest } = row; + return rest; +} + +function memoryStore(): ArtifactRoutesStore & { rows: Row[] } { + const rows: Row[] = []; return { rows, async list(tenantId, opts) { let data = rows .filter((r) => r._tenantId === tenantId) - .map(({ content: _c, _tenantId: _t, ...rest }) => rest); + .map((r) => { + const { content: _c, ...item } = stripTenant(r); + return item; + }); if (opts.query !== null) { const q = opts.query.toLowerCase(); data = data.filter((r) => r.title.toLowerCase().includes(q)); @@ -70,26 +89,21 @@ function memoryStore(): ArtifactRoutesStore & { (r) => r.id === artifactId && r._tenantId === tenantId, ); if (row === undefined) return null; - const { _tenantId: _t, ...rest } = row; - return rest; + return stripTenant(row); }, async upload( tenantId: string, principalId: string, files: readonly ArtifactUploadInput[], ) { - const created = files.map((file, index) => { - const item = { - ...sampleListItem(`up_${rows.length + index}`, tenantId), - title: file.filename, - ownerPrincipalId: principalId, - content: new TextDecoder().decode(file.bytes), - }; + return files.map((file, index) => { + const item = sampleRow(`up_${rows.length + index}`, tenantId); + item.title = file.filename; + item.ownerPrincipalId = principalId; + item.content = new TextDecoder().decode(file.bytes); rows.push(item); - const { _tenantId: _t, ...rest } = item; - return rest; + return stripTenant(item); }); - return created; }, }; } @@ -103,12 +117,7 @@ function mount(store: ArtifactRoutesStore) { }); app.route( "/artifacts", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - createArtifactRoutes({ - store, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - requireGrant: allowAllRequireGrant() as any, - }) as any, + createArtifactRoutes({ store, requireGrant: allowAll }), ); return app; } @@ -129,14 +138,8 @@ describe("artifact routes", () => { }); test("GET / returns only the calling tenant's rows", async () => { - store.rows.push({ - ...sampleListItem("a1", TENANT.id), - content: "mine", - }); - store.rows.push({ - ...sampleListItem("b1", OTHER.id), - content: "theirs", - }); + store.rows.push(sampleRow("a1", TENANT.id, "mine")); + store.rows.push(sampleRow("b1", OTHER.id, "theirs")); const res = await app.request("/artifacts"); expect(res.status).toBe(200); const body = (await res.json()) as { data: { id: string }[] }; @@ -145,14 +148,12 @@ describe("artifact routes", () => { test("GET /?q= filters by title", async () => { store.rows.push({ - ...sampleListItem("a1", TENANT.id), + ...sampleRow("a1", TENANT.id, "x"), title: "Quarterly report.pdf", - content: "x", }); store.rows.push({ - ...sampleListItem("a2", TENANT.id), + ...sampleRow("a2", TENANT.id, "y"), title: "notes.txt", - content: "y", }); const res = await app.request("/artifacts?q=report"); expect(res.status).toBe(200); @@ -161,19 +162,13 @@ describe("artifact routes", () => { }); test("GET /:id returns 404 for a foreign tenant row", async () => { - store.rows.push({ - ...sampleListItem("b1", OTHER.id), - content: "secret", - }); + store.rows.push(sampleRow("b1", OTHER.id, "secret")); const res = await app.request("/artifacts/b1"); expect(res.status).toBe(404); }); test("GET /:id returns the row for the calling tenant", async () => { - store.rows.push({ - ...sampleListItem("a1", TENANT.id), - content: "hello", - }); + store.rows.push(sampleRow("a1", TENANT.id, "hello")); const res = await app.request("/artifacts/a1"); expect(res.status).toBe(200); const body = (await res.json()) as { id: string; content: string }; @@ -220,11 +215,7 @@ describe("unavailable artifact routes", () => { c.set("principal", PRINCIPAL); await next(); }); - app.route( - "/artifacts", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - createUnavailableArtifactRoutes(allowAllRequireGrant() as any) as any, - ); + app.route("/artifacts", createUnavailableArtifactRoutes(allowAll)); for (const path of ["/artifacts", "/artifacts/upload", "/artifacts/x"]) { const method = path.endsWith("/upload") ? "POST" : "GET";