diff --git a/apps/console/pnpm-workspace.yaml b/apps/console/pnpm-workspace.yaml new file mode 100644 index 000000000..2f24ed578 --- /dev/null +++ b/apps/console/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +packages: + - . + +allowBuilds: + esbuild@0.25.12: true diff --git a/apps/console/src/browse.ts b/apps/console/src/browse.ts new file mode 100644 index 000000000..7da26ed58 --- /dev/null +++ b/apps/console/src/browse.ts @@ -0,0 +1,95 @@ +type ToolResult = { + structuredContent?: unknown; + isError?: boolean; +}; + +type ReadTransport = { + call(tool: string, arguments_: Record): Promise; +}; + +type TableEntry = { table: string; description?: string; zone_admitted?: boolean }; +type FileEntry = { table: string; path: string; label: string }; +type BrowseRequest = { asOf?: string }; +const MAX_TABLES = 64; +const MAX_FILES = 128; +const MAX_PREVIEW_ROWS = 100; + +export class BrowseError extends Error { + readonly code: string; + + constructor(code: string, message = code) { + super(message); + this.name = "BrowseError"; + this.code = code; + } +} + +const abbreviations = new Set(["API", "CSV", "PDF", "SEC", "SQL", "URL"]); + +export function humanizeLabel(identifier: string): string { + const tail = identifier.split("/").at(-1) ?? identifier; + return tail.replace(/\.[^.]+$/, "").replace(/([a-z])([A-Z])/g, "$1 $2") + .split(/[-_\s]+/).filter(Boolean) + .map((part) => { + const upper = part.toUpperCase(); + return abbreviations.has(upper) || /^[Qq]\d+$/.test(part) + ? upper : part[0].toUpperCase() + part.slice(1).toLowerCase(); + }).join(" "); +} + +function object(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new BrowseError("ConsoleBrowseResponseMalformed"); + return value as Record; +} + +function bounds(request: BrowseRequest): Record { + return request.asOf ? { as_of: request.asOf } : {}; +} + +export function createBrowse(transport: ReadTransport) { + async function read(tool: string, arguments_: Record): Promise> { + const result = await transport.call(tool, arguments_); + if (result.isError) { + const error = object(object(result.structuredContent).error); + throw new BrowseError(String(error.identifier ?? "ConsoleBrowseReadRefused")); + } + return object(result.structuredContent); + } + + async function listed(request: BrowseRequest): Promise<{ tables: TableEntry[]; files: FileEntry[] }> { + const listing = await read("context.describe", bounds(request)); + if (!Array.isArray(listing.tables)) throw new BrowseError("ConsoleBrowseResponseMalformed"); + const tables = listing.tables.map((item) => object(item)).filter((item) => item.zone_admitted === true && item.kind !== "memory" && typeof item.table === "string") + .slice(0, MAX_TABLES).map((item) => ({ table: item.table as string, description: typeof item.description === "string" ? item.description : undefined })); + const names = new Set(tables.map((table) => table.table)); + const fileListing = await read("context.files", bounds(request)); + if (!Array.isArray(fileListing.rows)) throw new BrowseError("ConsoleBrowseResponseMalformed"); + const files = fileListing.rows.filter((row): row is [string, string] => + Array.isArray(row) && typeof row[0] === "string" && typeof row[1] === "string" && names.has(row[0])) + .slice(0, MAX_FILES).map(([table, path]) => ({ table, path, label: humanizeLabel(path) })); + return { tables, files }; + } + + return { + async discover(request: BrowseRequest) { + const { tables, files } = await listed(request); + const chips = tables.map((table) => ({ table: table.table, label: humanizeLabel(table.table), description: table.description })); + const descriptions = await Promise.all(tables.map((table) => read("context.describe", { table: table.table, ...bounds(request) }))); + const insights = tables.flatMap((table, index) => { + const count = descriptions[index].row_count; + return typeof count === "number" && Number.isFinite(count) && count >= 0 + ? [{ table: table.table, label: humanizeLabel(table.table), rows: count }] : []; + }); + return { chips, insights, files }; + }, + + async preview(request: BrowseRequest & { path: string }) { + const { files } = await listed(request); + if (!files.some((file) => file.path === request.path)) throw new BrowseError("ConsoleGalleryPathUnlisted", request.path); + const response = await read("context.file", { path: request.path, limit: MAX_PREVIEW_ROWS, ...bounds(request) }); + if (!Array.isArray(response.columns) || !Array.isArray(response.rows)) throw new BrowseError("ConsoleBrowseResponseMalformed"); + return { columns: response.columns.slice(0, 64), rows: response.rows.slice(0, MAX_PREVIEW_ROWS).map((row) => + Array.isArray(row) ? row.slice(0, 64) : []) }; + }, + }; +} diff --git a/apps/console/src/entry.ts b/apps/console/src/entry.ts index a86974d02..e5a0d39c3 100644 --- a/apps/console/src/entry.ts +++ b/apps/console/src/entry.ts @@ -3,10 +3,11 @@ import { pathToFileURL } from "node:url"; import { isAbsolute, resolve } from "node:path"; import { registryFromEnv } from "../../gateway/src/index.ts"; import { createLiveTurn } from "./live.ts"; +import { createLiveBrowse } from "./live_browse.ts"; import type { ConsoleAdapters, Identity } from "./index.ts"; import { serveConsole } from "./server.ts"; -type AdapterFactory = (input: { stores: ConsoleAdapters["stores"]; env: NodeJS.ProcessEnv }) => Promise & { read?: ConsoleAdapters["read"] }>; +type AdapterFactory = (input: { stores: ConsoleAdapters["stores"]; env: NodeJS.ProcessEnv }) => Promise & { read?: ConsoleAdapters["read"]; browse?: ConsoleAdapters["browse"] }>; function required(name: string): string { const value = process.env[name]; @@ -82,9 +83,10 @@ async function main(): Promise { const registry = registryFromEnv(process.env.CONTEXTFUL_STORES_JSON); const stores = registry.entries.map(({ id, label }) => ({ id, label })); const modulePath = process.env.CONTEXTFUL_CONSOLE_ADAPTER_MODULE; - let adapters: Pick & { read?: ConsoleAdapters["read"] } = { + let adapters: Pick & { read?: ConsoleAdapters["read"]; browse?: ConsoleAdapters["browse"] } = { ...unavailableAdapters(), turn: modulePath ? unavailableAdapters().turn : createLiveTurn({ stores: registry.entries, env: process.env }), + browse: modulePath ? undefined : createLiveBrowse({ stores: registry.entries, env: process.env }), }; if (modulePath) { const absolute = isAbsolute(modulePath) ? modulePath : resolve(modulePath); @@ -99,6 +101,7 @@ async function main(): Promise { adminCapability: process.env.CONTEXTFUL_ADMIN_CAPABILITY, turn: adapters.turn, read: adapters.read ?? { list: async () => stores }, + browse: adapters.browse, control: adapters.control, }, () => origin); await new Promise((resolveListen, rejectListen) => { diff --git a/apps/console/src/index.ts b/apps/console/src/index.ts index 8691579fc..b5175bae8 100644 --- a/apps/console/src/index.ts +++ b/apps/console/src/index.ts @@ -1,5 +1,7 @@ import { createHash, createHmac, createPublicKey, randomBytes, timingSafeEqual, verify, type KeyObject } from "node:crypto"; import { ConsoleError } from "./turn.ts"; +import { parseVantage } from "./turn.ts"; +import { BrowseError } from "./browse.ts"; export type Operator = { subject: string; grants: ReadonlySet<"query" | "admin">; assertion?: string }; export type Store = { id: string; label: string }; @@ -35,6 +37,10 @@ export type ConsoleAdapters = { adminCapability?: string; turn: (input: TurnInput) => Promise; read: { list: (operator: Operator) => Promise }; + browse?: { + discover: (input: { operator: Operator; store: string; asOf?: string }) => Promise; + preview: (input: { operator: Operator; store: string; asOf?: string; path: string }) => Promise; + }; control: { workflows: (operator: Operator, store: string | null) => Promise; record: (operator: Operator, store: string | null) => Promise; @@ -261,6 +267,28 @@ export function createConsole(adapters: ConsoleAdapters): { fetch: (request: Req if (request.method === "GET" && path === `/${grant}`) return page(grant); if (grant === "query") { if (request.method === "GET" && path === "/query/api/stores") return json(await adapters.read.list(operator)); + if (request.method === "POST" && (path === "/query/api/browse" || path === "/query/api/preview")) { + let input: unknown; + try { input = await body(request); } catch (error) { return bodyFailure(error); } + if (!object(input) || typeof input.store !== "string" || !adapters.stores.some((store) => store.id === input.store) || + (input.asOf !== undefined && typeof input.asOf !== "string") || + (path.endsWith("/preview") && (typeof input.path !== "string" || !input.path || input.path.length > 4096))) { + return refusal("ConsoleRequestMalformed", 400); + } + let asOf: string | undefined; + try { asOf = input.asOf === undefined ? undefined : parseVantage(input.asOf as string); } + catch { return refusal("ConsoleVantageUnparseable", 400); } + if (!adapters.browse) return refusal("ConsoleAdapterUnavailable", 503); + try { + return json(path.endsWith("/preview") ? + await adapters.browse.preview({ operator, store: input.store, asOf, path: input.path as string }) : + await adapters.browse.discover({ operator, store: input.store, asOf })); + } catch (error) { + if (error instanceof BrowseError) return refusal(error.code, error.code === "ConsoleGalleryPathUnlisted" ? 403 : 400); + if (error instanceof ConsoleError) return refusal(error.code, error.status); + throw error; + } + } if (request.method === "POST" && path === "/query/api/ask") { let input: unknown; try { input = await body(request); } catch (error) { return bodyFailure(error); } @@ -303,11 +331,15 @@ const sharedStyle = ``; -const queryPage = `Query · Contextful${sharedStyle}

Ask the store

Answers cite the rows your access permits.

Conversation

Your answer appears here.

Results

`; const adminPage = `Admin · Contextful${sharedStyle}

Store operations

Pipelines, schedules, steps and run outcomes come from the store.

Workflow canvas

Operational record

Control document